Los decoradores, callbacks y funciones de orden superior suelen recibir una función y devolver otra. El reto del tipado estático es conservar todos los parámetros de la callable original sin recurrir a Callable[..., T], que acepta cualquier firma y descarta información útil. typing.ParamSpec representa la lista completa de parámetros, incluidos posicionales, nombrados, variádicos y valores por defecto.
Esta guía explica ParamSpec en decoradores, wrappers síncronos y asíncronos, factories, Protocol, métodos y utilidades genéricas. También cubre P.args, P.kwargs, TypeVar, Concatenate y errores frecuentes.
El problema de Callable con puntos suspensivos
from collections.abc import Callable
from typing import TypeVar
R = TypeVar("R")
def registrar(funcion: Callable[..., R]) -> Callable[..., R]:
def wrapper(*args, **kwargs):
print("llamada")
return funcion(*args, **kwargs)
return wrapperEl tipo de resultado R se conserva, pero el analizador ya no sabe qué argumentos acepta el wrapper. Las llamadas inválidas pueden superar el análisis estático.
Crear un ParamSpec
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def registrar(funcion: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("llamada")
return funcion(*args, **kwargs)
return wrapperCallable[P, R] representa una callable cuyos parámetros están descritos por P y cuyo retorno es R. El wrapper recibe los mismos parámetros y devuelve el mismo resultado.
Qué significan P.args y P.kwargs
P.args anota los argumentos posicionales capturados. P.kwargs anota los argumentos nombrados. Son marcadores especiales y deben usarse juntos en *args y **kwargs vinculados al mismo ParamSpec.
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return funcion(*args, **kwargs)No son tuplas o diccionarios de tipos normales para manipulación arbitraria.
Decorador de tiempo
from functools import wraps
from time import perf_counter
P = ParamSpec("P")
R = TypeVar("R")
def medir(funcion: Callable[P, R]) -> Callable[P, R]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
inicio = perf_counter()
try:
return funcion(*args, **kwargs)
finally:
duracion = perf_counter() - inicio
print(f"{funcion.__name__}: {duracion:.6f}s")
return wrapperfunctools.wraps conserva metadatos en runtime, mientras ParamSpec conserva la firma estática. Son soluciones complementarias.
Factory de decorador
def repetir(veces: int):
def decorar(funcion: Callable[P, R]) -> Callable[P, R]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
resultado: R
for _ in range(veces):
resultado = funcion(*args, **kwargs)
return resultado
return wrapper
return decorarLa función externa recibe configuración, mientras el decorador interno sigue siendo genérico respecto a la firma decorada.
Callables asíncronas
from collections.abc import Awaitable
async def ejecutar_con_log(
funcion: Callable[P, Awaitable[R]],
*args: P.args,
**kwargs: P.kwargs,
) -> R:
print("iniciando")
resultado = await funcion(*args, **kwargs)
print("finalizado")
return resultadoParamSpec conserva los argumentos de la coroutine function y Awaitable[R] describe el resultado esperable.
Convertir sync en async
import asyncio
def en_thread(
funcion: Callable[P, R],
) -> Callable[P, Awaitable[R]]:
@wraps(funcion)
async def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
return await asyncio.to_thread(funcion, *args, **kwargs)
return wrapperLa firma de parámetros permanece igual, pero el retorno cambia de R a Awaitable[R].
Ejecución tipada de callbacks
class Ejecutor:
def ejecutar(
self,
callback: Callable[P, R],
*args: P.args,
**kwargs: P.kwargs,
) -> R:
return callback(*args, **kwargs)El método acepta cualquier callback, pero exige argumentos compatibles con esa callable. Parámetros faltantes o extra pueden detectarse estáticamente.
ParamSpec en Protocol
from typing import Protocol
class Middleware(Protocol[P, R]):
def __call__(
self,
siguiente: Callable[P, R],
) -> Callable[P, R]: ...Este contrato describe objetos que reciben una función y devuelven otra con la misma firma. Protocol es útil para decoradores con estado y extensiones de frameworks.
Clases decoradoras
class Contador:
def __init__(self, funcion: Callable[P, R]) -> None:
self.funcion = funcion
self.llamadas = 0
def __call__(
self,
*args: P.args,
**kwargs: P.kwargs,
) -> R:
self.llamadas += 1
return self.funcion(*args, **kwargs)La inferencia de clases decoradoras puede variar entre analizadores y versiones. Las bibliotecas públicas deberían incluir pruebas de tipado específicas.
Métodos y self
Cuando un decorador se aplica a un método, el receptor forma parte de la firma capturada. Normalmente, el wrapper no necesita un tratamiento especial para self.
def auditar(funcion: Callable[P, R]) -> Callable[P, R]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(funcion.__qualname__)
return funcion(*args, **kwargs)
return wrapperSi el decorador añade o elimina explícitamente un parámetro inicial, usa Concatenate.
Añadir un parámetro con Concatenate
from typing import Concatenate
class Contexto:
usuario: str
def inyectar_contexto(
funcion: Callable[Concatenate[Contexto, P], R],
) -> Callable[P, R]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
contexto = Contexto()
contexto.usuario = "sistema"
return funcion(contexto, *args, **kwargs)
return wrapperLa función original exige Contexto como primer argumento, pero la callable decorada lo oculta al consumidor.
ParamSpec frente a TypeVar
TypeVar representa un tipo individual. ParamSpec representa una lista completa de parámetros. Un TypeVar no puede capturar dos posicionales, una opción keyword-only y keywords arbitrarios como una sola relación.
T = TypeVar("T")
# T puede ser int, str, Usuario...
P = ParamSpec("P")
# P puede representar (id: int, *, activo: bool)ParamSpec frente a Callable[…, R]
Callable[..., R] es apropiado cuando los parámetros realmente no importan o la API acepta dinámicamente cualquier firma. Para decoradores que prometen conservar una interfaz, ParamSpec es más seguro.
Parámetros keyword-only
def enviar(destino: str, *, urgente: bool = False) -> int:
...
enviar_tipado = registrar(enviar)
enviar_tipado("cola", urgente=True)ParamSpec captura que urgente es keyword-only. El analizador puede rechazar una llamada posicional incompatible.
Overloads y ParamSpec
Un decorador basado en ParamSpec suele conservar la firma inferida de cada overload, siempre que el analizador pueda aplicar el decorador a las declaraciones. APIs públicas muy complejas pueden necesitar overloads explícitos en el propio decorador.
Cambiar el retorno
def ignorar_errores(
funcion: Callable[P, R],
) -> Callable[P, R | None]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
try:
return funcion(*args, **kwargs)
except Exception:
return None
return wrapperLos parámetros permanecen idénticos, pero el consumidor debe tratar None. La anotación debe exponer toda transformación del retorno.
Comportamiento fuera de la firma
ParamSpec no describe excepciones, efectos secundarios, retries, latencia o seguridad entre hilos. Los parámetros estáticos son solo una parte del contrato. Documenta por separado los cambios de comportamiento.
Compatibilidad de versiones
ParamSpec está disponible en versiones modernas de typing. Las bibliotecas compatibles con intérpretes anteriores pueden usar typing_extensions.ParamSpec. La forma tradicional ParamSpec("P") tiene amplia compatibilidad.
Errores comunes
- Usar Callable[…, R]: se pierde la validación de argumentos.
- Olvidar P.args o P.kwargs: el wrapper no reenvía correctamente la firma.
- Usar ParamSpec en posiciones no admitidas: posee contextos específicos relacionados con callables.
- Cambiar parámetros sin Concatenate: la anotación deja de coincidir con la realidad.
- Confundir wraps con tipado: wraps conserva metadatos, no toda la relación estática.
- Ocultar un retorno modificado: el consumidor necesita el tipo transformado.
Ejemplo completo: retry tipado
from functools import wraps
from time import sleep
P = ParamSpec("P")
R = TypeVar("R")
def retry(intentos: int, espera: float = 0.0):
if intentos < 1:
raise ValueError("intentos debe ser positivo")
def decorar(funcion: Callable[P, R]) -> Callable[P, R]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
ultimo_error: Exception | None = None
for indice in range(intentos):
try:
return funcion(*args, **kwargs)
except Exception as error:
ultimo_error = error
if indice + 1 < intentos and espera:
sleep(espera)
assert ultimo_error is not None
raise ultimo_error
return wrapper
return decorarEl decorador añade reintentos sin perder parámetros ni retorno. En producción, limita las excepciones capturadas y considera la idempotencia.
Probar el tipado
Además de pruebas de runtime, crea fixtures con llamadas válidas e intencionalmente inválidas. Ejecuta mypy o pyright en integración continua. reveal_type() ayuda a confirmar que la función decorada mantiene la firma esperada.
Conclusión
typing.ParamSpec permite escribir decoradores y funciones de orden superior que conservan firmas completas. Reduce el uso inseguro de Callable[..., R] y mejora autocompletado, documentación y detección de errores.
La documentación oficial de ParamSpec en Python describe sus contextos. Combina ParamSpec con TypeVar para resultados, functools.wraps para metadatos y Concatenate solo cuando el wrapper realmente añade o elimina parámetros.







