Python: metaprogramación en producción. Primera parte

Mucha gente piensa que la metaprogramación en Python complica innecesariamente el código, pero si lo usa correctamente, puede implementar rápida y elegantemente patrones de diseño complejos. Además, los marcos de Python conocidos como Django, DRF y SQLAlchemy utilizan metaclases para proporcionar una fácil extensibilidad y una reutilización de código fácil.



En este artículo, le diré por qué no debe tener miedo de usar la metaprogramación en sus proyectos y mostrarle para qué tareas es más adecuada. Puede obtener más información sobre las opciones de metaprogramación en el curso avanzado de Python .


Para comenzar, recordemos los conceptos básicos de la metaprogramación en Python. No será superfluo agregar que todo lo que se escribe a continuación se aplica a Python versión 3.5 y superior.


Un recorrido rápido por el modelo de datos de Python


Entonces, todos sabemos que todo en Python es un objeto, y no es ningún secreto que para cada objeto hay una cierta clase por la cual se generó, por ejemplo:


>>> def f(): pass >>> type(f) <class 'function'> 

El tipo del objeto o la clase por la cual se generó el objeto se puede determinar utilizando la función de tipo incorporada, que tiene una firma de llamada bastante interesante (hablaremos de ello un poco más adelante). Se puede lograr el mismo efecto derivando el atributo __class__ en cualquier objeto.


Entonces, para crear funciones, function alguna function clase function . Veamos qué podemos hacer con él. Para hacer esto, tome el espacio en blanco del módulo de tipos incorporado:


 >>> from types import FunctionType >>> FunctionType <class 'function'> >>> help(FunctionType) class function(object) | function(code, globals[, name[, argdefs[, closure]]]) | | Create a function object from a code object and a dictionary. | The optional name string overrides the name from the code object. | The optional argdefs tuple specifies the default argument values. | The optional closure tuple supplies the bindings for free variables. 

Como podemos ver, cualquier función en Python es una instancia de la clase descrita anteriormente. Ahora tratemos de crear una nueva función sin recurrir a su declaración a través de def . Para hacer esto, necesitamos aprender a crear objetos de código utilizando la función de compilación integrada en el intérprete:


 #   ,    "Hello, world!" >>> code = compile('print("Hello, world!")', '<repl>', 'eval') >>> code <code object <module> at 0xdeadbeef, file "<repl>", line 1> #  ,     , #      >>> func = FunctionType(code, globals(), 'greetings') >>> func <function <module> at 0xcafefeed> >>> func.__name__ 'greetings' >>> func() Hello, world! 

Genial Con la ayuda de metaherramientas, aprendimos cómo crear funciones sobre la marcha, pero en la práctica ese conocimiento rara vez se usa. Ahora echemos un vistazo a cómo se crean los objetos de clase y los objetos de instancia de estas clases:


 >>> class User: pass >>> user = User() >>> type(user) <class '__main__.User'> >>> type(User) <class 'type'> 

Es bastante obvio que la clase User se usa para crear una instancia de user , es mucho más interesante observar la clase de type , que se usa para crear la clase User sí. Aquí pasaremos a la segunda opción de llamar a la función de type incorporada, que en combinación es una metaclase para cualquier clase en Python. Una metaclase es, por definición, una clase cuya instancia es otra clase. Las metaclases nos permiten personalizar el proceso de creación de una clase y controlar parcialmente el proceso de creación de una instancia de una clase.


Según la documentación, la segunda variante del type(name, bases, attrs) firma type(name, bases, attrs) - devuelve un nuevo tipo de datos o, si es simple - una nueva clase, y el atributo de name convierte en el atributo __name__ de la clase devuelta, bases - la lista de clases primarias estará disponible como __bases__ , Bueno, attrs , un objeto tipo dict que contiene todos los atributos y métodos de la clase, entrará en __dict__ . El principio de la función se puede describir como un pseudocódigo simple en Python:


 type(name, bases, attrs) ~ class name(bases): attrs 

Veamos cómo puedes, usando solo la llamada de type , construir una clase completamente nueva:


 >>> User = type('User', (), {}) >>> User <class '__main__.User'> 

Como puede ver, no necesitamos usar la palabra clave de class para crear una nueva clase, la función de type no funciona, ahora veamos un ejemplo más complicado:


 class User: def __init__(self, name): self.name = name class SuperUser(User): """Encapsulate domain logic to work with super users""" group_name = 'admin' @property def login(self): return f'{self.group_name}/{self.name}'.lower() #     SuperUser "" CustomSuperUser = type( #   'SuperUser', #  ,      (User, ), #         { '__doc__': 'Encapsulate domain logic to work with super users', 'group_name': 'admin', 'login': property(lambda self: f'{self.group_name}/{self.name}'.lower()), } ) assert SuperUser.__doc__ == CustomSuperUser.__doc__ assert SuperUser('Vladimir').login == CustomSuperUser('Vladimir').login 

Como puede ver en los ejemplos anteriores, la descripción de clases y funciones usando las palabras clave class y def es simplemente azúcar sintáctica y cualquier tipo de objeto puede ser creado mediante llamadas ordinarias a funciones integradas. Y ahora, finalmente, hablemos sobre cómo puede usar la creación dinámica de clases en proyectos reales.


Crear dinámicamente formularios y validadores


A veces necesitamos validar información del usuario o de otras fuentes externas de acuerdo con un esquema de datos previamente conocido. Por ejemplo, queremos cambiar el formulario de inicio de sesión del usuario desde el panel de administración: eliminar y agregar campos, cambiar la estrategia de su validación, etc.


Para ilustrar, intentemos crear dinámicamente un formulario de Django , cuya descripción del esquema se almacena en el siguiente formato json :


 { "fist_name": { "type": "str", "max_length": 25 }, "last_name": { "type": "str", "max_length": 30 }, "age": { "type": "int", "min_value": 18, "max_value": 99 } } 

Ahora, según la descripción anterior, cree un conjunto de campos y un nuevo formulario utilizando la función de type que ya conocemos:


 import json from django import forms fields_type_map = { 'str': forms.CharField, 'int': forms.IntegerField, } # form_description –  json    deserialized_form_description: dict = json.loads(form_description) form_attrs = {} #            for field_name, field_description in deserialized_form_description.items(): field_class = fields_type_map[field_description.pop('type')] form_attrs[field_name] = field_class(**field_description) user_form_class = type('DynamicForm', (forms.Form, ), form_attrs) >>> form = user_form_class({'age': 101}) >>> form <DynamicForm bound=True, valid=Unknown, fields=(fist_name;last_name;age)> >>> form.is_valid() False >>> form.errors {'fist_name': ['This field is required.'], 'last_name': ['This field is required.'], 'age': ['Ensure this value is less than or equal to 99.']} 

Genial Ahora puede transferir el formulario creado a la plantilla y representarlo para el usuario. El mismo enfoque se puede utilizar con otros marcos para la validación y presentación de datos ( serializadores DRF , malvaviscos y otros).


Configurar la creación de una nueva clase a través de la metaclase


Arriba, observamos la metaclase de type ya "terminada", pero con mayor frecuencia en el código creará sus propias metaclases y las usará para configurar la creación de nuevas clases y sus instancias. En el caso general, el "espacio en blanco" de una metaclase se ve así:


 class MetaClass(type): """   : mcs –  ,  <__main__.MetaClass> name – ,  ,     ,  "User" bases –   -,  (SomeMixin, AbstractUser) attrs – dict-like ,         cls –  ,  <__main__.User> extra_kwargs –  keyword-     args  kwargs –          """ def __new__(mcs, name, bases, attrs, **extra_kwargs): return super().__new__(mcs, name, bases, attrs) def __init__(cls, name, bases, attrs, **extra_kwargs): super().__init__(cls) @classmethod def __prepare__(mcs, cls, bases, **extra_kwargs): return super().__prepare__(mcs, cls, bases, **kwargs) def __call__(cls, *args, **kwargs): return super().__call__(*args, **kwargs) 

Para usar esta metaclase para configurar la clase User , se usa la siguiente sintaxis:


 class User(metaclass=MetaClass): def __new__(cls, name): return super().__new__(cls) def __init__(self, name): self.name = name 

Lo más interesante es el orden en que el intérprete de Python llama al metametodo metaclase en el momento en que se crea la clase:


  1. El intérprete determina y encuentra las clases principales para la clase actual (si existe).
  2. El intérprete define una metaclase ( MetaClass en nuestro caso).
  3. Se MetaClass.__prepare__ método MetaClass.__prepare__ : debe devolver un objeto tipo dict en el que se escribirán los atributos y métodos de la clase. Después de eso, el objeto se pasará al MetaClass.__new__ través del argumento attrs . Hablaremos sobre el uso práctico de este método un poco más adelante en los ejemplos.
  4. El intérprete lee el cuerpo de la clase User y genera parámetros para pasarlos a la metaclase MetaClass .
  5. El método MetaClass.__new__ se MetaClass.__new__ - el método MetaClass.__new__ , devuelve el objeto de clase creado. Ya nos reunimos con los argumentos name , bases y attrs cuando los pasamos a la función type , y hablaremos sobre el parámetro **extra_kwargs un poco más tarde. Si el tipo del argumento attrs se cambió usando __prepare__ , entonces debe convertirse a un dict antes de pasarlo a la llamada al método super() .
  6. El método MetaClass.__init__ se MetaClass.__init__ : el método inicializador con el que puede agregar atributos y métodos adicionales al objeto de clase en la clase. En la práctica, se usa en casos donde las metaclases se heredan de otras metaclases, de lo contrario, todo lo que se puede hacer en __init__ se hace mejor en __new__ . Por ejemplo, el parámetro __slots__ solo se puede establecer en el método __new__ escribiéndolo en el objeto attrs .
  7. En este paso, la clase se considera creada.

Ahora cree una instancia de nuestra clase de User y observe la cadena de llamadas:


 user = User(name='Alyosha') 

  1. En el momento de llamar al User(...) intérprete llama al MetaClass.__call__(name='Alyosha') , donde pasa el objeto de clase y pasan los argumentos.
  2. MetaClass.__call__ calls User.__new__(name='Alyosha') - un método constructor que crea y devuelve una instancia de la clase User
  3. A continuación, MetaClass.__call__ llama a User.__init__(name='Alyosha') , un método inicializador que agrega nuevos atributos a la instancia creada.
  4. MetaClass.__call__ devuelve la instancia creada e inicializada de la clase User .
  5. En este punto, se considera creada una instancia de la clase.

Esta descripción, por supuesto, no cubre todos los matices del uso de metaclases, pero es suficiente para comenzar a usar la metaprogramación para implementar algunos patrones arquitectónicos. ¡Adelante a los ejemplos!


Clases abstractas


Y el primer ejemplo se puede encontrar en la biblioteca estándar: ABCMeta : una metaclase le permite declarar cualquiera de nuestros resúmenes de clase y obligar a todos sus descendientes a implementar métodos, propiedades y atributos predefinidos, así que mire:


 from abc import ABCMeta, abstractmethod class BasePlugin(metaclass=ABCMeta): """   supported_formats   run        """ @property @abstractmethod def supported_formats(self) -> list: pass @abstractmethod def run(self, input_data: dict): pass 

Si no se implementan todos los métodos y atributos abstractos en el heredero, cuando intentamos crear una instancia de la clase heredera, obtenemos un TypeError :


 class VideoPlugin(BasePlugin): def run(self): print('Processing video...') plugin = VideoPlugin() # TypeError: Can't instantiate abstract class VideoPlugin # with abstract methods supported_formats 

El uso de clases abstractas ayuda a corregir inmediatamente la interfaz de la clase base y evitar futuros errores de herencia, por ejemplo, errores tipográficos en el nombre de un método anulado.


Sistema de complemento de registro automático


Muy a menudo, la metaprogramación se usa para implementar varios patrones de diseño. Casi cualquier marco conocido utiliza metaclases para crear objetos de registro . Dichos objetos almacenan enlaces a otros objetos y permiten que se reciban rápidamente en cualquier parte del programa. Considere un ejemplo simple de registro automático de complementos para reproducir archivos multimedia de varios formatos.


Implementación de metaclase:


 class RegistryMeta(ABCMeta): """ ,      .     " " -> " " """ _registry_formats = {} def __new__(mcs, name, bases, attrs): cls: 'BasePlugin' = super().__new__(mcs, name, bases, attrs) #     (BasePlugin) if inspect.isabstract(cls): return cls for media_format in cls.supported_formats: if media_format in mcs._registry_formats: raise ValueError(f'Format {media_format} is already registered') #       mcs._registry_formats[media_format] = cls return cls @classmethod def get_plugin(mcs, media_format: str): try: return mcs._registry_formats[media_format] except KeyError: raise RuntimeError(f'Plugin is not defined for {media_format}') @classmethod def show_registry(mcs): from pprint import pprint pprint(mcs._registry_formats) 

Y aquí están los complementos mismos, tomaremos la implementación de BasePlugin del ejemplo anterior:


 class BasePlugin(metaclass=RegistryMeta): ... class VideoPlugin(BasePlugin): supported_formats = ['mpg', 'mov'] def run(self): ... class AudioPlugin(BasePlugin): supported_formats = ['mp3', 'flac'] def run(self): ... 

Después de ejecutar este código, el intérprete registrará 4 formatos y 2 complementos en nuestro registro que pueden procesar estos formatos:


 >>> RegistryMeta.show_registry() {'flac': <class '__main__.AudioPlugin'>, 'mov': <class '__main__.VideoPlugin'>, 'mp3': <class '__main__.AudioPlugin'>, 'mpg': <class '__main__.VideoPlugin'>} >>> plugin_class = RegistryMeta.get_plugin('mov') >>> plugin_class <class '__main__.VideoPlugin'> >>> plugin_class().run() Processing video... 

Vale la pena señalar un matiz más interesante de trabajar con metaclases, gracias al orden de resolución de método no obvio, podemos llamar al método show_registry no solo en la clase RegistyMeta , sino en cualquier otra clase de la que sea una metaclase:


 >>> AudioPlugin.get_plugin('avi') # RuntimeError: Plugin is not found for avi 

Usar nombres de atributos como metadatos


Usando metaclases, puede usar nombres de atributos de clase como metadatos para otros objetos. Nada esta claro? Pero estoy seguro de que ya has visto este enfoque muchas veces, por ejemplo, declaración declarativa de campos modelo en Django:


 class Book(models.Model): title = models.Charfield(max_length=250) 

En el ejemplo anterior, title es el nombre del identificador de Python, también se usa para nombrar la columna en la tabla del book , aunque no lo indicamos explícitamente en ninguna parte. Sí, esa "magia" se puede realizar con la ayuda de la metaprogramación. Implementemos, por ejemplo, un sistema para transmitir errores de aplicación al front-end, de modo que cada mensaje tenga un código legible que pueda usarse para traducir el mensaje a otro idioma. Entonces, tenemos un objeto de mensaje que se puede convertir a json :


 class Message: def __init__(self, text, code=None): self.text = text self.code = code def to_json(self): return json.dumps({'text': self.text, 'code': self.code}) 

Todos nuestros mensajes de error se almacenarán en un "espacio de nombres" separado:


 class Messages: not_found = Message('Resource not found') bad_request = Message('Request body is invalid') ... >>> Messages.not_found.to_json() {"text": "Resource not found", "code": null} 

Ahora queremos que el code no sea null , pero no not_found , para esto escribimos la siguiente metaclase:


 class MetaMessage(type): def __new__(mcs, name, bases, attrs): for attr, value in attrs.items(): #          Message #    code    # ( code   ) if isinstance(value, Message) and value.code is None: value.code = attr return super().__new__(mcs, name, bases, attrs) class Messages(metaclass=MetaMessage): ... 

Veamos cómo se ven nuestras publicaciones ahora:


 >>> Messages.not_found.to_json() {"text": "Resource not found", "code": "not_found"} >>> Messages.bad_request.to_json() {"text": "Request body is invalid", "code": "bad_request"} 

Lo que necesitas! Ahora sabe qué hacer para que por el formato de datos pueda encontrar fácilmente el código que los procesa.


Almacenamiento en caché de metadatos sobre una clase y sus descendientes


Otro caso común es el almacenamiento en caché de los datos estáticos en la etapa de creación de la clase, para no perder el tiempo calculando mientras se ejecuta la aplicación. Además, algunos datos se pueden actualizar al crear nuevas instancias de clases, por ejemplo, un contador del número de objetos creados.


¿Cómo se puede usar esto? Supongamos que está desarrollando un marco para crear informes y tablas y tiene un objeto de este tipo:


 class Row(metaclass=MetaRow): name: str age: int ... def __init__(self, **kwargs): self.counter = None for attr, value in kwargs.items(): setattr(self, attr, value) def __str__(self): out = [self.counter] #  __header__      for name in self.__header__[1:]: out.append(getattr(self, name, 'N/A')) return ' | '.join(map(str, out)) 

Queremos guardar y aumentar el contador al crear una nueva fila, y también queremos generar el encabezado de la tabla resultante de antemano. Metaclase al rescate!


 class MetaRow(type): #      row_count = 0 def __new__(mcs, name, bases, attrs): cls = super().__new__(mcs, name, bases, attrs) #          cls.__header__ = ['№'] + sorted(attrs['__annotations__'].keys()) return cls def __call__(cls, *args, **kwargs): #      row: 'Row' = super().__call__(*args, **kwargs) #    cls.row_count += 1 #     row.counter = cls.row_count return row 

Aquí hay que aclarar dos cosas:


  • La clase Row no tiene atributos de clase con el name y la age los nombres: son anotaciones de tipo , por lo que no están en las attrs diccionario attrs , y para obtener una lista de campos, utilizamos el __annotations__ clase __annotations__ .
  • Se cls.row_count += 1 operación cls.row_count += 1 debía confundirlo: ¿cómo es eso? Después de todo, cls es una clase Row ; no tiene el atributo row_count . Todo es cierto, pero como expliqué anteriormente, si la clase creada no tiene un atributo o método al que están tratando de llamar, entonces el intérprete va más allá de la cadena de clases base; si no hay ninguno en ellos, se realiza una búsqueda en la metaclase. En tales casos, para no confundir a nadie, es mejor usar otro registro: MetaRow.row_count += 1 .

Vea cuán elegantemente puede mostrar ahora toda la tabla:


 rows = [ Row(name='Valentin', age=25), Row(name='Sergey', age=33), Row(name='Gosha'), ] print(' | '.join(Row.__header__)) for row in rows: print(row) 

 | age | name 1 | 25 | Valentin 2 | 33 | Sergey 3 | N/A | Gosha 

Por cierto, mostrar y trabajar con una tabla se puede encapsular en una clase de Sheet separada.


Continuará ...


En la siguiente parte de este artículo, describiré cómo usar las metaclases para depurar el código de su aplicación, cómo parametrizar la creación de una metaclase y mostraré ejemplos básicos del uso del método __prepare__ . Estén atentos!


Con más detalle sobre metaclases y descriptores en Python, lo contaré en el marco de Advanced Python .

Source: https://habr.com/ru/post/es422409/


All Articles