Типизация коллбэков — вечная боль production-кода, особенно при реализации диспетчеров событий или callback-based middleware. Частая ошибка: использовать
Callable[..., Any], теряя сигнатуру и получая TypeError на проде.Проблема
def register(handler: Callable[..., Any]):
...
Здесь анализатор не проверяет аргументы. В production это приводит к ошибкам при первом несовпадении типов — например, когда обработчик ожидает
(int, str), а передается (float, User).Решение
from typing import TypeVar, ParamSpec, Callable
P = ParamSpec("P")
T = TypeVar("T")
def register(
handler: Callable[P, T],
*args: P.args,
**kwargs: P.kwargs,
) -> T:
return handler(*args, **kwargs)
Статический анализатор теперь видит точную сигнатуру коллбэка. Никаких сюрпризов.
Реальный пример: диспетчер событий
class EventDispatcher:
def on(self, event: str) -> Callable[[Callable[P, T]], Callable[P, T]]:
def wrapper(handler: Callable[P, T]) -> Callable[P, T]:
self.handlers[event] = handler
return handler
return wrapper
dispatcher = EventDispatcher()
@dispatcher.on("user_login")
def handle_login(user_id: int, timestamp: float) -> str:
return f"User {user_id} logged in at {timestamp}"
# Правильно
result = dispatcher.handlers["user_login"](42, 1689000000.0)
# Ошибка типов: expected float, got str
result = dispatcher.handlers["user_login"](42, "bad")
Почему это лучше Protocol?
* Меньше кода — не надо объявлять абстрактные классы с
__call__* Точность — сохраняются имена параметров, порядок, типы и возвращаемое значение
* Универсальность — один декоратор работает с любыми сигнатурами
* Совместимость — работает с callback-based либами вроде
functools или asyncioКогда Protocol все же нужен?
Если коллбэк должен иметь атрибуты (например,
handler.priority = 5) или наследовать несколько абстракций. По опыту — в 80% случаев это избыточно.Вывод: ParamSpec + TypeVar дают чистую и проверяемую типизацию generic-коллбэков без лишних сущностей — замените
Callable[..., Any] на точные сигнатуры на ревью.