ParamSpec en Python: conserva firmas

Publicado el: 28/08/2026
Tempo de leitura: 5 minutos
Close-up view of a computer screen displaying code in a software development environment.

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 wrapper

El 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 wrapper

Callable[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 wrapper

functools.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 decorar

La 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 resultado

ParamSpec 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 wrapper

La 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 wrapper

Si 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 wrapper

La 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 wrapper

Los 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 decorar

El 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.

Compartilhe:

Facebook
WhatsApp
Twitter
LinkedIn

Contenido del artículo

    Artículos relacionados

    Close-up of a python snake curled up, showcasing its detailed scales.
    Python Avanzado
    Foto de perfil de Leandro Hirt da Academify

    TypeIs en Python: refina ambas ramas

    Aprende TypeIs en Python para refinar las ramas verdadera y falsa, compararlo con TypeGuard y crear predicados de tipo seguros.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    A person typing on a laptop with a Python programming book visible, capturing technology and learning.
    Python Avanzado
    Foto de perfil de Leandro Hirt da Academify

    TypeGuard en Python: refina tipos seguros

    Aprende TypeGuard en Python para refinar tipos y validar colecciones, TypedDict, Protocol y datos externos con comprobaciones reales.

    Ler mais

    Tempo de leitura: 6 minutos
    28/08/2026
    A developer typing code on a laptop with a Python book beside in an office.
    Python Avanzado
    Foto de perfil de Leandro Hirt da Academify

    typing.Self en Python: retornos fluidos

    Aprende typing.Self en Python para métodos fluidos, classmethods, builders, clones, Protocol, context managers y retornos que conservan subclases.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Detailed view of programming code in a dark theme on a computer screen.
    Python Avanzado
    Foto de perfil de Leandro Hirt da Academify

    ExceptionGroup en Python: múltiples errores

    Aprende ExceptionGroup en Python para múltiples errores, except*, grupos anidados, TaskGroup, filtros, logging y validación por lotes.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Picturesque wooden boardwalk leading to a serene beach under clear blue skies.
    Python Avanzado
    Foto de perfil de Leandro Hirt da Academify

    Path.walk en Python: recorre directorios

    Aprende Path.walk en Python para recorrer directorios, podar carpetas, manejar errores y symlinks, calcular tamaños y soportar versiones antiguas.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    A man in a blue shirt holding a wall clock above his head, contemplating time.
    Python Avanzado
    Foto de perfil de Leandro Hirt da Academify

    asyncio.timeout en Python: controla plazos

    Aprende asyncio.timeout en Python para deadlines, timeout_at, reagendamiento, TaskGroup, cleanup, retries y cancelación asíncrona segura.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026