2025, Nov 24 09:01

Вывод типа из __orig_bases__ в метаклассе Python для обёрток

Как метакласс в Python извлекает параметр дженерика через __orig_bases__, валидирует обёртку без дублирования типов и синхронизирует типизацию с рантаймом.

Когда вы создаёте типы-обёртки вокруг существующих классов в Python, обычно хочется сразу двух вещей: статической типизации, которая фиксирует, какой класс оборачивается, и проверки во время выполнения, контролирующей соответствие API оригиналу. Простой подход заставляет указывать один и тот же тип дважды — один раз как параметр обобщённого типа для типизатора и ещё раз как аргумент времени выполнения для метакласса, который валидирует реализацию. Такое дублирование шумное и хрупкое. Идея в том, чтобы объявить тип один раз и позволить рантайму вывести его автоматически.

Постановка задачи

Обёртка должна реализовать каждый публичный атрибут оборачиваемого класса. Метакласс выполняет проверку, а параметр типа сообщает типизатору, что именно оборачивается. Трение возникает из‑за того, что тип приходится указывать дважды, что легко приводит к расхождениям.

class MetaHook(type):
    def __new__(
        mcls,
        cls_name: str,
        parents: tuple[type, ...],
        namespace: dict[str, object],
        target_cls: type,
    ):
        for attr_name in target_cls.__dict__:
            if attr_name.startswith("__"):
                continue
            if attr_name not in namespace:
                raise TypeError(f"Need to implement {attr_name}")
        return super().__new__(mcls, cls_name, parents, namespace)
class Model:
    def foo(self):
        return "bar"
class Proxy[U](metaclass=MetaHook, target_cls=object):
    def do_proxy_work(self, x: U) -> U:
        return x
class ModelProxy(Proxy[Model], target_cls=Model): ...  # TypeError: «Нужно реализовать foo»

Механически это работает, но требует повторять тип и в качестве аргумента дженерика, и как параметр метакласса — ровно то, чего хочется избежать.

Откуда берётся дублирование

Аргументы обобщённых типов существуют для статического анализатора и не предназначены напрямую управлять поведением во время выполнения. Однако определение класса всё же запоминает, как были параметризованы дженерики. Во время создания класса Python сохраняет исходные выражения базовых классов в специальном атрибуте пространства имён класса. Этого достаточно, чтобы восстановить конкретный аргумент типа и использовать его в метаклассе, убирая необходимость в отдельном параметре только для рантайма.

Решение: выводить оборачиваемый класс из объявленного обобщённого базового класса

Подход в том, чтобы читать объявленные базовые классы как они записаны, извлечь параметр типа и запустить проверку на его основе. Есть один нюанс: при определении самой обобщённой обёртки базовый typing.Generic вставляется неявно; в этом случае проверку нужно пропускать.

from typing import Any, Generic
class MetaHook(type):
    def __new__[MC: MetaHook](
        mcls: type[MC],
        cls_name: str,
        parents: tuple[type, ...],
        namespace: dict[str, Any],
    ) -> MC:
        # Пропускаем валидацию для самого «скелета» обобщённой обёртки
        if parents and parents[0] is Generic:
            return super().__new__(mcls, cls_name, parents, namespace)
        # Восстанавливаем исходные выражения баз(ы), как они объявлены, напр. (Proxy[Model],)
        orig = namespace.get("__orig_bases__")
        if orig is not None:
            wrapped = orig[0].__args__[0]
        else:
            # Нет параметризованного дженерика; нечего проверять
            return super().__new__(mcls, cls_name, parents, namespace)
        for attr_name in wrapped.__dict__:
            if attr_name.startswith("__"):
                continue
            if attr_name not in namespace:
                raise TypeError(f"Need to implement {attr_name}")
        return super().__new__(mcls, cls_name, parents, namespace)
class Model:
    def foo(self) -> str:
        return "bar"
class Proxy[U](metaclass=MetaHook):
    def do_proxy_work(self, x: U) -> U:
        return x
class ModelProxy(Proxy[Model]): ...  # TypeError: Нужно реализовать foo

Теперь метакласс извлекает оборачиваемый класс из обобщённого базового класса через __orig_bases__, и больше не требуется передавать отдельный аргумент для рантайма. Проверка соответствия по‑прежнему сработает, если обёртка пропустит атрибут.

Что здесь на самом деле происходит

Во время создания класса в пространстве имён присутствует кортеж __orig_bases__, который хранит исходные объявления базовых классов, включая параметры дженериков. Для сценария с одной обёрткой достаточно извлечь первую базу и её первый аргумент. Специальная обработка typing.Generic позволяет не запускать проверку при определении самой обобщённой обёртки, когда конкретного типа ещё нет.

Почему это важно

Устранение дублирующих объявлений типов снижает риск расхождений между статическими намерениями и поведением во время выполнения. В системах, где обёртки обеспечивают соответствие API и контролируют доступ, единый источник истины для оборачиваемого типа поддерживает целостность модели и предотвращает тихие несоответствия. Типизатор остаётся согласован с тем, что реально происходит в рантайме, без лишней шаблонной обвязки.

Итог

Если вам нужен метакласс, который гарантирует соответствие обёртки целевому API, можно вывести конкретный параметр типа из объявленного обобщённого базового класса через __orig_bases__. Это убирает второй, чисто рантаймовый аргумент и делает дизайн одновременно проще и надёжнее. Имейте в виду, что выбор правильной базы может усложниться при множественных параметризованных базовых классах, но в простом случае такой подход синхронизирует типизацию и поведение во время выполнения с минимальными усилиями.