Cómo encontrar bugs de importación en Tor usando Grafos AST
Hay una clase de bug que las pruebas no atrapan y los linters no ven: el módulo funciona, pero el orden en que lo importas decide si arranca. El código no está roto; el grafo de dependencias sí.
Este es el registro de uno real, encontrado en torproject/stem —la librería de Python para controlar un daemon de Tor— y reportado en torproject/stem#160.
Paso 1 — Clonar el sujeto y declarar el universo
git clone --depth 50 https://github.com/torproject/stem.git stem_art
cd stem_art && git rev-parse HEAD
# d5012a1039f05c69ebe832723ce96ecbe8f79fe1
El universo es solo archivos .py. Cualquier cifra que se publique después tiene que declarar ese universo: un árbol sin Python no produce grafo, y un grafo vacío no es un grafo limpio.
Paso 2 — Construir el grafo de imports desde el AST
La herramienta no ejecuta el código: parsea cada .py con ast, extrae las sentencias import/from ... import, las resuelve a módulos del corpus y separa dos subgrafos:
-
eager: la sentencia corre cuando se importa el módulo (cuerpo del módulo, incluido el cuerpo de una
class). - diferida: vive dentro de una función; no corre al importar.
Esa separación es todo el método. Un ciclo diferido no puede cerrarse al importar. Un ciclo eager sí.
python3 tools/grafo_ciclos_tt_metal_20261026.py --root /tmp/stem_art --raices-no-resueltas
Salida, medida sobre d5012a1:
archivos python : 219
aristas import : 611
eager (al importar): 594
diferidas : 17
SCC total : 5
SCC eager : 5
ciclo(11): stem/connection.py, stem/control.py, stem/response/__init__.py,
stem/response/authchallenge.py, stem/response/events.py,
stem/response/getconf.py, stem/response/getinfo.py,
stem/response/mapaddress.py, stem/response/onion_client_auth.py,
stem/response/protocolinfo.py, stem/socket.py
CICLO EAGER(2): stem/response/__init__.py, stem/socket.py
Cinco componentes fuertemente conexas, y las cinco contienen al menos una arista eager. La más pequeña es la interesante: stem/response/__init__.py ↔ stem/socket.py.
Paso 3 — Convertir la sospecha en una reproducción de dos comandos
Un ciclo eager es una hipótesis, no un hallazgo. El falsificador más barato son dos procesos nuevos, con puntos de entrada distintos:
python -c 'import stem.response' # entrada por el paquete
python -c 'import stem.socket' # entrada por el otro extremo
Medido con Python 3.12.13:
--- import stem.response: RC=1
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "stem/response/__init__.py", line 37, in <module>
import stem.socket
File "stem/socket.py", line 105, in <module>
class BaseSocket(object):
File "stem/socket.py", line 253, in BaseSocket
async def _recv(self, handler: Callable[[asyncio.StreamReader], Awaitable[stem.response.ControlMessage]]) -> stem.response.ControlMessage:
^^^^^^^^^^^^^
AttributeError: cannot access submodule 'response' of module 'stem' (most likely due to a circular import)
--- import stem.socket: RC=0
El mismo código, dos resultados opuestos según por dónde entres. Eso es la firma de un defecto de orden de carga, no de una función mal escrita.
Por qué es un defecto de orden de carga
Tres hechos, cada uno verificable en el archivo:
-
stem/response/__init__.py:37haceimport stem.socketmientrasstem.responsetodavía se está inicializando. En ese instante,stem.responseexiste ensys.modules, pero el atributoresponsedel paquetestem—el que hace funcionarstem.response.ControlMessage— aún no está ligado. -
stem/socket.py:105abreclass BaseSocket(object):. El cuerpo de una clase se ejecuta al importar, ystem/socket.pyno tienefrom __future__ import annotations. - Por eso la anotación de
stem/socket.py:253se evalúa de inmediato, en el momento exacto en que el nombrestem.responseno es alcanzable como atributo.
La anotación no es código que corra; es metadata que Python 3 evalúa con ganas (eager) si no se le dice lo contrario. Y stem/socket.py:90 (import stem.response) cierra el círculo: los dos módulos se necesitan, y quién termina primero decide si la anotación resuelve.
Cuando entras por stem.socket, stem.socket se inicializa primero, su import stem.response corre hasta el final, y para cuando toca evaluar la anotación el nombre ya existe. Cuando entras por stem.response, el orden se invierte y revienta.
El arreglo
Cualquiera de los dos rompe la dependencia de orden:
from __future__ import annotations # al inicio de stem/socket.py
o citar la anotación afectada ('stem.response.ControlMessage'). Ambos dejan typing.get_type_hints() funcionando, porque los nombres resuelven cuando el paquete ya está cargado.
Medido, no supuesto: con la línea añadida, import stem.response sale RC 0; revertida la línea, vuelve a salir RC 1 con el mismo AttributeError. El defecto y su cierre están en el mismo par de comandos.
Por qué un grafo AST y no un linter
- Un linter de imports mira un archivo; el bug no está en un archivo, está en el par.
- Las pruebas de
stemno lo ven porque cada módulo de prueba importa lo que necesita en el orden que le conviene: el orden accidental del test enmascara el orden real del usuario. - El grafo cuesta segundos y no ejecuta el sujeto. Produce un ranking: 5 SCC, todas con arista eager. Eso reduce el espacio de búsqueda de "219 archivos" a "un par".
El costo real está en la resolución de nombres, no en el parseo: sobre este checkout, 1 015 imports no resolvieron (913 de STDLIB, 102 de dependencias externas como cryptography), y 211 aristas hacia el __init__ de un paquete ancestro fueron descartadas a propósito —el paquete ya está en sys.modules cuando corre el hijo, contarlas fabrica ciclos que el runtime nunca recorre—. Un motor que no declara esas dos decisiones publica ciclos falsos con total comodidad.
Alcance y límites
-
Ninguna afirmación de seguridad. Es un defecto de tiempo de importación: se manifiesta como un
AttributeErrorduro en vez de un import que funciona. - No se probó contra un daemon de Tor en ejecución. Solo el import, en un intérprete limpio.
- El bug es del sujeto, no del método: el método solo lo hizo visible sin leer
stem/socket.pyde arriba abajo primero.
Prueba
- Issue abierto: torproject/stem#160 — AttributeError on class-body annotation evaluation in stem/socket.py when importing stem.response first
- Observado y reproducible en
d5012a1039f05c69ebe832723ce96ecbe8f79fe1, Python 3.12.13, macOS.
Si mantienes una librería con paquetes que se importan entre sí, el experimento te cuesta dos comandos: construye el grafo, separa eager de diferido, y prueba cada ciclo pequeño con dos puntos de entrada opuestos. El que falle en uno y no en el otro es tu bug.
Top comments (0)