Python: метапрограммирование в продакшене. Часть первая
Многие считают, что метапрограммирование в Python излишне усложняет код, но если использовать его правильно, то можно быстро и элегантно реализовать сложные паттерны проектирования. Помимо этого, такие известные Python-фреймворки, как Django, DRF и SQLAlchemy, используют метаклассы, чтобы обеспечить легкую расширяемость и простое переиспользование кода.
В этой статье расскажу, почему не стоит бояться использовать метапрограммирование в своих проектах и покажу, для каких задач оно подходит лучше всего. Еще больше о возможностях метапрограммирования можно узнать на курсе Advanced Python.
Для сначала давайте вспомним основы метапрограммирования в Python. Не лишним будет добавить, что все что написано ниже относится к версии Python 3.5 и выше.
Краткий экскурс в модель данных Python
Итак, все мы знаем, что все в Python является объектом, и не секрет, что для каждого объекта существует некий класс, которым он был порожден, например:
>>> def f(): pass
>>> type(f)
Тип объекта или же класс, которым объект был порожден, можно определить с помощью встроенной функции type, которая имеет достаточно интересную сигнатуру вызова (о ней речь пойдет немного позже). Такого же эффекта можно добиться, если вывести атрибут __class__
у любого объекта.
Итак, для создания функций служит некий встроенный класс function
. Посмотрим, что мы сможем сделать с его помощью. Для этого возьмем заготовку из встроенного модуля types:
>>> from types import FunctionType
>>> FunctionType
>>> 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!")', '', 'eval')
>>> code
at 0xdeadbeef, file "", line 1>
# создаем функцию, передав в конструктор объект кода,
# глобальные переменные и название функции
>>> func = FunctionType(code, globals(), 'greetings')
>>> func
at 0xcafefeed>
>>> func.__name__
'greetings'
>>> func()
Hello, world!
Отлично! С помощью мета-инструментов мы научились создавать функции «на лету», однако на практике подобное знание используется редко. Теперь давайте взглянем, как создаются объекты-классы и объекты-экземпляры этих классов:
>>> class User: pass
>>> user = User()
>>> type(user)
>>> type(User)
Вполне очевидно, что класс 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
, чтобы создать новый класс, функция 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
>>> 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 вызывает метаметоды метакласса в момент создания самого класса:
- Интерпретатор определяет и находит классы-родители для текущего класса (если они есть).
- Интерпретатор определяет метакласс (
MetaClass
в нашем случае). - Вызывается метод
MetaClass.__prepare__
— он должен возвратить dict-like объект, в который будут записаны атрибуты и методы класса. После этого объект будет передан в методMetaClass.__new__
через аргументattrs
. О практическом использовании этого метода мы поговорим немного позже в примерах. - Интерпретатор читает тело класса
User
и формирует параметры для передачи их в метаклассMetaClass
. - Вызывается метод
MetaClass.__new__
— метод-коструктор, возвращает созданный объект класса. C аргументамиname
,bases
иattrs
мы уже встречались, когда передавали их в функциюtype
, а о параметре**extra_kwargs
мы поговорим немного позже. Если тип аргументаattrs
был изменен с помощью__prepare__
, то его необходимо конвертировать вdict
, прежде чем передать в вызов методаsuper()
. - Вызывается метод
MetaClass.__init__
— метод-инициализатор, с помощью которого в класс можно добавить дополнительные атрибуты и методы в объект класса. На практике используется в случаях, когда метаклассы наследуются от других метаклассов, в остальном все что можно сделать в__init__
, лучше сделать в__new__
. Например параметр__slots__
можно задать только в методе__new__
, записав его в объектattrs
. - На этом шаге класс считается созданным.
А теперь создадим экземпляр нашего класса User
и посмотрим на цепочку вызовов:
user = User(name='Alyosha')
- В момент вызова
User(...)
интерпретатор вызывает методMetaClass.__call__(name='Alyosha')
, куда передает объект класса и переданные аргументы. MetaClass.__call__
вызываетUser.__new__(name='Alyosha')
— метод-конструктор, который создает и возвращает экземпляр классаUser
- Далее
MetaClass.__call__
вызываетUser.__init__(name='Alyosha')
— метод-инициализатор, который добавляет новые атрибуты к созданному экземпляру. MetaClass.__call__
возвращает созданный и проинициализированный экземпляр классаUser
.- В этот момент экземпляр класса считается созданным.
Это описание, конечно, не покрывает все нюансы использования метаклассов, но его достаточно, чтобы начать применять метапрограммирование для реализации некоторых архитектурных паттернов. Вперед — к примерам!
Абстрактные классы
И самый первый пример можно найти в стандартной библиотеке: 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': ,
'mov': ,
'mp3': ,
'mpg': }
>>> plugin_class = RegistryMeta.get_plugin('mov')
>>> plugin_class
>>> 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.