Decoradores, callbacks e funções de ordem superior costumam receber uma função e devolver outra. O desafio da tipagem estática é preservar todos os parâmetros da função original sem recorrer a Callable[..., T], que aceita qualquer assinatura e perde informações importantes. typing.ParamSpec representa a lista completa de parâmetros de uma callable, incluindo posicionais, nomeados, variádicos e defaults.
Neste guia, você aprenderá a usar ParamSpec em decoradores, wrappers síncronos e assíncronos, factories, Protocol, métodos e funções genéricas. Também verá como combinar P.args, P.kwargs, TypeVar e Concatenate, além dos erros mais comuns.
O problema de Callable com reticências
from collections.abc import Callable
from typing import TypeVar
R = TypeVar("R")
def registrar(funcao: Callable[..., R]) -> Callable[..., R]:
def wrapper(*args, **kwargs):
print("chamada")
return funcao(*args, **kwargs)
return wrapperO tipo de retorno preserva apenas o resultado R. O analisador não sabe quais argumentos o wrapper aceita, portanto chamadas incorretas podem passar sem aviso.
Criando um ParamSpec
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def registrar(funcao: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("chamada")
return funcao(*args, **kwargs)
return wrapperCallable[P, R] significa uma função com parâmetros descritos por P e retorno R. O wrapper recebe exatamente os mesmos parâmetros e devolve o mesmo resultado.
O que P.args e P.kwargs representam
P.args anota o pacote de argumentos posicionais recebido pelo wrapper. P.kwargs representa os argumentos nomeados. Eles só devem aparecer juntos em funções que capturam *args e **kwargs relacionados ao mesmo ParamSpec.
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return funcao(*args, **kwargs)Esses atributos não são listas de tipos para manipulação arbitrária; são marcadores especiais usados pelo analisador.
Decorador com tempo de execução
from functools import wraps
from time import perf_counter
P = ParamSpec("P")
R = TypeVar("R")
def medir(funcao: Callable[P, R]) -> Callable[P, R]:
@wraps(funcao)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
inicio = perf_counter()
try:
return funcao(*args, **kwargs)
finally:
duracao = perf_counter() - inicio
print(f"{funcao.__name__}: {duracao:.6f}s")
return wrapperfunctools.wraps preserva metadados em runtime, enquanto ParamSpec preserva a assinatura para o analisador. As duas ferramentas resolvem problemas diferentes e complementares.
Decorador com argumentos
def repetir(vezes: int):
def decorar(funcao: Callable[P, R]) -> Callable[P, R]:
@wraps(funcao)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
resultado: R
for _ in range(vezes):
resultado = funcao(*args, **kwargs)
return resultado
return wrapper
return decorarA factory externa recebe a configuração. A função interna continua genérica em relação à assinatura decorada.
Wrappers assíncronos
from collections.abc import Awaitable
async def executar_com_log(
funcao: Callable[P, Awaitable[R]],
*args: P.args,
**kwargs: P.kwargs,
) -> R:
print("iniciando")
resultado = await funcao(*args, **kwargs)
print("concluído")
return resultadoO ParamSpec preserva os parâmetros da coroutine function, enquanto Awaitable[R] descreve o valor aguardável retornado.
Decorador que transforma sync em async
import asyncio
def em_thread(
funcao: Callable[P, R],
) -> Callable[P, Awaitable[R]]:
@wraps(funcao)
async def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
return await asyncio.to_thread(funcao, *args, **kwargs)
return wrapperA assinatura dos parâmetros é preservada, mas o retorno muda de R para Awaitable[R].
Callbacks configuráveis
class Executor:
def executar(
self,
callback: Callable[P, R],
*args: P.args,
**kwargs: P.kwargs,
) -> R:
return callback(*args, **kwargs)Esse método aceita qualquer callback, mas exige argumentos compatíveis com a callable fornecida. Uma chamada com parâmetros faltando ou extras pode ser detectada estaticamente.
ParamSpec em Protocol
from typing import Protocol
class Middleware(Protocol[P, R]):
def __call__(
self,
proximo: Callable[P, R],
) -> Callable[P, R]: ...Esse contrato descreve objetos que recebem uma função e devolvem outra com a mesma assinatura. Protocol é útil quando o decorador possui estado ou é implementado por uma classe.
Classes decoradoras
class Contador:
def __init__(self, funcao: Callable[P, R]) -> None:
self.funcao = funcao
self.chamadas = 0
def __call__(
self,
*args: P.args,
**kwargs: P.kwargs,
) -> R:
self.chamadas += 1
return self.funcao(*args, **kwargs)A inferência de classes decoradoras pode variar entre analisadores e versões. Em bibliotecas públicas, execute testes de tipagem específicos para garantir que a assinatura exposta permaneça correta.
Métodos e self
Quando um decorador é aplicado a métodos, o primeiro parâmetro faz parte da assinatura capturada. Normalmente, o wrapper não precisa tratar self separadamente.
def auditar(funcao: Callable[P, R]) -> Callable[P, R]:
@wraps(funcao)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(funcao.__qualname__)
return funcao(*args, **kwargs)
return wrapperSe o decorador adiciona ou remove explicitamente um parâmetro, use Concatenate.
Adicionando um parâmetro com Concatenate
from typing import Concatenate
class Contexto:
usuario: str
def injetar_contexto(
funcao: Callable[Concatenate[Contexto, P], R],
) -> Callable[P, R]:
@wraps(funcao)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
contexto = Contexto()
contexto.usuario = "sistema"
return funcao(contexto, *args, **kwargs)
return wrapperA função original exige Contexto como primeiro argumento, mas a função decorada não o expõe ao chamador.
ParamSpec versus TypeVar
TypeVar representa um único tipo. ParamSpec representa uma lista completa de parâmetros. Um TypeVar não consegue capturar, por exemplo, dois argumentos posicionais, um keyword-only e **kwargs como uma unidade.
T = TypeVar("T")
# T pode ser int, str, Usuario...
P = ParamSpec("P")
# P pode representar (id: int, *, ativo: bool)ParamSpec versus Callable[…, R]
Callable[..., R] é apropriado quando os parâmetros realmente não importam ou quando a API aceita qualquer chamada de forma dinâmica. Para decoradores que devem preservar a interface original, ParamSpec é muito mais seguro.
Parâmetros keyword-only
def enviar(destino: str, *, urgente: bool = False) -> int:
...
funcao_tipica = registrar(enviar)
funcao_tipica("fila", urgente=True)O ParamSpec captura a natureza keyword-only de urgente. O analisador pode rejeitar uma chamada posicional incompatível.
Overloads e ParamSpec
Um decorador tipado com ParamSpec normalmente preserva a assinatura inferida de cada overload, desde que o analisador consiga aplicar o decorador às declarações. Bibliotecas com APIs muito complexas podem precisar expor overloads explícitos no próprio decorador.
Retorno covariante e restrições
O TypeVar de retorno pode usar limites ou covariância conforme o contrato. Não altere a relação sem necessidade. Para um decorador que apenas observa a chamada, Callable[P, R] -> Callable[P, R] é a forma mais transparente.
Decoradores que podem retornar None
def ignorar_erros(
funcao: Callable[P, R],
) -> Callable[P, R | None]:
@wraps(funcao)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
try:
return funcao(*args, **kwargs)
except Exception:
return None
return wrapperOs parâmetros continuam iguais, mas o tipo de retorno muda. O chamador agora precisa tratar None.
Preservando exceções e comportamento
ParamSpec não descreve exceções, efeitos colaterais, desempenho ou política de retries. A assinatura estática é apenas parte do contrato. Documente mudanças comportamentais introduzidas pelo wrapper.
Compatibilidade de versões
ParamSpec está disponível no módulo typing de versões modernas. Para versões anteriores, use typing_extensions.ParamSpec. A sintaxe genérica mais recente pode variar, mas a forma tradicional com ParamSpec("P") possui ampla compatibilidade.
Erros comuns
- Usar Callable[…, R]: perde a validação dos argumentos.
- Esquecer P.args ou P.kwargs: o wrapper deixa de encaminhar corretamente a assinatura.
- Usar ParamSpec fora de Callable: ele possui contextos específicos.
- Alterar parâmetros sem Concatenate: a anotação deixa de refletir a função real.
- Confundir wraps com tipagem: wraps preserva metadados, não resolve tudo estaticamente.
- Ocultar mudança de retorno: o tipo do wrapper precisa mostrar a transformação.
Exemplo completo: retry tipado
from functools import wraps
from time import sleep
P = ParamSpec("P")
R = TypeVar("R")
def retry(
tentativas: int,
espera: float = 0.0,
):
if tentativas < 1:
raise ValueError("tentativas deve ser positiva")
def decorar(funcao: Callable[P, R]) -> Callable[P, R]:
@wraps(funcao)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
ultimo_erro: Exception | None = None
for indice in range(tentativas):
try:
return funcao(*args, **kwargs)
except Exception as erro:
ultimo_erro = erro
if indice + 1 < tentativas and espera:
sleep(espera)
assert ultimo_erro is not None
raise ultimo_erro
return wrapper
return decorarO decorador adiciona retries sem perder os parâmetros ou o retorno da função original. Em produção, limite as exceções capturadas e considere idempotência.
Testando tipagem
Além de testes de runtime, crie arquivos de teste com chamadas válidas e inválidas. Ferramentas como mypy e pyright podem ser executadas no CI. Recursos como reveal_type() ajudam a verificar se a função decorada mantém a assinatura esperada.
Conclusão
typing.ParamSpec permite escrever decoradores e funções de ordem superior que preservam assinaturas completas. Ele elimina grande parte do uso inseguro de Callable[..., R] e melhora autocomplete, documentação e detecção de erros.
A documentação oficial de ParamSpec no Python detalha seus contextos. Combine ParamSpec com TypeVar para retornos, functools.wraps para metadados e Concatenate apenas quando o wrapper realmente adiciona ou remove parâmetros.







