2025, Nov 29 21:02

Как сохранить сигнатуру конструктора в фабрике Python 3.12 с ParamSpec и Concatenate

Как в Python 3.12 типизировать фабрику с частичным применением и сохранить сигнатуру конструктора: ParamSpec, Concatenate, советы для mypy и pyright, IDE.

Типизация фабрики в Python 3.12 так, чтобы она сохраняла сигнатуру конструктора подклассов, кажется простой — пока не попытаешься угодить IDE и типизатору. Обычный прием с частичным применением аргументов конструктора отлично работает во время выполнения, но без аккуратной типизации информация о сигнатуре теряется, и IntelliSense работает хуже. Ниже — краткое объяснение, почему так происходит, и как вернуть точные сигнатуры с помощью ParamSpec и Concatenate.

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

Нам нужен classmethod, который заранее фиксирует часть аргументов конструктора, возвращает вызываемый объект, а затем завершает создание, когда ему передадут первый недостающий параметр. Во время выполнения все работает, но сигнатура возвращаемого вызываемого должна отражать конструктор подкласса без этого первого параметра, чтобы редакторы и типизаторы подсказывали корректное использование.

from typing import ParamSpec, TypeVar

Q = ParamSpec("Q")
U = TypeVar("U")

class Root:
    @classmethod
    def make(cls, *args: Q.args, **kwargs: Q.kwargs):
        def binder(anchor):
            return cls(anchor, *args, **kwargs)
        return binder

class VariantA(Root):
    def __init__(self, anchor: str, x1: int, x2: int):
        print(f"VariantA created with: {anchor}, {x1}, {x2}")

stub = VariantA.make(1, 2)
print(stub)
obj = stub("ctx")
print(obj)

Фабрика работает, объект создается, но возвращаемый make вызываемый не «публикует» сигнатуру, полученную из VariantA.__init__ без первого параметра. Автодополнение не помогает, а статический анализ не может проверить вызовы.

Почему сигнатура теряется

ParamSpec отслеживает списки параметров вызываемых объектов, но в приведенном коде он не связан с типом конструктора cls. Типизатор видит cls как объект класса, а не как вызываемый с конкретным списком параметров. Чтобы сохранить сигнатуру, нужно сделать две вещи. Во‑первых, переосмыслить текущий класс cls как Callable, чьи параметры соответствуют его конструктору. С точки зрения typing, type[Something] ведет себя как тип его конструктора, так что такая трактовка корректна для целей типизации. Во‑вторых, убрать из этой сигнатуры первый аргумент, моделируя частичное применение. Concatenate позволяет выразить «один ведущий параметр плюс остальные», после чего мы можем разделить их на захваченные аргументы и аргумент, который будет передан позже.

Решение с Concatenate и Callable

Привязка ParamSpec к конструктору и извлечение первого параметра решает проблему. Тип результата фабрики — вызываемый, принимающий только первый аргумент, тогда как сама фабрика принимает остальные параметры конструктора. Такой подход без нареканий проходит проверку в mypy. Для pyright достаточно определить минимальный __init__ в базовом классе хотя бы с одним аргументом, чтобы Callable с ведущим параметром оставался совместимым.

from typing import Callable, Concatenate, ParamSpec, TypeVar

Q = ParamSpec("Q")
U = TypeVar("U")
V = TypeVar("V")

class Root:
    def __init__(self, anchor: str) -> None:
        pass

    @classmethod
    def make(
        cls: Callable[Concatenate[U, Q], V],
        *args: Q.args,
        **kwargs: Q.kwargs,
    ) -> Callable[[U], V]:
        def binder(anchor: U) -> V:
            return cls(anchor, *args, **kwargs)
        return binder

class VariantA(Root):
    def __init__(self, anchor: str, x1: int, x2: int) -> None:
        print(f"VariantA created with: {anchor}, {x1}, {x2}")

stub = VariantA.make(1, 2)
print(stub)
obj = stub("ctx")
print(obj)

Так мы перепривязываем класс к Callable[Concatenate[U, Q], V], где U — первый параметр конструктора (тот, который будет передан позже), а Q — остальные (захватываются make). Фабрика возвращает Callable[[U], V] — функцию, которая принимает первый параметр и выдает сконструированный объект. mypy принимает такую схему, а pyright согласуется, когда в базовом классе объявлен __init__ как минимум с одним параметром.

Зачем это нужно

Правильная типизация здесь — не дань красоте. Точные сигнатуры улучшают подсказки IDE, убирают догадки и ускоряют обратную связь в ревью и CI. С ParamSpec и Concatenate фабричный паттерн с частичным применением аргументов конструктора остается понятным и проверяемым. Команды выигрывают от стабильных, предсказуемых подсказок редактора и более ясных сообщений об ошибках от типизаторов.

Итоги

Если фабрике нужно сохранять сигнатуру конструктора, откладывая один ведущий аргумент, моделируйте класс как Callable и используйте Concatenate, чтобы отделить первый параметр от остальных. Привяжите ParamSpec к «хвосту» конструктора, возвращайте вызываемый для «головного» параметра и оставьте минимальный __init__ в базе, чтобы pyright был доволен. Так вы сохраните удобство рантайма и согласуете его со статическими гарантиями.