Cómo acceder al "code object" de una clase Python

Una de las características más interesantes de Python es su capacidad de introspección. Recientemente necesité acceder al code object de una clase Python y lo cierto es que no es tarea trivial.

Contexto: Creo una clase caché con persistencia transparente. Normalmente incluiría un número de versión en la persistencia y un mecanismo de migración para que pueda reutilizar los datos persistentes si modifico el código que interactúa con ella. Eso requiere estar atento y actualizar la versión y el proceso de migración cuando hay cambios relevantes en el código. Esto es manual. En este caso concreto no me interesa tener un mecanismo de migración de versiones con su mantenimiento manual. Dado que esto es una caché, si hay un cambio de código lo que quiero es que se detecte y se ignore la persistencia antigua.

He encontrado tres formas de acceder al code object de una clase Python:

  1. Compilar la clase directamente desde texto:

    >>> import dis, marshal
    >>> a="""
    ... class a:
    ...   z = 7
    ...   def pepe(self):
    ...     pass
    ...
    ... """
    >>> code = compile(a, 'MAIN', 'exec')
    >>> len(marshal.dumps(code))
    327
    

    Definir la función como una cadena de texto puede ser problemático a la hora de depurar o refactorizar el código. En la práctica yo recomendaría tener el código en un fichero clase.py y leerlo en modo texto para hacer el compile.

  2. Utilizar el fichero compilado pyc.

    Supongamos que tenemos un fichero clase.py con este contenido:

    class a:
        z=7
        def pepe(self):
            pass
    

    Si importamos este módulo en alguna parte de nuestro programa, tendremos -con suerte- un fichero compilado en __pycache__/clase.cpython-36.pyc. Este fichero se puede abrir e interpretar de la siguiente manera:

    >>> codigo = open("__pycache__/clase.cpython-36.pyc", "rb").read()[12:]
    >>> len(codigo)
    332
    >>> codigo = marshal.loads(codigo)
    

    Este enfoque es similar al anterior, con varias desventajas: falta de portabilidad, la necesidad de importar primero el código para que se genere el fichero pyc (puede ser un problema serio si el módulo tiene efectos colaterales al importarse) y que este proceso puede referenciar a un fichero inexistente o anticuado (que ya no se corresponde al código actual) porque, por ejemplo, no tenemos permiso para escribir o actualizar el fichero pyc.

    Advertencia

    El formato de los ficheros pyc no está documentado y, de hecho, cambia de vez en cuando. Por ejemplo, la cabecera tradicional de 8 bytes pasó a medir 12 bytes en Python 3.3.

    El bytecode marshal varía entre versiones de Python. Debemos analizar la clase desde la misma versión del intérprete que generó el fichero pyc.

    Si utilizamos este enfoque tendremos que ir siguiendo los fragmentos de código usados para crear cada método de la clase, etc. No es trivial juntar todos esos trozos para mis necesidades específicas.

  3. Acceder al code object de una clase Python no es trivial, como estamos viendo. Pero acceder al code object de un método concreto o una función, sí. ¿Qué ocurre si incluimos la clase que nos interesa en una función? Por ejemplo:

    >>> def wrapper():
    ...   class a:
    ...     z = 7
    ...     def pepe(self):
    ...       pass
    ...   return a()
    
    >>> len(marshal.dumps(wrapper.__code__))
    367
    

    Esta aproximación es simple y portable. Usar una función contenedora es algo feo, pero he visto apaños peores.

    Por curiosidad, veamos cómo funciona esto:

    >>> dis.dis(wrapper.__code__)
      2           0 LOAD_BUILD_CLASS
                  2 LOAD_CONST               1 (<code object a at 0x7f0127d63030, file "<stdin>", line 2>)
                  4 LOAD_CONST               2 ('a')
                  6 MAKE_FUNCTION            0
                  8 LOAD_CONST               2 ('a')
                 10 CALL_FUNCTION            2
                 12 STORE_FAST               0 (a)
    
      6          14 LOAD_FAST                0 (a)
                 16 CALL_FUNCTION            0
                 18 RETURN_VALUE
    

    Esto es el cuerpo de la función wrapper(). Este fragmento muestra que cada vez que se la llama crea una clase nueva y luego devuelve una instancia de dicha clase. El código de la clase está almacenado en la tabla de constantes de este code object (línea 2). Veamos:

    >>> dis.dis(wrapper.__code__.co_consts[1])
      2           0 LOAD_NAME                0 (__name__)
                  2 STORE_NAME               1 (__module__)
                  4 LOAD_CONST               0 ('wrapper.<locals>.a')
                  6 STORE_NAME               2 (__qualname__)
    
      3           8 LOAD_CONST               1 (7)
                 10 STORE_NAME               3 (z)
    
      4          12 LOAD_CONST               2 (<code object pepe at 0x7f012821bdb0, file "<stdin>", line 4>)
                 14 LOAD_CONST               3 ('wrapper.<locals>.a.pepe')
                 16 MAKE_FUNCTION            0
                 18 STORE_NAME               4 (pepe)
                 20 LOAD_CONST               4 (None)
                 22 RETURN_VALUE
    

    Aquí se muestra cómo se construye la clase en sí, incluyendo la asignación de la variable de clase y la creación del método. Examinemos el code object del método:

    >>> dis.dis(wrapper.__code__.co_consts[1].co_consts[2])
      5           0 LOAD_CONST               0 (None)
                  2 RETURN_VALUE
    

    Vemos que hacer el seguimiento de code objects no es trivial. En este caso tenemos que acceder a la estructura co_consts del co_consts de __code__ de la función wrapper(). Escribir código general que reconstruya clases arbitrarias tiene miga. El tenerlo todo bien empaquetado dentro de wrapper.__code__ resulta la mar de conveniente para cosas como calcular su hash.

Algunas referencias útiles:

Si se te ocurre una forma mejor de hacer esto, estaré encantado de que me la enseñes :-).

Nota

Una posibilidad muy interesante sería poder acceder al code object a través de sys.modules['modulo'], pero esto no es posible. La documentación de Python dice lo siguiente al respecto:

A module object does not contain the code object used to initialize the module (since it isn’t needed once the initialization is done).

Nota

Para el caso concreto que estoy haciendo, una aproximación trivial sería leer el código fuente de la clase caché y simplemente almacenar su hash en la persistencia. Si hay cambios en dicho hash, ignoramos la persistencia almacenada con anterioridad. Eso requiere que el código de la clase resida en un fichero separado. Esto es apropiado e incluso recomendable de forma general, pero en mi proyecto concreto resulta problemático y el fichero donde está esa clase contiene código no relacionado que cambia con frecuencia.

Podría insertar comentarios especiales en el fichero para marcar las partes sobre las que calcular el hash, pero una vez metido en faena y en vez de refactorizar sin más, me interesa resolver el problema de acceder al código de una clase como conocimiento abstracto que me puede resultar útil en el futuro.