Python: метапрограммирование в продакшене. Часть первая

от автора

Python: метапрограммирование в продакшене. Часть первая

Многие считают, что метапрограммирование в Python излишне усложняет код, но если использовать его правильно, то можно быстро и элегантно реализовать сложные паттерны проектирования. Помимо этого, такие известные Python-фреймворки, как Django, DRF и SQLAlchemy, используют метаклассы, чтобы обеспечить легкую расширяемость и простое переиспользование кода.

В этой статье расскажу, почему не стоит бояться использовать метапрограммирование в своих проектах и покажу для каких задач оно подходит лучше всего. Еще больше о возможностях метапрограммирования можно узнать на курсе Advanced Python.

Для сначала давайте вспомним основы метапрограммирования в Python. Не лишним будет добавить, что все что написано ниже относится к версии Python 3.5 и выше.

Краткий экскурс в модель данных Python

Итак, все мы знаем, что все в Python является объектом, и не секрет, что для каждого объекта существует некий класс, которым он был порожден, например:

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

Тип объекта или же класс, которым объект был порожден, можно определить с помощью встроенной функции type, которая имеет достаточно интересную сигнатуру вызова (о ней речь пойдет немного позже). Такого же эффекта можно добиться, если вывести атрибут __class__ у любого объекта.

Итак, для создания функций служит некий встроенный класс function. Посмотрим, что мы сможем сделать с его помощью. Для этого возьмем заготовку из встроенного модуля types:

>>> 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.

Как мы видим, любая функция в Python – это экземпляр описанного выше класса. Давайте теперь попробуем создать новую функцию, не прибегая к её объявлению через def. Для этого нам потребуется научиться создавать объекты кода с помощью встроенной в интерпретатор функции compile:

# создаем объект кода, который выводит строку "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!

Отлично! С помощью мета-инструментов мы научились создавать функции «на лету», однако на практике подобное знание используется редко. Теперь давайте взглянем, как создаются объекты-классы и объекты-экземпляры этих классов:

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

Вполне очевидно, что класс User используется для создания экземпляра user, намного интереснее посмотреть на класс type, который используется для создания самого класса User. Вот здесь мы и обратимся ко второму варианту вызова встроенной функции type, которая по совместительству является метаклассом для любого класса в Python. Метакласс по определению – это класс, экземпляром которого является другой класс. Метаклассы позволяют нам настраивать процесс создания класса и частично управлять процессом создания экземпляра класса.

Согласно документации, второй вариант сигнатуры type(name, bases, attrs) – возвращает новый тип данных или, если по-простому – новый класс, причем атрибут name станет атрибутом __name__ у возвращенного класса, bases – список классов-родителей будет доступен как __bases__, ну а attrs – dict-like объект, содержащий все атрибуты и методы класса, перейдет в __dict__. Принцип работы функции можно описать в виде простого псевдокода на Python:

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

Посмотрим, как можно, используя только вызов type, сконструировать совершенно новый класс:

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

Как видим, нам не требуется использовать ключевое слово class, чтобы создать новый класс, функция type справляется и без этого, теперь давайте рассмотрим пример посложнее:

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

Как видно из примеров выше, описание классов и функций с помощью ключевых слов class и def – это всего лишь синтаксический сахар и любые типы объектов можно создавать обычными вызовами встроенных функций. А теперь, наконец, поговорим о том, как можно использовать динамическое создание классов в реальных проектах.

Динамическое создание форм и валидаторов

Иногда нам требуется провалидировать информацию от пользователя или из других внешних источников согласно заранее известной схеме данных. Например, мы хотим изменять форму логина пользователя из админки – удалять и добавлять поля, менять стратегию их валидации и т.д.

Для иллюстрации, попробуем динамически создать Django-форму, описание схемы которой хранится в следующем json формате:

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

Теперь на основе описания выше создадим набор полей и новую форму с помощью уже известной нам функции type:

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.']}

Супер! Теперь можно передать созданную форму в шаблон и отрендерить ее для пользователя. Такой же подход можно использовать и с другими фреймворками для валидации и представления данных (DRF Serializers, marshmallow и другие).

Конфигурируем создание нового класса через метакласс

Выше мы рассмотрели уже «готовый» метакласс type, но чаще всего в коде вы будете создавать свои собственные метаклассы и использовать их для конфигурации создания новых классов и их экземпляров. В общем случае «болванка» метакласса выглядит так:

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)

Чтобы воспользоваться этим метаклассом для конфигурации класса User, используется следующий синтаксис:

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

Самое интересное – это порядок, в котором интерпретатор Python вызывает метаметоды метакласса в момент создания самого класса:

  1. Интерпретатор определяет и находит классы-родители для текущего класса (если они есть).
  2. Интерпретатор определяет метакласс (MetaClass в нашем случае).
  3. Вызывается метод MetaClass.__prepare__ – он должен возвратить dict-like объект, в который будут записаны атрибуты и методы класса. После этого объект будет передан в метод MetaClass.__new__ через аргумент attrs. О практическом использовании этого метода мы поговорим немного позже в примерах.
  4. Интерпретатор читает тело класса User и формирует параметры для передачи их в метакласс MetaClass.
  5. Вызывается метод MetaClass.__new__ – метод-коструктор, возвращает созданный объект класса. C аргументами name, bases и attrs мы уже встречались, когда передавали их в функцию type, а о параметре **extra_kwargs мы поговорим немного позже. Если тип аргумента attrs был изменен с помощью __prepare__, то его необходимо конвертировать в dict, прежде чем передать в вызов метода super().
  6. Вызывается метод MetaClass.__init__ – метод-инициализатор, с помощью которого в класс можно добавить дополнительные атрибуты и методы в объект класса. На практике используется в случаях, когда метаклассы наследуются от других метаклассов, в остальном все что можно сделать в __init__, лучше сделать в __new__. Например параметр __slots__ можно задать только в методе __new__, записав его в объект attrs.
  7. На этом шаге класс считается созданным.

А теперь создадим экземпляр нашего класса User и посмотрим на цепочку вызовов:

user = User(name='Alyosha')

  1. В момент вызова User(...) интерпретатор вызывает метод MetaClass.__call__(name='Alyosha'), куда передает объект класса и переданные аргументы.
  2. MetaClass.__call__ вызывает User.__new__(name='Alyosha') – метод-конструктор, который создает и возвращает экземпляр класса User
  3. Далее MetaClass.__call__ вызывает User.__init__(name='Alyosha') – метод-инициализатор, который добавляет новые атрибуты к созданному экземпляру.
  4. MetaClass.__call__ возвращает созданный и проинициализированный экземпляр класса User.
  5. В этот момент экземпляр класса считается созданным.

Это описание, конечно, не покрывает все нюансы использования метаклассов, но его достаточно, чтобы начать применять метапрограммирование для реализации некоторых архитектурных паттернов. Вперед – к примерам!

Абстрактные классы

И самый первый пример можно найти в стандартной библиотеке: ABCMeta – метакласс позволяет объявить любой наш класс абстрактным и заставить всех его наследников реализовывать заранее заданные методы, свойства и атрибуты, вот посмотрите:

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  

Если в наследнике не будут реализованы все абстрактные методы и атрибуты, то при попытке создать экземпляр класса-наследника мы получим TypeError:

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

Использование абстрактных классов помогает сразу зафиксировать интерфейс базового класса и избежать ошибок при наследовании в будущем, например опечатки в названии переопределенного метода.

Система плагинов с автоматической регистрацией

Достаточно часто метапрограммирование применяют для реализации различных паттернов проектирования. Почти любой известный фреймворк использует метаклассы для создания registry-объектов. Такие объекты хранят в себе ссылки на другие объекты и позволяют их быстро получать в любом месте программы. Рассмотрим простой пример авторегистрации плагинов для проигрывания медиафайлов различных форматов.

Реализация метакласса:

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)  

А вот и сами плагины, реализацию BasePlugin возьмем из предыдущего примера:

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

После выполнения этого кода интерпретатором в нашем реестре будут зарегистрированы 4 формата и 2 плагина, которые могут обрабатывать эти форматы:

>>> 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...

Тут стоит отметить еще один интересный нюанс работы с метаклассами, благодаря неочевидному method resolution order, мы можем вызвать метод show_registry не только у класса RegistyMeta, но и у любого другого класса метаклассом которых он является:

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

Использование имен атрибутов в качестве метаданных

С помощью метаклассов можно использовать названия атрибутов классов в качестве метаданных для других объектов. Ничего непонятно? Но я уверен вы уже видели этот подход множество раз, например декларативное объявление полей модели в Django:

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

В пример выше title – это имя питоновского идентификатора, оно же используется и для названия колонки в таблице book, хотя мы это нигде явно не указывали. Да, подобная «магия» может быть реализована с помощью метапрограммирования. Давайте, например, реализуем систему передачи ошибок приложения на фронтенд, чтобы у каждого сообщения был читаемый код, который может быть использован для перевода сообщения на другой язык. Итак, у нас есть объект сообщения, который можно сконвертировать в 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})

Все наши сообщения об ошибках будем хранить в отдельном «namespace»:

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}

Теперь мы хотим, чтобы code стал не null, а not_found, для этого напишем следующий метакласс:

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):     ...

Посмотрим как наши сообщения выглядят теперь:

>>> 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"}

То что надо! Теперь вы знаете что делать, чтобы по формату данных можно было легко отыскать код, который их обрабатывает.

Кэширование метаданных о классе и его наследниках

Еще один частый случай – это кэширование каких-либо статических данных на этапе создания класса, чтобы не тратить время на их вычисление во время работы приложения. К тому же некоторые данные можно обновлять при создании новых экземпляров классов, например, счетчик количества созданных объектов.

Как это можно использовать? Допустим, вы разрабатываете фреймворк для построения отчетов и таблиц и у вас есть такой объект:

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)) 

Мы хотим сохранять и увеличивать счетчик при создании нового ряда, а также хотим сгенерировать заголовок результирующей таблицы заранее. Metaclass to the rescue!

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  

Здесь нужно пояснить 2 вещи:

  • У класс Row нет атрибутов класса с именами name и age – это аннотации типов, поэтому их нет в ключах словаря attrs, и, чтобы получить список полей, мы используем атрибут класса __annotations__.
  • Операция cls.row_count += 1 должна была ввести вас в заблуждение: как же так? Ведь cls это класс Row у него нет атрибута row_count. Всё верно, но как я уже объяснял выше – если у созданного класса нет атрибута или метода, который пытаются вызывать, то интерпретатор идет дальше по цепочке базовых классов – если и в них нет – происходит поиск в метаклассе. В таких случаях, чтобы никого не запутать лучше использовать другую запись: MetaRow.row_count += 1.

Смотрите, как элегантно теперь можно отобразить всю таблицу:

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

Кстати, отображение и работу с таблицой можно инкапсулировать в какой-нибудь отдельный класс Sheet.

Продолжение следует…

В следующей части этой статьи я расскажу как использовать метаклассы для отладки кода вашего приложения, как параметризовать создание метакласса, и покажу основные примеры использования метода __prepare__. Stay tuned!

Более подробно про метаклассы и дескрипторы в Python я буду рассказывать в рамках интенсива Advanced Python.


ссылка на оригинал статьи https://habr.com/post/422409/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *