Хабрахабр

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

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

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

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

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

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

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

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

Посмотрим, что мы сможем сделать с его помощью. Итак, для создания функций служит некий встроенный класс 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.

Давайте теперь попробуем создать новую функцию, не прибегая к её объявлению через def. Как мы видим, любая функция в Python – это экземпляр описанного выше класса. Для этого нам потребуется научиться создавать объекты кода с помощью встроенной в интерпретатор функции 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'>

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

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

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.

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

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

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

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть