Лекция 9. Метаклассы и __init_subclass__#

1. type как метакласс#

Класс — тоже объект. Кто его создаёт? Метакласс.

По умолчанию метакласс — type.

from typing import ClassVar

class Foo:
    x: ClassVar[int] = 1

print(type(Foo))
print(type(Foo()))
print(type(42))
print(type(int))
print(type(type))
<class 'type'>
<class '__main__.Foo'>
<class 'int'>
<class 'type'>
<class 'type'>

type(name, bases, namespace) — создание класса#

from typing import Protocol, cast

# Создадим класс обычным способом:
class Point:
    dim: ClassVar[int] = 2

    def __init__(self, x: int, y: int) -> None:
        self.x, self.y = x, y

class PointShape(Protocol):
    dim: ClassVar[int]
    x: int
    y: int

    def __init__(self, x: int, y: int) -> None: ...

# Теперь создадим похожий класс через type:
def _init(self: PointShape, x: int, y: int) -> None:
    self.x, self.y = x, y

Point2 = cast(
    "type[PointShape]",
    type(
        "Point2",  # имя класса
        (),  # базовые классы; пустой кортеж означает наследование от object
        {"dim": 2, "__init__": _init},  # пространство имён класса
    ),
)

p = Point2(1, 2)
print(f"Point2.dim = {Point2.dim}, p.x = {p.x}, p.y = {p.y}")
print(f"Point2 mro: {[c.__name__ for c in Point2.__mro__]}")
Point2.dim = 2, p.x = 1, p.y = 2
Point2 mro: ['Point2', 'object']

2. Собственный метакласс#

Наследуемся от type, переопределяем нужные методы.

Что можно переопределить#

  • __prepare__(name, bases, **kwargs) — подготовка пространства имён до выполнения тела класса.

  • __new__(mcs, name, bases, namespace, **kwargs) — создание объекта класса. Возвращает готовый класс.

  • __init__(cls, name, bases, namespace, **kwargs) — инициализация класса (после __new__).

  • __call__(cls, *args, **kwargs) — поведение при Foo(a, b), обычно создание экземпляра.

Для собственной реализации __prepare__ здесь показана сигнатура без декоратора. С @classmethod первым параметром добавляется mcs: __prepare__(mcs, name, bases, **kwargs). У __new__ декоратор не нужен: Python автоматически делает его статическим методом и передаёт mcs при создании класса.

В первых трёх методах **kwargs собирает дополнительные именованные аргументы из class, например register=False. Сам metaclass туда не попадает: Python использует его для выбора метакласса. Каждый из этих методов получает аргументы заголовка заново.

У __call__ это уже аргументы вызова Foo(...), например timeout=5 в Foo(timeout=5).

__prepare__, __new__ и __init__ участвуют в создании класса. Метод метакласса __call__ вызывается позже, когда мы пишем Foo(...).

__new__ — когда обрабатывается __slots__#

В теле класса __slots__ — обычное присваивание кортежа. После выполнения тела этот кортеж попадает в namespace, переданный метаклассу.

Его обрабатывает type.__new__: задаёт устройство будущих экземпляров и создаёт дескрипторы слотов. Посмотрим на пространство имён до и после вызова:

class SlotsMeta(type):
    def __new__(
        mcs: type[SlotsMeta],
        name: str,
        bases: tuple[type[object], ...],
        namespace: dict[str, object],
        **kwargs: object,
    ) -> SlotsMeta:
        print("before type.__new__:", namespace.get("__slots__"))
        print("x in namespace:", "x" in namespace)
        cls = super().__new__(mcs, name, bases, namespace, **kwargs)
        print("after type.__new__:", type(vars(cls)["x"]).__name__)
        return cls

class TracedPoint(metaclass=SlotsMeta):
    print("class body")
    __slots__ = ("x", "y")

    def __init__(self, x: int, y: int) -> None:
        print("instance init")
        self.x, self.y = x, y
class body
before type.__new__: ('x', 'y')
x in namespace: False
after type.__new__: member_descriptor

Дескриптор x уже создан, хотя экземпляров ещё нет. Если __dict__ не унаследован и не указан в __slots__, поддержка словаря экземпляра не добавляется. Память под поля конкретного объекта выделяется позже; __init__ заполняет их:

traced_point = TracedPoint(1, 2)
print(traced_point.x, traced_point.y)
print(hasattr(traced_point, "__dict__"))
instance init
1 2
False

Изменение кортежа после создания класса не добавляет новые слоты:

TracedPoint.__slots__ = ("x", "y", "extra")
traced_point.extra = 42
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[6], line 2
      1 TracedPoint.__slots__ = ("x", "y", "extra")
----> 2 traced_point.extra = 42

AttributeError: 'TracedPoint' object has no attribute 'extra' and no __dict__ for setting new attributes

__new__#

Добавим действие при создании класса: метакласс запишет его в реестр. Параметр register=False отключит регистрацию:

class RegistryMeta(type):
    _registry: ClassVar[dict[str, type[object]]] = {}

    def __new__(
        mcs: type[RegistryMeta],
        name: str,
        bases: tuple[type[object], ...],
        namespace: dict[str, object],
        *,
        register: bool = True,
        **kwargs: object,
    ) -> RegistryMeta:
        cls = super().__new__(mcs, name, bases, namespace, **kwargs)
        if register:
            RegistryMeta._registry[name] = cls
        return cls

class Plugin(metaclass=RegistryMeta, register=False):
    pass

class SlackPlugin(Plugin, register=True):
    channel: ClassVar[str] = "general"

class EmailPlugin(Plugin):
    from_addr: ClassVar[str] = "noreply@example.com"

class InternalPlugin(Plugin, register=False):
    pass

print(f"registered: {list(RegistryMeta._registry.keys())}")
print("register attribute:", hasattr(Plugin, "register"))
registered: ['SlackPlugin', 'EmailPlugin']
register attribute: False

register попадает в RegistryMeta.__new__. Он принят отдельным аргументом и не передаётся дальше в super().__new__.

У SlackPlugin явно передано register=True, у EmailPlugin срабатывает значение по умолчанию. Параметр не становится атрибутом класса: register=False у Plugin не наследуется подклассами.

Метакласс перехватил создание подклассов и записал их в реестр. Полезно для систем плагинов, но здесь __init_subclass__ проще.

__prepare__#

Обычно переопределять __prepare__ не нужно. Стандартный type.__prepare__ возвращает пустой dict, который заполнится при выполнении тела класса:

namespace = type.__prepare__("Example", ())
print(type(namespace))
print(namespace)
<class 'dict'>
{}

Если в теле класса дважды присвоить значение одному имени, обычный dict сохранит только второе. Через __prepare__ подставим словарь, который проверяет каждую запись во время выполнения тела класса:

class UniqueNames(dict[str, object]):
    def __setitem__(self, name: str, value: object) -> None:
        if name in self:
            raise TypeError(f"duplicate class attribute: {name}")
        super().__setitem__(name, value)

class UniqueMeta(type):
    @classmethod
    def __prepare__(
        mcs: type[UniqueMeta],
        name: str,
        bases: tuple[type[object], ...],
        /,
        **kwargs: object,
    ) -> UniqueNames:
        print(f"preparing {name}")
        return UniqueNames()

    def __new__(
        mcs: type[UniqueMeta],
        name: str,
        bases: tuple[type[object], ...],
        namespace: dict[str, object],
        **kwargs: object,
    ) -> UniqueMeta:
        print(f"creating {name}")
        return super().__new__(mcs, name, bases, namespace, **kwargs)

class Settings(metaclass=UniqueMeta):
    port: ClassVar[int] = 8000

print(Settings.port)
preparing Settings
creating Settings
8000

Повторное присваивание падает во время выполнения тела класса:

class BrokenSettings(metaclass=UniqueMeta):
    port: ClassVar[int] = 8000
    port = 9000
preparing BrokenSettings
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[10], line 1
----> 1 class BrokenSettings(metaclass=UniqueMeta):
      2     port: ClassVar[int] = 8000
      3     port = 9000

Cell In[10], line 3, in BrokenSettings()
      1 class BrokenSettings(metaclass=UniqueMeta):
      2     port: ClassVar[int] = 8000
----> 3     port = 9000

Cell In[9], line 4, in UniqueNames.__setitem__(self, name, value)
      2     def __setitem__(self, name: str, value: object) -> None:
      3         if name in self:
----> 4             raise TypeError(f"duplicate class attribute: {name}")
      5         super().__setitem__(name, value)

TypeError: duplicate class attribute: port

Напечаталось preparing BrokenSettings, но нет creating BrokenSettings: до __new__ выполнение не дошло. С обычным словарём он получил бы только port = 9000 и уже не увидел бы повторную запись.

__init__#

В __init__ класс уже создан: доступны его атрибуты и MRO. Подберём для него логгер по полному имени:

import logging

class LoggerMeta(type):
    logger: logging.Logger

    def __init__(
        cls: LoggerMeta,
        name: str,
        bases: tuple[type[object], ...],
        namespace: dict[str, object],
        **kwargs: object,
    ) -> None:
        super().__init__(name, bases, namespace, **kwargs)
        cls.logger = logging.getLogger(f"{cls.__module__}.{cls.__qualname__}")

class Task(metaclass=LoggerMeta):
    pass

class ExportTask(Task):
    pass

print(Task.logger.name)
print(ExportTask.logger.name)
print("same logger:", Task.logger is ExportTask.logger)
__main__.Task
__main__.ExportTask
same logger: False

Метод __init__ выполнился для Task и для ExportTask, каждому назначен свой логгер. Экземпляры для этого не нужны. Метод возвращает None; готовый класс возвращает __new__.

__call__#

Упрощённая схема type.__call__ (псевдокод):

def __call__(cls: type[object], *args: object, **kwargs: object) -> object:
    obj = cls.__new__(cls, *args, **kwargs)
    if isinstance(obj, cls):
        type(obj).__init__(obj, *args, **kwargs)
    return obj

Свой __call__ заменяет эту схему целиком и может вернуть уже готовый объект.

Сделаем singleton: повторный вызов Config() вернёт объект из кеша. Стандартную цепочку __new__ и __init__ запускаем только при первом вызове:

class SingletonMeta(type):
    _instances: ClassVar[dict[type[object], object]] = {}

    def __call__(cls: SingletonMeta, *args: object, **kwargs: object) -> object:
        if cls not in SingletonMeta._instances:
            SingletonMeta._instances[cls] = super().__call__(*args, **kwargs)
        return SingletonMeta._instances[cls]

class Config(metaclass=SingletonMeta):
    def __init__(self) -> None:
        print("Config __init__ called")
        self.data: dict[str, object] = {}

c1 = Config()
c2 = Config()  # не вызывает __init__ второй раз
print(f"same instance: {c1 is c2}")
c1.data["theme"] = "dark"
print(c2.data)
Config __init__ called
same instance: True
{'theme': 'dark'}

Оба имени ссылаются на один объект, поэтому изменение через c1 видно через c2. Вызов super().__call__ создаёт и инициализирует экземпляр; свой __call__ решает, нужно ли его вообще вызывать.

Наследование и выбор метакласса#

Метакласс действует и на потомков: повторять metaclass=LoggerMeta не нужно. При создании внука снова выполнится LoggerMeta.__init__:

class ExportTaskChild(ExportTask):
    pass

print(type(Task).__name__)
print(type(ExportTaskChild).__name__)
print(ExportTaskChild.logger.name)
LoggerMeta
LoggerMeta
__main__.ExportTaskChild

Явный metaclass=type не сбрасывает метакласс базы. Python выбирает наиболее конкретного из совместимых кандидатов:

class ExportWithType(Task, metaclass=type):
    pass

print(type(ExportWithType) is LoggerMeta)
True

Конфликт метаклассов#

Попробуем объединить регистрацию из Plugin и логгер из Task:

class LoggedPlugin(Plugin, Task):
    pass
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[15], line 1
----> 1 class LoggedPlugin(Plugin, Task):
      2     pass

TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

У Plugin метакласс RegistryMeta, у Task — LoggerMeta; они не наследуются друг от друга. Итоговый метакласс должен быть подклассом метаклассов всех баз, а Python не создаёт такой класс автоматически.

Указание type не обходит это ограничение:

class StillConflicting(Plugin, Task, metaclass=type):
    pass
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[16], line 1
----> 1 class StillConflicting(Plugin, Task, metaclass=type):
      2     pass

TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

Объединим метаклассы явно и проверим, что оба механизма работают:

class RegisteredLoggerMeta(RegistryMeta, LoggerMeta):
    pass

class LoggedPlugin(Plugin, Task, metaclass=RegisteredLoggerMeta):
    pass

print([meta.__name__ for meta in type(LoggedPlugin).__mro__])
print("registered:", RegistryMeta._registry["LoggedPlugin"] is LoggedPlugin)
print("logger:", LoggedPlugin.logger.name)
['RegisteredLoggerMeta', 'RegistryMeta', 'LoggerMeta', 'type', 'object']
registered: True
logger: __main__.LoggedPlugin

RegistryMeta.__new__ зарегистрировал класс, а LoggerMeta.__init__ назначил логгер. Если метаклассы переопределяют один и тот же метод, порядок задаёт MRO, а продолжение цепочки зависит от вызовов super().


3. Готовый метакласс: ABCMeta#

Создадим два класса с одинаковым пространством имён, меняя только метакласс. Один создаст type, другой — ABCMeta:

from abc import ABCMeta, abstractmethod

def read(self: object, key: str) -> bytes:
    raise NotImplementedError()

namespace = {"read": abstractmethod(read)}
PlainStorage = type("PlainStorage", (), namespace)
AbstractStorage = ABCMeta("AbstractStorage", (), namespace)

print("method marker:", getattr(read, "__isabstractmethod__"))
print("plain metaclass:", type(PlainStorage).__name__)
print("abstract metaclass:", type(AbstractStorage).__name__)
print("abstract methods:", AbstractStorage.__abstractmethods__)
print("created:", type(PlainStorage()).__name__)
method marker: True
plain metaclass: type
abstract metaclass: ABCMeta
abstract methods: frozenset({'read'})
created: PlainStorage

abstractmethod(read) добавил функции метку __isabstractmethod__. type её не обрабатывает, а ABCMeta собрал __abstractmethods__:

AbstractStorage()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 AbstractStorage()

TypeError: Can't instantiate abstract class AbstractStorage without an implementation for abstract method 'read'

Содержимое пространства имён одинаковое, но с ABCMeta создать экземпляр не получилось. Наследование от ABC выбирает этот метакласс автоматически.


4. __init_subclass__ — часто проще метакласса#

Соберём тот же реестр через __init_subclass__:

class Plugin:
    _registry: ClassVar[dict[str, type[Plugin]]] = {}

    def __init_subclass__(
        cls: type[Plugin], *, register: bool = True, **kwargs: object
    ) -> None:
        super().__init_subclass__(**kwargs)
        if register:
            Plugin._registry[cls.__name__] = cls

class SlackPlugin(Plugin): pass
class EmailPlugin(Plugin): pass
class InternalPlugin(Plugin, register=False): pass

print(f"registered: {list(Plugin._registry.keys())}")
registered: ['SlackPlugin', 'EmailPlugin']

__init_subclass__ автоматически работает как метод класса: cls — создаваемый подкласс. Вызов super() позволяет другим базовым классам тоже обработать создание подкласса.

Здесь register обрабатывает уже __init_subclass__; остальные аргументы передаются дальше через kwargs. Если аргумент никто не обработает, он дойдёт до object.__init_subclass__, который вызовет TypeError:

class UnknownOption(Plugin, unknown=True):
    pass
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[21], line 1
----> 1 class UnknownOption(Plugin, unknown=True):
      2     pass

Cell In[20], line 7, in Plugin.__init_subclass__(cls, register, **kwargs)
      4     def __init_subclass__(
      5         cls: type[Plugin], *, register: bool = True, **kwargs: object
      6     ) -> None:
----> 7         super().__init_subclass__(**kwargs)
      8         if register:
      9             Plugin._registry[cls.__name__] = cls

TypeError: UnknownOption.__init_subclass__() takes no keyword arguments

__init_subclass__ наследуется и работает для внуков#

Plugin.__init_subclass__ работает при создании потомков Plugin, а не самого класса. У внука может не быть собственного __init_subclass__:

class SlackChild(SlackPlugin):
    pass

class InternalChild(InternalPlugin):
    pass

print("registered:", list(Plugin._registry))
print("Plugin registered:", "Plugin" in Plugin._registry)
print("own __init_subclass__:", "__init_subclass__" in vars(SlackChild))
registered: ['SlackPlugin', 'EmailPlugin', 'SlackChild', 'InternalChild']
Plugin registered: False
own __init_subclass__: False

Оба внука зарегистрированы. register=False был аргументом при создании InternalPlugin; при создании InternalChild снова сработало значение по умолчанию register=True. Аргументы, переданные в __init_subclass__, сами не наследуются.

Переопределение без super() обрывает цепочку#

Переопределим __init_subclass__ в QuietPlugin без вызова super():

class QuietPlugin(Plugin):
    def __init_subclass__(cls: type[QuietPlugin], **kwargs: object) -> None:
        print("QuietPlugin.__init_subclass__:", cls.__name__)

class QuietChild(QuietPlugin):
    pass

print("QuietPlugin registered:", "QuietPlugin" in Plugin._registry)
print("QuietChild registered:", "QuietChild" in Plugin._registry)
QuietPlugin.__init_subclass__: QuietChild
QuietPlugin registered: True
QuietChild registered: False

Сам QuietPlugin ещё зарегистрирован методом Plugin.__init_subclass__. При создании QuietChild вызван QuietPlugin.__init_subclass__, который не передал управление дальше: регистрация пропала. Вернём super() и повторим создание классов:

class QuietPlugin(Plugin):
    def __init_subclass__(cls: type[QuietPlugin], **kwargs: object) -> None:
        super().__init_subclass__(**kwargs)
        print("QuietPlugin.__init_subclass__:", cls.__name__)

class QuietChild(QuietPlugin):
    pass

print("QuietChild registered:", Plugin._registry["QuietChild"] is QuietChild)
QuietPlugin.__init_subclass__: QuietChild
QuietChild registered: True

Несколько родителей: MRO и именованные аргументы#

Python выбирает первую реализацию __init_subclass__ по MRO родителей. Другие реализации вызываются через super(): каждый метод забирает свои аргументы, а оставшиеся передаёт дальше.

class Root:
    def __init_subclass__(cls: type[Root], **kwargs: object) -> None:
        print("Root.__init_subclass__", cls.__name__)
        super().__init_subclass__(**kwargs)

class Named(Root):
    def __init_subclass__(
        cls: type[Named], *, label: str = "default", **kwargs: object
    ) -> None:
        print("Named.__init_subclass__", cls.__name__, label)
        super().__init_subclass__(**kwargs)

class Tagged(Root):
    def __init_subclass__(
        cls: type[Tagged], *, tag: str = "none", **kwargs: object
    ) -> None:
        print("Tagged.__init_subclass__", cls.__name__, tag)
        super().__init_subclass__(**kwargs)
Root.__init_subclass__ Named
Root.__init_subclass__ Tagged

У Event ромб наследования. Посмотрим порядок вызовов __init_subclass__ и MRO:

class Event(Named, Tagged, label="Login", tag="security"):
    pass

print(" -> ".join(base.__name__ for base in Event.__mro__))
Named.__init_subclass__ Event Login
Tagged.__init_subclass__ Event security
Root.__init_subclass__ Event
Event -> Named -> Tagged -> Root -> object

Выполняются Named.__init_subclass__, Tagged.__init_subclass__ и Root.__init_subclass__; общая база сработала один раз. super() внутри Named перешёл в Tagged, хотя непосредственная база Named — Root: важен MRO создаваемого Event.

При создании внука цепочка повторяется, но аргументы заголовка Event заново не передаются:

class SpecialEvent(Event):
    pass
Named.__init_subclass__ SpecialEvent default
Tagged.__init_subclass__ SpecialEvent none
Root.__init_subclass__ SpecialEvent

При создании SpecialEvent метод Named.__init_subclass__ получил label="default", а Tagged.__init_subclass__ — tag="none". Если __init_subclass__ сохраняет настройку в атрибуте класса, этот атрибут наследуется по обычным правилам; сам аргумент вызова от этого не становится наследуемым.

Правило#

Если задачу решает __init_subclass__, используй его. Метакласс нужен, когда требуется:

  • Задать пространство имён до выполнения тела класса через __prepare__.

  • Изменить пространство имён или базовые классы перед вызовом type.__new__.

  • Управлять созданием экземпляров cls(...) через __call__.

  • Дополнить поведение существующего метакласса, например ABCMeta.


5. Пример: события Git в JSON#

Представим сведения о коммите и изменённом файле объектами с разными полями. У каждого будет общий метод to_json() для записи в JSON.

Сбор полей и генерацию __init__ выполняет @dataclass. Метакласс применит этот декоратор, добавит имя события и зарегистрирует подкласс GitEvent. Это учебный способ связать несколько действий при создании класса; здесь подошёл бы и __init_subclass__.

%%writefile /tmp/event_meta_demo.py
from dataclasses import asdict, dataclass, is_dataclass
from inspect import signature
import json
from typing import TYPE_CHECKING, ClassVar, cast

class EventMeta(type):
    registry: ClassVar[dict[str, type[GitEvent]]] = {}

    def __new__(
        mcs: type[EventMeta],
        name: str,
        bases: tuple[type[object], ...],
        namespace: dict[str, object],
        **kwargs: object,
    ) -> type[GitEvent]:
        cls = cast("type[GitEvent]", super().__new__(mcs, name, bases, namespace, **kwargs))
        if bases:
            cls = dataclass(cls)
            cls.event_type = name
            mcs.registry[name] = cls

        return cls

class GitEvent(metaclass=EventMeta):
    event_type: ClassVar[str]

    def to_json(self) -> str:
        if not is_dataclass(self):
            raise TypeError("GitEvent subclasses must be dataclasses")
        return json.dumps({"event": self.event_type, "data": asdict(self)})

class CommitCreated(GitEvent):
    commit_hash: str = ""
    author: str = ""

class FileChanged(GitEvent):
    path: str = ""
    lines_added: int = 0

if TYPE_CHECKING:
    reveal_type(FileChanged)
    reveal_type(FileChanged.path)

file_change = FileChanged(path="README.md", lines_added=12)
print("FileChanged constructor:", signature(FileChanged))
print(file_change.to_json())
print("event types:", list(EventMeta.registry))
Overwriting /tmp/event_meta_demo.py
!python /tmp/event_meta_demo.py
FileChanged constructor: (path: str = '', lines_added: int = 0) -> None
{"event": "FileChanged", "data": {"path": "README.md", "lines_added": 12}}
event types: ['CommitCreated', 'FileChanged']

Поля path и lines_added объявлены в классе. По ним dataclass(cls) сгенерировал __init__, который принимает эти аргументы и записывает их в экземпляр.

Подклассам не нужен отдельный декоратор. Реестр связывает имя события из JSON с соответствующим классом.

Что видит тайпчекер#

signature(FileChanged) показывает параметры path и lines_added, но mypy не выполняет код метакласса. Посмотрим, какие типы он вывел для класса и его поля:

!mypy --strict /tmp/event_meta_demo.py
/tmp/event_meta_demo.py:41: note: Revealed type is "def () -> event_meta_demo.FileChanged"
/tmp/event_meta_demo.py:42: note: Revealed type is "str"
/tmp/event_meta_demo.py:44: error: Unexpected keyword argument "path" for "FileChanged"  [call-arg]
/tmp/event_meta_demo.py:44: error: Unexpected keyword argument "lines_added" for "FileChanged"  [call-arg]
Found 2 errors in 1 file (checked 1 source file)

Тип поля path известен: str. Но конструктор для mypy имеет вид def () -> FileChanged, поэтому он отвергает корректный вызов с path= и lines_added=. Сгенерированный __init__ ему неизвестен.

Как сообщить тайпчекеру о конструкторе#

Обычный @dataclass mypy распознаёт сам, а его вызов внутри EventMeta не анализирует. Добавим @dataclass_transform() на метакласс: это сообщает тайпчекеру, что конструктор строится из полей. В сокращённом примере:

%%writefile /tmp/dc_transform_demo.py
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, dataclass_transform

@dataclass_transform()
class EventMeta(type):
    def __new__(
        mcs: type[EventMeta],
        name: str,
        bases: tuple[type[object], ...],
        namespace: dict[str, object],
        **kwargs: object,
    ) -> type[object]:
        cls = cast(type[object], super().__new__(mcs, name, bases, namespace, **kwargs))
        return dataclass(cls)

class FileChanged(metaclass=EventMeta):
    path: str = ""
    lines_added: int = 0

file_change = FileChanged(path="README.md", lines_added=12)
print(file_change)

if TYPE_CHECKING:
    reveal_type(FileChanged)  # mypy покажет сигнатуру конструктора
Overwriting /tmp/dc_transform_demo.py
!mypy --strict /tmp/dc_transform_demo.py
!python /tmp/dc_transform_demo.py
/tmp/dc_transform_demo.py:24: note: Revealed type is "def (path: str =, lines_added: int =) -> dc_transform_demo.FileChanged"
Success: no issues found in 1 source file
FileChanged(path='README.md', lines_added=12)

Теперь mypy вывел параметры конструктора из полей и принял тот же вызов. Автору метакласса достаточно поставить декоратор один раз, чтобы тайпчекер проверял вызовы всех таких классов. Сам __init__ по-прежнему создаёт dataclass(cls); dataclass_transform только сообщает тайпчекеру правило его построения.

Параметры dataclass_transform#

Посмотрим на сигнатуру декоратора:

from inspect import signature
from typing import dataclass_transform

print(signature(dataclass_transform))
(*, eq_default: bool = True, order_default: bool = False, kw_only_default: bool = False, frozen_default: bool = False, field_specifiers: tuple[type[typing.Any] | typing.Callable[..., typing.Any], ...] = (), **kwargs: Any) -> <class '_IdentityCallable'>

Параметр

По умолчанию

Что описывает для тайпчекера

eq_default

True

Генерируется ли сравнение по полям через __eq__.

order_default

False

Генерируются ли __lt__, __le__, __gt__, __ge__.

kw_only_default

False

Передаются ли все параметры полей в конструктор только по имени.

frozen_default

False

Запрещено ли менять поля после инициализации.

field_specifiers

()

Какие функции или классы описывают отдельные поля, например dataclasses.field.

**kwargs

нет

Резерв для будущих расширений.

Суффикс _default означает значение по умолчанию для соответствующего флага eq, order, kw_only или frozen. Если API библиотеки принимает такой флаг явно, он может переопределить это значение; обработать его при выполнении должна сама библиотека.

При выполнении декоратор только сохраняет настройки в __dataclass_transform__:

@dataclass_transform(kw_only_default=True, eq_default=False)
class ModelMeta(type):
    pass

print(getattr(ModelMeta, "__dataclass_transform__"))
{'eq_default': False, 'order_default': False, 'kw_only_default': True, 'frozen_default': False, 'field_specifiers': (), 'kwargs': {}}

В задаче mini_dataclass эти два аргумента описывают нужный нам интерфейс:

  • kw_only_default=True: Point(x=2) допустим, а Point(2) тайпчекер отвергнет.

  • eq_default=False: сравнение по полям не генерируется. Оператор == остаётся доступен; в этой задаче объекты сравниваются по идентичности.

Конструктор и __repr__ в задаче реализуем сами. Сам dataclass_transform не добавляет методы и не обеспечивает выполнение этих правил.


6. Когда метакласс — правильный ответ#

Метакласс оправдан, если нужно вмешаться в выполнение тела класса, изменить его атрибуты до type.__new__ или управлять созданием экземпляров при Cls(...). Например, запрет повторных имён требует проверки записей через __prepare__: после выполнения тела класса обычный словарь уже потерял предыдущее значение.

Для более простых задач достаточно обычных средств:

  • Регистрация подклассов — __init_subclass__.

  • Требование реализовать метод — ABC и abstractmethod.

  • Изменение готового класса — декоратор класса.

Материалы#