Decoradores e Metaprogramação

Decoradores e Metaprogramação

Decoradores, closures e metaprogramação em Python, do açúcar sintático ao que ele esconde. Com três ressalvas práticas: por que atribuir no escopo externo exige nonlocal, por que um cache caseiro vaza memória, e por que decorar uma classe pode deixá-la de ser classe.
Python

• • 22 min de leitura

Decoradores são um dos recursos mais elegantes do Python. Eles permitem modificar ou estender o comportamento de funções e classes sem alterar seu código-fonte — adicionando funcionalidades de forma declarativa e reutilizável. São amplamente usados em frameworks como Flask, FastAPI e Django, e entendê-los aprofunda significativamente sua compreensão da linguagem.

Funções são Objetos de Primeira Classe

Para entender decoradores, primeiro precisamos confirmar algo visto em Funções: definição, parâmetros e escopo: funções em Python são objetos — podem ser atribuídas a variáveis, passadas como argumentos e retornadas por outras funções:

def saudar(nome):
    return f"Olá, {nome}!"

# Atribuindo a uma variável
cumprimentar = saudar
print(cumprimentar("Ana"))  # Olá, Ana!

# Passando como argumento
def executar(func, valor):
    return func(valor)

print(executar(saudar, "Bruno"))  # Olá, Bruno!

# Retornando de outra função
def criar_saudacao(prefixo):
    def saudar(nome):
        return f"{prefixo}, {nome}!"
    return saudar

ola    = criar_saudacao("Olá")
bom_dia = criar_saudacao("Bom dia")

print(ola("Carla"))      # Olá, Carla!
print(bom_dia("Diego"))  # Bom dia, Diego!

Closures

Uma closure é uma função que captura variáveis do escopo onde foi criada, mesmo após esse escopo ter encerrado:

def contador(inicio=0):
    count = inicio

    def incrementar():
        nonlocal count      # declara que count é do escopo de fora
        count += 1
        return count

    def resetar():
        nonlocal count
        count = inicio

    def valor_atual():
        return count        # ler não precisa de nonlocal

    return incrementar, resetar, valor_atual


inc, reset, valor = contador(10)

print(inc())    # 11
print(inc())    # 12
print(inc())    # 13
print(valor())  # 13
reset()
print(valor())  # 10

A palavra nonlocal é o ponto central aqui. Ler uma variável do escopo de fora sempre funcionou — é o que valor_atual faz. O que não funciona é atribuir: qualquer atribuição dentro de uma função faz o Python tratar aquele nome como local da função inteira, e a leitura que vem antes falha porque a variável local ainda não recebeu valor.

def contador():
    c = 0
    def incrementar():
        c += 1       # lê c para somar, mas c já é local aqui
        return c
    return incrementar

contador()()
# UnboundLocalError: cannot access local variable 'c'
# where it is not associated with a value

Muito material contorna isso guardando o valor dentro de uma lista e alterando c[0] — o que funciona porque aí não há atribuição ao nome, e sim alteração do objeto. Era a única saída no Python 2, e continua aparecendo em exemplos copiados de lá. Desde o Python 3.0 existe nonlocal, que resolve o problema pelo nome certo e deixa a intenção explícita; a lista de um elemento só é ruído. O parente próximo é o global, para o escopo do módulo, mas quem precisa dele quase sempre precisa é de outro desenho.

O que é um Decorador?

Um decorador é uma função que recebe outra função, adiciona comportamento e retorna uma nova função:

def meu_decorador(func):
    def wrapper(*args, **kwargs):
        print("Antes da função")
        resultado = func(*args, **kwargs)
        print("Depois da função")
        return resultado
    return wrapper


def saudar(nome):
    print(f"Olá, {nome}!")


saudar_decorada = meu_decorador(saudar)
saudar_decorada("Ana")
# Antes da função
# Olá, Ana!
# Depois da função

A sintaxe @ é apenas açúcar sintático para isso:

@meu_decorador
def saudar(nome):
    print(f"Olá, {nome}!")

# Equivalente a: saudar = meu_decorador(saudar)
saudar("Bruno")

functools.wraps

Sem wraps, o decorador esconde o nome e a docstring da função original:

from functools import wraps

def meu_decorador(func):
    @wraps(func)   # preserva metadados da função original
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@meu_decorador
def saudar(nome):
    """Saúda uma pessoa pelo nome."""
    return f"Olá, {nome}!"


print(saudar.__name__)  # saudar (não wrapper)
print(saudar.__doc__)   # Saúda uma pessoa pelo nome.

Sempre use @wraps ao escrever decoradores.

Decoradores Práticos

Medição de tempo

import time
from functools import wraps

def medir_tempo(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        inicio    = time.perf_counter()
        resultado = func(*args, **kwargs)
        fim       = time.perf_counter()
        print(f"[{func.__name__}] executou em {fim - inicio:.4f}s")
        return resultado
    return wrapper


@medir_tempo
def processar_dados(n):
    return sum(i ** 2 for i in range(n))


print(processar_dados(1_000_000))
# [processar_dados] executou em 0.1823s
# 333332833333500000

Cache simples

from functools import wraps

def cache(func):
    _cache = {}

    @wraps(func)
    def wrapper(*args):
        if args not in _cache:
            _cache[args] = func(*args)
        return _cache[args]

    return wrapper


@cache
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(50))  # instantâneo

Este cache didático tem duas limitações que precisam ser conhecidas antes de ir para código de verdade. A primeira é que args vira chave de dicionário, e portanto todo argumento precisa ser hasheável:

@cache
def somar(lista):
    return sum(lista)

somar([1, 2, 3])
# TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list')

A segunda é que ele nunca esquece: o dicionário cresce enquanto o processo viver, e num servidor de longa duração isso é um vazamento de memória lento, difícil de atribuir à causa. Some-se que ele ignora argumentos nomeados, já que o wrapper só recebe *args — chamar f(n=5) passa direto pelo cache.

Na prática, a biblioteca padrão já resolve isso desde a 3.9: @functools.cache faz o mesmo com argumentos nomeados incluídos, e @functools.lru_cache(maxsize=1000) acrescenta o descarte dos itens menos usados, que é o que evita o crescimento sem fim. Escrever o próprio cache vale como exercício de entender o mecanismo; em produção, use o da biblioteca.

Controle de acesso

from functools import wraps

def requer_autenticacao(func):
    @wraps(func)
    def wrapper(usuario, *args, **kwargs):
        if not usuario.get("autenticado"):
            raise PermissionError("Acesso negado. Faça login.")
        return func(usuario, *args, **kwargs)
    return wrapper


def requer_admin(func):
    @wraps(func)
    def wrapper(usuario, *args, **kwargs):
        if usuario.get("nivel") != "admin":
            raise PermissionError("Requer privilégios de administrador.")
        return func(usuario, *args, **kwargs)
    return wrapper


@requer_autenticacao
@requer_admin
def deletar_usuario(usuario, alvo_id):
    print(f"{usuario['nome']} deletou o usuário {alvo_id}.")


admin = {"nome": "Ana", "autenticado": True,  "nivel": "admin"}
comum = {"nome": "Bruno", "autenticado": True, "nivel": "usuario"}

deletar_usuario(admin, 42)   # Ana deletou o usuário 42.

try:
    deletar_usuario(comum, 42)
except PermissionError as e:
    print(e)   # Requer privilégios de administrador.

Decoradores empilhados são aplicados de baixo para cima: primeiro requer_admin, depois requer_autenticacao.

Decoradores com Parâmetros

Para criar decoradores que aceitam argumentos, adicione mais um nível de função:

from functools import wraps
import time

def retry(tentativas=3, espera=1.0):
    """Tenta executar a função até n vezes em caso de exceção."""
    def decorador(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for tentativa in range(1, tentativas + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Tentativa {tentativa}/{tentativas} falhou: {e}")
                    if tentativa < tentativas:
                        time.sleep(espera)
            raise RuntimeError(f"{func.__name__} falhou após {tentativas} tentativas.")
        return wrapper
    return decorador


@retry(tentativas=3, espera=0.5)
def conectar_banco():
    import random
    if random.random() < 0.7:   # 70% de chance de falhar
        raise ConnectionError("Timeout na conexão.")
    return "Conectado!"


try:
    print(conectar_banco())
except RuntimeError as e:
    print(e)

Decoradores de Classe

Decoradores também funcionam em classes:

def singleton(cls):
    """Garante que apenas uma instância da classe seja criada."""
    instancias = {}

    @wraps(cls)
    def obter_instancia(*args, **kwargs):
        if cls not in instancias:
            instancias[cls] = cls(*args, **kwargs)
        return instancias[cls]

    return obter_instancia


@singleton
class ConfiguracaoApp:
    def __init__(self):
        self.tema    = "escuro"
        self.idioma  = "pt-BR"
        self.debug   = False


config1 = ConfiguracaoApp()
config2 = ConfiguracaoApp()

config1.tema = "claro"

print(config2.tema)          # claro — mesmo objeto
print(config1 is config2)    # True

O @singleton acima é o exemplo clássico, e tem um efeito colateral que raramente é mencionado junto: como o decorador devolve uma função, o nome ConfiguracaoApp deixa de ser uma classe. O que sobra no módulo é uma função que por acaso produz objetos.

type(ConfiguracaoApp)               # <class 'function'>

isinstance(config1, ConfiguracaoApp)
# TypeError: isinstance() arg 2 must be a type, a tuple of types,
# or a union

class Filha(ConfiguracaoApp): ...   # TypeError: também não dá para herdar

Ou seja: a classe perde a capacidade de ser usada em verificação de tipo, em anotação e como base de herança — e nada disso aparece no exemplo, porque ele só constrói dois objetos e compara. Em código que usa isinstance ou anotações de tipo, o defeito surge longe daqui. Quando o comportamento de instância única é mesmo necessário, o caminho que não estraga a classe é um módulo com a instância pronta, já que módulos são carregados uma vez só, ou uma função obter_configuracao() decorada com @functools.cache. Vale acrescentar que instância única global é, em si, um padrão a usar com desconfiança: ela dificulta teste, porque o estado atravessa os casos, e esconde dependências que ficariam visíveis se fossem passadas como parâmetro.

Introdução à Metaprogramação: getattr e setattr

Metaprogramação é escrever código que manipula código. Python expõe ganchos para interceptar operações comuns:

class Configuracao:
    """Classe que registra todo acesso e modificação de atributos."""

    def __init__(self, **kwargs):
        object.__setattr__(self, "_dados", {})
        object.__setattr__(self, "_log", [])
        for k, v in kwargs.items():
            setattr(self, k, v)

    def __setattr__(self, nome, valor):
        self._log.append(f"SET {nome} = {valor!r}")
        self._dados[nome] = valor

    def __getattr__(self, nome):
        if nome in self._dados:
            self._log.append(f"GET {nome}")
            return self._dados[nome]
        raise AttributeError(f"Atributo '{nome}' não encontrado.")

    def historico(self):
        return self._log.copy()


cfg = Configuracao(host="localhost", porta=5432)
cfg.debug = True
_ = cfg.host

for entrada in cfg.historico():
    print(entrada)

# SET host = 'localhost'
# SET porta = 5432
# SET debug = True
# GET host

dataclasses: Metaprogramação Embutida

O módulo dataclasses usa metaprogramação para gerar automaticamente __init__, __repr__, __eq__ e outros métodos:

from dataclasses import dataclass, field
from typing import List

@dataclass
class Aluno:
    nome:  str
    nota:  float
    turma: str = "A"
    tags:  List[str] = field(default_factory=list)

    def aprovado(self):
        return self.nota >= 6.0

    def __post_init__(self):
        if not 0 <= self.nota <= 10:
            raise ValueError(f"Nota inválida: {self.nota}")


@dataclass(order=True, frozen=True)
class Ponto:
    x: float
    y: float

    def distancia_origem(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5


a1 = Aluno("Ana", 9.5)
a2 = Aluno("Bruno", 7.0, turma="B", tags=["monitor", "destaque"])

print(a1)           # Aluno(nome='Ana', nota=9.5, turma='A', tags=[])
print(a1.aprovado()) # True
print(a2)

p1 = Ponto(3.0, 4.0)
p2 = Ponto(1.0, 1.0)

print(p1.distancia_origem())  # 5.0
print(p1 > p2)                # True — order=True gera comparadores
# p1.x = 5  # FrozenInstanceError — frozen=True impede modificação

Decorador não é um recurso especial da linguagem: é consequência de duas propriedades simples que já existiam. Funções são objetos, podendo ser passadas e devolvidas como qualquer valor, e uma função aninhada enxerga o escopo onde foi criada mesmo depois que ele termina. O @ é apenas açúcar sintático para f = decorador(f), e entender isso desfaz a maior parte do mistério — inclusive a razão de o decorador com parâmetros precisar de três níveis de função, já que o primeiro nível consome os parâmetros e devolve o decorador de verdade. O @functools.wraps não é opcional: sem ele a função decorada perde nome, docstring e assinatura, e ferramentas de documentação e depuração passam a mostrar wrapper em toda parte.

Três ressalvas valem para quem sair daqui escrevendo decoradores. Atribuir a uma variável do escopo de fora exige nonlocal — ler não exige, e é essa assimetria que produz o UnboundLocalError mais confuso do Python; a lista de um elemento que aparece em tantos exemplos é contorno da época em que nonlocal não existia. Um cache escrito à mão exige argumentos hasheáveis e nunca esquece nada, o que num processo de longa duração é vazamento de memória: @functools.cache e @lru_cache resolvem os dois pontos. E decorador de classe que devolve função troca a classe por uma função, de modo que isinstance, anotação de tipo e herança deixam de funcionar — um preço alto, e invisível no exemplo que apenas constrói dois objetos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma função de contagem é escrita assim e quebra na primeira chamada:

def criar_contador():
    total = 0
    def contar():
        total += 1
        return total
    return contar

Diga qual é o erro, por que ele é confuso, e por que trocar total += 1 por print(total) faria a função passar a funcionar.

Ver resposta

✓ Resposta: O erro é UnboundLocalError: cannot access local variable 'total' where it is not associated with a value, e o que o torna confuso é que a mensagem fala em variável local para um nome que está declarado fora da função. A explicação está em quando o Python decide o escopo de um nome: não durante a execução, e sim na compilação da função. Ao compilar contar, o interpretador encontra uma atribuição a total — e total += 1 é uma atribuição — e classifica aquele nome como local para o corpo inteiro da função, da primeira à última linha. Quando a execução chega ao +=, ele precisa primeiro ler o valor atual para somar 1, e a leitura procura no escopo local, onde nada foi atribuído ainda. Daí a mensagem aparentemente contraditória. Sobre a segunda parte: trocar por print(total) faria funcionar justamente porque eliminaria a atribuição. Sem nenhuma atribuição ao nome no corpo, o Python o classifica como vindo do escopo de fora e a leitura sobe normalmente até a closure. É essa assimetria que confunde — ler o escopo externo sempre funciona, atribuir não —, e ela explica por que uma função pode acessar a variável de fora numa linha e falhar na seguinte. A correção é declarar a intenção com nonlocal total na primeira linha de contar, que instrui o compilador a tratar o nome como pertencente ao escopo envolvente. Há um contorno que aparece muito em exemplos antigos: guardar o valor numa lista e alterar total[0] += 1. Funciona porque aí não há atribuição ao nome total, e sim alteração do objeto que ele referencia — mas era a saída obrigatória no Python 2, e desde o Python 3.0 o nonlocal resolve pelo nome certo. Quem escreve a lista hoje está copiando material desatualizado.

Exercício 2

Uma classe de configuração é decorada com @singleton, que devolve uma função fechando sobre um dicionário de instâncias. O sistema roda bem por meses. Ao introduzir anotações de tipo e um isinstance numa função de validação, tudo quebra. Explique por que, e proponha duas alternativas.

Ver resposta

✓ Resposta: Porque o decorador substitui o objeto associado ao nome. Depois de @singleton, o nome ConfiguracaoApp não referencia mais uma classe: referencia a função obter_instancia, que por acaso devolve objetos daquela classe. Confirmando com type(ConfiguracaoApp), a resposta é <class 'function'>. As consequências aparecem exatamente onde uma classe é exigida como classe: isinstance(config, ConfiguracaoApp) levanta TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union; herdar com class Filha(ConfiguracaoApp) também falha; e anotar um parâmetro com esse nome passa a ser inválido para o verificador de tipos, porque uma função não é um tipo. Nada disso apareceu antes porque o código anterior só construía o objeto e chamava métodos — os únicos usos que uma função disfarçada de classe consegue atender. A primeira alternativa é não decorar a classe e criar a instância única no módulo: módulos em Python são carregados uma vez por processo, e o objeto definido no nível do módulo já é, por construção, único; a classe continua sendo classe e tudo o que depende disso funciona. A segunda é uma função de acesso decorada com @functools.cache, como def obter_configuracao() -> ConfiguracaoApp, que separa claramente o que é o tipo do que é a política de criação — e a anotação de retorno continua válida porque o nome da classe nunca foi substituído. Existe ainda o caminho de implementar o comportamento dentro da própria classe, sobrescrevendo __new__ para devolver sempre a mesma instância, o que preserva o nome como classe, mas costuma surpreender quem lê, porque __init__ passa a rodar a cada chamada sobre o mesmo objeto. Vale a ressalva de fundo: instância única global dificulta teste, porque o estado atravessa os casos, e esconde dependências que ficariam explícitas se fossem passadas como parâmetro. Antes de escolher entre as implementações, vale perguntar se o padrão é mesmo necessário.

Exercício 3

Um decorador de cache caseiro guarda resultados num dicionário indexado por args. Depois de semanas em produção num servidor que não reinicia, a memória do processo cresceu sem parar. Além disso, descobriu-se que chamadas com argumentos nomeados não estavam sendo cacheadas, e uma chamada com lista quebrou. Explique os três problemas e diga o que usar.

Ver resposta

✓ Resposta: Os três vêm do mesmo desenho minimalista. O crescimento de memória acontece porque o dicionário não tem política de descarte: cada combinação nova de argumentos acrescenta uma entrada que nunca sai, e o dicionário vive enquanto o decorador existir, isto é, enquanto o processo viver. Num script que roda e termina isso é irrelevante; num servidor de longa duração é um vazamento lento, e dos mais difíceis de diagnosticar, porque o crescimento é gradual e a causa está num decorador que ninguém suspeita. Vale notar o agravante de o cache guardar também os argumentos, mantendo vivos objetos que de outra forma seriam coletados. Os argumentos nomeados escapam porque o wrapper foi escrito com *args apenas — na prática, f(5) e f(n=5) são a mesma chamada com resultados iguais, mas a segunda nem chega a consultar o cache, e dependendo de como o wrapper foi escrito ela pode até levantar TypeError por argumento inesperado. A lista quebra porque args é usado como chave de dicionário, e chave precisa ser hasheável: passar [1, 2, 3] produz TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list'), já que a tupla de argumentos contém um elemento que não pode ser hasheado. O que usar é a biblioteca padrão. O @functools.lru_cache(maxsize=1000) resolve os dois primeiros de uma vez: descarta os itens menos usados ao atingir o limite e trata argumentos nomeados. O @functools.cache, disponível desde a 3.9, é o mesmo sem limite de tamanho, apropriado quando o conjunto de entradas possíveis é pequeno e conhecido. O terceiro problema é inerente à ideia de cache por argumento e nenhuma implementação resolve: se a função precisa receber coleções mutáveis, a saída é converter para uma forma hasheável na entrada — uma tupla — ou cachear num nível diferente, com uma chave que o próprio chamador define.

Exercício 4

Um decorador de log foi escrito sem @functools.wraps. Tudo funciona, e ninguém percebe nada de errado até que: a documentação gerada automaticamente passa a listar dezenas de funções chamadas wrapper; um teste que verifica func.__name__ falha; e uma ferramenta que inspeciona assinaturas passa a ver (*args, **kwargs) em toda parte. Explique o que wraps faz e por que a ausência dele demora tanto a doer.

Ver resposta

✓ Resposta: Um decorador devolve um objeto novo — a função wrapper — e é esse objeto que passa a atender pelo nome da função original. O wrapper tem os metadados dele próprio: __name__ é "wrapper", __doc__ é o docstring do wrapper ou None, __module__ aponta para onde o decorador foi definido, e a assinatura visível é a que ele declara, tipicamente (*args, **kwargs). A função original continua existindo, guardada na closure, mas nada aponta para os metadados dela. O @functools.wraps(func) é um decorador que copia esses atributos do original para o wrapper — __name__, __doc__, __module__, __qualname__, as anotações — e ainda grava uma referência ao original em __wrapped__, que é o que permite a inspect.signature recuperar a assinatura verdadeira. A ausência demora a doer porque nada do comportamento em execução depende desses metadados. A função decorada recebe os mesmos argumentos, executa a mesma lógica e devolve o mesmo resultado; testes de comportamento passam, a aplicação roda, e nenhuma exceção é levantada. Os metadados só são consultados por ferramentas — geradores de documentação, o help(), depuradores, frameworks que decidem rotas ou injeção de dependência inspecionando assinaturas, e bibliotecas de teste que identificam funções pelo nome. Por isso o sintoma aparece tarde, e aparece longe: não no código decorado, e sim na ferramenta, que de repente mostra tudo igual. Há um caso em que a falta de wraps deixa de ser cosmética e vira defeito de funcionamento: frameworks como FastAPI leem as anotações e a assinatura da função para decidir como interpretar a requisição. Um decorador sem wraps apaga essa informação, e o framework passa a ver apenas (*args, **kwargs) — a rota deixa de receber os parâmetros corretos, e o erro resultante não menciona decorador nenhum.

Exercício 5

Explique por que um decorador que aceita parâmetros precisa de três níveis de função aninhada, enquanto um sem parâmetros precisa de dois. Em seguida, diga o que acontece se alguém aplicar um decorador com parâmetros esquecendo os parênteses, como @repetir em vez de @repetir(3).

Ver resposta

✓ Resposta: A resposta sai de traduzir o @ para o que ele significa. A sintaxe @d acima de uma função equivale a f = d(f): o que estiver depois do arroba é avaliado e o resultado é chamado com a função como argumento. Num decorador sem parâmetros, o que está depois do arroba é o próprio nome, então d precisa ser algo que receba a função e devolva a substituta — dois níveis: a função externa que recebe func e o wrapper interno que recebe os argumentos da chamada. Num decorador com parâmetros, o que está depois do arroba é repetir(3), uma chamada, que é avaliada antes de qualquer decoração acontecer. O equivalente é f = repetir(3)(f), e fica claro que repetir(3) tem de devolver o decorador de verdade. Daí os três níveis: repetir recebe os parâmetros e devolve o decorador, o decorador recebe func e devolve o wrapper, e o wrapper recebe os argumentos da chamada. Cada nível existe porque recebe uma coisa diferente, em um momento diferente. Sobre o esquecimento dos parênteses: @repetir faz f = repetir(f), isto é, a função decorada é passada no lugar do parâmetro vezes. Não há erro nenhum nesse instante — o Python aceita de bom grado —, e o que repetir devolve é o decorador, que passa a atender pelo nome da função original. O erro só aparece quando alguém chama a função: como o que está lá é um decorador esperando receber uma função, a chamada f(10) devolve um wrapper em vez de executar qualquer coisa, ou levanta TypeError conforme a implementação. A mensagem não menciona parênteses faltando, e o rastro de pilha aponta para dentro do decorador, longe da linha que tem o defeito — que é a linha do arroba. É um dos enganos mais difíceis de diagnosticar em código com decoradores, e a defesa prática é escrever decoradores que aceitem as duas formas, verificando se o primeiro argumento recebido é chamável, ou aceitar apenas argumentos nomeados, o que torna o esquecimento impossível.

Comentários

Mais em Python

A História do Python e os Primeiros Passos
A História do Python e os Primeiros Passos

De um projeto de férias de Natal em 1989 à linguagem que hoje sai em versão…

Módulos, Pacotes e Organização de Projetos
Módulos, Pacotes e Organização de Projetos

Módulos, pacotes, ambientes virtuais e a estrutura de um projeto Python…

WebSockets e Comunicação em Tempo Real em Python
WebSockets e Comunicação em Tempo Real em Python

WebSockets e SSE com FastAPI, do eco ao chat e às notificações, com as falhas…