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. Так вы сохраните удобство декораторов и при этом не потеряете важные статические гарантии.