Cuando el estado de un decorador crece, un closure empieza a sentirse forzado. Python te permite usar cualquier callable como decorador, y una clase con __call__ es exactamente eso: un callable cuyo estado vive de forma natural en atributos de instancia, no en variables capturadas por una función anidada.

La mecánica es directa. Cuando escribes @MiDecorador encima de una función, Python ejecuta MiDecorador(fn) y reemplaza el nombre de la función con el resultado. Si MiDecorador es una clase, ese resultado es una instancia de la clase. Cada vez que llamas a la función decorada, Python invoca instancia.__call__(...). El estado del decorador —contadores, cachés, timestamps— vive en self con toda la claridad de un atributo de instancia.

¿Cuándo merece la pena el cambio? Cuando tienes dos o más variables de estado que interactúan entre sí, cuando necesitas resetear o inspeccionar ese estado desde fuera, o cuando el closure empieza a parecerse a una clase disfrazada de función. Un rate limiter, un contador de llamadas con umbral, una caché con política de expiración: todos ellos son casos donde la clase gana en legibilidad.

Lo que rompe si te equivocas: olvidar @functools.wraps dentro de __call__ (o su equivalente manual) hace que la función decorada pierda su __name__, __doc__ y __wrapped__, lo que rompe herramientas como help(), pytest y cualquier introspección que espere la identidad original.

import functools
import time
from collections import deque


class RateLimiter:
    """Permite como máximo `max_calls` llamadas en una ventana de `period` segundos."""

    def __init__(self, max_calls: int, period: float):
        self.max_calls = max_calls
        self.period = period
        self._timestamps: deque[float] = deque()
        self._fn = None  # se asigna en __call__ durante la decoración

    def __call__(self, *args, **kwargs):
        # Primera vez que se llama: recibimos la función a decorar
        if self._fn is None and len(args) == 1 and callable(args[0]):
            fn = args[0]
            self._fn = fn
            # Preservamos la identidad de la función original
            functools.update_wrapper(self, fn)
            return self

        # Llamadas posteriores: aplicamos la lógica de rate limiting
        now = time.monotonic()
        # Descartamos timestamps fuera de la ventana
        while self._timestamps and now - self._timestamps[0] > self.period:
            self._timestamps.popleft()

        if len(self._timestamps) >= self.max_calls:
            oldest = self._timestamps[0]
            wait = self.period - (now - oldest)
            raise RuntimeError(
                f"Límite alcanzado: {self.max_calls} llamadas en {self.period}s. "
                f"Reintenta en {wait:.2f}s."
            )

        self._timestamps.append(now)
        return self._fn(*args, **kwargs)

    def reset(self):
        """Permite resetear el estado desde el exterior — algo imposible con un closure."""
        self._timestamps.clear()


@RateLimiter(max_calls=3, period=10.0)
def fetch_data(url: str) -> str:
    return f"datos de {url}"


# --- Decorador de clase que modifica una CLASE, no una función ---

def add_repr(cls):
    """
    Decorador de clase: añade un __repr__ genérico basado en __dict__
    si la clase no define uno propio.
    """
    if "__repr__" not in cls.__dict__:  # __dict__ evita heredar el de object
        def __repr__(self):
            attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
            return f"{cls.__name__}({attrs})"
        cls.__repr__ = __repr__
    return cls


@add_repr
class Config:
    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port


if __name__ == "__main__":
    print(fetch_data("https://api.ejemplo.com/datos"))
    print(fetch_data("https://api.ejemplo.com/datos"))
    print(fetch_data("https://api.ejemplo.com/datos"))

    try:
        fetch_data("https://api.ejemplo.com/datos")  # cuarta llamada: explota
    except RuntimeError as e:
        print(e)

    fetch_data.reset()  # reseteamos el estado — llamada 4 ahora es válida
    print(fetch_data("https://api.ejemplo.com/datos"))

    cfg = Config("localhost", 8080)
    print(cfg)  # Config(host='localhost', port=8080)

Qué está pasando realmente

RateLimiter tiene un truco de inicialización que vale la pena entender. La clase usa un patrón de doble invocación: @RateLimiter(max_calls=3, period=10.0) primero crea una instancia con la configuración (vía __init__), y luego Python llama a esa instancia con la función como argumento. El __call__ detecta ese primer contacto comprobando self._fn is None. A partir de ese momento, el mismo __call__ sirve como wrapper de cada llamada real.

El estado reside en self._timestamps, una deque que actúa como ventana deslizante: en cada invocación se purgan los timestamps más antiguos que period segundos antes de contar. Esto es O(n) en el peor caso pero n está acotado a max_calls, así que en la práctica es O(1).

El método reset() ilustra la ventaja semántica sobre un closure. Con un closure necesitarías exponer una función auxiliar o usar una lista mutable como hack. Con una clase, reset es un método de primera clase que cualquier consumidor puede llamar con fetch_data.reset(), porque fetch_data es la instancia del decorador.

functools.update_wrapper(self, fn) copia __name__, __qualname__, __doc__, __dict__ y __wrapped__ desde fn hacia la instancia del decorador. Es el equivalente de @functools.wraps pero aplicado a instancias en lugar de funciones internas.

El decorador add_repr funciona de forma completamente distinta: recibe una clase, la modifica directamente y devuelve la misma clase. Fíjate en cls.__dict__ en lugar de hasattr: hasattr encontraría el __repr__ heredado de object, mientras que cls.__dict__ solo mira lo que la clase define explícitamente. Es un detalle pequeño con un impacto real: si una subclase ya define su propio __repr__, no lo pisamos.

Errores que debes conocer

Error: usar @RateLimiter sin paréntesis cuando el __init__ espera argumentos hace que Python pase la función como max_calls, lo que provoca un TypeError críptico en tiempo de decoración.

# ❌ Wrong
@RateLimiter          # Python llama RateLimiter(fetch_data) → max_calls recibe una función
def fetch_data(url):
    ...

# ✅ Right
@RateLimiter(max_calls=3, period=10.0)
def fetch_data(url):
    ...

Los paréntesis crean la instancia primero; sin ellos la clase recibe la función directamente en __init__.


Error: olvidar functools.update_wrapper (o no llamarlo antes de devolver self) hace que fetch_data.__name__ sea "RateLimiter" y que herramientas de introspección fallen de formas no obvias.

# ❌ Wrong — __call__ devuelve self sin actualizar los metadatos
def __call__(self, *args, **kwargs):
    if self._fn is None and callable(args[0]):
        self._fn = args[0]
        return self  # los metadatos de la función original se pierden

# ✅ Right
def __call__(self, *args, **kwargs):
    if self._fn is None and callable(args[0]):
        self._fn = args[0]
        functools.update_wrapper(self, args[0])  # copia __name__, __doc__, __wrapped__
        return self

update_wrapper necesita ejecutarse antes de que la instancia reemplace a la función en el namespace del módulo.


Error: compartir estado mutable entre instancias al definir atributos de clase en lugar de atributos de instancia.

# ❌ Wrong — todos los decoradores comparten el mismo deque
class RateLimiter:
    _timestamps = deque()  # atributo de clase: compartido entre todas las instancias

# ✅ Right
class RateLimiter:
    def __init__(self, max_calls, period):
        self._timestamps = deque()  # atributo de instancia: aislado por decorador

Cada función decorada necesita su propia ventana de tiempo; si el deque es de clase, decorar dos funciones distintas las hace competir por el mismo límite.

119