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.

    • +30
    • 15k
    • 7
    Binary District
    214,00
    Курсы, хакатоны и конференции по новым технологиям
    Поделиться публикацией

    Комментарии 7

      0
      Спасибо, отличная статья.
        0
        Недавно в рабочем проекте потребовалось условно создавать класс, у которого в зависимости от тех или иных входных параметров должен быть:
        • тот или иной набор инстанс-методов,
        • каждый из которых имеет свой атрибут помимо self,
        • также к каждому такому методу должен быть применён кастомный декоратор, импортированный из стороннего пакета.
        Пытался использовать приёмы метапрограммирования, но инстанс-методы оказывались привязанными (bounded) не к самому классу, а к метаклассам, в которых они объявлялись. Рабочим оказалось, может быть, не самое красивое решение — условно конструировать строку с исходным кодом для создания класса и выполнять её с помощью exec(), добавляя созданный класс в globals того модуля, где его требовалось получить.
          0
          А почему бы для этих целей не использовать фабрику классов, которая в зависимости от переданных параметров возвращала бы нужный класс с нужным набором методов?
            0
            Комбинация методов и их имена могут быть какими угодно в зависимости от входных параметров, это не конечный заранее известный набор.
            +2
            А зачем вы объявляли методы в метаклассе?
            0
            Спасибо, теперь я понимаю как тип class передается параметром функции…
              0
              Thanks!

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое