2025, Dec 10 18:03

Объединяем фабрики декораторов в Python: ParamSpec и перегрузки

Разбираем, почему union ломает типизацию декораторов в Python, и как решить это через перегрузки. Примеры с ParamSpec, Callable, pyright/mypy и рабочая фабрика

Декораторы, которые сохраняют сигнатуру функции, несложно типизировать — пока не понадобится одна фабрика, поддерживающая два несовместимых сценария. В одном случае ожидается вспомогательный вызов с точной копией сигнатуры оборачиваемой функции; в другом — вызов без аргументов. Во время выполнения можно разветвить логику, но статический типизатор не примет наивный union, потому что переменные типа в каждом из случаев означают разное.

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

Рассмотрим две фабрики декораторов. Первая принимает вызываемый объект с той же сигнатурой, что и у оборачиваемой функции, и передаёт ему аргументы перед выполнением оборачиваемой функции. Вторая принимает нулевой по параметрам вызываемый объект и безусловно вызывает его. Обе работают и проходят тип-проверку по отдельности.

import inspect
from typing import Any, Callable, ParamSpec, TypeVar
PRMS = ParamSpec("PRMS")
R = TypeVar("R")
def hook_with_params(cb: Callable[PRMS, Any]) -> Callable[[Callable[PRMS, R]], Callable[PRMS, R]]:
    def _decor(fn: Callable[PRMS, R]) -> Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            cb(*args, **kwargs)
            return fn(*args, **kwargs)
        return _inner
    return _decor
def trace_with_args(a: int, b: str) -> None:
    print(f"trace_with_args: a={a}, b={b}")
@hook_with_params(trace_with_args)
def handled1(a: int, b: str) -> None: ...
handled1(1, "test")  # выводит "trace_with_args: a=1, b=test"
def hook_no_params(cb: Callable[[], Any]) -> Callable[[Callable[PRMS, R]], Callable[PRMS, R]]:
    def _decor(fn: Callable[PRMS, R]) -> Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            cb()
            return fn(*args, **kwargs)
        return _inner
    return _decor
def trace_no_args() -> None:
    print("trace_no_args")
@hook_no_params(trace_no_args)
def handled2(a: int, b: str) -> None: ...
handled2(1, "test")  # выводит "trace_no_args"

Теперь попробуем объединить обе в одну фабрику, которая инспектирует вызываемый объект во время выполнения. Логика корректна, но типизатор её отклоняет.

def fuse_hooks(
    cb: Callable[[], Any] | Callable[PRMS, Any],
) -> Callable[[Callable[PRMS, R]], Callable[PRMS, R]]:
    def _decor(fn: Callable[PRMS, R]) -> Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            if len(inspect.signature(cb).parameters):
                cb(*args, **kwargs)
            else:
                cb()  # ошибка pyright: отсутствуют аргументы для ParamSpec "PRMS@fuse_hooks" (reportCallIssue)
            return fn(*args, **kwargs)
        return _inner
    return _decor
@fuse_hooks(trace_with_args)
def with_trace1(a: int, b: str) -> None: ...
@fuse_hooks(trace_no_args)  # ошибка pyright: аргумент типа "(a: int, b: str) -> None" нельзя присвоить параметру типа "() -> T@fuse_hooks"
def with_trace2(a: int, b: str) -> None: ...
with_trace1(1, "test")  # выводит "trace_with_args: a=1, b=test"
with_trace2(1, "test")  # выводит "trace_no_args", но ошибка pyright: ожидалось 0 позиционных аргументов (reportCallIssue)

Почему объединение не работает

Обе фабрики возвращают декоратор типа Callable[[Callable[PRMS, R]], Callable[PRMS, R]], но роль PRMS в этих двух сценариях различается. В первой фабрике PRMS связывает сигнатуры оборачиваемой функции и вспомогательного вызова и статически гарантирует их совпадение. Во второй PRMS лишь сохраняет сигнатуру оборачиваемой функции, а вспомогательный вызов — нулевой арности и никак с ней не связан. Это два разных обобщённых контекста. Попытка втиснуть их в одну сигнатуру через union заставляет переменную типа выполнять две несовместимые роли, что статический проверяющий разрешить не может.

Перегрузки разделяют контексты

Решение — объявить две перегрузки фабрики декораторов. Каждая перегрузка задаёт PRMS в корректном контексте. Единственная реализация при этом может использовать более свободный тип для вспомогательного вызова и, как и раньше, ветвить логику на этапе выполнения.

import collections.abc as cabc
import inspect
import typing as t
PRMS = t.ParamSpec("PRMS")
R = t.TypeVar("R")
@t.overload
def fuse_hooks(cb: cabc.Callable[[], t.Any], /) -> cabc.Callable[[cabc.Callable[PRMS, R]], cabc.Callable[PRMS, R]]:
    """
    Случай с пустым списком параметров, эквивалент фабрике с нулевой арностью.
    Возвращает декоратор, сохраняющий сигнатуру оборачиваемой функции.
    """
@t.overload
def fuse_hooks(  # pyright: ignore[reportOverlappingOverload]
    cb: cabc.Callable[PRMS, t.Any], /
) -> cabc.Callable[[cabc.Callable[PRMS, R]], cabc.Callable[PRMS, R]]:
    """
    Случай с непустыми параметрами, эквивалент фабрике с совпадающей сигнатурой.
    Возвращает декоратор, который проверяет и сопоставляет сигнатуру оборачиваемой функции с `cb`.
    Игнор для pyright отражает баг, наблюдающийся и в mypy; на практике эти сигнатуры не пересекаются.
    """
def fuse_hooks(  # pyright: ignore[reportInconsistentOverload]
    cb: cabc.Callable[..., t.Any],
) -> cabc.Callable[[cabc.Callable[PRMS, R]], cabc.Callable[PRMS, R]]:
    """
    Свободная типизация реализации ради удобства использования.
    """
    def _decor(fn: cabc.Callable[PRMS, R]) -> cabc.Callable[PRMS, R]:
        def _inner(*args: PRMS.args, **kwargs: PRMS.kwargs) -> R:
            if len(inspect.signature(cb).parameters):
                cb(*args, **kwargs)
            else:
                cb()
            return fn(*args, **kwargs)
        return _inner
    return _decor
# Все проходит
def probe_a(u: int, v: str) -> None: ...
def probe_b() -> None: ...
@fuse_hooks(probe_a)
def target_a(u: int, v: str) -> None: ...
@fuse_hooks(probe_b)
def target_b(u: int, v: str) -> None: ...
target_a(1, "test")
target_b(1, "test")
# Статическая проверка в деле
@fuse_hooks(probe_a)  # Ошибка: лишний параметр, не соответствует сигнатуре `probe_a`
def target_a_fail(u: int, v: str, w: bytes) -> None: ...
@fuse_hooks(probe_b)  # Ок: сигнатура сохранена
def target_b_any(u: int, v: str, w: bytes) -> None: ...
target_b_any(1, "test")  # Ошибка: отсутствует аргумент `w: bytes`

Зачем это всё

Декораторы — частое место пересечения дженериков, ParamSpec и перегрузок. Одна фабрика, поддерживающая и вызываемый объект с совпадающей сигнатурой, и нулевой по параметрам — это удобно, но обобщения в этих случаях описывают разные истории. Перегрузки позволяют выразить их порознь, чтобы типизатор и сохранял сигнатуру оборачиваемой функции, и проверял совпадение там, где это нужно. В некоторых кодовых базах проще объявить нулевой по параметрам вызов так, чтобы он принимал и игнорировал произвольные аргументы, обходясь без специальной типизации Callable[[], Any], но подход с перегрузками даёт точную проверку, когда она важна.

Выводы

Когда ParamSpec играет разные роли в разных сценариях, объединять их через union не получится — генерики вступают в конфликт. Смоделируйте каждый сценарий отдельной перегрузкой. Реализацию держите гибкой через Callable[..., Any] и, при необходимости, используйте точечные игноры для известных пересечений в pyright. Так вы сохраните удобство декораторов и при этом не потеряете важные статические гарантии.