Eu organizei estas notas em seis partes. A primeira prepara a máquina. As três seguintes seguem a progressão que eu uso quando ensino ou quando integro alguém a um time: primeiro a linguagem, depois o código que outras pessoas conseguem manter, e por fim o que sustenta um sistema em produção. A quinta leva isso a um backend de verdade, e a sexta reúne tudo em três projetos que você constrói do começo ao fim.
Parte
Para quem
O que você sabe fazer no final
Ambiente
Quem vai instalar Python hoje, em qualquer sistema
Instalar versões, criar ambientes isolados e escolher entre pip, pipx, Poetry e uv
Básico
Quem está começando ou precisa consolidar as bases
Escrever scripts corretos com funções, estruturas de dados, exceções, arquivos e módulos
Intermediário
Quem já escreve scripts e quer escrever código de equipe
Modelar com classes, usar geradores e decoradores, tipar, testar, registrar logs e criar uma CLI
Avançado
Quem mantém código em produção
Dominar o modelo de dados, a concorrência, o desempenho, o empacotamento e a qualidade automatizada
Backend
Quem vai construir serviços
Consumir e projetar APIs REST, usar FastAPI, PostgreSQL, SQLAlchemy, Alembic, Docker e observabilidade
Projetos
Quem quer provar que aprendeu
Entregar três projetos completos, um por nível, com testes e verificações automáticas
Se você nunca programou, comece pela parte Ambiente e siga a ordem. Se já programa em outra linguagem, leia os capítulos de ambiente e o de mutabilidade e identidade, e depois pule para onde sentir lacuna. Os projetos da última parte dizem, em cada um, depois de qual capítulo fazê-los.
Como cada capítulo funciona
Todo capítulo segue o mesmo desenho: uma explicação curta, um trecho de código e, quando o código imprime algo, a saída real dele. Cada bloco de código mostra, no topo, o arquivo e as linhas do repositório onde ele está. Assim você nunca precisa adivinhar onde está o código que acabou de ler.
Três detalhes que fazem diferença:
A saída é real. Eu executei cada capítulo e copiei o que o Python imprimiu. Quando o resultado depende da sua máquina (versão, caminho, sistema), o bloco diz que é um exemplo.
Os exercícios têm solução. Tente primeiro. A solução fica recolhida e termina com assert, então você descobre sozinho se acertou.
As abas lembram o seu sistema. Nas instruções de instalação, escolha macOS, Windows ou Linux uma vez e o livro troca todas as abas de uma vez.
Como rodar o código
O repositório tem uma pasta por parte e um arquivo por capítulo, com o nome capNN_assunto.py.
Cada arquivo roda sozinho, a partir da raiz do repositório:
Terminal
python3basico/cap09_variaveis.py
No Windows, troque python3 por py. Quando você tiver o uv instalado (capítulo 7), a forma mais curta é uv run basico/cap09_variaveis.py.
Alguns arquivos pedem a sua participação
Capítulos que usam input() esperam que você digite. Os que gravam arquivos criam esses arquivos na pasta onde você executou o comando, e o .gitignore do repositório já os ignora. Os capítulos de concorrência usam vários processos e por isso protegem a execução com if __name__ == "__main__".
Qual versão do Python este livro assume
O piso é o Python 3.12. Quando escrevi, a série estável mais recente era a 3.14, e a 3.15 estava programada para o início de outubro de 2026. O Python 3.10 chega ao fim do suporte em outubro de 2026, e é por isso que eu não uso nada abaixo do 3.12.
Quando um recurso depende de versão, o texto diz qual. Exemplos: match (3.10), tomllib (3.11), TaskGroup e except* (3.11), a sintaxe class Pilha[T] (3.12) e a avaliação adiada de anotações (3.14).
Como eu recomendo estudar
Digite o código em vez de copiar. Rode. Depois quebre de propósito: troque um tipo, apague um parêntese, passe um valor inesperado e leia a mensagem de erro com calma. Quem aprende a ler traceback aprende Python duas vezes mais rápido.
Use a busca na lateral (a tecla / abre o campo) para voltar a um assunto sem rolar a página inteira.
Alexsander Valente
Software & AI Architecture, Product Engineering e Data Engineering.
Trabalho com Engenharia de Software, Arquitetura e Inteligência Artificial, participando de decisões técnicas que definem como sistemas são projetados, integrados, evoluídos e operados em produção.
Atuo com arquitetura de software, APIs, backend, integrações, sistemas distribuídos e plataformas de dados, além de soluções com GenAI, RAG, agentes de IA e MCP. Também trabalho com AI-Assisted Development e Harness Engineering, estruturando contexto, ferramentas e validações para tornar o desenvolvimento assistido por IA mais consistente e confiável.
Sou formado em Análise e Desenvolvimento de Sistemas, com pós-graduações em Arquitetura de Software, Inteligência Artificial e Machine Learning, Gestão da Qualidade de Software e Arquitetura de Soluções.
Mantenho uma atuação hands-on, conectando arquitetura e implementação na construção de sistemas escaláveis, resilientes e preparados para produção.
“Arquitetura e engenharia para sistemas que precisam funcionar, evoluir e permanecer confiáveis em produção.”
Antes de instalar qualquer coisa, eu quero que você tenha o modelo de execução certo na cabeça, porque ele explica quase todo erro de iniciante.
Código deste capítulo: ambiente/cap01_como_executa.py
Python em poucas palavras
Python é uma linguagem de alto nível e de propósito geral, criada por Guido van Rossum e lançada em 1991. O nome vem do grupo de comédia Monty Python, e não da cobra. Ela suporta programação procedural, orientada a objetos e funcional, e a sintaxe usa indentação para delimitar blocos, o que obriga todo código a ter a mesma cara.
ambiente/cap01_como_executa.pylinha 10
print("Olá, Python")
Saída
Olá, Python
Uma linha basta para um programa completo. Em Java, o equivalente exige uma classe, um método main e uma chamada longa. Essa economia é a primeira razão pela qual Python é uma boa primeira linguagem.
Compilado ou interpretado: os dois
A explicação popular diz que Python é "interpretado, linha por linha". Ela é útil para começar e errada em um detalhe que importa. O que o CPython (a implementação de referência) faz é:
Lê o arquivo inteiro e compila para bytecode, uma representação intermediária.
Executa esse bytecode na máquina virtual do Python.
Por isso um erro de sintaxe na última linha impede que a primeira linha rode. O Python nem chega a executar nada.
ambiente/cap01_como_executa.pylinhas 15 a 20
codigo="print('primeira linha')\nprint('segunda linha'"try:compile(codigo,"<exemplo>","exec")exceptSyntaxErroraserro:print("Nada foi executado. Erro de sintaxe:",erro.msg)
Saída
Nada foi executado. Erro de sintaxe: '(' was never closed
Já os erros de lógica, de tipo e de valor só aparecem quando a linha problemática é executada:
ambiente/cap01_como_executa.pylinhas 22 a 29
defdividir(a,b):returna/bprint("esta linha roda normalmente")try:dividir(1,0)exceptZeroDivisionErroraserro:print("erro só apareceu na execução:",erro)
Saída
esta linha roda normalmente
erro só apareceu na execução: division by zero
Se você quiser ver o bytecode, o módulo dis mostra. O resultado muda de versão para versão do Python, por isso eu não fixo a saída aqui. Rode no seu computador:
ambiente/cap01_como_executa.pylinhas 31 a 38
importdisdefsoma(a,b):returna+bdis.dis(soma)
Onde cada erro aparece
Fase
O que acontece
Erros típicos
Compilação
O arquivo inteiro é lido e traduzido para bytecode
Guarde essa tabela. No capítulo de exceções eu mostro que os dois grupos pertencem à mesma família de classes, mas a diferença de quando o erro aparece continua valendo.
Bytecode e a pasta __pycache__
Quando você importa um módulo, o Python grava o bytecode em arquivos .pyc dentro de __pycache__, para não recompilar na próxima vez. Pode apagar a pasta quando quiser: ela é recriada. E ela nunca vai para o Git.
Onde Python entra no seu trabalho
Python aparece em automação e scripts, em análise de dados, em APIs e sistemas web (Django, Flask e FastAPI), em testes e em quase toda a camada de orquestração de projetos de inteligência artificial. Vale uma observação honesta sobre este último caso: as bibliotecas de IA fazem o trabalho pesado em código compilado (C, C++, CUDA), e Python é a cola que as conecta. Essa é a forma mais saudável de pensar na linguagem: ela é excelente para expressar a intenção e boa o bastante em desempenho quando o gargalo está em outra camada.
Capítulo 2, parte Ambiente
Instalando o Python no macOS, Windows e Linux
A forma correta de instalar muda por sistema operacional, e a forma errada costuma custar horas mais tarde. Eu mostro o caminho que eu uso em cada um.
Código deste capítulo: ambiente/cap02_instalacao.py
Qual versão instalar
Instale a série estável mais recente, que quando escrevi era a 3.14, e nunca baixe nada abaixo da 3.12 para um projeto novo. O Python 3.10 sai de suporte em outubro de 2026.
Uma regra que me poupa dor de cabeça: o Python que veio com o sistema é do sistema. Ele serve ao macOS ou à sua distribuição Linux, que dependem de uma versão específica. Para os seus projetos, instale um Python à parte.
Instalar por sistema operacional
O caminho mais direto é o Homebrew. Se você ainda não o tem, instale pelo site oficial (brew.sh) e depois:
Terminal
brewinstallpython
python3--version
Você também pode baixar o instalador do site oficial (python.org, seção macOS), que funciona em Macs Intel e Apple Silicon. Eu prefiro o Homebrew porque atualizar vira um comando.
No macOS, o comando é python3. O comando python sozinho só existe dentro de um ambiente virtual ou quando você usa pyenv ou uv.
No Windows, o instalador atual é o Python install manager. O instalador tradicional deixa de ser publicado a partir do Python 3.16, então vale aprender o caminho novo. Instale pela Microsoft Store (procure "Python Install Manager") ou pelo winget:
PowerShell
wingetinstall9NQ7512CXL7T
Na primeira execução, o gerenciador roda uma verificação de configuração. Depois, instale e confira os ambientes de execução:
PowerShell
pyinstall3.14pylistpy--version
No Windows, o comando é py. Se você já tinha o antigo Python launcher, o site oficial recomenda desinstalá-lo antes, porque os dois usam o mesmo comando py. Para refazer a verificação de configuração, use py install --configure.
Cada família de distribuição tem o seu gerenciador de pacotes. Instale o interpretador, o pip e o módulo venv, que em algumas distribuições vem em pacote separado.
A versão que a distribuição oferece pode ser mais antiga do que você precisa. Nesse caso, use o pyenv (capítulo 3) ou o uv (capítulo 7) para instalar uma versão mais nova sem tocar no Python do sistema.
Erro externally-managed-environment
Em distribuições Linux recentes e no Homebrew, rodar pip install fora de um ambiente virtual falha com a mensagem externally-managed-environment. Isso é de propósito (a regra se chama PEP 668) e protege o Python do sistema. A solução correta é criar um ambiente virtual, que é o assunto do capítulo 4. Não use --break-system-packages.
Conferir a instalação
Antes de qualquer coisa, confirme a versão no terminal. Use python3 --version no macOS e no Linux, e py --version no Windows. Depois, rode este script, que mostra de onde o seu Python vem:
Versão: 3.14.6
Implementação: CPython
Executável: /opt/homebrew/bin/python3
Sistema: Darwin
O bloco acima é um exemplo de um Mac com Homebrew. A sua saída vai variar, e é isso mesmo que você quer observar: o caminho do executável diz qual Python você está usando de fato.
Os comandos python, python3 e py
Situação
Comando
macOS e Linux, fora de um venv
python3
Windows, fora de um venv
py
Dentro de um ambiente virtual (qualquer sistema)
python
Com pyenv ou uv configurados
python
O modo interativo (REPL)
Digitar python3 (ou py) sem argumentos abre o modo interativo, ótimo para testar uma expressão rápida:
Console Python
>>> 2+24>>> "python".upper()'PYTHON'>>> exit()
Capítulo 3, parte Ambiente
pyenv: várias versões de Python na mesma máquina
Quando dois projetos pedem versões diferentes, trocar o Python do sistema não é uma opção. O pyenv resolve isso compilando versões isoladas na sua pasta de usuário.
Código deste capítulo: ambiente/cap03_pyenv.py
O problema que o pyenv resolve
Um projeto antigo roda no Python 3.12, um novo pede 3.14, e a biblioteca que você testa só funciona na 3.13. O pyenv instala todas elas lado a lado e escolhe a versão certa conforme a pasta em que você está, usando um arquivo .python-version.
Terminal
brewinstallpyenv
xcode-select--install
Se as ferramentas de linha de comando da Apple já estiverem instaladas, o segundo comando avisa e não faz nada.
O instalador oficial baixa o pyenv para ~/.pyenv:
Terminal
curl-fsSLhttps://pyenv.run|bash
O pyenv compila o Python a partir do código-fonte, então você precisa das bibliotecas de desenvolvimento. No Debian e no Ubuntu:
No Windows eu não uso pyenv. O Python install manager do capítulo anterior já instala e alterna várias versões lado a lado:
PowerShell
pylistpyinstall3.13
Existe um projeto separado chamado pyenv-win, mantido pela comunidade. Se você precisar da mesma interface do pyenv no Windows, consulte o repositório dele.
Ativar o pyenv no shell
No macOS e no Linux, o pyenv precisa interceptar o comando python. Adicione estas linhas ao ~/.zshrc (zsh) ou ao ~/.bashrc (bash) e abra um terminal novo:
Dentro da pasta de um projeto, pyenv local grava o arquivo .python-version, que deve ir para o Git:
Terminal
cdmeu-projeto
pyenvlocal3.12
python--version
O pyenv escolhe a versão ativa seguindo uma ordem de prioridade:
Prioridade
Mecanismo
Como definir
1 (maior)
Sessão atual do terminal
pyenv shell 3.14
2
Pasta do projeto
pyenv local 3.12
3 (menor)
Padrão do usuário
pyenv global 3.13
Para garantir no próprio código que a versão é a esperada, o script abaixo falha cedo e com uma mensagem clara:
ambiente/cap03_pyenv.pylinhas 10 a 13
importsysassertsys.version_info>=(3,12),"Este livro exige Python 3.12 ou superior"print("versão ok")
Saída
versão ok
O custo de compilar
O pyenv compila cada versão a partir do código-fonte, o que leva alguns minutos e depende das bibliotecas de desenvolvimento instaladas. O uv, que eu apresento no capítulo 7, baixa binários prontos e instala a mesma versão em segundos. Eu uso pyenv quando preciso de uma compilação específica e uv no resto.
Capítulo 4, parte Ambiente
pip e venv: dependências isoladas por projeto
Todo projeto deveria ter o seu próprio ambiente virtual. É a regra mais barata de seguir e a que mais evita problema.
Código deste capítulo: ambiente/cap04_pip_venv.py
Por que um ambiente virtual
Se você instala tudo no mesmo Python, dois projetos que precisam de versões diferentes da mesma biblioteca se atropelam. Um ambiente virtual é uma pasta com um interpretador e um conjunto próprio de pacotes. Instalar ali não afeta nada fora dela.
Criar e ativar
Crie o ambiente dentro da pasta do projeto, com o nome .venv, que é a convenção que as ferramentas e os editores reconhecem sozinhos:
No prompt de comando (cmd), a ativação é .venv\Scripts\activate.bat. Para sair, deactivate.
Windows e a política de execução do PowerShell
Se o PowerShell recusar o script de ativação, libere scripts assinados localmente, só para o seu usuário: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned. Feche e abra o terminal depois.
Como saber se você está dentro de um venv
O prompt do terminal ganha o prefixo (.venv). Dentro do Python, dá para checar comparando dois caminhos:
ambiente/cap04_pip_venv.pylinhas 10 a 13
importsysdentro_de_venv=sys.prefix!=sys.base_prefixprint("Dentro de um venv:",dentro_de_venv)
Saída
Dentro de um venv: True
Dentro do ambiente virtual a saída é True. Fora dele, imprime False.
O pip
O pip instala pacotes do PyPI. Eu sempre uso python -m pip em vez de pip puro, porque assim o pip é obrigatoriamente o do interpretador ativo:
O pip freeze lista tudo o que está instalado, dependências diretas e indiretas misturadas, sem distinguir o que você pediu do que veio de carona. Para reprodutibilidade de verdade, eu prefiro um arquivo de bloqueio (lockfile) gerado por Poetry ou uv, que você verá nos próximos capítulos.
A pasta .venv nunca vai para o Git. Coloque .venv/ no .gitignore. Quem clona o projeto recria o ambiente a partir da lista de dependências.
Capítulo 5, parte Ambiente
pipx: ferramentas de linha de comando isoladas
Aplicações como o ruff ou o poetry não são bibliotecas do seu projeto. O pipx instala cada uma no seu próprio ambiente e a deixa disponível em qualquer pasta.
Código deste capítulo: ambiente/cap05_pipx.py
Aplicação não é biblioteca
Uma biblioteca (requests, por exemplo) você usa dentro do seu código, então ela mora no venv do projeto. Uma ferramenta de linha de comando (ruff, poetry, httpie) você executa no terminal em qualquer lugar. Instalar isso com pip global polui o Python e causa conflito. O pipx cria um venv por ferramenta e expõe só o comando.
Instalação
Terminal
brewinstallpipx
pipxensurepath
PowerShell
py-mpipinstall--userpipxpy-mpipxensurepath
Debian e Ubuntu
sudoaptinstallpipx
pipxensurepath
Fedora
sudodnfinstallpipx
pipxensurepath
O pipx ensurepath adiciona a pasta dos executáveis do pipx ao seu PATH. Feche e abra o terminal depois.
Para rodar uma ferramenta uma única vez, sem instalar, use pipx run. Ela vive num ambiente temporário:
Terminal
pipxrunruff--version
Para escolher a versão do Python que a ferramenta usa:
Terminal
pipxinstall--python3.13ruff
Para adicionar um plugin ao ambiente de uma ferramenta já instalada, use pipx inject:
Terminal
pipxinjectpoetrypoetry-plugin-export
Quais ferramentas estão no seu PATH
O módulo shutil ajuda a ver, a partir do Python, o que está instalado e onde:
ambiente/cap05_pipx.pylinhas 10 a 15
importshutilforferramentain["python3","pipx","uv","poetry","pyenv"]:caminho=shutil.which(ferramenta)situacao=f"instalado em {caminho}"ifcaminhoelse"não encontrado"print(f"{ferramenta:8}{situacao}")
Saída
python3 instalado em /opt/homebrew/bin/python3
pipx instalado em /Users/ana/.local/bin/pipx
uv instalado em /Users/ana/.local/bin/uv
poetry instalado em /Users/ana/.local/bin/poetry
pyenv não encontrado
O bloco acima é um exemplo. A sua saída depende do que você instalou.
O uv faz o mesmo papel
O comando uv tool install ruff equivale a pipx install ruff, e uvx equivale a pipx run. Se você adotar o uv (capítulo 7), pode dispensar o pipx.
Capítulo 6, parte Ambiente
Poetry: projetos, dependências e lockfile
O Poetry junta criação de ambiente, resolução de dependências, arquivo de bloqueio e publicação num único comando. Eu mostro como ele funciona hoje, na versão 2.
Código deste capítulo: ambiente/cap06_poetry.py
O que o Poetry resolve
O pip instala. O Poetry gerencia: declara as dependências do projeto no pyproject.toml, resolve a árvore completa de dependências indiretas, grava as versões exatas em poetry.lock e cria o ambiente virtual para você. Dois colegas que rodam poetry install obtêm exatamente os mesmos pacotes.
Um ponto de atenção da versão 2: o Poetry passou a respeitar a seção [project] do pyproject.toml, que é o padrão da comunidade (PEP 621). O comando poetry shell foi movido para um plugin, e o substituto é poetry env activate.
Instalar
O caminho que eu recomendo é instalar o Poetry com o pipx, para que ele fique isolado:
O Poetry cria a estrutura e o pyproject.toml. Nas versões atuais, o código já vai para a pasta src/ por padrão, que é o layout que eu recomendo no capítulo de empacotamento. A opção --src, que você ainda vê em tutoriais antigos, está sendo descontinuada justamente por ter virado o comportamento padrão:
Terminal
poetrynewmeu-projeto
cdmeu-projeto
Para transformar uma pasta existente em projeto Poetry, use poetry init, que faz perguntas no terminal. Depois de poetry add requests e poetry add --group dev pytest, o arquivo fica assim. Repare que o grupo de desenvolvimento vai para [dependency-groups], o mesmo padrão que o uv usa, e que os campos de autor vêm com valores de exemplo para você trocar:
A diferença entre install e sync: o install garante que o necessário esteja instalado. O sync faz o ambiente ficar idêntico ao lockfile, removendo o que sobrar.
Ativar o ambiente
Você pode sempre usar poetry run, sem ativar nada. Se preferir ativar o ambiente na sua sessão, no Poetry 2 o comando imprime a instrução de ativação, e você a executa. No macOS e no Linux, eval $(poetry env activate) faz as duas coisas de uma vez:
Em aplicações, o poetry.lock vai para o Git. É ele que garante que o seu servidor de produção instale o mesmo que você testou. Em bibliotecas publicadas, a discussão é mais sutil, e eu volto a ela no capítulo de empacotamento.
Capítulo 7, parte Ambiente
uv: o gerenciador unificado
O uv instala versões de Python, cria ambientes, resolve dependências, roda scripts e instala ferramentas, tudo em um único binário muito rápido. É o que eu uso por padrão em projetos novos.
Este capítulo usa comandos de terminal. Os arquivos deste capítulo estão em exemplos/.
Por que eu uso o uv
O uv é feito pela Astral (os criadores do ruff) e escrito em Rust. O que me convenceu foi a combinação de três coisas: ele substitui pyenv, venv, pip, pipx e boa parte do Poetry; ele baixa Pythons prontos em vez de compilar; e ele tem uma interface compatível com o pip para quando você já tem um fluxo montado.
O uv add altera o pyproject.toml e atualiza o arquivo uv.lock. O uv run garante que o ambiente exista e esteja em dia antes de executar, então você não precisa ativar nada. O pyproject.toml fica assim:
exemplos/uv/pyproject.toml
[project]name="meu-app"version="0.1.0"description="Exemplo de projeto gerenciado pelo uv"readme="README.md"requires-python=">=3.12"dependencies=["requests>=2.34.2",][dependency-groups]dev=["pytest>=9.1.1",]
Outros comandos que eu uso o tempo todo:
Terminal
uvremoverequests
uvsync
uvlock
uvtree
Scripts com dependências embutidas
Para um script solto que precisa de uma biblioteca, o uv aceita metadados no próprio arquivo (um padrão chamado PEP 723). Ele cria um ambiente temporário só para aquela execução:
exemplos/uv/script_inline.py
# /// script# requires-python = ">=3.12"# dependencies = ["rich"]# ///fromrichimportprintprint("[bold]Olá[/bold] de um script com dependências embutidas")
Terminal
uvrunexemplos/uv/script_inline.py
Para adicionar uma dependência a um script existente: uv add --script script.py requests.
O uvx é um atalho para uv tool run: roda a ferramenta num ambiente temporário, como o pipx run.
Compatibilidade com o pip
Se você já tem requirements.txt e não quer migrar agora, use a interface compatível:
Terminal
uvvenv
uvpipinstall-rrequirements.txt
uv add versus uv pip install
O uv add registra a dependência no pyproject.toml e no lockfile. O uv pip install só instala no ambiente, no estilo do pip, e não registra nada. Misturar os dois no mesmo projeto é a causa mais comum de "funciona na minha máquina".
Capítulo 8, parte Ambiente
Qual ferramenta escolher, editor e estrutura de projeto
Cinco ferramentas fazem coisas parecidas. Eu comparo com critérios objetivos e digo o que escolheria em cada situação.
Código deste capítulo: ambiente/cap08_estrutura_projeto.py
Comparação
Critério
pip e venv
pyenv
pipx
Poetry
uv
Instala versões de Python
Não
Sim (compila)
Não
Não
Sim (binários prontos)
Cria ambiente virtual
Sim (venv)
Não
Um por ferramenta
Sim
Sim
Resolve dependências indiretas
Básico
Não
Não
Sim
Sim
Gera lockfile
Não
Não
Não
Sim
Sim
Instala ferramentas de CLI
Não
Não
Sim
Não
Sim (uv tool)
Publica pacotes
Não
Não
Não
Sim
Sim (uv publish)
Curva de aprendizado
Baixa
Média
Baixa
Média
Baixa
Minha recomendação
Eu escolheria uv para projetos novos, porque ele cobre o ciclo inteiro com uma ferramenta só. Escolheria Poetry se o time já o usa e o poetry.lock está em produção: trocar de gerenciador por modismo é custo sem retorno. Escolheria pip e venv para aprender e para ambientes restritos em que só o Python do sistema está disponível: o que você aprende ali serve em todo o resto. Escolheria pyenv se eu precisasse de uma compilação específica do Python. Escolheria pipx para ferramentas globais em máquinas onde o uv não está disponível.
Editor
O VS Code com a extensão Python da Microsoft resolve a maior parte dos casos. Depois de abrir a pasta do projeto, escolha o interpretador pelo comando Python: Select Interpreter (a paleta de comandos abre com Ctrl+Shift+P, ou Cmd+Shift+P no macOS) e aponte para o .venv do projeto. O PyCharm é uma alternativa completa se você prefere um IDE.
Cuidado com a extensão Code Runner
Por padrão, o Code Runner executa o arquivo no painel Output do VS Code, que não aceita digitação. Os exemplos com input() ficam parados ou falham ali. Rode no terminal integrado (python3 arquivo.py) ou use o botão Run Python File da extensão oficial.
Uma variável em Python não é uma caixa que guarda um valor. É um nome que aponta para um objeto, e essa diferença explica muita coisa mais adiante.
Código deste capítulo: basico/cap09_variaveis.py
Comentários
Comentários são notas para quem lê o código. O Python ignora tudo o que vem depois de um #, até o fim da linha.
basico/cap09_variaveis.pylinhas 10 a 17
# Comentário de uma linhaidade=30# comentário no fim da linha"""Isto é uma string de várias linhas.O Python a avalia e descarta, mas ela não é um comentário de verdade."""print(idade)
Saída
30
Python não tem comentário de várias linhas. O que as pessoas fazem é usar uma string de três aspas como se fosse comentário. Funciona, mas é uma string solta no código. Quando essa string é a primeira instrução de uma função, classe ou módulo, ela vira uma docstring, a documentação oficial daquele objeto:
basico/cap09_variaveis.pylinhas 19 a 24
defarea_quadrado(lado):"""Devolve a área de um quadrado de lado dado."""returnlado*ladoprint(area_quadrado.__doc__)
Saída
Devolve a área de um quadrado de lado dado.
Eu escrevo comentários para explicar por quê, nunca o quê. O código já diz o que faz. O comentário diz o motivo da decisão.
Variáveis são nomes
Em Python, idade = 30 não "guarda 30 dentro de idade". Ela cria o objeto inteiro 30 e faz o nome idade apontar para ele. O tipo pertence ao objeto, não ao nome. Por isso o mesmo nome pode apontar para tipos diferentes ao longo do programa:
Essa visão de etiqueta (nome apontando para objeto) vai ser decisiva quando chegarmos em listas e funções. Guarde-a.
Atribuições múltiplas e a troca de valores entre duas variáveis ficam naturais:
basico/cap09_variaveis.pylinhas 39 a 44
a,b=1,2a,b=b,aprint(a,b)x=y=0print(x,y)
Saída
2 1
0 0
Nomes válidos e convenções
Um nome começa com letra ou sublinhado, contém letras, números e sublinhados, diferencia maiúsculas de minúsculas e não pode ser uma palavra reservada. O módulo keyword conhece todas as palavras reservadas:
Escrever LIMITE = 3 em maiúsculas é um aviso para outras pessoas de que o valor não deve mudar. A linguagem não impede a alteração. Existe typing.Final para ferramentas de análise estática, e eu o mostro no capítulo de type hints.
Exercício 1
Troque dois valores sem criar uma terceira variável
Dados a = 3 e b = 7, troque os valores de modo que a termine valendo 7 e b valendo 3.
Ver solução
basico/cap09_variaveis.pylinhas 58 a 61
a,b=3,7a,b=b,aassert(a,b)==(7,3)print("ok")
Saída
ok
Capítulo 10, parte Básico
Tipos de dados
Todo valor tem um tipo, e o tipo decide o que você pode fazer com ele. Python descobre o tipo sozinho, mas isso não significa que você possa ignorá-lo.
O int do Python cresce até a memória acabar. O underscore ajuda a ler números grandes:
basico/cap10_tipos.pylinhas 15 a 16
print(2**100)print(1_000_000)
Saída
1267650600228229401496703205376
1000000
O float segue o padrão binário IEEE 754 e não representa exatamente a maioria das frações decimais. Isso vale para qualquer linguagem, e é o motivo pelo qual você nunca deve comparar dinheiro com ==:
'str' object does not support item assignment
Jython
Os métodos do dia a dia
Estes são os métodos que eu uso toda semana. Todos devolvem uma string nova (ou outro valor) e deixam a original intacta:
basico/cap11_strings.pylinhas 27 a 37
frase=" Aprender Python é divertido "print(frase.strip())print(frase.strip().lower())print(frase.strip().upper())print(frase.strip().split())print("-".join(["a","b","c"]))print(frase.replace("Python","Go").strip())print(frase.find("Python"))print(frase.strip().startswith("Aprender"))print(frase.count("e"))print("Python"infrase)
Saída
Aprender Python é divertido
aprender python é divertido
APRENDER PYTHON É DIVERTIDO
['Aprender', 'Python', 'é', 'divertido']
a-b-c
Aprender Go é divertido
11
True
3
True
O find devolve -1 quando não acha. O index faz o mesmo, mas levanta ValueError. Para decidir se uma string contém outra, use o operador in.
f-strings
As f-strings são a forma moderna de montar texto. Depois dos dois pontos vai o formato do valor:
A palavra ação tem 4 caracteres e ocupa 6 bytes em UTF-8, porque ç e ã usam dois bytes cada. Sempre diga a codificação explicitamente ao ler e gravar arquivos. Retomo isso no capítulo de arquivos.
Exercício 1
Palíndromo
Escreva eh_palindromo(texto) que ignore maiúsculas e espaços. Dica: lower, replace e uma fatia invertida resolvem sem laço.
Ver solução
basico/cap11_strings.pylinhas 70 a 78
defeh_palindromo(texto):limpo=texto.lower().replace(" ","")returnlimpo==limpo[::-1]asserteh_palindromo("Anotaram a data da maratona")asserteh_palindromo("radar")assertnoteh_palindromo("python")print("ok")
Saída
ok
Exercício 2
Inverter a ordem das palavras
Escreva inverter_palavras("python é bom") que devolva "bom é python".
Ver solução
basico/cap11_strings.pylinhas 83 a 88
definverter_palavras(frase):return" ".join(frase.split()[::-1])assertinverter_palavras("python é bom")=="bom é python"print("ok")
Saída
ok
Capítulo 12, parte Básico
Conversão de tipos e valores falsy
Converter tipos é rotina. O que costuma pegar iniciante é não saber o que o Python considera verdadeiro ou falso.
Código deste capítulo: basico/cap12_conversao_falsy.py
Conversão explícita
As funções int(), float(), str() e bool() convertem de forma explícita. Duas surpresas: int() trunca em direção ao zero, e round() arredonda para o número par mais próximo quando o valor está exatamente no meio:
Quando o texto não é conversível, o Python levanta ValueError, e a mensagem diz exatamente qual era o valor:
basico/cap12_conversao_falsy.pylinhas 16 a 19
try:int("abc")exceptValueErroraserro:print(erro)
Saída
invalid literal for int() with base 10: 'abc'
Conversão implícita
O Python converte sozinho quando a conta pede, sempre para o tipo mais "largo". O operador / é um caso à parte: ele sempre devolve float, mesmo quando as duas pontas são inteiras. Para divisão inteira use //:
Em uma condição, o Python converte o valor com bool(). A regra é curta: valores "vazios" ou "zero" são falsos, e todo o resto é verdadeiro. Esta é a lista completa dos que você mais encontra:
Se eu escrevesse apenas if not quantidade, None e 0 caíam no mesmo ramo, e o sistema trataria "não informado" como "zero". Em estoque, isso é um bug de verdade.
Exercício 1
Números no formato brasileiro
Escreva para_float(texto) que converta "12,5" em 12.5 e "1.234,56" em 1234.56.
O input mostra uma pergunta, espera a pessoa digitar e devolve sempre uma string. Se você precisa de número, converta:
basico/cap13_entrada_operadores.pylinhas 17 a 19
nome=input("Qual é o seu nome? ")idade=int(input("Quantos anos você tem? "))print(f"Olá, {nome}. Em 5 anos você terá {idade+5} anos.")
Saída
Qual é o seu nome? Ana
Quantos anos você tem? 30
Olá, Ana. Em 5 anos você terá 35 anos.
Nunca confie no que a pessoa digita
Se alguém digitar trinta, o int() levanta ValueError e o programa cai. No capítulo de exceções eu mostro como validar a entrada sem derrubar o programa.
Cuidado com -7 // 2: a divisão inteira arredonda para baixo, então o resultado é -4, e não -3. O divmod devolve quociente e resto de uma vez só.
Comparação e operadores lógicos
As comparações podem ser encadeadas, como na matemática:
basico/cap13_entrada_operadores.pylinhas 34 a 37
x=7print(1<x<10)print(x==7.0)print("a"<"b")
Saída
True
True
True
Os operadores and, or e not têm duas características que a maioria dos cursos não conta. Primeiro, and e or devolvem um dos operandos, não necessariamente True ou False. Segundo, eles fazem curto-circuito: param assim que o resultado está decidido.
O idioma valor or padrao é comum para definir um valor padrão. Use com cuidado: ele também troca 0 e "", que podem ser valores válidos.
Pertencimento e identidade
Os operadores in e not in perguntam se um item está em uma coleção. Os operadores is e is not perguntam se dois nomes apontam para o mesmo objeto. Use is apenas com None, True e False:
Os operadores +=, -=, *=, //= e companhia modificam e atribuem de uma vez. O operador := (chamado de morsa, disponível desde o Python 3.8) atribui dentro de uma expressão:
Quando uma expressão mistura operadores, a precedência decide a ordem: primeiro **, depois *///%, depois +-, depois comparações, depois not, and e or. Eu nunca decoro a tabela inteira: uso parênteses sempre que a ordem não é óbvia à primeira leitura.
Exercício 1
Converter segundos em horas, minutos e segundos
Escreva decompor(segundos) que devolva uma tupla (horas, minutos, segundos). Por exemplo, 3725 segundos são (1, 2, 5).
Desde o Python 3.10 existe o match, que é muito mais do que um switch. Ele casa a forma do dado, e pode capturar partes dele:
basico/cap14_condicionais.pylinhas 36 a 51
defresponder(comando):matchcomando.split():case["parar"]:return"parando"case["mover",direcao]:returnf"movendo para {direcao}"case["mover",direcao,passos]ifpassos.isdigit():returnf"movendo {passos} passos para {direcao}"case_:return"comando desconhecido"print(responder("parar"))print(responder("mover norte"))print(responder("mover sul 3"))print(responder("voar"))
Saída
parando
movendo para norte
movendo 3 passos para sul
comando desconhecido
O case _ é o caso padrão. Eu uso match quando o dado tem estrutura (comandos, mensagens, árvores). Para comparar um valor com poucas opções simples, if e elif continuam mais diretos.
Idiomas que deixam o código limpo
Evite
Prefira
if ativo == True:
if ativo:
if len(lista) > 0:
if lista:
if valor == None:
if valor is None:
ifs aninhados em cascata
Retornos antecipados
O retorno antecipado (guard clause) trata os casos de saída primeiro e deixa o caminho principal sem indentação extra:
Quando você precisa de uma sequência de números, use range. Ele aceita range(fim), range(inicio, fim) e range(inicio, fim, passo), e o fim nunca é incluído. Ele é preguiçoso: não cria a lista na memória. O list() abaixo serve só para mostrar o conteúdo:
O break encerra o laço. O continue pula para a próxima volta. O else do laço é pouco conhecido e muito útil: ele roda somente se o laço terminou sem break.
basico/cap15_laco_for.pylinhas 38 a 45
fornumeroinrange(1,11):ifnumero==3:continueifnumero==6:breakprint(numero)else:print("terminou sem break")
Saída
1
2
4
5
O else do laço brilha em buscas. Para saber se um número é primo, procure um divisor. Se achar, break. Se o laço acabar sem achar, o else confirma:
basico/cap15_laco_for.pylinhas 47 a 53
numero=17fordivisorinrange(2,numero):ifnumero%divisor==0:print(f"{numero} não é primo")breakelse:print(f"{numero} é primo")
Saída
17 é primo
Laços aninhados e padrões
Um laço dentro de outro executa o interno por completo a cada volta do externo. É a base de tabelas, matrizes e padrões desenhados com texto:
basico/cap15_laco_for.pylinhas 58 a 59
forlinhasinrange(1,5):print("*"*linhas)
Saída
*
**
***
****
basico/cap15_laco_for.pylinhas 61 a 64
foriinrange(1,4):forjinrange(1,4):print(f"{i} x {j} = {i*j}",end=" ")print()
Saída
1 x 1 = 1 1 x 2 = 2 1 x 3 = 3
2 x 1 = 2 2 x 2 = 4 2 x 3 = 6
3 x 1 = 3 3 x 2 = 6 3 x 3 = 9
A multiplicação de uma string por um inteiro ("*" * 3) repete o texto, e é o truque que evita um segundo laço em muitos desenhos.
Exercício 1
Fatorial
Escreva fatorial(n) com um laço for. O fatorial de 0 é 1.
Se você esquecer de atualizar a variável da condição, o programa fica preso para sempre. No terminal, Ctrl+C interrompe. Antes de rodar um while, pergunte: o que muda a cada volta e faz a condição virar falsa?
while True com break
Quando você não sabe quantas vezes vai repetir, o padrão while True com um break na saída é o mais claro. Ele é o jeito certo de validar entrada:
basico/cap16_laco_while.pylinhas 19 a 24
whileTrue:texto=input("Digite um número positivo: ")iftexto.isdigit()andint(texto)>0:breakprint("Valor inválido, tente de novo.")print(f"Você digitou {int(texto)}")
Saída
Digite um número positivo: abc
Valor inválido, tente de novo.
Digite um número positivo: -3
Valor inválido, tente de novo.
Digite um número positivo: 42
Você digitou 42
Dígitos de um número
A combinação de while com divmod(n, 10) extrai um dígito por vez, do último para o primeiro. É a base de somar dígitos, inverter números e testar palíndromos numéricos:
Uma função dá nome a um pedaço de lógica. Escrever boas funções é o que separa um script que funciona de um programa que dá para manter.
Código deste capítulo: basico/cap17_funcoes.py
Definir, chamar e devolver
Uma função se define com def, recebe parâmetros e devolve um valor com return. Se não houver return, ela devolve None:
basico/cap17_funcoes.pylinhas 10 a 20
defsaudacao(nome):"""Devolve uma saudação."""returnf"Olá, {nome}!"defsem_retorno():passprint(saudacao("Ana"))print(sem_retorno())
Saída
Olá, Ana!
None
Uma confusão comum: print mostra na tela, return devolve o valor para quem chamou. Uma função que só imprime não pode ter o resultado usado em outra conta.
Parâmetros e argumentos
Parâmetro é o nome na definição. Argumento é o valor passado na chamada. Há quatro formas de passar:
Posicional: a ordem importa.
Nomeada:nome=valor, em qualquer ordem.
Padrão: o parâmetro tem um valor caso não seja passado.
Somente posicional (/) e somente nomeado (*): você controla como a função pode ser chamada.
Eu uso parâmetros somente nomeados (depois do *) para opções booleanas, porque formatar(texto, True) não diz nada e formatar(texto, maiusculo=True) diz tudo.
*args e **kwargs
Para aceitar uma quantidade variável de argumentos, *args junta os posicionais em uma tupla e **kwargs junta os nomeados em um dicionário. Na chamada, o mesmo símbolo faz o caminho inverso: desempacota:
Em Python, uma função é um valor como qualquer outro: pode ser guardada em variável e passada como argumento. Essa ideia é a base dos decoradores e da programação funcional que vêm no nível intermediário:
A docstring explica o contrato. As anotações de tipo (type hints) o tornam legível por ferramentas. O Python não verifica as anotações ao executar:
basico/cap17_funcoes.pylinhas 79 a 85
defsoma(a:int,b:int)->int:"""Soma dois inteiros e devolve o resultado."""returna+bprint(soma(2,3))print(soma("a","b"))
Saída
5
ab
A segunda chamada funciona e junta duas strings, apesar de a anotação dizer int. Quem verifica é uma ferramenta externa, como o mypy, que eu apresento no nível intermediário.
Exercício 1
Média de quantos números quiser
Escreva media(*numeros) que devolva a média aritmética.
Onde uma variável existe, e as duas armadilhas que fazem mais gente perder tempo: variável local mal usada e valor padrão mutável.
Código deste capítulo: basico/cap18_escopo.py
Escopo: onde um nome vale
O Python procura um nome em quatro lugares, nesta ordem, que a comunidade chama de LEGB: Local (dentro da função), Enclosing (função externa), Global (módulo) e Built-in (nomes embutidos, como len).
basico/cap18_escopo.pylinhas 10 a 24
mensagem="global"defexterna():mensagem="local da externa"definterna():print(mensagem)interna()print(mensagem)externa()print(mensagem)
Saída
local da externa
local da externa
global
Para alterar uma variável global dentro de uma função, é preciso declarar global. Eu evito: estado global torna o código difícil de testar e de entender. Prefira receber o valor como parâmetro e devolver o novo:
Se você atribui a um nome dentro da função, o Python o trata como local na função inteira, inclusive nas linhas anteriores à atribuição. Por isso o erro abaixo acontece mesmo existindo um total global:
cannot access local variable 'total' where it is not associated with a value
O valor padrão mutável
Esta é a armadilha mais famosa da linguagem. O valor padrão de um parâmetro é criado uma única vez, quando a função é definida. Se for uma lista, todas as chamadas compartilham a mesma lista:
A segunda chamada devolve [1, 2], embora você não tenha passado lista nenhuma: o 1 ficou guardado no próprio valor padrão. A solução é usar None como sentinela e criar a lista dentro da função:
A regra que eu sigo: nunca use lista, dicionário ou conjunto como valor padrão. Linters como o ruff acusam esse erro automaticamente (regra B006).
Efeitos colaterais
Uma função que altera os argumentos que recebe tem um efeito colateral. Isso não é proibido, mas deve ser explícito no nome e na documentação. Compare:
Uma função pode chamar a si mesma. Toda função recursiva precisa de um caso base que pare a recursão. O Python limita a profundidade (1000 chamadas por padrão) e não otimiza recursão de cauda, então para problemas profundos prefira um laço:
basico/cap18_escopo.pylinhas 95 a 113
importsysdeffatorial(n):return1ifn<=1elsen*fatorial(n-1)print(fatorial(5))print(sys.getrecursionlimit())defsem_fim(n):returnsem_fim(n+1)try:sem_fim(0)exceptRecursionError:print("limite de recursão atingido")
Saída
120
1000
limite de recursão atingido
Exercício 1
Corrija o bug do valor padrão
A função abaixo deveria devolver uma lista nova a cada chamada sem argumento. Corrija-a.
A lista é a estrutura de dados que você mais vai usar. Entender o que é mutável nela evita os bugs mais difíceis de achar.
Código deste capítulo: basico/cap19_listas.py
Criar, acessar e fatiar
Uma lista é uma sequência ordenada e mutável de itens de qualquer tipo. Os índices e as fatias funcionam como nas strings, mas aqui você pode alterar os itens:
Quase todos os métodos que alteram a lista devolvem None. O erro clássico é escrever lista = lista.sort() e ficar com None. Compare com sorted(), que devolve uma lista nova e não mexe na original:
b = a não cria uma lista nova: cria um segundo nome para a mesma lista. Para copiar, use copy(). E a cópia é rasa: os itens internos continuam compartilhados. Para listas dentro de listas, é preciso copy.deepcopy:
Uma lista funciona bem como pilha (último a entrar, primeiro a sair), com append e pop no fim, que são rápidos. Como fila, ela é ruim: pop(0) precisa deslocar todos os itens. Para filas, use collections.deque:
Um conjunto guarda itens únicos, sem ordem garantida, e responde "este item está aqui?" em tempo praticamente constante. As operações seguem a teoria dos conjuntos:
{} cria um dicionário vazio, não um conjunto. Para um conjunto vazio, use set().
Os itens de um conjunto precisam ser hashable, o que na prática significa imutáveis. Uma lista não pode entrar, mas uma tupla pode. O frozenset é a versão imutável do conjunto:
Um conjunto não tem posição, então não existe s[0]. A ordem em que ele imprime pode mudar entre execuções, principalmente com strings. Se você precisa de ordem, use sorted(conjunto).
Qual estrutura usar
Eu preciso de...
Uso
Itens em ordem que podem mudar
list
Um registro fixo, ou uma chave composta
tuple
Remover duplicatas e testar pertencimento rápido
set
Associar valores a chaves
dict (próximo capítulo)
Exercício 1
Remover duplicatas preservando a ordem
Escreva sem_duplicados(itens). Para [3, 1, 3, 2, 1] o resultado é [3, 1, 2].
O dicionário associa chaves a valores e é a estrutura mais poderosa da linguagem. Ele aparece em toda parte, de configurações a respostas de API.
Código deste capítulo: basico/cap21_dicionarios.py
Chaves e valores
Um dicionário guarda pares chave: valor. Desde o Python 3.7, ele preserva a ordem de inserção. As chaves precisam ser hashable (strings, números, tuplas) e únicas:
Para juntar dois dicionários, o operador | (Python 3.9 ou superior) e o desempacotamento com ** dão o mesmo resultado, e em caso de chave repetida vence o da direita:
Escreva somar_dicts(d1, d2) que junte os dois somando os valores das chaves em comum. Para {"a": 5, "b": 3} e {"b": 4, "c": 2}, o resultado é {"a": 5, "b": 7, "c": 2}.
Este é o capítulo que separa quem decorou a sintaxe de quem entende o modelo de objetos de Python. Quase todo bug estranho com listas e funções nasce aqui.
Código deste capítulo: basico/cap22_mutabilidade.py
Nomes, objetos e id
Lembra da etiqueta do primeiro capítulo? Cada objeto tem uma identidade (o id), e dois nomes podem apontar para o mesmo objeto. O operador == compara valores. O operador is compara identidades:
a e b têm o mesmo conteúdo, mas são objetos diferentes. c é só outro nome para a.
Mutável e imutável
Imutáveis (o objeto nunca muda)
Mutáveis (o objeto pode mudar)
int, float, bool, complex
list
str, bytes
dict
tuple, frozenset
set, bytearray
None
A maioria dos objetos que você define
Quando você "altera" um imutável, na verdade cria um novo objeto e aponta o nome para ele. Quando altera um mutável, o objeto muda, e todos os nomes que apontam para ele enxergam a mudança.
O que acontece nas funções
O Python passa o nome do objeto, não uma cópia. Se a função altera o objeto, quem chamou vê. Se a função apenas aponta o nome local para outro objeto, quem chamou não vê:
A segunda forma usa uma compreensão de lista, que eu apresento no nível intermediário. Ela cria uma lista nova a cada volta.
Quando usar is
Use is com None, True e False e com valores sentinela que você mesmo criou. Para todo o resto, use ==. Comparar números ou strings com is pode funcionar por acaso, por causa de otimizações do interpretador, e quebrar em outro momento.
Exercício 1
Copiar uma matriz de verdade
Escreva copiar_matriz(matriz) de modo que alterar a cópia não altere o original.
Erros vão acontecer: arquivo ausente, rede fora, entrada inválida. O que define um bom programa é o que ele faz quando acontecem.
Código deste capítulo: basico/cap23_excecoes.py
Exceções são objetos organizados em hierarquia
Um erro em tempo de execução é representado por uma exceção, que é uma instância de uma classe. Todas descendem de BaseException, e as que você trata no dia a dia descendem de Exception:
Isso importa porque except ArithmeticError captura ZeroDivisionError também: capturar uma classe captura as filhas. Até os erros de sintaxe fazem parte da hierarquia (SyntaxError descende de Exception), embora apareçam na compilação, antes de o programa rodar.
try, except, else e finally
basico/cap23_excecoes.pylinhas 15 a 29
defconverter(texto):try:valor=int(texto)exceptValueError:print(f"'{texto}' não é um inteiro")returnNoneelse:print("conversão feita")returnvalorfinally:print("fim da tentativa")print(converter("42"))print(converter("abc"))
Saída
conversão feita
fim da tentativa
42
'abc' não é um inteiro
fim da tentativa
None
try: o código que pode falhar.
except: o que fazer se aquela exceção ocorrer.
else: roda só se não houve exceção.
finally: roda sempre, mesmo com return ou erro. É o lugar da limpeza.
Capture apenas o que você sabe tratar
Eu sigo três regras. Primeiro: capture exceções específicas, nunca um except: vazio, que engole até o Ctrl+C. Segundo: o bloco try deve ser o menor possível. Terceiro: quando você precisar traduzir uma exceção, use raise ... from, que preserva a causa original:
basico/cap23_excecoes.pylinhas 34 a 47
defdividir(a,b):try:returna/bexceptZeroDivisionError:returnfloat("inf")exceptTypeErroraserro:raiseValueError("os dois valores devem ser números")fromerroprint(dividir(1,0))try:dividir(1,"a")exceptValueErroraserro:print(erro,"|",type(erro.__cause__).__name__)
Saída
inf
os dois valores devem ser números | TypeError
Exceções próprias
Quando o seu domínio tem uma falha com nome (saldo insuficiente, pedido inexistente), crie uma classe. Quem usa seu código captura pelo nome, sem depender de texto de mensagem. As classes são o assunto do nível intermediário, então por enquanto copie o molde:
basico/cap23_excecoes.pylinhas 52 a 68
classSaldoInsuficiente(Exception):def__init__(self,saldo,valor):super().__init__(f"saldo {saldo} insuficiente para sacar {valor}")self.saldo=saldoself.valor=valordefsacar(saldo,valor):ifvalor>saldo:raiseSaldoInsuficiente(saldo,valor)returnsaldo-valortry:sacar(100,150)exceptSaldoInsuficienteaserro:print(erro,erro.valor-erro.saldo)
Saída
saldo 100 insuficiente para sacar 150 50
Pedir perdão ou pedir licença
Há dois estilos. LBYL (look before you leap) verifica antes de agir. EAFP (easier to ask forgiveness than permission) tenta e trata a falha. O estilo EAFP é o mais comum em Python, e é mais seguro em cenários concorrentes, porque entre "verificar" e "agir" o mundo pode mudar:
O assert serve para declarar o que precisa ser verdade no seu código, como uma checagem de sanidade para quem desenvolve. Ele pode ser desligado com a opção -O do interpretador. Para validar dados de fora (entrada de usuário, arquivos, rede), use if e raise.
Exercício 1
Divisão segura
Escreva dividir_seguro(a, b) que devolva None quando b for zero.
Escreva ler_idade(texto) que devolva um inteiro e levante ValueError com mensagem clara se o texto não for número ou se o valor for negativo.
Ver solução
basico/cap23_excecoes.pylinhas 104 a 122
defler_idade(texto):try:idade=int(texto)exceptValueErroraserro:raiseValueError(f"idade inválida: {texto!r}")fromerroifidade<0:raiseValueError("idade não pode ser negativa")returnidadeassertler_idade("30")==30forentradain("abc","-1"):try:ler_idade(entrada)exceptValueError:passelse:raiseAssertionError(f"{entrada!r} deveria falhar")print("ok")
Saída
ok
Capítulo 24, parte Básico
Arquivos e pathlib
Ler e gravar arquivos é a primeira vez que o seu programa toca o mundo de fora. Eu mostro o jeito seguro: `with`, codificação explícita e `pathlib`.
Código deste capítulo: basico/cap24_arquivos.py
Abrir com with
O with garante que o arquivo seja fechado, mesmo que ocorra um erro no meio. Eu sempre passo encoding="utf-8" de forma explícita:
basico/cap24_arquivos.pylinhas 10 a 19
withopen("notas.txt","w",encoding="utf-8")asarquivo:arquivo.write("primeira linha\n")arquivo.write("segunda linha com acentuação\n")withopen("notas.txt","r",encoding="utf-8")asarquivo:conteudo=arquivo.read()print(conteudo)withopen("notas.txt","a",encoding="utf-8")asarquivo:arquivo.write("terceira linha\n")
Saída
primeira linha
segunda linha com acentuação
Por que dizer o encoding
Se você não informa a codificação, o Python usa a padrão do sistema. No Windows, isso historicamente não era UTF-8, e o código que funcionava no seu Mac quebrava com acentos na máquina de um colega. O Python 3.15 passa a usar UTF-8 como padrão (PEP 686), mas versões anteriores continuam dependendo do sistema, então a regra continua valendo: sempre explícito.
Modos de abertura
Modo
Efeito
Cria o arquivo?
"r"
Leitura (padrão). Falha se não existir
Não
"w"
Escrita. Apaga o conteúdo existente
Sim
"a"
Acrescenta ao final
Sim
"x"
Criação exclusiva. Falha se já existir
Sim
"b"
Acrescentado aos demais para modo binário ("rb", "wb")
basico/cap24_arquivos.pylinhas 24 a 28
try:withopen("notas.txt","x",encoding="utf-8")asarquivo:arquivo.write("não vai acontecer")exceptFileExistsError:print("notas.txt já existe, o modo x recusou")
Saída
notas.txt já existe, o modo x recusou
Ler linha por linha
read() carrega o arquivo inteiro na memória. Para arquivos grandes, percorra o arquivo com for: o Python lê uma linha por vez:
1 primeira linha
2 segunda linha com acentuação
3 terceira linha
pathlib
Montar caminhos com texto ("pasta/" + nome) quebra entre sistemas operacionais. O pathlib trata caminhos como objetos, com o operador / e métodos prontos:
O erro mais comum é FileNotFoundError. E quase sempre o motivo é o diretório atual: um caminho relativo como "dados/entrada.txt" é resolvido a partir da pasta onde você executou o comando, não da pasta do script. Para localizar arquivos ao lado do script, parta de __file__:
[Errno 2] No such file or directory: 'nao_existe.txt'
dados
Exercício 1
Contar linhas e palavras
Escreva estatisticas(caminho) que devolva (linhas, palavras) de um arquivo, lendo uma linha por vez.
Ver solução
basico/cap24_arquivos.pylinhas 66 a 77
defestatisticas(caminho):linhas=palavras=0withopen(caminho,encoding="utf-8")asarquivo:forlinhainarquivo:linhas+=1palavras+=len(linha.split())returnlinhas,palavrasPath("exemplo.txt").write_text("um dois\ntrês quatro cinco\n",encoding="utf-8")assertestatisticas("exemplo.txt")==(2,5)print("ok")
Saída
ok
Capítulo 25, parte Básico
Módulos e importação
Um módulo é um arquivo `.py`. Um pacote é uma pasta de módulos. É assim que o Python organiza código próprio e de terceiros.
Eu prefiro import modulo e uso modulo.funcao, porque o leitor vê de onde vem cada nome. Evite from modulo import *: ele joga dezenas de nomes no seu código e torna impossível saber de onde cada um veio.
A biblioteca padrão
O Python vem com uma biblioteca padrão enorme, e conhecê-la evita instalar pacote para o que já existe:
O random serve para jogos e simulações. Para senhas e tokens, que precisam ser imprevisíveis de verdade, use o módulo secrets.
Seus próprios módulos
Qualquer arquivo .py vira um módulo importável. Veja um par de arquivos na pasta exemplos/modulos:
exemplos/modulos/util.py
"""Funções de apoio importadas pelo programa principal."""defsaudacao(nome:str)->str:returnf"Olá, {nome}!"if__name__=="__main__":print(saudacao("teste do módulo"))
exemplos/modulos/principal.py
fromutilimportsaudacaoprint(saudacao("Ana"))
Terminal
python3exemplos/modulos/principal.py
Ao executar um script, o Python coloca a pasta do script no início da lista de busca de módulos, e é por isso que from util import saudacao funciona.
O padrão if __name__ == "__main__"
Todo módulo tem uma variável __name__. Quando o arquivo é executado como programa, o valor é "__main__". Quando é importado, o valor é o nome do módulo. Esse teste permite que o mesmo arquivo seja, ao mesmo tempo, uma biblioteca e um programa:
basico/cap25_modulos.pylinhas 39 a 44
defmain():print(f"executando com __name__ = {__name__!r}")if__name__=="__main__":main()
Saída
executando com __name__ = '__main__'
Sem essa proteção, importar o módulo executaria o programa inteiro como efeito colateral.
Como o Python encontra módulos
O interpretador procura em sys.path, uma lista de pastas, na ordem. Depois de importado, o módulo fica guardado em sys.modules, e o segundo import apenas reaproveita o que já está lá:
Pacotes de terceiros vêm do PyPI e se instalam dentro de um ambiente virtual (capítulo 4), com pip install, uv add ou poetry add. Os pacotes próprios, com __init__.py e estrutura de pastas, voltam no capítulo de empacotamento.
Exercício 1
Dias entre duas datas
Escreva dias_entre(inicio, fim) que receba datas no formato "2026-01-01" e devolva a quantidade de dias entre elas. Dica: date.fromisoformat.
Uma classe agrupa dados e comportamento que pertencem juntos. Eu mostro o essencial e, principalmente, quando **não** criar uma.
Código deste capítulo: intermediario/cap26_classes.py
A classe é o molde, o objeto é a coisa
Uma classe descreve como são e o que fazem os objetos de um tipo. Cada objeto criado a partir dela é uma instância, com os seus próprios dados:
intermediario/cap26_classes.pylinhas 10 a 22
classCachorro:def__init__(self,nome,idade):self.nome=nomeself.idade=idadedeflatir(self):returnf"{self.nome} diz: au au"rex=Cachorro("Rex",3)mel=Cachorro("Mel",5)print(rex.nome,mel.nome)print(rex.latir())
Saída
Rex Mel
Rex diz: au au
O __init__ é o inicializador: ele roda logo depois que o objeto é criado e prepara os dados iniciais. (Quem de fato cria o objeto é o __new__, que você raramente escreve.) A palavra self é o próprio objeto, e é por ela que cada método sabe de quem são os dados.
O que o self realmente é
Uma chamada como rex.latir() é só açúcar para Cachorro.latir(rex). O Python passa o objeto como primeiro argumento. Ver isso uma vez desfaz o mistério do self:
Imprimir um objeto sem __repr__ mostra algo como <Ponto object at 0x...>, que não ajuda ninguém. Defina o __repr__ em quase toda classe: ele deve mostrar os dados e, de preferência, parecer o código que recria o objeto:
O vars() mostra os atributos do objeto como um dicionário. É assim que o Python guarda os dados de cada instância.
Quando criar uma classe
Eu crio uma classe quando tenho estado e comportamento que andam juntos, como uma conta bancária, que tem saldo e sabe depositar e sacar. Se a classe só tem métodos e nenhum dado, ela provavelmente deveria ser uma função (ou um módulo). Uma Calculadora com somar(a, b) e subtrair(a, b) sem nenhum atributo é a forma de escrever Java dentro do Python.
Exercício 1
Uma conta bancária
Crie ContaBancaria com titular e saldo inicial (padrão 0), e os métodos depositar e sacar. Valores inválidos e saques acima do saldo levantam ValueError.
Ver solução
intermediario/cap26_classes.pylinhas 49 a 75
classContaBancaria:def__init__(self,titular,saldo=0):self.titular=titularself.saldo=saldodefdepositar(self,valor):ifvalor<=0:raiseValueError("o depósito deve ser positivo")self.saldo+=valordefsacar(self,valor):ifvalor>self.saldo:raiseValueError("saldo insuficiente")self.saldo-=valorconta=ContaBancaria("Ana",100)conta.depositar(50)conta.sacar(30)assertconta.saldo==120try:conta.sacar(500)exceptValueError:passelse:raiseAssertionError("deveria falhar")print("ok")
Saída
ok
Capítulo 27, parte Intermediário
Atributos e métodos
Atributo de instância, atributo de classe, método de instância, de classe e estático. Cada um tem um papel, e confundi-los gera bugs sutis.
Código deste capítulo: intermediario/cap27_atributos_metodos.py
Atributos de instância e de classe
Um atributo de instância pertence a um objeto. Um atributo de classe é definido no corpo da classe e é compartilhado por todas as instâncias:
intermediario/cap27_atributos_metodos.pylinhas 10 a 20
Ao ler a.total_criados, o Python procura primeiro no objeto, depois na classe, depois nas classes pai. Ao atribuira.nivel = ..., ele sempre cria o atributo no objeto, sem tocar na classe:
intermediario/cap27_atributos_metodos.pylinhas 22 a 29
Herança reaproveita comportamento e polimorfismo permite tratar tipos diferentes do mesmo jeito. Eu mostro como usar, e principalmente quando preferir composição.
Código deste capítulo: intermediario/cap28_heranca.py
Herdar e sobrescrever
Uma classe filha recebe tudo da classe pai e pode sobrescrever o que quiser. O resultado é o polimorfismo: o mesmo código chama o mesmo nome de método e cada objeto responde do seu jeito:
intermediario/cap28_heranca.pylinhas 10 a 32
classAnimal:def__init__(self,nome):self.nome=nomedeffalar(self):return"..."defapresentar(self):returnf"{self.nome} diz {self.falar()}"classCachorro(Animal):deffalar(self):return"au"classGato(Animal):deffalar(self):return"miau"foranimalin[Cachorro("Rex"),Gato("Mia"),Animal("Ser")]:print(animal.apresentar())
Saída
Rex diz au
Mia diz miau
Ser diz ...
O apresentar está escrito uma vez e funciona para todos, porque chama self.falar(), e o self decide qual versão rodar.
super(): reaproveitar o pai
Quando a filha precisa estender o comportamento do pai, em vez de substituí-lo, ela chama o pai com super():
A herança múltipla (class D(B, C)) existe e funciona, mas é fonte de complexidade. Eu a trato com mais detalhe no nível avançado.
Duck typing
Python não exige uma hierarquia para o polimorfismo funcionar. Se o objeto tem o método que você chama, ele serve: "se anda como pato e grasna como pato, é pato":
Herança significa "é um". Composição significa "tem um". Uma Carro é um veículo, mas tem um motor. Eu só herdo quando a relação "é um" é verdadeira e a filha pode ser usada em qualquer lugar onde o pai é esperado. Para todo o resto, componho:
Herança acopla a filha aos detalhes internos do pai: mudar o pai pode quebrar todas as filhas. Composição acopla apenas à interface pública. Quando em dúvida, comece com composição. Dá para evoluir para herança depois, mas desfazer uma hierarquia é caro.
Exercício 1
Formas geométricas
Crie Forma com o método area() que levanta NotImplementedError, e as subclasses Retangulo(largura, altura) e Circulo(raio). Escreva area_total(formas).
Ana 100
'Conta' object has no attribute '__pin'
1234
O __pin não está protegido: está só com outro nome. O objetivo do duplo sublinhado é evitar colisão em herança, não impedir acesso. Quem quer o valor, acha.
property: validação sem mudar a interface
Em Java, escreve-se getSaldo() e setSaldo() desde o primeiro dia. Em Python, você começa com um atributo comum. Se mais tarde precisar validar, troca por uma @propertysem mudar nada em quem usa a classe, porque o acesso continua sendo conta.saldo:
intermediario/cap29_encapsulamento.pylinhas 28 a 49
classContaBancaria:def__init__(self,saldo=0):self._saldo=saldo@propertydefsaldo(self):returnself._saldo@saldo.setterdefsaldo(self,valor):ifvalor<0:raiseValueError("saldo não pode ser negativo")self._saldo=valorc=ContaBancaria(50)c.saldo=80print(c.saldo)try:c.saldo=-1exceptValueErroraserro:print(erro)
Saída
80
saldo não pode ser negativo
Propriedades calculadas e somente leitura
Uma @property sem setter vira um atributo calculado e somente leitura. É o jeito certo de expor um valor derivado, que nunca fica desatualizado, porque é recalculado a cada leitura:
intermediario/cap29_encapsulamento.pylinhas 54 a 69
12
property 'area' of 'Retangulo' object has no setter
Exercício 1
Temperatura em Celsius e Fahrenheit
Crie Temperatura com a propriedade celsius (que recusa valores abaixo de -273,15) e a propriedade fahrenheit, que lê e também escreve, convertendo.
Ver solução
intermediario/cap29_encapsulamento.pylinhas 74 a 101
classTemperatura:def__init__(self,celsius=0):self.celsius=celsius@propertydefcelsius(self):returnself._celsius@celsius.setterdefcelsius(self,valor):ifvalor<-273.15:raiseValueError("abaixo do zero absoluto")self._celsius=valor@propertydeffahrenheit(self):returnself._celsius*9/5+32@fahrenheit.setterdeffahrenheit(self,valor):self.celsius=(valor-32)*5/9t=Temperatura(100)assertt.fahrenheit==212t.fahrenheit=32assertt.celsius==0print("ok")
Saída
ok
Capítulo 30, parte Intermediário
Abstração com ABC
Uma classe abstrata define um contrato que as filhas são obrigadas a cumprir. É o jeito de dizer "qualquer classe que faça X precisa ter estes métodos".
Código deste capítulo: intermediario/cap30_abstracao.py
O contrato
O módulo abc oferece ABC e @abstractmethod. Uma classe com métodos abstratos não pode ser instanciada. As subclasses precisam implementar todos eles:
intermediario/cap30_abstracao.pylinhas 10 a 40
fromabcimportABC,abstractmethodimportmathclassForma(ABC):@abstractmethoddefarea(self):...defdescrever(self):returnf"{type(self).__name__} com área {self.area():.2f}"classCirculo(Forma):def__init__(self,raio):self.raio=raiodefarea(self):returnmath.pi*self.raio**2classQuadrado(Forma):def__init__(self,lado):self.lado=ladodefarea(self):returnself.lado**2forformain(Circulo(1),Quadrado(2)):print(forma.descrever())
Saída
Circulo com área 3.14
Quadrado com área 4.00
O método descrever é concreto e usa o area abstrato: a classe base escreve o algoritmo e as filhas preenchem as lacunas. Esse padrão (método molde) é um dos usos mais úteis de classes abstratas.
O erro vem cedo
A diferença para o NotImplementedError do capítulo anterior é quando o erro aparece. Com ABC, tentar criar um objeto incompleto falha na criação, e não só quando o método ausente for chamado:
Can't instantiate abstract class Forma without an implementation for abstract method 'area'
Can't instantiate abstract class Incompleta without an implementation for abstract method 'area'
Abstração e encapsulamento não são a mesma coisa
Conceito
Pergunta que responde
Ferramenta em Python
Encapsulamento
O que eu escondo ou protejo dentro do objeto?
Convenção _nome e @property
Abstração
O que qualquer implementação precisa oferecer?
ABC e @abstractmethod
E o duck typing?
Você não precisa de ABC para o polimorfismo funcionar. Eu uso classes abstratas quando existe uma família de implementações com comportamento comum (o método molde). Quando só importa a forma do objeto, a ferramenta mais leve é typing.Protocol, que mostro no nível avançado.
Exercício 1
Notificadores
Crie a classe abstrata Notificador com o método enviar(mensagem), as implementações Email e SMS (que devolvem o texto "email: ..." e "sms: ...") e a função avisar(notificadores, mensagem) que devolve a lista de resultados.
Os métodos com sublinhado duplo (os *dunders*) são o gancho entre os seus objetos e a sintaxe da linguagem: `+`, `len()`, `in`, `==`, `print()`.
Código deste capítulo: intermediario/cap31_dunders.py
Operadores e comparação
Quando você escreve a + b, o Python chama a.__add__(b). Implementar os métodos especiais faz o seu objeto se comportar como um tipo nativo. Duas regras que eu sigo: devolver NotImplemented (e não levantar erro) quando o outro tipo não é suportado, e definir __hash__ sempre que definir __eq__:
Vetor(4, 6) Vetor(3, 6) Vetor(3, 6)
True False 5.0
1
unsupported operand type(s) for +: 'Vetor' and 'int'
O __rmul__ faz 3 * v1 funcionar: quando int.__mul__ não sabe o que fazer com um Vetor, o Python tenta o método "refletido" do operando da direita. Sem __hash__, um objeto que define __eq__ deixa de poder entrar em conjuntos e servir de chave de dicionário.
Fazer o objeto virar um contêiner
Com __len__, __getitem__ e __contains__, o seu objeto se comporta como uma sequência. E, se existe __getitem__ com índices a partir de zero, a iteração, reversed() e o in funcionam sozinhos:
Uma compreensão transforma um laço de quatro linhas em uma expressão. Usada com bom senso, deixa o código mais claro. Usada em excesso, o torna ilegível.
Código deste capítulo: intermediario/cap32_comprehensions.py
Listas, conjuntos e dicionários
A forma geral é [expressão for item in iterável if condição]. O mesmo vale para chaves {}, que criam conjuntos e dicionários:
intermediario/cap32_comprehensions.pylinhas 10 a 18
Trocando os colchetes por parênteses, você obtém uma expressão geradora, que não monta a lista na memória e produz um item de cada vez. Dentro de uma chamada como sum, os parênteses extras são opcionais:
intermediario/cap32_comprehensions.pylinhas 35 a 39
Funções como sum, any, all, min e max consomem geradores, e any e all param assim que o resultado está decidido.
O limite da legibilidade
Minha regra: uma compreensão com mais de um for ou com mais de uma condição já merece virar um laço comum ou uma função com nome. Se eu preciso reler duas vezes para entender, o próximo leitor também precisará.
Exercício 1
Transpor uma matriz
Escreva transpor(matriz) com uma compreensão. Dica: zip(*matriz).
Ver solução
intermediario/cap32_comprehensions.pylinhas 44 a 49
Funções que recebem funções, ou devolvem funções. É aqui que ordenar, filtrar e transformar dados fica expressivo.
Código deste capítulo: intermediario/cap33_ordem_superior.py
lambda e a chave de ordenação
Uma lambda é uma função anônima de uma única expressão. O uso mais comum é como argumento key de sorted, min e max, que dizem por qual critério comparar:
intermediario/cap33_ordem_superior.pylinhas 10 a 13
A ordenação do Python é estável: itens com a mesma chave mantêm a ordem original. E uma tupla como chave ordena por vários critérios; o sinal de menos inverte a ordem de um campo numérico.
Para chaves simples, o módulo operator evita a lambda:
intermediario/cap33_ordem_superior.pylinhas 15 a 22
O map aplica uma função a cada item e o filter mantém os itens que passam em um teste. Os dois são preguiçosos: devolvem iteradores, e o list() é quem consome:
intermediario/cap33_ordem_superior.pylinhas 27 a 31
Na prática, eu prefiro compreensões a map e filter com lambda, porque dizem a mesma coisa de forma mais legível. O map ainda vale quando a função já existe (map(str, numeros)).
O zip junta sequências em pares e para na mais curta, sem avisar. Desde o Python 3.10, o parâmetro strict=True transforma esse silêncio em erro:
intermediario/cap33_ordem_superior.pylinhas 33 a 39
A PEP 8 recomenda que uma função com nome seja definida com def, e não com quadrado = lambda x: x ** 2. A razão é prática: o def dá nome à função nas mensagens de erro e permite docstring. A lambda é para uso inline, como argumento.
Exercício 1
Ordenar alunos por dois critérios
Escreva ordenar_alunos(alunos) que receba uma lista de dicionários com nome e nota e ordene pela maior nota e, em caso de empate, pelo nome.
Ver solução
intermediario/cap33_ordem_superior.pylinhas 60 a 70
Um gerador produz valores sob demanda, um de cada vez. É a ferramenta que permite processar mais dados do que cabe na memória.
Código deste capítulo: intermediario/cap34_geradores.py
Iterável e iterador
Um iterável é qualquer objeto que pode ser percorrido por um for (lista, string, arquivo). Um iterador é o objeto que entrega os itens um a um, com next(), e levanta StopIteration quando acaba. O for faz exatamente isso por baixo:
Um gerador não guarda os itens. Ele guarda só o ponto em que parou. Por isso gerar cem mil números ocupa quase nada, enquanto a lista equivalente ocupa megabytes:
Geradores se encadeiam: cada etapa recebe o gerador da anterior e processa um item por vez. Eu uso esse padrão para ler arquivos grandes: a linha entra, é filtrada, transformada, e só então a próxima é lida:
Um decorador é uma função que recebe uma função e devolve outra, com comportamento extra. Para escrever um decorador sem erro é preciso entender closures e `functools.wraps`.
Código deste capítulo: intermediario/cap35_decoradores.py
Closures
Uma função definida dentro de outra "lembra" das variáveis da função externa, mesmo depois de ela ter terminado. Isso se chama closure. A palavra nonlocal permite alterar a variável da função externa:
decorador_ruim.<locals>.wrapper() takes 0 positional arguments but 2 were given
A forma correta tem três cuidados: o wrapper aceita *args e **kwargs, devolve o resultado da função original e usa functools.wraps para preservar o nome e a documentação:
intermediario/cap35_decoradores.pylinhas 45 a 64
importfunctoolsdefregistrar(funcao):@functools.wraps(funcao)defwrapper(*args,**kwargs):resultado=funcao(*args,**kwargs)print(f"{funcao.__name__}{args} -> {resultado}")returnresultadoreturnwrapper@registrardefmultiplicar(a,b):"""Multiplica dois números."""returna*bmultiplicar(3,4)print(multiplicar.__name__,multiplicar.__doc__)
Saída
multiplicar(3, 4) -> 12
multiplicar Multiplica dois números.
Sem o wraps, multiplicar.__name__ seria "wrapper", o que atrapalha depuração, logs e ferramentas de documentação.
Decoradores com parâmetros
Para receber argumentos (@repetir(3)), o decorador vira uma fábrica de decoradores: uma função que devolve o decorador de verdade. São três níveis de função:
Em produção, eu acrescentaria uma espera crescente entre as tentativas (backoff) e um limite de tempo. O capítulo de arquitetura mostra uma versão que injeta a função de espera para poder testar sem esperar de verdade.
Cache pronto
A biblioteca padrão já traz decoradores úteis. O functools.cache guarda o resultado de cada chamada e transforma uma recursão exponencial em linear:
intermediario/cap35_decoradores.pylinhas 123 a 132
O cache assume que a mesma entrada sempre dá a mesma saída. Usá-lo em funções que leem o relógio, o banco ou a rede devolve respostas velhas. E ele guarda os argumentos para sempre, então não o use com entradas ilimitadas.
Exercício 1
Contar as chamadas de uma função
Escreva o decorador contar_chamadas que mantenha o atributo chamadas na função decorada, sem perder o nome original.
Ver solução
intermediario/cap35_decoradores.pylinhas 137 a 156
Uma dataclass escreve por você o `__init__`, o `__repr__` e o `__eq__`. Para classes que existem principalmente para guardar dados, elimina um monte de código repetido.
Código deste capítulo: intermediario/cap36_dataclasses.py
O básico
Basta declarar os campos com tipo, e o decorador gera o resto:
mutable default <class 'list'> for field itens is not allowed: use default_factory
Imutável, ordenável e econômica
As opções do decorador cobrem os casos comuns. frozen=True impede a alteração depois de criado (e torna o objeto hashable). order=True gera os operadores de comparação. slots=True reduz a memória de cada instância:
O método __post_init__ roda logo depois do __init__ e é o lugar da validação. As funções asdict e replace convertem para dicionário e criam uma cópia com campos trocados, sem mutar o original:
intermediario/cap36_dataclasses.pylinhas 53 a 71
@dataclassclassPedido:cliente:stritens:list[tuple[str,float]]=field(default_factory=list)def__post_init__(self):ifnotself.cliente:raiseValueError("cliente é obrigatório")@propertydeftotal(self):returnsum(precofor_,precoinself.itens)pedido=Pedido("Ana",[("caneta",3.5),("caderno",18.9)])print(round(pedido.total,2))print(asdict(pedido))copia=replace(pedido,cliente="Bia")print(copia.cliente,pedido.cliente)
Saída
22.4
{'cliente': 'Ana', 'itens': [('caneta', 3.5), ('caderno', 18.9)]}
Bia Ana
Quando usar cada opção
Preciso de...
Uso
Um registro imutável e leve, que também se comporta como tupla
NamedTuple
Dados de fora (JSON) com forma conhecida, só para ferramentas de tipo
TypedDict
Um objeto de domínio com dados, métodos e validação simples
dataclass
Validação e conversão pesadas de dados de entrada
Uma biblioteca como o Pydantic
Exercício 1
Um livro validado
Crie a dataclass Livro(titulo, paginas) que recuse paginas menor ou igual a zero com ValueError.
Ver solução
intermediario/cap36_dataclasses.pylinhas 76 a 93
@dataclassclassLivro:titulo:strpaginas:intdef__post_init__(self):ifself.paginas<=0:raiseValueError("páginas devem ser positivas")assertLivro("Dom Casmurro",256).paginas==256try:Livro("Vazio",0)exceptValueError:passelse:raiseAssertionError("deveria falhar")print("ok")
Saída
ok
Capítulo 37, parte Intermediário
Type hints
Anotações de tipo documentam o contrato do código e permitem que ferramentas achem erros antes de rodar. O Python em si ignora as anotações.
Código deste capítulo: intermediario/cap37_type_hints.py
Anotar parâmetros e retorno
A anotação vai depois de dois pontos nos parâmetros e depois de -> no retorno. Elas ficam guardadas em __annotations__, mas o interpretador não as verifica:
A última chamada passa um inteiro onde se esperava texto, e o Python roda sem reclamar. Quem acusa o erro é uma ferramenta externa, o verificador de tipos.
Coleções, opcionais e uniões
Desde o Python 3.9 você pode usar list[int] e dict[str, int] diretamente. A barra vertical (int | None, desde o 3.10) expressa uma união:
Para nomear um tipo complexo, o Python 3.12 trouxe a instrução type. Funções como argumento usam Callable. E para registros com forma conhecida há NamedTuple e TypedDict:
A constante "de verdade" para ferramentas de tipo é typing.Final: LIMITE: Final = 3 avisa que reatribuir é um erro.
Quem verifica
O verificador mais usado é o mypy. O pyright é outra boa opção. Os dois leem o código sem executá-lo e apontam inconsistências. Instale como ferramenta de desenvolvimento do projeto:
exemplos/tipos/erro_de_tipo.py:5: error: Argument 1 to "dobrar" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Referências a tipos ainda não definidos
Antes do Python 3.14, anotar com uma classe definida mais abaixo exigia aspas ("Cliente") ou from __future__ import annotations. A partir do 3.14, as anotações são avaliadas de forma adiada por padrão, e esse problema desaparece. Se o seu projeto ainda suporta versões anteriores, mantenha o __future__.
Exercício 1
Anote uma função de agrupamento
Escreva agrupar_por_inicial(palavras: list[str]) -> dict[str, list[str]], que agrupe as palavras pela primeira letra em minúscula.
O `with` garante que algo seja desfeito no fim, aconteça o que acontecer. Arquivos, conexões, travas e diretórios temporários usam o mesmo mecanismo.
Código deste capítulo: intermediario/cap38_context_managers.py
O protocolo
Qualquer objeto com __enter__ e __exit__ funciona com with. O __enter__ prepara e devolve o recurso. O __exit__ limpa, e recebe os dados da exceção, se houve:
intermediario/cap38_context_managers.pylinhas 10 a 25
Escrever uma classe só para isso é cerimônia. O decorador contextlib.contextmanager transforma um gerador com um único yield em um gerenciador de contexto. Tudo antes do yield é o __enter__. Tudo depois é o __exit__. Coloque a limpeza em um finally:
intermediario/cap38_context_managers.pylinhas 43 a 56
A biblioteca padrão já traz os casos mais comuns. O suppress ignora uma exceção específica de forma explícita, e o TemporaryDirectory cria uma pasta que desaparece no fim:
intermediario/cap38_context_managers.pylinhas 61 a 73
Repare que True virou true e None virou null. Os tipos de Python que o JSON não conhece (como datetime e Decimal) precisam ser convertidos antes.
csv
Ler CSV com DictReader dá a você dicionários com o cabeçalho como chave. Os exemplos usam io.StringIO para simular um arquivo, mas com um arquivo real você passa o objeto do open() (com newline=""):
Guarde e calcule datas com fuso horário, de preferência em UTC, e converta só na hora de mostrar. Datas "ingênuas" (sem fuso) são fonte de bugs em sistemas com mais de uma região:
Para fusos com nome e horário de verão, use zoneinfo.ZoneInfo("America/Sao_Paulo"). No Windows, o banco de fusos não vem com o sistema, e é preciso instalar o pacote tzdata (pip install tzdata).
re
As expressões regulares resolvem extração e troca de padrões em texto. Use strings brutas (r"...") para não duplicar as barras invertidas:
Código sem teste é código que você tem medo de mexer. O pytest tem a menor barreira de entrada entre as ferramentas de teste de Python.
Código deste capítulo: intermediario/cap40_pytest.py
Instalar
O pytest é uma dependência de desenvolvimento. Instale no ambiente do projeto:
Terminal
uvadd--devpytest
Sem o uv, dentro de um ambiente virtual: python -m pip install pytest.
O primeiro teste
Um teste é uma função cujo nome começa com test_, e o corpo usa o assert comum do Python. Quando o assert falha, o pytest mostra os valores envolvidos. Primeiro o código que vamos testar:
intermediario/cap40_pytest.pylinhas 10 a 25
importpytestdefeh_primo(n:int)->bool:ifn<2:returnFalsefordivisorinrange(2,int(n**0.5)+1):ifn%divisor==0:returnFalsereturnTruedefdividir(a:float,b:float)->float:ifb==0:raiseZeroDivisionError("divisor não pode ser zero")returna/b
Agora os testes. O pytest.raises verifica que uma exceção acontece, e o parametrize roda o mesmo teste com vários dados:
Uma fixture prepara algo de que o teste precisa e é entregue pelo nome do parâmetro. O pytest traz várias prontas: tmp_path (uma pasta temporária), capsys (captura o que foi impresso) e monkeypatch (altera variáveis de ambiente e atributos, e desfaz no fim):
Eu sigo uma ordem de prioridade: primeiro o caminho feliz, depois os limites (zero, vazio, o maior valor), depois os erros esperados. Para cada bug corrigido, escrevo antes o teste que o reproduz, para ele nunca voltar. E teste comportamento, não implementação: se você trocar o algoritmo e os testes quebrarem sem que o resultado mude, eles estão testando a coisa errada.
Exercício 1
Teste uma conversão com tolerância
Escreva celsius_para_fahrenheit(c) e um teste que use pytest.approx para o valor decimal 36.6, que vira 97.88.
Aponte o pytest para o arquivo, ou para a pasta tests/ do seu projeto. A opção -q deixa a saída curta:
Terminal
pytest-q
Saída
.............. [100%]
14 passed in 0.01s
Capítulo 41, parte Intermediário
Logging e linha de comando com argparse
`print` serve para depurar na sua máquina. Em produção você precisa de níveis, destinos e formato, e é para isso que existe o `logging`. E um programa útil quase sempre recebe argumentos pelo terminal.
Código deste capítulo: intermediario/cap41_logging_cli.py
Níveis e formato
O logging tem cinco níveis: DEBUG, INFO, WARNING, ERROR e CRITICAL. O basicConfig define o nível mínimo e o formato. Mensagens abaixo do nível são descartadas. E eu passo os valores como argumentos (%d), e não como f-string, para que a mensagem só seja montada se for realmente emitida:
intermediario/cap41_logging_cli.pylinhas 10 a 21
importloggingimportsyslogging.basicConfig(stream=sys.stdout,level=logging.INFO,format="%(levelname)s%(name)s: %(message)s",)log=logging.getLogger("pedidos")log.debug("não aparece, nível abaixo de INFO")log.info("pedido recebido")log.warning("estoque baixo: %d unidades",3)
Saída
INFO pedidos: pedido recebido
WARNING pedidos: estoque baixo: 3 unidades
Um logger por módulo
A convenção é logger = logging.getLogger(__name__) no topo de cada módulo. Assim o nome do logger mostra de onde veio a mensagem, e quem usa a sua biblioteca decide o nível e o destino sem editar o seu código. Bibliotecas nunca devem chamar basicConfig, que é uma decisão da aplicação:
Para registrar uma exceção com o traceback completo, use logger.exception(...) dentro do except. Ele grava no nível ERROR e inclui a pilha de chamadas:
intermediario/cap41_logging_cli.pylinhas 29 a 32
try:1/0exceptZeroDivisionError:log.exception("falha ao calcular")
Saída estruturada
Em sistemas com agregação de logs, texto livre é difícil de pesquisar. Um formatador que emite uma linha de JSON por mensagem resolve. Cada logger pode ter o seu próprio destino:
O argparse converte os argumentos do terminal, valida, gera a ajuda (--help) e as mensagens de erro. Eu sempre escrevo a função main recebendo a lista de argumentos como parâmetro, o que a torna testável sem abrir um terminal:
intermediario/cap41_logging_cli.pylinhas 63 a 84
importargparsedefcriar_parser():parser=argparse.ArgumentParser(description="Saudação em linha de comando")parser.add_argument("nome",help="quem cumprimentar")parser.add_argument("--vezes",type=int,default=1,help="quantas vezes repetir")parser.add_argument("--gritar",action="store_true",help="usar maiúsculas")returnparserdefmain(argv=None):args=criar_parser().parse_args(argv)texto=f"Olá, {args.nome}!"ifargs.gritar:texto=texto.upper()for_inrange(args.vezes):print(texto)return0main(["Ana","--vezes","2","--gritar"])
Saída
OLÁ, ANA!
OLÁ, ANA!
Quando argv é None, o argparse lê sys.argv sozinho. Para executar o programa de verdade, o final do arquivo fica assim, e o código de saída do main vira o código de saída do processo:
Final do arquivo de um programa
if__name__=="__main__":raiseSystemExit(main())
Argumentos inválidos fazem o argparse imprimir o erro e terminar com o código 2, levantando SystemExit. Dá para capturar em teste:
intermediario/cap41_logging_cli.pylinhas 86 a 89
try:criar_parser().parse_args(["Ana","--vezes","muitas"])exceptSystemExitascodigo:print("saiu com código",codigo.code)
Saída
saiu com código 2
Para programas com vários comandos, como git add e git commit, o argparse oferece subcomandos:
Quase tudo que parece mágica em Python é o interpretador chamando métodos especiais nos seus objetos. Quem domina esses protocolos escreve classes que se encaixam na linguagem.
Código deste capítulo: avancado/cap42_modelo_dados.py
Uma sequência com dois métodos
Se um objeto tem __len__ e __getitem__, ele já se comporta como uma sequência: aceita índice, fatia, in, iteração, reversed e random.choice. Nenhuma herança é necessária. É o duck typing levado a sério:
O in funciona porque, sem __contains__ nem __iter__, o Python cai no protocolo antigo de iteração: chama __getitem__ com 0, 1, 2... até receber IndexError.
Ser, de fato, uma Sequence
O fato de o objeto se comportar como sequência não o torna uma Sequence para quem verifica o tipo. As classes abstratas de collections.abc testam a presença de métodos específicos, e essa verificação é mais exigente do que a iteração real:
Ele é Sized (tem __len__), mas não é Iterable (falta __iter__), embora o for funcione. A lição prática: para saber se algo é iterável, tente iter(obj). E, se você quer o contrato completo, herde de Sequence: você implementa só __len__ e __getitem__, e ganha de graça __contains__, __iter__, __reversed__, index e count:
Dois objetos iguais precisam ter o mesmo hash, e o hash de um objeto não pode mudar enquanto ele estiver em um conjunto ou for chave de dicionário. Quebrar a segunda regra produz um dos bugs mais confusos da linguagem: o objeto está no conjunto, mas a busca não o encontra:
A primeira classe define __eq__ e, por isso, o Python remove o __hash__ dela. A segunda define o hash a partir de um campo mutável: depois de m.v = 2, o objeto continua no conjunto, guardado no "balde" do hash antigo, e a consulta procura no balde novo. A regra que eu sigo: só defina __hash__ para objetos imutáveis, e calcule o hash apenas de campos imutáveis.
Acesso dinâmico a atributos
O método __getattr__ só é chamado quando a busca normal falha. É a base de proxies, objetos de configuração e clientes de API dinâmicos. O __format__ controla o que vem depois dos dois pontos em uma f-string:
No __getattr__ acima, se _dados ainda não existir (durante copy ou pickle, por exemplo), a leitura de self._dados chama __getattr__ de novo, e a recursão não tem fim. Em código de produção, trate os nomes que começam com sublinhado de forma explícita.
Exercício 1
Um intervalo que se comporta como range
Escreva a classe Intervalo(inicio, fim) com __len__, __getitem__ (aceitando índices negativos e levantando IndexError fora do limite) e __contains__.
A herança múltipla assusta porque quase ninguém aprende a regra que a organiza. Ela é pequena: a linearização C3.
Código deste capítulo: avancado/cap43_mro_mixins.py
A ordem que o Python segue
Cada classe tem uma lista ordenada de superclasses, o MRO. O algoritmo (C3) garante duas coisas: uma classe filha vem sempre antes das suas pais, e a ordem em que você listou as bases é respeitada. No problema do losango, a classe Base aparece uma única vez:
O detalhe que quase todo mundo entende errado: super()não significa "a classe pai". Significa "a próxima classe no MRO do objeto". Dentro de Esquerda, o super().ola() chama a Direita, e não a Base. É isso que faz a cadeia funcionar com cooperação.
__init__ cooperativo
Para que todas as classes de uma hierarquia múltipla sejam inicializadas, cada uma deve consumir os argumentos que lhe pertencem e repassar o resto com super().__init__(**kwargs):
Um mixin é uma classe pequena que oferece um comportamento para ser misturado em outras, sem ser uma entidade por si só. Ele não tem estado próprio nem __init__, e o nome costuma terminar em Mixin:
O método __init_subclass__ roda sempre que alguém cria uma subclasse. É a ferramenta mais simples para registrar plugins, e na maioria dos casos substitui uma metaclasse:
Cannot create a consistent method resolution
order (MRO) for bases X, Y
Minha regra para herança múltipla
Eu uso herança múltipla em um caso: mixins sem estado, que oferecem um comportamento isolado. Para todo o resto, composição. Quando o super() cooperativo é necessário em uma hierarquia complexa, isso costuma ser o sinal de que o desenho pediria composição.
Exercício 1
Registro de comandos
Usando __init_subclass__, faça Comando.comandos registrar Listar com o nome "ls" e Remover com o nome "remover", e verifique o resultado.
Protocolos, genéricos e sobrecargas deixam o verificador de tipos entender código flexível sem você abrir mão da flexibilidade do Python.
Código deste capítulo: avancado/cap44_tipagem_avancada.py
Protocol: tipagem estrutural
Um Protocol descreve uma forma: qualquer classe com os métodos certos serve, sem herdar de nada. É o duck typing verificável por ferramentas. Com runtime_checkable, o isinstance também funciona, mas só checa a presença dos métodos:
A vantagem sobre uma ABC é o acoplamento: Arquivo e Conexao não conhecem Fechavel. Quem define o contrato é quem consome, no lado dele, e isso torna as dependências mais fáceis de trocar e de simular em teste. Quando um objeto não cumpre o contrato, o verificador acusa:
exemplos/tipos/protocolo_erro.py:16: error: Argument 1 to "encerrar" has incompatible type "SemFechar"; expected "Fechavel" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Genéricos com a sintaxe do Python 3.12
Uma classe ou função genérica funciona com qualquer tipo e preserva a informação sobre ele. Desde o Python 3.12, a sintaxe com colchetes dispensa o TypeVar. Os parênteses depois de T: restringem os tipos aceitos:
Para o verificador, Pilha[int] só aceita inteiros, e pilha.desempilhar() é um int, sem conversão nem comentário.
Literal, Final, TypedDict e Self
avancado/cap44_tipagem_avancada.pylinhas 70 a 95
fromtypingimportFinal,Literal,NotRequired,Self,TypedDictModo=Literal["leitura","escrita"]LIMITE:Final=3classOpcoes(TypedDict):modo:Modotentativas:NotRequired[int]defabrir(opcoes:Opcoes)->str:returnf"{opcoes['modo']} com {opcoes.get('tentativas',LIMITE)} tentativas"classConstrutor:def__init__(self)->None:self.partes:list[str]=[]defadicionar(self,parte:str)->Self:self.partes.append(parte)returnselfprint(abrir({"modo":"leitura"}))print(Construtor().adicionar("a").adicionar("b").partes)
Saída
leitura com 3 tentativas
['a', 'b']
O Literal restringe a valores exatos, e o Self mantém o tipo correto em métodos encadeáveis, mesmo em subclasses.
Sobrecarga e decoradores tipados
O @overload declara assinaturas diferentes para a mesma função, para que o verificador saiba que dobrar(4) devolve int e dobrar("ab") devolve str. E um decorador que preserva a assinatura da função decorada usa ParamSpec, que na sintaxe nova se escreve **P:
avancado/cap44_tipagem_avancada.pylinhas 100 a 128
Você não precisa tipar tudo. Eu começo pelas fronteiras: funções públicas, modelos de dados e interfaces entre módulos. O verificador entra no CI em modo permissivo e eu aperto as regras aos poucos, módulo por módulo.
Exercício 1
Um repositório genérico
Escreva Repositorio[T] com salvar(id_, item) e buscar(id_), que devolva T | None.
Ver solução
avancado/cap44_tipagem_avancada.pylinhas 133 a 148
O mecanismo que faz `property`, métodos e `dataclass` funcionarem é público, e você pode usá-lo. Mas o mais importante deste capítulo é saber quando **não** usar.
Código deste capítulo: avancado/cap45_descritores_slots.py
O protocolo do descritor
Um descritor é um objeto, guardado em uma classe, que define __get__, __set__ ou __delete__. Quando você lê ou grava o atributo, o Python delega a esse objeto. O __set_name__ avisa ao descritor o nome com que ele foi atribuído:
avancado/cap45_descritores_slots.pylinhas 10 a 40
classPositivo:def__set_name__(self,dono,nome):self.nome="_"+nomedef__get__(self,instancia,dono=None):ifinstanciaisNone:returnselfreturngetattr(instancia,self.nome)def__set__(self,instancia,valor):ifvalor<=0:raiseValueError(f"{self.nome[1:]} deve ser positivo")setattr(instancia,self.nome,valor)classItem:preco=Positivo()quantidade=Positivo()def__init__(self,preco,quantidade):self.preco=precoself.quantidade=quantidadeitem=Item(10,2)print(item.preco*item.quantidade)try:item.quantidade=0exceptValueErroraserro:print(erro)print(type(Item.preco).__name__)
Saída
20
quantidade deve ser positivo
Positivo
A vantagem sobre @property é o reuso: a regra "positivo" foi escrita uma vez e aplicada a dois atributos. Com property, seriam dois pares de getter e setter.
Descritores estão por toda parte. A própria property é um. E as funções também: é o __get__ da função que a transforma em método ligado ao objeto:
Por padrão, cada instância guarda os atributos em um dicionário (__dict__). Declarar __slots__ troca esse dicionário por um espaço fixo, o que economiza memória quando há muitas instâncias, e impede a criação de atributos novos:
Eu só uso __slots__ quando medi um problema de memória com milhões de objetos. Para o resto, a economia não compensa a rigidez. A forma mais simples de obter o benefício é @dataclass(slots=True).
Metaclasses
Em Python, uma classe também é um objeto, e quem a cria é a metaclasse, por padrão type. Dá para criar classes dinamicamente e interceptar a criação de instâncias:
avancado/cap45_descritores_slots.pylinhas 87 a 105
Eu quase nunca escrevo uma. Para registrar subclasses, use __init_subclass__. Para validar atributos, use descritores ou dataclass. Para um objeto único, use uma instância no nível do módulo (módulos já são carregados uma vez só). A metaclasse é o recurso mais difícil de depurar e de combinar com outras, e raramente é a ferramenta certa.
Exercício 1
Um descritor de texto curto
Escreva o descritor TextoCurto(limite) que levante ValueError se o texto passar do limite, e use-o em Perfil.bio com limite 5.
Ver solução
avancado/cap45_descritores_slots.pylinhas 110 a 139
classTextoCurto:def__init__(self,limite):self.limite=limitedef__set_name__(self,dono,nome):self.nome="_"+nomedef__get__(self,instancia,dono=None):returnselfifinstanciaisNoneelsegetattr(instancia,self.nome)def__set__(self,instancia,valor):iflen(valor)>self.limite:raiseValueError("texto longo demais")setattr(instancia,self.nome,valor)classPerfil:bio=TextoCurto(5)p=Perfil()p.bio="oi"assertp.bio=="oi"try:p.bio="texto enorme"exceptValueError:passelse:raiseAssertionError("deveria falhar")print("ok")
Saída
ok
Capítulo 46, parte Avançado
Concorrência: GIL, threads e processos
Escolher o modelo de concorrência errado é o erro de desempenho mais caro em Python. A decisão depende de uma pergunta: o seu gargalo é esperar ou calcular?
Código deste capítulo: avancado/cap46_concorrencia.py
O GIL
No CPython, uma trava global (o GIL) permite que apenas uma thread execute bytecode Python por vez. Duas consequências práticas:
Para trabalho que espera (rede, disco, banco), as threads funcionam bem, porque o GIL é liberado enquanto a thread espera.
Para trabalho que calcula (laços, contas), threads não aceleram nada, porque só uma roda por vez. Aqui você precisa de processos.
Existe um build opcional do interpretador sem GIL (o executável python3.14t no 3.14). Eu o trato como algo a testar com a sua carga de trabalho, e não como padrão: o ecossistema de extensões ainda está se adaptando.
O gargalo é...
Use
Espera de I/O, poucas dezenas de tarefas
ThreadPoolExecutor
Espera de I/O, milhares de conexões
asyncio (próximo capítulo)
Cálculo pesado em Python puro
ProcessPoolExecutor
Cálculo numérico em arrays
Bibliotecas em C, como NumPy
Proteja a execução com __name__ == "__main__"
No Windows e no macOS, os processos filhos são criados importando o arquivo principal de novo. Sem a proteção if __name__ == "__main__", cada filho executaria o programa inteiro e criaria mais filhos. Por isso, neste capítulo, todo código que faz algo fica dentro dessa condição. As funções que os processos executam precisam estar no nível do módulo, e os argumentos e resultados precisam ser serializáveis (picklable).
Threads para I/O
O concurrent.futures oferece uma interface única para threads e processos. Aqui, cinco "downloads" de 0,2 segundo cada. Em sequência levam um segundo. Em cinco threads, cerca de 0,2:
if__name__=="__main__":inicio=time.perf_counter()sequencial=[baixar(i)foriinrange(5)]t_seq=time.perf_counter()-inicioinicio=time.perf_counter()withThreadPoolExecutor(max_workers=5)aspool:concorrente=list(pool.map(baixar,range(5)))t_conc=time.perf_counter()-inicioprint(sequencial==concorrente)print("threads foram mais rápidas:",t_conc<t_seq)
Saída
True
threads foram mais rápidas: True
Processos para CPU
Contar primos é cálculo puro. Cada processo tem o seu próprio interpretador e o seu próprio GIL, então eles rodam em núcleos diferentes de verdade. O custo é criar os processos e copiar os dados entre eles, por isso só compensa quando cada tarefa é pesada:
Threads compartilham memória. Quando duas alteram o mesmo dado, o resultado pode se perder, porque contador += 1 não é uma operação indivisível (ler, somar e gravar são passos separados). O Lock garante que só uma thread entre na região crítica por vez:
A forma mais segura de coordenar threads é não compartilhar estado: uma queue.Queue entrega os itens de um produtor para um consumidor, e já vem com a sincronização embutida:
O `asyncio` roda milhares de tarefas que esperam em **uma única thread**, alternando entre elas nos pontos em que cada uma precisa esperar. É a ferramenta certa para muita conexão e pouco cálculo.
Código deste capítulo: avancado/cap47_assincrono.py
Corrotinas e o laço de eventos
Uma função async def cria uma corrotina. Chamá-la não executa nada: devolve um objeto que precisa ser agendado. O await marca o ponto em que a corrotina cede o controle para o laço de eventos poder rodar outra. O asyncio.run cria o laço e executa a corrotina principal:
A concorrência é cooperativa: só existe troca de tarefa em um await. Isso torna o código mais fácil de raciocinar do que com threads (não há dois trechos rodando ao mesmo tempo), mas cobra uma regra rígida: nunca bloqueie o laço.
Várias tarefas ao mesmo tempo
O asyncio.gather agenda várias corrotinas juntas e devolve os resultados na ordem em que você as passou. Três esperas de 0,3, 0,1 e 0,2 segundo levam cerca de 0,3 no total, e não 0,6:
Desde o Python 3.11, o TaskGroup é a forma recomendada de agrupar tarefas: se uma falha, as outras são canceladas, e o bloco só termina quando todas acabam. O asyncio.timeout impõe um prazo a um trecho:
avancado/cap47_assincrono.pylinhas 49 a 65
asyncdefcom_grupo():asyncwithasyncio.TaskGroup()asgrupo:t1=grupo.create_task(buscar("x",0.1))t2=grupo.create_task(buscar("y",0.2))print(t1.result(),t2.result())asyncdefcom_timeout():try:asyncwithasyncio.timeout(0.1):awaitbuscar("demorado",1)exceptTimeoutError:print("estourou o tempo")asyncio.run(com_grupo())asyncio.run(com_timeout())
Saída
x pronto y pronto
estourou o tempo
Quando várias tarefas do grupo falham ao mesmo tempo, o erro chega como um ExceptionGroup, e o except* (também do 3.11) trata cada tipo separadamente:
Disparar dez mil requisições de uma vez derruba o servidor do outro lado (e o seu). O Semaphore limita quantas tarefas passam pela região ao mesmo tempo:
Uma chamada bloqueante dentro de uma corrotina (time.sleep, requests.get, uma consulta síncrona) para todas as tarefas enquanto ela roda. Quando não há alternativa assíncrona, mande a chamada para uma thread com asyncio.to_thread:
Eu escolho asyncio quando há muitas conexões simultâneas (milhares) e as bibliotecas que preciso têm versão assíncrona (httpx, aiohttp, drivers async de banco). Para poucas dezenas de chamadas bloqueantes, ThreadPoolExecutor é mais simples e basta. Misturar os dois mundos custa caro em complexidade, então prefira um só por serviço.
Exercício 1
Buscar vários com um limite
Escreva buscar_todos(nomes, limite=2) que use Semaphore e devolva um dicionário nome: resultado.
A regra de ouro: meça antes de otimizar. Quase sempre o gargalo está em um lugar diferente do que você imagina.
Código deste capítulo: avancado/cap48_desempenho.py
Medir um trecho com timeit
O timeit executa um trecho várias vezes e reduz o ruído. Ele responde "qual das duas formas é mais rápida?". Aqui, buscar um item em uma lista (varre tudo, O(n)) contra buscar em um conjunto (tabela hash, O(1) em média):
avancado/cap48_desempenho.pylinhas 10 a 15
importtimeitsetup="dados = list(range(10_000)); conjunto = set(dados)"na_lista=timeit.timeit("9_999 in dados",setup=setup,number=2_000)no_conjunto=timeit.timeit("9_999 in conjunto",setup=setup,number=2_000)print("conjunto mais rápido:",no_conjunto<na_lista)
Saída
conjunto mais rápido: True
Eu não mostro os tempos porque mudam de máquina para máquina. A relação entre eles é o que importa, e ela só cresce com o tamanho dos dados.
Achar o gargalo com cProfile
Quando o programa inteiro está lento, ninguém sabe onde. O cProfile mostra quanto tempo cada função consome. As colunas que importam: ncalls (quantas chamadas), tottime (tempo dentro da função, sem contar as chamadas internas) e cumtime (tempo acumulado, contando tudo o que ela chamou):
Trocar for por compreensão ganha alguns por cento. Trocar um algoritmo O(n²) por O(n) ganha ordens de grandeza. Veja a detecção de duplicatas:
avancado/cap48_desempenho.pylinhas 46 a 63
deftem_duplicado_lento(itens):fori,ainenumerate(itens):forbinitens[i+1:]:ifa==b:returnTruereturnFalsedeftem_duplicado_rapido(itens):returnlen(set(itens))!=len(itens)amostra=list(range(2_000))print(tem_duplicado_lento(amostra)==tem_duplicado_rapido(amostra))t_lento=timeit.timeit(lambda:tem_duplicado_lento(amostra),number=3)t_rapido=timeit.timeit(lambda:tem_duplicado_rapido(amostra),number=3)print("a versão com set é mais rápida:",t_rapido<t_lento)
Saída
True
a versão com set é mais rápida: True
Memória
Para medir o consumo de memória, o tracemalloc registra o pico. O gerador mostra a vantagem de não materializar a sequência inteira:
avancado/cap48_desempenho.pylinhas 68 a 81
importtracemallocdefpico(funcao):tracemalloc.start()funcao()_,maximo=tracemalloc.get_traced_memory()tracemalloc.stop()returnmaximopico_lista=pico(lambda:sum([nforninrange(200_000)]))pico_gerador=pico(lambda:sum(nforninrange(200_000)))print("gerador usa menos memória:",pico_gerador<pico_lista)
Saída
gerador usa menos memória: True
O que eu verifico antes de mexer em desempenho
Sintoma
Suspeita
Ferramenta
O programa todo é lento
Algoritmo, ou espera de I/O
cProfile
Um trecho específico parece lento
Alternativas equivalentes
timeit
Consumo alto de memória
Listas completas em vez de geradores
tracemalloc
Início do programa demora
Imports pesados
python -X importtime meu_script.py
Cálculo numérico lento
Laço Python sobre números
NumPy ou outra biblioteca em C
Ordem das ações
Primeiro, o algoritmo e as estruturas de dados. Depois, evitar trabalho repetido (functools.cache, mover cálculos para fora do laço). Depois, trocar o trabalho em Python puro por bibliotecas em C. Concorrência e reescrita em outra linguagem ficam por último, porque são as mudanças mais caras de manter.
Exercício 1
Interseção sem laço duplo
A função intersecao_lenta(a, b) é O(n por m). Escreva intersecao_rapida com o mesmo resultado, usando um conjunto.
Transformar código em algo instalável, versionável e publicável. Mesmo que você nunca publique no PyPI, a estrutura de pacote resolve problemas de importação e de testes.
Código deste capítulo: avancado/cap49_empacotamento.py
Por que usar o layout src
Colocar o pacote dentro de uma pasta src/ evita um erro sutil: com o código na raiz, os testes importam a pasta local por acidente e passam mesmo que o pacote instalado esteja quebrado. Com src/, a única forma de importar é instalando o pacote, e então você testa o que o usuário vai receber.
Tudo vive em um arquivo. A seção [build-system] diz como construir. A seção [project] descreve o pacote. E [project.scripts] cria um comando de terminal ligado a uma função:
exemplos/pacote/pyproject.toml
[build-system]requires=["hatchling"]build-backend="hatchling.build"[project]name="calc-notes"version="0.1.0"description="Calculadora de exemplo do livro Python na Prática"readme="README.md"requires-python=">=3.12"dependencies=[][project.scripts]calc-notes="calc_notes.cli:main"[dependency-groups]dev=["pytest>=8"]
O código do pacote
exemplos/pacote/src/calc_notes/operacoes.py
defsomar(a:float,b:float)->float:returna+bdefdividir(a:float,b:float)->float:ifb==0:raiseZeroDivisionError("divisor não pode ser zero")returna/b
exemplos/pacote/src/calc_notes/__init__.py
"""Pacote de exemplo do livro Python na Prática."""fromimportlib.metadataimportPackageNotFoundError,versionfromcalc_notes.operacoesimportdividir,somartry:__version__=version("calc-notes")exceptPackageNotFoundError:__version__="0+local"__all__=["__version__","dividir","somar"]
Eu leio a versão dos metadados do pacote instalado em vez de repeti-la no código. Assim existe uma só fonte da verdade, o pyproject.toml.
não instalado: No package metadata was found for pacote-que-nao-existe
Construir e publicar
Um pacote é distribuído em dois formatos: o sdist (código-fonte, .tar.gz) e o wheel (já pronto para instalar, .whl). Qualquer um dos comandos abaixo gera os dois na pasta dist/:
Terminal
uvbuild
Saída
Building source distribution...
Building wheel from source distribution...
Successfully built dist/calc_notes-0.1.0.tar.gz
Successfully built dist/calc_notes-0.1.0-py3-none-any.whl
Alternativas equivalentes: python -m build (com o pacote build) e poetry build. Para publicar, treine primeiro no TestPyPI, um índice de ensaio:
Também funciona o twine upload --repository testpypi dist/*. Em projetos reais, eu prefiro a publicação confiável (trusted publishing): o GitHub Actions prova a sua identidade ao PyPI por um token temporário, e você nunca guarda uma senha de longa duração.
Escolher o backend de construção
Backend
Quando usar
hatchling
Escolha padrão para pacotes em Python puro, simples e configurável
uv_build
Mesmo cenário, muito rápido, integrado ao uv
setuptools
Projetos antigos, ou os que têm extensões em C
poetry-core
Projetos gerenciados pelo Poetry
maturin, scikit-build-core
Extensões em Rust ou C++
Bibliotecas e aplicações pensam diferente sobre versões
Uma aplicação é instalada em um ambiente que você controla. Faça commit do lockfile e fixe as versões. Uma biblioteca é instalada no ambiente de outras pessoas, junto com outras bibliotecas. Declare intervalos amplos (requests>=2.32,<3), porque versões exatas causam conflito para quem usa a sua. O lockfile da biblioteca serve só para o seu desenvolvimento e para os seus testes.
Exercício 1
Executar com python -m
Faça o pacote rodar com python -m calc_notes 2 3, adicionando um arquivo ao pacote.
O Python procura o __main__.py de um pacote quando você o executa com -m. Depois de instalar, python -m calc_notes 2 3 imprime 5.0.
Capítulo 50, parte Avançado
Qualidade e automação
O que não é verificado por uma máquina é esquecido por um humano. Este capítulo monta o conjunto mínimo de verificações que eu coloco em qualquer projeto.
Código deste capítulo: avancado/cap50_qualidade.py
Lint e formatação com ruff
O ruff une linter e formatador em uma ferramenta única e muito rápida. O linter acha erros e padrões suspeitos. O formatador padroniza o estilo, o que acaba as discussões sobre vírgulas em revisão de código. Veja um código com problemas reais:
exemplos/qualidade/antes.py:1:1: E401 [*] Multiple imports on one line
exemplos/qualidade/antes.py:1:1: I001 [*] Import block is un-sorted or un-formatted
exemplos/qualidade/antes.py:1:8: F401 [*] `os` imported but unused
exemplos/qualidade/antes.py:1:12: F401 [*] `sys` imported but unused
exemplos/qualidade/antes.py:4:27: B006 Do not use mutable data structures for argument defaults
exemplos/qualidade/antes.py:7:5: E722 Do not use bare `except`
Found 6 errors.
[*] 4 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).
O ruff apontou seis achados: duas importações não usadas, imports na mesma linha e fora de ordem, um valor padrão mutável (o bug do capítulo 18) e um except que engole qualquer erro. Os quatro marcados com [*] ele corrige sozinho com --fix. Os outros dois, o valor mutável e o except vazio, exigem uma decisão sua, porque mudam o comportamento. A versão corrigida:
avancado/cap50_qualidade.pylinhas 10 a 19
defadicionar(item,lista=None):iflistaisNone:lista=[]lista.append(item)returnlistaassertadicionar(1)==[1]assertadicionar(2)==[2]print("sem os problemas apontados")
Saída
sem os problemas apontados
Configuração em um só lugar
Eu escolho explicitamente os grupos de regras, para que a configuração seja uma decisão e não o padrão da ferramenta. O mypy fica no mesmo arquivo:
Erros lógicos simples, como nomes não usados (pyflakes)
I
Ordem dos imports
B
Armadilhas prováveis de bug (flake8-bugbear)
UP
Sintaxe antiga que pode ser modernizada
Antes do commit: pre-commit
O pre-commit roda as verificações no momento do commit, e impede que o problema chegue ao repositório. Instale com uv tool install pre-commit e ative com pre-commit install:
O pre-commit é uma conveniência local e pode ser pulado. A garantia é o CI, que roda em um ambiente limpo a cada push e pull request. Este fluxo do GitHub Actions usa uv e testa três versões do Python:
exemplos/qualidade/ci.yml
name:cion:push:branches:[main]pull_request:jobs:qualidade:runs-on:ubuntu-lateststrategy:matrix:python:["3.12","3.13","3.14"]steps:-uses:actions/checkout@v4-uses:astral-sh/setup-uv@v6with:python-version:${{ matrix.python }}-run:uv sync --locked-run:uv run ruff check .-run:uv run ruff format --check .-run:uv run mypy-run:uv run pytest -q
Confira as versões das actions
As versões das actions (actions/checkout, astral-sh/setup-uv) evoluem. Antes de copiar este arquivo, confira a versão mais recente de cada uma na página da própria action. O formato do fluxo muda pouco, mas as tags mudam.
Cobertura
A cobertura mede quais linhas os testes executam. Ela diz o que não está testado, e não que o que está testado esteja correto. Eu a uso para descobrir buracos, e não como meta numérica:
Código que funciona na sua máquina é o começo. Produção exige que ele falhe de forma previsível, seja testável sem infraestrutura e mostre o que está acontecendo às três da manhã.
Código deste capítulo: avancado/cap51_arquitetura.py
Separar a regra de negócio do mundo externo
A decisão de arquitetura que mais retorno dá: a lógica do domínio não importa banco, rede nem relógio. Ela recebe interfaces (que descrevemos com Protocol) e quem a monta decide as implementações. Em teste, entram versões em memória. Em produção, as reais:
O existe seguido de salvar tem uma janela de corrida: dois pedidos iguais, em paralelo, passam pela verificação antes de qualquer um gravar. Em produção, a garantia de unicidade vem do banco (uma restrição UNIQUE), e o serviço trata o erro que ela devolve. A verificação no código é só uma cortesia.
Configuração fora do código
A configuração vem do ambiente (variáveis), e nunca do código nem do repositório. Eu leio tudo em um único lugar, na inicialização, e falho cedo se faltar algo obrigatório. O programa que cai na partida com uma mensagem clara é muito melhor do que o que cai uma hora depois com um erro obscuro:
Segredos (senhas, chaves de API) vêm de um gerenciador de segredos ou das variáveis do ambiente de execução. Eles nunca entram no Git, nem em logs.
Retentativas com espera crescente
Falhas transitórias (rede, indisponibilidade breve) merecem uma nova tentativa, com espera que cresce a cada vez (backoff exponencial) e um sorteio de variação (jitter), para que mil clientes não voltem todos no mesmo instante. Eu injeto a função de espera e a de sorteio como parâmetros, e isso torna o comportamento testável sem esperar de verdade:
Três regras que acompanham as retentativas: toda chamada externa tem um tempo limite (sem timeout, uma dependência lenta trava o seu serviço); só repita operações idempotentes (repetir "consultar" é seguro, repetir "cobrar" não é, a menos que exista uma chave de idempotência); e ponha um limite de tentativas, para a falha não virar uma avalanche.
Logs que se conectam
Quando mil requisições rodam ao mesmo tempo, as linhas de log se misturam. Um identificador de correlação em cada linha permite reconstruir o caminho de uma requisição. O contextvars guarda esse valor de forma segura tanto em threads quanto em asyncio:
avancado/cap51_arquitetura.pylinhas 150 a 195
importcontextvarsimportjsonimportloggingimportsysid_requisicao=contextvars.ContextVar("id_requisicao",default="-")classFiltroDeContexto(logging.Filter):deffilter(self,record:logging.LogRecord)->bool:setattr(record,"id_requisicao",id_requisicao.get())returnTrueclassFormatoJson(logging.Formatter):defformat(self,record:logging.LogRecord)->str:returnjson.dumps({"nivel":record.levelname,"mensagem":record.getMessage(),"id_requisicao":getattr(record,"id_requisicao","-"),},ensure_ascii=False,)manipulador=logging.StreamHandler(sys.stdout)manipulador.setFormatter(FormatoJson())manipulador.addFilter(FiltroDeContexto())log=logging.getLogger("api")log.addHandler(manipulador)log.setLevel(logging.INFO)log.propagate=Falsedeftratar(id_:str)->None:token=id_requisicao.set(id_)try:log.info("pedido processado")finally:id_requisicao.reset(token)tratar("req-1")tratar("req-2")log.info("fora de requisição")
Dentro do sistema, use exceções do domínio (PedidoDuplicado). Na fronteira (a API, a CLI), traduza para o que o mundo externo entende e nunca vaze detalhes internos na resposta: o traceback vai para o log, e quem chamou recebe uma mensagem segura:
avancado/cap51_arquitetura.pylinhas 200 a 208
defpara_resposta(excecao:Exception)->tuple[int,str]:ifisinstance(excecao,PedidoDuplicado):return409,"pedido já registrado"ifisinstance(excecao,ValueError):return422,str(excecao)return500,"erro interno"print(para_resposta(PedidoDuplicado("p1")),para_resposta(RuntimeError("segredo")))
Saída
(409, 'pedido já registrado') (500, 'erro interno')
Perguntas que eu faço antes de aprovar um serviço
O que acontece quando cada dependência fica lenta? E quando fica fora do ar?
Toda chamada externa tem timeout e política de retentativa explícita?
A operação pode ser repetida sem efeito colateral (é idempotente)?
Dá para reproduzir uma requisição a partir dos logs, com um identificador?
A configuração vem do ambiente, e a ausência de um valor obrigatório derruba a partida?
Qual é o plano de reverter o deploy, e o que acontece com os dados já gravados?
O que quebra primeiro com dez vezes mais carga?
Exercício 1
Teste a regra de duplicidade
Escreva um teste para ServicoPedidos que garanta que um pedido duplicado levanta PedidoDuplicado e não envia a segunda notificação.
Quase todo sistema real conversa com outro por HTTP. Aprender a consumir uma API com timeout, tratamento de erro e retentativa é o que separa um script de uma integração confiável.
Código deste capítulo: backend/cap52_http_apis.py
O que acontece em uma requisição
Uma requisição HTTP é um texto com quatro partes: o método (o que fazer), a URL (onde), os cabeçalhos (metadados como formato e credenciais) e, às vezes, um corpo. A resposta tem um código de status, cabeçalhos e um corpo. Quase tudo que você vai depurar está em uma dessas partes.
Faixa do status
Significado
O que o cliente faz
2xx
Deu certo
Segue em frente
3xx
Redirecionamento
O cliente HTTP normalmente segue sozinho
4xx
Erro do cliente (você pediu errado)
Não repita igual: corrija o pedido
5xx
Erro do servidor
Pode valer uma nova tentativa
A distinção entre 4xx e 5xx decide a sua estratégia de erro. Repetir um 404 mil vezes não o transforma em 200. Repetir um 503 muitas vezes pode funcionar.
Um servidor local para praticar
Para os exemplos terem saída previsível, sem depender de internet, eu subo um servidor de brincadeira na própria máquina, usando só a biblioteca padrão. Ele tem um endpoint estável, um instável (falha duas vezes e depois funciona), um lento, um paginado e um que recebe JSON. Você não precisa entender o código dele, apenas saber que as rotas existem:
O httpx2 é o cliente HTTP que eu recomendo hoje. Ele tem a mesma interface simples do requests (que continua muito usado), e acrescenta cliente assíncrono, timeouts por padrão e suporte a HTTP/2 (instalado com httpx2[http2]). Use sempre um Client dentro de um with: ele reaproveita a conexão entre as chamadas, o que é muito mais rápido do que abrir uma nova a cada requisição:
Um 404 ou um 503não levantam exceção sozinhos: a resposta chega normalmente, e você precisa olhar o status. O método raise_for_status() converte qualquer 4xx ou 5xx em exceção, o que costuma ser o que você quer:
Uma chamada de rede sem timeout pode ficar esperando para sempre e travar o seu serviço inteiro. Toda chamada externa precisa de um limite de tempo explícito:
backend/cap52_http_apis.pylinhas 84 a 87
try:httpx2.get(f"{BASE}/lento",timeout=0.2)excepthttpx2.TimeoutExceptionaserro:print("estourou o tempo:",type(erro).__name__)
Saída
estourou o tempo: ReadTimeout
Retentativas com espera crescente
Falhas 5xx e de rede costumam ser transitórias. A política razoável é tentar de novo poucas vezes, esperando mais a cada tentativa (backoff exponencial), e desistir. Como no capítulo de arquitetura, eu injeto a função de espera para poder testar sem esperar de verdade:
Repetir um GET é seguro. Repetir um POST que cria algo pode criar duas vezes. Só faça retentativa automática em operações idempotentes, ou envie uma chave de idempotência (capítulo seguinte).
Paginação
APIs não devolvem milhões de itens de uma vez. Elas entregam por páginas, e o cliente segue até acabar. Um gerador encapsula isso e quem consome vê um fluxo contínuo:
O httpx clássico (versão 0.28) tem a mesma interface, e você o encontra em muito código existente. O httpx2 se apresenta como a próxima geração do mesmo cliente, e é mantido pela Pydantic. O Starlette, a base do FastAPI, já passou a preferir o httpx2 no seu TestClient e emite um aviso de depreciação se encontrar só o httpx. Para código novo, eu uso o httpx2. Para tudo o que este capítulo usa, a migração consiste em trocar o nome do pacote e do import: eu executei todos os exemplos com o httpx2, sem nenhuma outra mudança.
O que eu faço em toda integração
Cliente reutilizado, timeout explícito, raise_for_status (ou tratamento deliberado do status), retentativa só para o que é seguro, credenciais lidas do ambiente (nunca no código) e nenhum segredo nos logs.
Exercício 1
Um cliente que traduz erro HTTP em exceção do domínio
Escreva buscar_json(cliente, caminho) que devolva o JSON em caso de sucesso e levante ErroDeApi (com o atributo status) para qualquer resposta que não seja 2xx.
Uma API é um contrato entre quem a oferece e quem a consome. Quando o contrato é previsível, os clientes funcionam sem você precisar explicar cada rota.
Código deste capítulo: backend/cap53_rest_contratos.py
Recursos, não ações
Em REST você modela coisas (recursos) com URLs no plural, e o verbo HTTP diz o que fazer com elas. POST /pedidos cria. GET /pedidos/7 lê. POST /criarPedido é o erro clássico: o verbo já está no método.
Verbo
Faz
Seguro
Idempotente
GET
Lê
Sim
Sim
POST
Cria (ou executa uma ação)
Não
Não
PUT
Substitui por inteiro
Não
Sim
PATCH
Altera parcialmente
Não
Depende
DELETE
Remove
Não
Sim
Seguro significa que não altera estado. Idempotente significa que repetir a chamada dá o mesmo resultado que chamar uma vez. O POST é o único que não é idempotente, e por isso é o que mais merece atenção.
Códigos de status que importam
Código
Quando usar
200
Sucesso com corpo
201
Recurso criado (devolva o recurso)
204
Sucesso sem corpo (um DELETE, por exemplo)
400
Pedido malformado
401
Sem credencial, ou credencial inválida
403
Credencial válida, mas sem permissão
404
O recurso não existe
409
Conflito com o estado atual (cancelar um pedido já cancelado)
422
O formato está certo, mas os dados não passam na validação
429
Limite de requisições excedido
503
Serviço indisponível
Paginação: por deslocamento ou por cursor
A paginação por deslocamento (?pagina=2) é a mais intuitiva e tem um defeito sério: se alguém cria um item enquanto você navega, os itens se deslocam e você vê um repetido (ou perde um). A paginação por cursor pergunta "o que vem depois do último que eu vi?", e por isso é estável:
backend/cap53_rest_contratos.pylinhas 10 a 20
itens=list(range(1,11))defpor_deslocamento(itens,deslocamento,limite):returnitens[deslocamento:deslocamento+limite]pagina_1=por_deslocamento(itens,0,3)itens.insert(0,0)# alguém cria um item no começo enquanto você navegapagina_2=por_deslocamento(itens,3,3)print(pagina_1,pagina_2)
Em um banco, o cursor vira WHERE id > :cursor ORDER BY id LIMIT :limite, que usa o índice da chave primária e continua rápido na página um milhão. O deslocamento (OFFSET) precisa varrer e descartar todas as linhas anteriores.
Erros com um formato único
Um cliente precisa tratar erro de forma programática. Isso exige que todos os erros da API tenham o mesmo formato, e não texto livre que muda por rota. O padrão é o problem details (RFC 9457): um objeto com tipo, título, status e detalhe, e a mesma resposta serve para pessoas e para código:
backend/cap53_rest_contratos.pylinhas 39 a 49
defproblema(status,tipo,titulo,detalhe,**extras):return{"tipo":f"https://exemplo.com/erros/{tipo}","titulo":titulo,"status":status,"detalhe":detalhe,**extras,}print(problema(409,"pedido-ja-cancelado","Conflito de estado","O pedido 7 já está cancelado.",pedido_id=7))
Saída
{'tipo': 'https://exemplo.com/erros/pedido-ja-cancelado', 'titulo': 'Conflito de estado', 'status': 409, 'detalhe': 'O pedido 7 já está cancelado.', 'pedido_id': 7}
O Content-Type dessa resposta é application/problem+json. O cliente decide pelo tipo (estável), e o detalhe é só para leitura humana.
Idempotência no POST
O POST não é idempotente, mas você pode torná-lo. O cliente gera uma chave única (Idempotency-Key) por operação e a reenvia nas retentativas. O servidor guarda o resultado da primeira vez e devolve o mesmo nas seguintes. Assim um timeout de rede não gera dois pedidos:
Repare nos códigos: 201 na criação e 200 quando a chave já existia. Em produção, a verificação "essa chave já existe?" não basta (duas requisições simultâneas passam juntas por ela), e a garantia vem de uma restrição UNIQUE no banco. O capítulo da API completa mostra isso.
Versionar e evoluir
Uma API publicada é difícil de mudar, porque você não controla os clientes. A regra é: acrescentar campos e rotas é compatível. Remover ou renomear um campo, mudar um tipo ou apertar uma validação quebra quem já usa. Para uma mudança incompatível, publique uma nova versão (/v2/pedidos) e mantenha a antiga por um prazo combinado.
O contrato como código
Eu escrevo o contrato primeiro, em forma de schemas. O FastAPI (próximo capítulo) gera a documentação OpenAPI a partir deles automaticamente, e a mesma definição serve para validar a entrada, serializar a saída e documentar. Documentação escrita à mão envelhece, e a gerada do código não.
Exercício 1
Paginação por cursor
Escreva paginar(itens, cursor, limite) que devolva {"itens": [...], "proximo_cursor": ...}. Busque um item a mais que o limite para saber se existe próxima página, e use None quando não houver.
O FastAPI transforma funções Python com anotações de tipo em uma API validada e documentada. Ele só faz sentido depois que você domina tipos, exceções e testes, e é por isso que ele vem agora.
Código deste capítulo: backend/cap54_fastapi_basico.py
Instalar e rodar
O FastAPI roda sobre o Starlette e usa o Pydantic para validação. O uvicorn é o servidor. O httpx2 é necessário para o cliente de testes (o Starlette 1.x o prefere, e com o httpx antigo ele ainda funciona, mas avisa que está obsoleto):
Terminal
uvaddfastapiuvicorn
uvadd--devhttpx2pytest
Com um arquivo main.py que defina app, o servidor sobe assim, e --reload recarrega ao salvar (só em desenvolvimento):
Terminal
uvrunuvicornmain:app--reload
Em seguida, abra http://127.0.0.1:8000/docs: o FastAPI gera uma página interativa com todas as rotas, a partir do seu código.
Rotas, modelos e validação
Cada rota é uma função. O parâmetro de caminho vem da URL, o parâmetro de consulta vem de ?x=1, e o corpo é um modelo Pydantic. A validação acontece antes de a sua função rodar: se os dados são inválidos, ela nem é chamada.
backend/cap54_fastapi_basico.pylinhas 10 a 47
fromtypingimportAnnotatedfromfastapiimportDepends,FastAPI,HTTPException,Queryfromfastapi.testclientimportTestClientfrompydanticimportBaseModel,Fieldapp=FastAPI(title="Biblioteca")classLivroCriar(BaseModel):titulo:str=Field(min_length=1,max_length=100)paginas:int=Field(gt=0)classLivro(LivroCriar):id:intbanco:dict[int,Livro]={}@app.post("/livros",response_model=Livro,status_code=201)defcriar(dados:LivroCriar)->Livro:livro=Livro(id=len(banco)+1,**dados.model_dump())banco[livro.id]=livroreturnlivro@app.get("/livros/{livro_id}",response_model=Livro)defobter(livro_id:int)->Livro:iflivro_idnotinbanco:raiseHTTPException(status_code=404,detail="livro não encontrado")returnbanco[livro_id]@app.get("/livros")deflistar(minimo_paginas:Annotated[int,Query(ge=0)]=0)->list[Livro]:return[livroforlivroinbanco.values()iflivro.paginas>=minimo_paginas]
O response_model filtra a saída: se o seu objeto interno tiver um campo senha, ele não vaza, porque só o que está no modelo é serializado.
Testar sem subir servidor
O TestClient chama a aplicação diretamente, em memória, sem rede. É assim que eu testo toda rota:
backend/cap54_fastapi_basico.pylinhas 52 a 56
cliente=TestClient(app)print(cliente.post("/livros",json={"titulo":"Python na Prática","paginas":500}).json())print(cliente.get("/livros/1").status_code)nao_existe=cliente.get("/livros/9")print(nao_existe.status_code,nao_existe.json())
Saída
{'titulo': 'Python na Prática', 'paginas': 500, 'id': 1}
200
404 {'detail': 'livro não encontrado'}
Quando o corpo é inválido, o FastAPI responde 422 com a lista exata dos campos errados, o local de cada um e o tipo do erro:
Com Depends, uma rota declara do que precisa (uma sessão de banco, o usuário logado, uma configuração), e o FastAPI entrega. A vantagem decisiva é nos testes: dá para trocar a dependência real por uma falsa sem alterar a rota:
A última linha mostra que o contrato OpenAPI existe e lista as rotas. É essa especificação que alimenta a página /docs e que ferramentas geram clientes e testes de contrato.
def ou async def
Declare a rota com async def apenas se tudo que ela chama for assíncrono (await). Se ela chamar uma biblioteca síncrona (um driver de banco comum, o requests), use def simples: o FastAPI a executa em uma thread separada e o servidor continua responsivo. Uma rota async def que chama código bloqueante trava todas as requisições, o erro que o capítulo 47 já mostrou.
Onde cada responsabilidade mora
Nas rotas ficam só a tradução HTTP (ler a entrada, escolher o status, devolver a saída). A regra de negócio mora em um serviço, e o acesso ao banco em um repositório. É a separação que o capítulo de arquitetura apresentou, e o projeto final (capítulo 62) a aplica.
Exercício 1
Remover um livro
Acrescente DELETE /livros/{livro_id} que responda 204 quando remover e 404 quando o livro não existir.
Ver solução
backend/cap54_fastapi_basico.pylinhas 87 a 99
fromfastapiimportResponse@app.delete("/livros/{livro_id}",status_code=204)defremover(livro_id:int)->Response:ifbanco.pop(livro_id,None)isNone:raiseHTTPException(status_code=404,detail="livro não encontrado")returnResponse(status_code=204)assertcliente.delete("/livros/1").status_code==204assertcliente.delete("/livros/1").status_code==404print("ok")
Saída
ok
Capítulo 55, parte Backend
PostgreSQL e SQL
O banco de dados é, quase sempre, a parte mais duradoura de um sistema. Eu ensino SQL com o SQLite, que vem com o Python e não exige instalar nada, e depois mostro o que muda no PostgreSQL.
Código deste capítulo: backend/cap55_sql.py
Por que SQL, e por que PostgreSQL
SQL é a linguagem declarativa que descreve o que você quer, e o banco decide como buscar. O PostgreSQL é o banco relacional que eu escolho por padrão: é aberto, extremamente confiável, tem tipos ricos (JSON, arrays, datas com fuso) e transações sólidas. Os conceitos deste capítulo valem para qualquer banco relacional. O SQLite serve para aprender e para testes rápidos, e eu mostro onde o PostgreSQL difere.
Tabelas e restrições
As restrições (NOT NULL, UNIQUE, CHECK, REFERENCES) fazem o banco recusar dado inválido, mesmo que um bug no código tente gravá-lo. Eu sempre as declaro, porque o banco é a última linha de defesa:
backend/cap55_sql.pylinhas 10 a 28
importsqlite3conexao=sqlite3.connect(":memory:")conexao.row_factory=sqlite3.Rowconexao.execute("PRAGMA foreign_keys = ON")conexao.executescript("""CREATE TABLE clientes ( id INTEGER PRIMARY KEY, nome TEXT NOT NULL, cidade TEXT);CREATE TABLE pedidos ( id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL REFERENCES clientes(id), total_centavos INTEGER NOT NULL CHECK (total_centavos > 0));INSERT INTO clientes (nome, cidade) VALUES ('Ana', 'Recife'), ('Bia', 'Natal'), ('Caio', 'Recife');INSERT INTO pedidos (cliente_id, total_centavos) VALUES (1, 5000), (1, 2500), (2, 10000);""")
Valores monetários ficam em centavos inteiros (INTEGER) ou em NUMERIC, nunca em FLOAT, pelo mesmo motivo do capítulo de tipos.
Consultar: SELECT, JOIN e agregação
backend/cap55_sql.pylinhas 33 a 42
consulta="SELECT nome FROM clientes WHERE cidade = 'Recife' ORDER BY nome"print([linha["nome"]forlinhainconexao.execute(consulta)])juncao="""SELECT c.nome, p.total_centavosFROM pedidos pJOIN clientes c ON c.id = p.cliente_idORDER BY p.id"""print([tuple(linha)forlinhainconexao.execute(juncao)])
O JOIN (ou INNER JOIN) só devolve clientes que têm pedido. O LEFT JOIN mantém todos os clientes, e quem não tem pedido aparece com NULL. O GROUP BY agrupa linhas e o HAVING filtra os grupos (o WHERE filtra linhas antes de agrupar):
backend/cap55_sql.pylinhas 44 a 59
agregacao="""SELECT c.nome, COALESCE(SUM(p.total_centavos), 0) AS totalFROM clientes cLEFT JOIN pedidos p ON p.cliente_id = c.idGROUP BY c.nomeORDER BY c.nome"""print([tuple(linha)forlinhainconexao.execute(agregacao)])com_filtro="""SELECT cliente_id, COUNT(*) AS quantidadeFROM pedidosGROUP BY cliente_idHAVING COUNT(*) > 1"""print([tuple(linha)forlinhainconexao.execute(com_filtro)])
O COALESCE troca o NULL do Caio (sem pedidos) por zero. Sem o LEFT JOIN, ele nem apareceria no resultado.
Parâmetros: nunca monte SQL com texto
Montar o SQL com f-string ou concatenação é a vulnerabilidade mais antiga e ainda mais comum: a injeção de SQL. A entrada do usuário vira parte do comando. Os parâmetros enviam o valor separado do comando, e o banco jamais o interpreta como SQL:
backend/cap55_sql.pylinhas 64 a 67
entrada="x' OR '1'='1"inseguro=conexao.execute(f"SELECT nome FROM clientes WHERE nome = '{entrada}'").fetchall()seguro=conexao.execute("SELECT nome FROM clientes WHERE nome = ?",(entrada,)).fetchall()print(len(inseguro),len(seguro))
Saída
3 0
A versão com f-string devolveu todos os clientes (3), porque a entrada reescreveu a condição. A versão com parâmetro procurou por um nome literalmente estranho e achou zero. O marcador é ? no SQLite e %s no psycopg, mas a regra é a mesma em qualquer driver.
Transações: tudo ou nada
Uma transação agrupa operações que precisam acontecer juntas. Transferir dinheiro é o exemplo clássico: debitar de uma conta e creditar em outra não pode ficar pela metade. O with conexao confirma (commit) se o bloco termina bem e desfaz (rollback) se levantar exceção:
backend/cap55_sql.pylinhas 72 a 88
conexao.execute("CREATE TABLE contas (id INTEGER PRIMARY KEY, saldo INTEGER NOT NULL CHECK (saldo >= 0))")conexao.executemany("INSERT INTO contas VALUES (?, ?)",[(1,100),(2,50)])conexao.commit()deftransferir(origem,destino,valor):withconexao:conexao.execute("UPDATE contas SET saldo = saldo - ? WHERE id = ?",(valor,origem))conexao.execute("UPDATE contas SET saldo = saldo + ? WHERE id = ?",(valor,destino))transferir(1,2,30)try:transferir(1,2,500)exceptsqlite3.IntegrityErroraserro:print("recusado:",erro)print([tuple(linha)forlinhainconexao.execute("SELECT id, saldo FROM contas ORDER BY id")])
A segunda transferência falhou no débito (saldo negativo viola o CHECK), e o rollback garantiu que nada mudou: os saldos são 70 e 80, que somam os mesmos 150 de antes. Essas garantias (atomicidade, consistência, isolamento e durabilidade) são o ACID.
Índices e o plano de consulta
Sem índice, buscar por uma coluna obriga o banco a ler a tabela inteira. Um índice é uma estrutura ordenada auxiliar que torna a busca rápida, ao custo de espaço e de escrita um pouco mais lenta. O comando EXPLAIN mostra como o banco pretende executar a consulta:
backend/cap55_sql.pylinhas 93 a 101
defusa_indice(sql):plano=conexao.execute("EXPLAIN QUERY PLAN "+sql).fetchall()returnany("INDEX"inlinha["detail"]forlinhainplano)busca="SELECT * FROM pedidos WHERE cliente_id = 1"print("antes do índice:",usa_indice(busca))conexao.execute("CREATE INDEX ix_pedidos_cliente ON pedidos (cliente_id)")print("depois do índice:",usa_indice(busca))
Saída
antes do índice: False
depois do índice: True
Crie índices para as colunas que aparecem em WHERE, em JOIN e em ORDER BY nas consultas frequentes. Não crie um para cada coluna: cada índice custa a cada INSERT.
O que muda no PostgreSQL
Assunto
SQLite
PostgreSQL
Servidor
Um arquivo, sem processo
Um servidor, com usuários e rede
Chave automática
INTEGER PRIMARY KEY
INTEGER GENERATED ALWAYS AS IDENTITY
Marcador de parâmetro
?
%s (no psycopg)
Tipos
Flexíveis (tipagem fraca)
Estritos, com JSONB, ARRAY, TIMESTAMPTZ
Concorrência
Uma escrita por vez
Muitas escritas simultâneas (MVCC)
Devolver a linha criada
Limitado
INSERT ... RETURNING
Com o driver psycopg (versão 3), a conexão também é um gerenciador de contexto: sair do with sem erro confirma a transação, e sair com exceção a desfaz. Os blocos abaixo rodam contra um PostgreSQL de verdade (a variável DATABASE_URL aponta para ele):
Com PostgreSQL (psycopg)
importosimportpsycopgfrompsycopg.types.jsonimportJsonbwithpsycopg.connect(os.environ["DATABASE_URL"])asconexao:conexao.execute("DROP TABLE IF EXISTS produtos")conexao.execute(""" CREATE TABLE produtos ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, nome TEXT UNIQUE NOT NULL, preco_centavos INTEGER NOT NULL, atributos JSONB NOT NULL DEFAULT '{}' ) """)cursor=conexao.execute("INSERT INTO produtos (nome, preco_centavos, atributos) VALUES (%s, %s, %s) RETURNING id",("caneta",350,Jsonb({"cor":"azul"})),)print("id criado:",cursor.fetchone()[0])
Saída
id criado: 1
O RETURNING devolve a chave gerada na mesma operação, sem uma segunda consulta. O ON CONFLICT faz um upsert (insere, ou atualiza se a chave única já existir), e o operador ->> consulta dentro do JSON:
Upsert e consulta em JSONB
importosimportpsycopgwithpsycopg.connect(os.environ["DATABASE_URL"])asconexao:conexao.execute("""INSERT INTO produtos (nome, preco_centavos) VALUES (%s, %s) ON CONFLICT (nome) DO UPDATE SET preco_centavos = EXCLUDED.preco_centavos""",("caneta",400),)print(conexao.execute("SELECT nome, preco_centavos FROM produtos").fetchall())print(conexao.execute("SELECT nome FROM produtos WHERE atributos->>'cor' = %s",("azul",)).fetchall())withpsycopg.connect(os.environ["DATABASE_URL"])asconexao:try:conexao.execute("INSERT INTO produtos (nome, preco_centavos) VALUES (%s, %s)",("caneta",1))exceptpsycopg.errors.UniqueViolationaserro:print("violação de unicidade:",erro.diag.constraint_name)conexao.rollback()conexao.execute("DROP TABLE produtos")
Saída
[('caneta', 400)]
[('caneta',)]
violação de unicidade: produtos_nome_key
Duas transações, dois mundos
No PostgreSQL, o isolamento padrão (READ COMMITTED) significa que cada comando enxerga o que já foi confirmado por outras transações. Para evitar a corrida de "ler o saldo, calcular, gravar", use SELECT ... FOR UPDATE, que trava a linha até o fim da transação, ou uma restrição no banco. Concorrência de dados é problema do banco, e não do Python.
Exercício 1
Total por cliente, incluindo quem não comprou
Escreva total_por_cliente() que use a conexão acima e devolva uma lista de (nome, total) ordenada por nome, com zero para quem não tem pedidos.
Ver solução
backend/cap55_sql.pylinhas 106 a 118
deftotal_por_cliente():consulta=""" SELECT c.nome, COALESCE(SUM(p.total_centavos), 0) FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id GROUP BY c.nome ORDER BY c.nome """return[tuple(linha)forlinhainconexao.execute(consulta)]asserttotal_por_cliente()==[("Ana",7500),("Bia",10000),("Caio",0)]print("ok")
Saída
ok
Capítulo 56, parte Backend
SQLAlchemy
O SQLAlchemy mapeia tabelas para classes Python. Usado com disciplina, ele elimina SQL repetitivo. Usado sem disciplina, ele esconde consultas que o derrubam em produção.
Código deste capítulo: backend/cap56_sqlalchemy_orm.py
Modelos tipados (estilo 2.0)
Cada classe é uma tabela, cada atributo anotado com Mapped[...] é uma coluna, e o mypy entende tudo isso. As relationship navegam entre tabelas relacionadas. Para aprender e testar uso o SQLite em memória, e o mesmo código roda no PostgreSQL trocando só a URL:
A Session acompanha os objetos que você carregou e criou, e grava tudo de uma vez no commit. Eu abro uma sessão por operação (ou por requisição web) e a fecho no fim, com with:
backend/cap56_sqlalchemy_orm.pylinhas 51 a 60
withSession(engine)assessao:sessao.add_all([Autor(nome="Machado de Assis",livros=[Livro(titulo="Dom Casmurro",paginas=256),Livro(titulo="Quincas Borba",paginas=300),]),Autor(nome="Clarice Lispector",livros=[Livro(titulo="A Hora da Estrela",paginas=88)]),Autor(nome="Graciliano Ramos",livros=[Livro(titulo="Vidas Secas",paginas=176)]),])sessao.commit()
Consultar com select
O estilo 2.0 usa select(...) e as funções de SQL de func. O que você escreve é praticamente o SQL do capítulo anterior, só que com classes:
É o erro de desempenho mais comum com ORM. Ao percorrer os autores e acessar autor.livros, o SQLAlchemy, por padrão, faz uma consulta nova para cada autor (carregamento preguiçoso). Com 1000 autores, são 1001 consultas. A correção é pedir os relacionados de antemão, com selectinload. Um ouvinte de eventos conta as consultas para você enxergar o problema:
carregamento preguiçoso: 4 consultas
com selectinload: 2 consultas
Com três autores a diferença é de 4 para 2. Com mil, é de 1001 para 2.
Alterar, apagar e transações
Alterar um atributo de um objeto carregado basta: no commit, a sessão detecta a mudança e emite o UPDATE. O cascade="all, delete-orphan" que declaramos faz apagar um autor apagar os seus livros. E o bloco with sessao.begin() abre uma transação que confirma sozinha ou desfaz em caso de erro:
backend/cap56_sqlalchemy_orm.pylinhas 101 a 114
withSession(engine)assessao:livro=sessao.scalars(select(Livro).where(Livro.titulo=="Vidas Secas")).one()livro.paginas=180sessao.commit()autor=sessao.scalars(select(Autor).where(Autor.nome=="Graciliano Ramos")).one()sessao.delete(autor)sessao.commit()print(sessao.scalar(select(func.count(Livro.id))))try:withSession(engine)assessao,sessao.begin():sessao.add(Autor(nome="Machado de Assis"))exceptExceptionaserro:print(type(erro).__name__)
Saída
3
IntegrityError
O segundo bloco tenta repetir um autor que já existe, e o banco recusa pela restrição UNIQUE. O IntegrityError desfaz a transação inteira.
Repositório
Eu isolo as consultas em uma classe de repositório, para que o resto do código não conheça o SQLAlchemy. Fica fácil de testar (com um repositório falso) e de trocar:
O create_all só cria tabelas que não existem. Ele não altera uma tabela existente, não adiciona coluna, não renomeia nada. Serve para testes e para o primeiro dia. Em produção, quem evolui o esquema é uma ferramenta de migração, o assunto do próximo capítulo.
Exercício 1
Títulos de um autor
Escreva titulos_do_autor(sessao, nome) que devolva, ordenados, os títulos dos livros de um autor, com uma única consulta (sem N+1).
Ver solução
backend/cap56_sqlalchemy_orm.pylinhas 138 a 146
deftitulos_do_autor(sessao,nome):consulta=select(Livro.titulo).join(Autor).where(Autor.nome==nome).order_by(Livro.titulo)returnlist(sessao.scalars(consulta))withSession(engine)assessao:asserttitulos_do_autor(sessao,"Machado de Assis")==["Dom Casmurro","Quincas Borba"]asserttitulos_do_autor(sessao,"Ninguém")==[]print("ok")
Saída
ok
Capítulo 57, parte Backend
Alembic: migrações de banco
O esquema do banco evolui junto com o código. A migração é o histórico versionado dessa evolução, aplicável em qualquer ambiente de forma repetível.
Os arquivos deste capítulo estão em exemplos/api_pedidos/.
O problema
Você adiciona uma coluna ao modelo. O seu banco local não tem essa coluna, o de teste tampouco, e o de produção ainda menos. Alterar à mão em cada ambiente é erro certo. O Alembic guarda cada mudança como um arquivo de migração versionado, que sabe subir (upgrade) e descer (downgrade) o esquema, e registra em uma tabela do próprio banco qual versão está aplicada.
Configuração
O alembic.ini aponta para a pasta das migrações. O env.py é o coração: ele conecta o Alembic aos seus modelos e à mesma configuração de banco que a aplicação usa, para que migração e API nunca divirjam de URL:
fromlogging.configimportfileConfigfromalembicimportcontextfromsqlalchemyimportengine_from_config,poolfromapi_pedidos.configimportobter_configuracaofromapi_pedidos.modelosimportBaseconfig=context.configifconfig.config_file_nameisnotNone:fileConfig(config.config_file_name,disable_existing_loggers=False)# A URL vem da configuração da aplicação, a mesma que a API usa. O "%" precisa ser escapado# porque o ConfigParser do Alembic trata "%" como caractere especial.url=obter_configuracao().database_url.get_secret_value()config.set_main_option("sqlalchemy.url",url.replace("%","%%"))target_metadata=Base.metadatadefrun_migrations_offline()->None:"""Gera o SQL sem conectar ao banco (alembic upgrade head --sql)."""context.configure(url=config.get_main_option("sqlalchemy.url"),target_metadata=target_metadata,literal_binds=True,dialect_opts={"paramstyle":"named"},)withcontext.begin_transaction():context.run_migrations()defrun_migrations_online()->None:connectable=engine_from_config(config.get_section(config.config_ini_section,{}),prefix="sqlalchemy.",poolclass=pool.NullPool,)withconnectable.connect()asconexao:context.configure(connection=conexao,target_metadata=target_metadata)withcontext.begin_transaction():context.run_migrations()ifcontext.is_offline_mode():run_migrations_offline()else:run_migrations_online()
O target_metadata = Base.metadata é o que permite o --autogenerate comparar os seus modelos com o banco real.
Criar uma migração
O --autogenerate compara os modelos com o banco e escreve a migração por você. Ele só gera o rascunho, e revisar é obrigatório:
INFO [alembic.autogenerate.compare.tables] Detected added table 'pedidos'
INFO [alembic.autogenerate.compare.constraints] Detected added index 'ix_pedidos_cliente' on '('cliente',)'
INFO [alembic.autogenerate.compare.tables] Detected added table 'itens_pedido'
Generating migrations/versions/0001_criar_pedidos.py ... done
O arquivo gerado, depois da minha revisão (o Alembic escreve comentários e tipagem antiga que eu limpo):
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 0001, criar pedidos
O PostgreSQL executa mudanças de esquema dentro de uma transação (transactional DDL), então uma migração que falha no meio é desfeita por inteiro. Esse é um dos motivos pelos quais eu o prefiro. Os comandos que você usa o tempo todo:
O check falha se os modelos mudaram sem uma migração correspondente (ele diz No new upgrade operations detected. quando está tudo sincronizado), e o --sql imprime o SQL sem executar nada, útil para revisão por quem administra o banco.
Mudanças que mexem em dados
Adicionar uma coluna NOT NULL a uma tabela que já tem linhas falharia, porque as linhas existentes não têm valor. O padrão seguro tem três passos na mesma migração: adicionar a coluna aceitando nulo, preencher as linhas existentes, e só então tornar obrigatória:
Uma migração que preenche dados
defupgrade()->None:op.add_column("pedidos",sa.Column("canal",sa.String(length=20),nullable=True))op.execute("UPDATE pedidos SET canal = 'web' WHERE canal IS NULL")op.alter_column("pedidos","canal",existing_type=sa.String(length=20),nullable=False)defdowngrade()->None:op.drop_column("pedidos","canal")
Para implantar sem parar o sistema, o padrão se chama expandir e contrair: primeiro uma migração que só acrescenta (compatível com o código antigo), depois o deploy do código novo, e só numa migração posterior a remoção do que ficou obsoleto. Nunca renomeie ou apague uma coluna na mesma versão em que o código deixa de usá-la.
Regras que eu não quebro
Nunca edite uma migração que já foi aplicada em outro ambiente: crie uma nova. Mantenha uma únicahead (duas linhas de migração em paralelo precisam ser unificadas com alembic merge). O autogenerate não detecta renomeação de coluna: ele vê uma remoção e uma criação, o que apagaria os dados. Para renomear, escreva op.alter_column(..., new_column_name=...) à mão.
No SQLite, alterar tabela é diferente
O SQLite não consegue alterar colunas existentes. O Alembic oferece o modo batch (render_as_batch=True), que recria a tabela. Por isso eu rodo as migrações contra PostgreSQL, o mesmo banco da produção, e evito ter o SQLite como "banco de teste" das migrações.
Capítulo 58, parte Backend
Configuração profissional
Configuração é tudo o que muda entre o seu computador, o CI e a produção, sem mudar o código. Errar aqui é a causa mais comum de segredo vazado e de "funciona na minha máquina".
Código deste capítulo: backend/cap58_configuracao.py
A regra: configuração vem do ambiente
O princípio (dos twelve-factor apps) é simples: o mesmo código roda em qualquer lugar, e o que muda é lido de variáveis de ambiente. Nada de if ambiente == "prod" espalhado pelo código, e nenhuma senha em arquivo versionado.
Ambiente
De onde vêm os valores
Observação
Desenvolvimento
Um arquivo .env local
Fora do Git (.gitignore)
Testes e CI
Variáveis definidas no job
Valores descartáveis
Produção
Gerenciador de segredos ou variáveis do orquestrador
Nunca em imagem nem em repositório
Configuração tipada com pydantic-settings
Ler os.environ["PORTA"] devolve texto, e esquecer de converter é um bug esperando para acontecer. O pydantic-settings lê as variáveis, converte para o tipo declarado e valida, tudo ao iniciar. Se algo está errado, o programa nem sobe, o que é exatamente o que você quer:
O campo chave_api não tem valor padrão, então é obrigatório. Sem a variável LOJA_CHAVE_API, a configuração recusa iniciar e diz exatamente o que falta:
Definindo as variáveis, a conversão acontece sozinha. A porta chegou como texto e virou int. E o SecretStresconde o valor ao imprimir, o que protege a chave de vazar em logs e em mensagens de erro:
Os valores podem vir de vários lugares, e a ordem decide. Da maior para a menor prioridade: argumentos passados ao construtor, depois variáveis de ambiente, depois o arquivo .env, depois os padrões do código. Isso permite que a produção sobrescreva o arquivo local sem editar nada:
Na primeira linha a variável de ambiente (9000) venceu o arquivo (7000). Depois de removê-la, valeu o arquivo. E o argumento explícito venceu os dois.
Regras de negócio sobre a configuração
Validações que cruzam campos entram em um model_validator. O exemplo clássico: produção exige uma chave forte, e o programa se recusa a subir com uma fraca:
backend/cap58_configuracao.pylinhas 56 a 69
classConfiguracaoSegura(Configuracao):@model_validator(mode="after")defproducao_exige_chave_forte(self):ifself.ambiente=="prod"andlen(self.chave_api.get_secret_value())<16:raiseValueError("em produção a chave precisa ter pelo menos 16 caracteres")returnselfos.environ["LOJA_AMBIENTE"]="prod"try:ConfiguracaoSegura(_env_file=None)exceptValidationErroraserro:print(erro.errors()[0]["msg"])delos.environ["LOJA_AMBIENTE"]
Saída
Value error, em produção a chave precisa ter pelo menos 16 caracteres
O projeto final (capítulo 62) usa a mesma ideia: API_AMBIENTE=prod com um banco SQLite é recusado na partida.
Segredos
Prática
Por quê
.env no .gitignore, e um .env.example versionado sem valores reais
Documenta quais variáveis existem, sem vazar
SecretStr para senhas e chaves
Não aparecem em print, em repr nem em logs
Segredos injetados na execução, nunca copiados para a imagem Docker
Uma imagem é distribuída, e as camadas guardam o que foi copiado
Um arquivo por segredo (secrets_dir="/run/secrets"), no Docker e no Kubernetes
Não ficam visíveis na lista de variáveis do processo
Rotação possível sem redeploy
Uma chave vazada precisa poder ser trocada rápido
Um segredo que entrou no Git está vazado
Apagar o arquivo no commit seguinte não resolve: o histórico guarda tudo. A resposta correta a um segredo commitado é revogá-lo e gerar outro. Limpar o histórico é secundário.
Exercício 1
Um campo booleano de depuração
Acrescente debug: bool = False a uma subclasse de Configuracao. Mostre que LOJA_DEBUG=1 vira True e que LOJA_DEBUG=talvez é recusado.
Testar uma aplicação não é testar tudo do mesmo jeito. É escolher, para cada risco, o teste mais barato que o pegaria.
Código deste capítulo: backend/cap59_testes_aplicacao.py
A pirâmide
Tipo
O que exercita
Velocidade
Quantos
Unitário
Uma regra de negócio isolada, sem rede nem banco
Milissegundos
Muitos
Integração
O seu código com uma peça real (um banco, uma rota HTTP)
Dezenas de milissegundos
Alguns
Ponta a ponta
O sistema inteiro, de fora, como um usuário
Segundos
Poucos
A pirâmide existe por custo: os testes de baixo são rápidos e apontam o problema com precisão, e os de cima são lentos e dizem apenas "algo quebrou". Eu escrevo muitos dos primeiros e poucos dos últimos.
O que isolar: as fronteiras
Dependências que atravessam a fronteira do seu processo (gateway de pagamento, e-mail, relógio, rede) devem ser substituíveis. A regra de negócio recebe essas dependências como interfaces (Protocol), e o teste entrega uma versão falsa. É o que o capítulo de arquitetura mostrou, agora aplicado. O código a testar, um checkout que cobra e depois grava o pedido:
Um fake é uma implementação de verdade, só que simples (guarda em memória). Ele é melhor do que um Mock na maioria dos casos, porque o teste verifica o resultado (o que ficou salvo), e não a forma como o código chamou. Testes presos à forma quebram a cada refatoração, mesmo quando o comportamento continua certo:
O primeiro teste cobre o caminho feliz. Os seguintes cobrem o que não pode acontecer: pagamento recusado não pode gravar pedido (senão o cliente tem um pedido que não pagou), e carrinho vazio não pode nem cobrar:
O Mock é a ferramenta certa quando o que importa é a interação: "o e-mail foi enviado exatamente uma vez, com este destinatário". Nesse caso, a chamada é o comportamento:
Para a camada web, o TestClient e a troca de dependência (dependency_overrides ou, como aqui, uma fábrica que recebe o serviço) permitem verificar o contrato HTTP: o código de status de cada situação, e o corpo. Isso é um teste de integração entre o roteamento, a validação e o serviço:
Os fakes provam a lógica, mas não provam que o SQL funciona. Um teste de integração usa o banco de verdade (no SQLite em memória, rápido) para verificar que o repositório real grava e lê. O truque é uma fixture que cria o esquema do zero a cada teste, e assim nenhum teste depende de outro:
Nada de relógio, aleatoriedade ou rede reais: injete-os ou use fakes
Independente
Cada teste cria o seu estado e roda em qualquer ordem
Testa comportamento
Verifica o resultado, e não a implementação
Falha por um motivo só
O nome diz o que quebrou, e um teste só afirma uma coisa
Rápido
Se a suíte demora, ninguém a roda
Cobertura não é qualidade
Um teste sem assert aumenta a cobertura e não verifica nada. Eu uso a cobertura para descobrir o que não está testado, nunca como meta a atingir. E o teste de integração com o banco pega o que os fakes escondem: um SQL errado, uma restrição que falta, uma transação que não confirma.
Exercício 1
O gateway falha no meio
Escreva um teste com Mock(side_effect=PagamentoRecusado(...)) que mostre que, quando o pagamento é recusado, o repositório nunca é chamado.
O pytest encontra as funções test_* do arquivo, executa as fixtures e mostra um ponto por teste que passou:
Terminal
pytest-q
Saída
........... [100%]
11 passed in 0.01s
Capítulo 60, parte Backend
Docker para Python
Um contêiner empacota a aplicação com tudo de que ela precisa, de modo que ela rode igual no seu notebook, no CI e no servidor. Escrever um bom Dockerfile é uma questão de ordem das camadas e de menos privilégio.
Os arquivos deste capítulo estão em exemplos/api_pedidos/.
O que é uma imagem
Uma imagem é uma pilha de camadas somente-leitura, e cada instrução do Dockerfile cria uma. Um contêiner é uma imagem em execução. O Docker guarda cada camada em cache e só a refaz se a instrução ou os arquivos que ela usa mudaram. A consequência prática governa todo o desenho: o que muda raramente vai primeiro, e o que muda sempre (o seu código) vai por último.
O Dockerfile da API de pedidos
Ele tem dois estágios. No primeiro, o uv instala as dependências a partir do uv.lock (as mesmas versões que você testou). No segundo, só o resultado vai para a imagem final: sem o uv, sem cache, sem ferramentas de compilação:
exemplos/api_pedidos/Dockerfile
# Estágio 1: instala as dependências com o uvFROMpython:3.13-slimASconstrucaoCOPY--from=ghcr.io/astral-sh/uv:latest/uv/uvx/bin/
ENVUV_COMPILE_BYTECODE=1UV_LINK_MODE=copy
WORKDIR/app# Dependências primeiro: esta camada só é refeita quando o uv.lock mudaCOPYpyproject.tomluv.lockREADME.md./
RUN--mount=type=cache,target=/root/.cache/uv\uvsync--locked--no-install-project--no-dev
COPYsrc./src
COPYmigrations./migrations
COPYalembic.ini./
RUN--mount=type=cache,target=/root/.cache/uv\uvsync--locked--no-dev
# Estágio 2: imagem final, sem o uv e sem ferramentas de construçãoFROMpython:3.13-slimRUNuseradd--create-home--uid10001app
WORKDIR/appCOPY--from=construcao--chown=app:app/app/app
ENVPATH="/app/.venv/bin:$PATH"PYTHONUNBUFFERED=1USERappEXPOSE8000HEALTHCHECK--interval=15s--timeout=3s--retries=3\CMDpython-c"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/saude').status == 200 else 1)"CMD["uvicorn","api_pedidos.app:criar_app","--factory","--host","0.0.0.0","--port","8000"]
Cada decisão tem um motivo:
Decisão
Motivo
Copiar pyproject.toml e uv.lockantes do código
A camada das dependências só é refeita quando elas mudam, e não a cada alteração de código
uv sync --locked --no-dev
Reproduzível (falha se o lock estiver desatualizado) e sem pytest, mypy ou ruff na imagem de produção
Dois estágios (multi-stage)
A imagem final é menor e tem menos superfície de ataque
useradd e USER app
Rodar como root dentro do contêiner agrava qualquer vulnerabilidade
CMD na forma de lista
O uvicorn vira o processo principal e recebe o SIGTERM do orquestrador, encerrando com elegância
PYTHONUNBUFFERED=1
Os logs saem na hora para a saída padrão, onde o Docker os coleta
HEALTHCHECK
O orquestrador sabe quando a aplicação está de fato respondendo
O que fica de fora da imagem
O .dockerignore mantém lixo e segredos fora do contexto de construção. Sem ele, o COPY poderia levar o seu .env e o seu .venv para dentro da imagem:
A configuração entra por variáveis de ambiente (-e), exatamente como o capítulo 58 ensinou. A imagem é a mesma em todos os ambientes.
Docker Compose: a aplicação com o banco
Uma API real precisa do banco. O Compose descreve os serviços, as variáveis e a ordem em que sobem. Aqui, o PostgreSQL só é considerado pronto quando o seu healthcheck passa, a migração roda uma vez e termina, e só então a API sobe:
O depends_on com service_healthy e service_completed_successfully resolve o problema clássico de "a API subiu antes do banco". O down -v apaga também o volume do banco, e sem o -v os dados sobrevivem entre execuções.
O que nunca fazer em um Dockerfile
Copiar o .env para a imagem, colocar uma senha em ENV ou em ARG (ficam gravadas nas camadas e no histórico), usar a tag latest da imagem base em produção (a build deixa de ser reprodutível), rodar como root, ou instalar as ferramentas de teste na imagem final. E um contêiner roda um processo principal: se você precisa de dois, são dois serviços.
Migração é um passo do deploy, não da partida da API
Se a API aplicasse as migrações ao subir, duas réplicas subindo juntas tentariam migrar ao mesmo tempo. Por isso a migração é um serviço separado, que roda uma vez antes de qualquer réplica da API. Em Kubernetes, esse papel é de um Job.
Capítulo 61, parte Backend
Observabilidade
Em produção você não pode abrir o programa e olhar dentro. Observabilidade é a capacidade de entender o que o sistema está fazendo a partir do que ele emite: logs, métricas e traces.
Código deste capítulo: backend/cap61_observabilidade.py
Os três sinais
Sinal
Responde
Exemplo
Logs
O que aconteceu, em detalhe
"pedido 7 recusado: cartão sem saldo"
Métricas
Quanto, com que frequência, quão rápido
Requisições por segundo, p99 de latência
Traces
Por onde uma requisição passou e quanto cada etapa demorou
checkout chamou cobrar e gravar
As métricas dizem que há um problema (a latência subiu). Os traces dizem onde (a chamada ao gateway). Os logs dizem por quê (a mensagem de erro dele). Os três se complementam e se ligam por um identificador comum.
Logs estruturados com identificador de correlação
Um log em texto livre é para humanos lerem. Um log em JSON é para máquinas pesquisarem: nivel = "ERROR" e id_requisicao = "abc". Um middleware atribui um identificador a cada requisição, o guarda em uma ContextVar (que funciona em threads e em asyncio) e o devolve no cabeçalho, para o cliente poder citá-lo em um chamado:
A primeira linha impressa é o log da rota, com o identificador que veio no cabeçalho, e a segunda é o cabeçalho devolvido. É o mesmo mecanismo da API de pedidos, que registra uma linha por requisição:
Logs da API de pedidos (execução real, com PostgreSQL)
Senhas, tokens, números de cartão e dados pessoais (CPF, e-mail completo). Um log é copiado para vários sistemas e guardado por meses. O SecretStr do capítulo 58 existe para isso. E registre a rota com o padrão (/pedidos/{id}), e não com a URL já preenchida, para não vazar identificadores.
Métricas com Prometheus
Uma métrica é um número que o sistema expõe e uma ferramenta coleta periodicamente. Há dois tipos que cobrem quase tudo: o Counter, que só cresce (pedidos criados), e o Histogram, que distribui valores em faixas (duração do checkout). O Prometheus lê o texto de uma rota /metricas:
Terminal
uvaddprometheus-client
backend/cap61_observabilidade.pylinhas 69 a 83
fromprometheus_clientimportCollectorRegistry,Counter,Histogram,generate_latestregistro=CollectorRegistry()PEDIDOS=Counter("pedidos_total","Pedidos criados",["status"],registry=registro)LATENCIA=Histogram("checkout_segundos","Duração do checkout",buckets=(0.1,0.5,1.0),registry=registro)PEDIDOS.labels("aprovado").inc()PEDIDOS.labels("aprovado").inc()PEDIDOS.labels("recusado").inc()LATENCIA.observe(0.3)LATENCIA.observe(0.05)texto=generate_latest(registro).decode()interessantes=("pedidos_total{","checkout_segundos_bucket","checkout_segundos_count")print("\n".join(linhaforlinhaintexto.splitlines()iflinha.startswith(interessantes)))
Os bucket são cumulativos: a faixa le="0.5" conta tudo que levou até 0,5 segundo. É daí que se calcula o percentil (p95, p99) de latência, que diz muito mais do que a média.
Cuidado com a cardinalidade dos rótulos
Cada combinação de valores de rótulo cria uma série nova na memória do Prometheus. Um rótulo com valores ilimitados (id do usuário, URL completa, e-mail) cria milhões de séries e derruba o sistema de métricas. Rotule só com valores de conjunto pequeno e fixo: método, rota padrão, status, resultado.
Traces com OpenTelemetry
Um trace é a árvore de uma requisição. Cada etapa é um span, com início, duração, atributos e um pai. O OpenTelemetry é o padrão aberto para isso. Para ver a estrutura sem precisar de um servidor de traces, o exemplo guarda os spans em memória:
Repare na ordem: um span só é "finalizado" quando termina, então os filhos aparecem antes da raiz. Em produção, em vez do exportador de memória, você configura um exportador OTLP que envia os spans a um coletor (Jaeger, Tempo, Datadog), e as bibliotecas de instrumentação automática (para FastAPI, SQLAlchemy e httpx) criam os spans de entrada e de saída sem você escrever nada.
O que medir: RED e saúde
Para um serviço que atende requisições, eu começo pelo método RED:
Letra
Métrica
Pergunta
Rate
Requisições por segundo
Quanto tráfego existe?
Errors
Proporção de respostas 5xx
Está falhando?
Duration
Percentis de latência (p95, p99)
Está lento?
E dois endpoints de saúde com papéis diferentes: liveness ("o processo está vivo?", se falhar o orquestrador reinicia) e readiness ("posso receber tráfego?", se falhar ele para de enviar requisições). Um banco fora do ar deve derrubar a readiness, e não a liveness: reiniciar a API não conserta o banco.
Alerte sobre sintomas
Um alerta bom acorda alguém por algo que o usuário sente: taxa de erro alta, latência acima do objetivo. Alertar para "CPU em 80%" gera ruído. Eu defino um objetivo (por exemplo, 99% das requisições abaixo de 500 ms) e alerto quando o orçamento de erro está sendo gasto rápido demais.
Exercício 1
Contar requisições por rota
Crie um middleware que incremente um Counter com o rótulo rota a cada requisição e mostre, com get_sample_value, que duas chamadas a /ping resultam em 2.0.
Este capítulo junta tudo o que os anteriores ensinaram em uma API de pedidos que funciona de ponta a ponta. Cada arquivo abaixo foi executado: os testes passam contra SQLite e contra PostgreSQL, e o mypy estrito e o ruff aprovam o código.
Os arquivos deste capítulo estão em exemplos/api_pedidos/.
O que a API faz
É uma API de pedidos com cinco rotas, pensada para mostrar decisões de produção, e não só rotas que funcionam:
Rota
O que faz
Detalhe de produção
POST /pedidos
Cria um pedido
Idempotency-Key evita pedido duplicado
GET /pedidos/{id}
Lê um pedido
Erro 404 no formato problem+json
GET /pedidos
Lista com paginação
Cursor, e não deslocamento
POST /pedidos/{id}/cancelar
Cancela
409 se já estiver cancelado
GET /saude e GET /metricas
Saúde e métricas
Para o orquestrador e o Prometheus
A estrutura em camadas
Quem depende de quem
src/api_pedidos/
app.py HTTP: rotas, status, erros (conhece FastAPI)
servico.py Regras de negócio (não conhece HTTP)
repositorio.py Consultas ao banco (conhece SQLAlchemy)
modelos.py Tabelas (SQLAlchemy)
esquemas.py Contrato de entrada e saída (Pydantic)
config.py Configuração do ambiente (pydantic-settings)
db.py Engine e sessão
observabilidade.py Logs JSON, id de requisição, métricas
migrations/ Histórico do esquema (Alembic)
tests/ Testes de API, de serviço e de migração
A seta de dependência vai sempre de cima para baixo: a rota chama o serviço, o serviço chama o repositório. O serviço não importa o FastAPI, e por isso dá para testá-lo sem HTTP e reaproveitá-lo em uma CLI ou em um worker.
Dependências e ferramentas
O pyproject.toml declara as dependências de produção, as de desenvolvimento (em um grupo separado, que o Dockerfile não instala) e a configuração do pytest, do ruff e do mypy:
exemplos/api_pedidos/pyproject.toml
[build-system]requires=["hatchling"]build-backend="hatchling.build"[project]name="api-pedidos"version="0.1.0"description="API de pedidos do livro Python na Prática"readme="README.md"requires-python=">=3.12"dependencies=["fastapi>=0.115","uvicorn>=0.30","sqlalchemy>=2.0","alembic>=1.13","psycopg[binary]>=3.2","pydantic-settings>=2.4","prometheus-client>=0.20",][dependency-groups]dev=["pytest>=8","httpx2>=2.0","mypy>=1.10","ruff>=0.6",][tool.pytest.ini_options]testpaths=["tests"][tool.ruff]line-length=100target-version="py312"[tool.ruff.lint]select=["E","F","I","B","UP"][tool.mypy]python_version="3.12"strict=trueplugins=["pydantic.mypy"]files=["src"]
Configuração e banco
A configuração é a do capítulo 58, e a guarda prod com SQLite impede o erro mais comum de deploy:
exemplos/api_pedidos/src/api_pedidos/config.py
fromfunctoolsimportlru_cachefromtypingimportLiteral,SelffrompydanticimportSecretStr,model_validatorfrompydantic_settingsimportBaseSettings,SettingsConfigDictclassConfiguracao(BaseSettings):model_config=SettingsConfigDict(env_prefix="API_",env_file=".env",extra="ignore")ambiente:Literal["dev","test","prod"]="dev"database_url:SecretStr=SecretStr("sqlite:///./dev.db")log_nivel:str="INFO"@model_validator(mode="after")defexigir_banco_de_servidor_em_producao(self)->Self:ifself.ambiente=="prod"andself.database_url.get_secret_value().startswith("sqlite"):raiseValueError("produção exige um banco de dados de servidor, não SQLite")returnself@lru_cachedefobter_configuracao()->Configuracao:returnConfiguracao()
O modelo declara as restrições do banco: chave única de idempotência, índice por cliente e chave estrangeira com ON DELETE CASCADE. O dinheiro fica em centavos inteiros:
Os esquemas Pydantic são o contrato: validam a entrada (quantidade positiva, no mínimo um item) e definem exatamente o que a saída contém. O FastAPI deriva a documentação OpenAPI deles:
O repositório só sabe consultar. O serviço decide. Repare em criar: a verificação da chave e o tratamento do IntegrityError coexistem, porque a verificação evita o trabalho na maioria das vezes e a restrição UNIQUE do banco garante a correção quando duas requisições disputam a mesma chave:
fromsqlalchemy.excimportIntegrityErrorfromsqlalchemy.ormimportSessionfromapi_pedidos.esquemasimportPedidoCriarfromapi_pedidos.modelosimportItemPedido,Pedidofromapi_pedidos.repositorioimportRepositorioPedidosclassPedidoNaoEncontrado(Exception):def__init__(self,pedido_id:int)->None:super().__init__(f"pedido {pedido_id} não encontrado")self.pedido_id=pedido_idclassPedidoJaCancelado(Exception):def__init__(self,pedido_id:int)->None:super().__init__(f"pedido {pedido_id} já está cancelado")self.pedido_id=pedido_idclassServicoPedidos:def__init__(self,sessao:Session)->None:self._sessao=sessaoself._repositorio=RepositorioPedidos(sessao)defcriar(self,dados:PedidoCriar,chave:str|None=None)->tuple[Pedido,bool]:"""Devolve o pedido e um indicador de criação (False quando a chave já existia)."""ifchaveand(existente:=self._repositorio.por_chave(chave)):returnexistente,Falsepedido=Pedido(cliente=dados.cliente,chave_idempotencia=chave,itens=[ItemPedido(**item.model_dump())foritemindados.itens],)try:self._repositorio.adicionar(pedido)self._sessao.commit()exceptIntegrityError:# Duas requisições com a mesma chave podem passar pela verificação acima ao mesmo# tempo. Quem garante a unicidade é o banco, e aqui tratamos o erro dele.self._sessao.rollback()ifchaveand(existente:=self._repositorio.por_chave(chave)):returnexistente,Falseraisereturnpedido,Truedefobter(self,pedido_id:int)->Pedido:pedido=self._repositorio.obter(pedido_id)ifpedidoisNone:raisePedidoNaoEncontrado(pedido_id)returnpedidodefcancelar(self,pedido_id:int)->Pedido:pedido=self.obter(pedido_id)ifpedido.status=="cancelado":raisePedidoJaCancelado(pedido_id)pedido.status="cancelado"self._sessao.commit()returnpedidodeflistar(self,cursor:int,limite:int)->tuple[list[Pedido],int|None]:# Busca um item a mais para saber se existe próxima página.encontrados=self._repositorio.listar(cursor,limite+1)pagina=encontrados[:limite]proximo=pagina[-1].idiflen(encontrados)>limiteelseNonereturnpagina,proximo
Observabilidade
O middleware atribui o X-Request-ID, mede a duração, conta a requisição por rota padrão (baixa cardinalidade) e escreve uma linha de log em JSON:
importjsonimportloggingimportsysfromcollections.abcimportAwaitable,CallablefromcontextvarsimportContextVarfromtimeimportperf_counterfromuuidimportuuid4fromfastapiimportFastAPI,Request,Responsefromprometheus_clientimportCONTENT_TYPE_LATEST,Counter,Histogram,generate_latestid_requisicao:ContextVar[str]=ContextVar("id_requisicao",default="-")REQUISICOES=Counter("api_requisicoes_total","Total de requisições",["metodo","rota","status"])LATENCIA=Histogram("api_latencia_segundos","Latência das requisições",["rota"])log=logging.getLogger("api")classFormatoJson(logging.Formatter):CAMPOS_EXTRAS=("metodo","rota","status","duracao_ms")defformat(self,record:logging.LogRecord)->str:dados:dict[str,object]={"nivel":record.levelname,"mensagem":record.getMessage(),"id_requisicao":id_requisicao.get(),}forcampoinself.CAMPOS_EXTRAS:ifhasattr(record,campo):dados[campo]=getattr(record,campo)returnjson.dumps(dados,ensure_ascii=False)defconfigurar_logs(nivel:str="INFO")->None:manipulador=logging.StreamHandler(sys.stdout)manipulador.setFormatter(FormatoJson())log.handlers=[manipulador]log.setLevel(nivel)log.propagate=Falsedefinstalar_observabilidade(app:FastAPI)->None:@app.middleware("http")asyncdefmedir(request:Request,call_next:Callable[[Request],Awaitable[Response]])->Response:identificador=request.headers.get("X-Request-ID")oruuid4().hextoken=id_requisicao.set(identificador)inicio=perf_counter()status=500try:resposta=awaitcall_next(request)status=resposta.status_codefinally:duracao=perf_counter()-iniciorota_encontrada=request.scope.get("route")rota=getattr(rota_encontrada,"path","desconhecida")REQUISICOES.labels(request.method,rota,str(status)).inc()LATENCIA.labels(rota).observe(duracao)log.info("requisição concluída",extra={"metodo":request.method,"rota":rota,"status":status,"duracao_ms":round(duracao*1000,2),},)id_requisicao.reset(token)resposta.headers["X-Request-ID"]=identificadorreturnresposta@app.get("/metricas",include_in_schema=False)defmetricas()->Response:returnResponse(generate_latest(),media_type=CONTENT_TYPE_LATEST)
A aplicação
A rota só traduz HTTP. Os erros do domínio viram problem+json em um único lugar, e a função criar_app recebe o engine como parâmetro, o que permite aos testes injetarem um banco descartável:
exemplos/api_pedidos/src/api_pedidos/app.py
fromtypingimportAnnotatedfromfastapiimportDepends,FastAPI,Header,Query,Request,Responsefromfastapi.responsesimportJSONResponsefromsqlalchemyimportEngine,textfromsqlalchemy.ormimportSessionfromapi_pedidos.configimportobter_configuracaofromapi_pedidos.dbimportcriar_engine,criar_fabrica_de_sessoes,obter_sessaofromapi_pedidos.esquemasimportPagina,PedidoCriar,PedidoLerfromapi_pedidos.observabilidadeimportconfigurar_logs,instalar_observabilidadefromapi_pedidos.servicoimportPedidoJaCancelado,PedidoNaoEncontrado,ServicoPedidosSessaoDep=Annotated[Session,Depends(obter_sessao)]defobter_servico(sessao:SessaoDep)->ServicoPedidos:returnServicoPedidos(sessao)ServicoDep=Annotated[ServicoPedidos,Depends(obter_servico)]defproblema(status:int,titulo:str,detalhe:str)->JSONResponse:"""Formato de erro único para toda a API (inspirado no RFC 9457)."""returnJSONResponse({"titulo":titulo,"status":status,"detalhe":detalhe},status_code=status,media_type="application/problem+json",)defcriar_app(engine:Engine|None=None)->FastAPI:config=obter_configuracao()configurar_logs(config.log_nivel)engine=engineorcriar_engine(config.database_url.get_secret_value())app=FastAPI(title="API de pedidos",version="0.1.0")app.state.engine=engineapp.state.fabrica=criar_fabrica_de_sessoes(engine)instalar_observabilidade(app)@app.exception_handler(PedidoNaoEncontrado)defnao_encontrado(_:Request,erro:PedidoNaoEncontrado)->JSONResponse:returnproblema(404,"Pedido não encontrado",str(erro))@app.exception_handler(PedidoJaCancelado)defja_cancelado(_:Request,erro:PedidoJaCancelado)->JSONResponse:returnproblema(409,"Conflito de estado",str(erro))@app.post("/pedidos",response_model=PedidoLer,status_code=201)defcriar_pedido(dados:PedidoCriar,servico:ServicoDep,resposta:Response,idempotency_key:Annotated[str|None,Header(max_length=80)]=None,)->object:pedido,criado=servico.criar(dados,idempotency_key)ifnotcriado:resposta.status_code=200returnpedido@app.get("/pedidos/{pedido_id}",response_model=PedidoLer)defobter_pedido(pedido_id:int,servico:ServicoDep)->object:returnservico.obter(pedido_id)@app.get("/pedidos",response_model=Pagina)deflistar_pedidos(servico:ServicoDep,limite:Annotated[int,Query(ge=1,le=100)]=20,cursor:Annotated[int,Query(ge=0)]=0,)->object:pagina,proximo=servico.listar(cursor,limite)return{"itens":pagina,"proximo_cursor":proximo}@app.post("/pedidos/{pedido_id}/cancelar",response_model=PedidoLer)defcancelar_pedido(pedido_id:int,servico:ServicoDep)->object:returnservico.cancelar(pedido_id)@app.get("/saude")defsaude(sessao:SessaoDep)->JSONResponse:try:sessao.execute(text("SELECT 1"))exceptException:returnJSONResponse({"banco":"indisponível"},status_code=503)returnJSONResponse({"banco":"ok"})returnapp
Os testes
Os testes de API usam o banco real (SQLite em memória por padrão). Defina TEST_DATABASE_URL para rodar a mesma suíte contra o PostgreSQL, e o que passa nos dois é o que dá confiança:
exemplos/api_pedidos/tests/conftest.py
importosfromcollections.abcimportIteratorimportpytestfromfastapi.testclientimportTestClientfromsqlalchemyimportEngine,create_enginefromsqlalchemy.poolimportStaticPoolfromapi_pedidos.appimportcriar_appfromapi_pedidos.modelosimportBase@pytest.fixturedefengine()->Iterator[Engine]:"""SQLite em memória por padrão. Defina TEST_DATABASE_URL para testar contra PostgreSQL."""url=os.environ.get("TEST_DATABASE_URL")ifurl:eng=create_engine(url)else:eng=create_engine("sqlite://",connect_args={"check_same_thread":False},poolclass=StaticPool)Base.metadata.drop_all(eng)Base.metadata.create_all(eng)yieldengBase.metadata.drop_all(eng)eng.dispose()@pytest.fixturedefcliente(engine:Engine)->TestClient:returnTestClient(criar_app(engine))
exemplos/api_pedidos/tests/test_api.py
fromfastapi.testclientimportTestClientCORPO={"cliente":"Ana","itens":[{"produto":"caneta","quantidade":2,"preco_centavos":350},{"produto":"caderno","quantidade":1,"preco_centavos":1890},],}deftest_criar_pedido(cliente:TestClient)->None:resposta=cliente.post("/pedidos",json=CORPO)assertresposta.status_code==201dados=resposta.json()assertdados["total_centavos"]==2590assertdados["status"]=="aberto"assertlen(dados["itens"])==2deftest_idempotencia_devolve_o_mesmo_pedido(cliente:TestClient)->None:cabecalho={"Idempotency-Key":"abc-123"}primeira=cliente.post("/pedidos",json=CORPO,headers=cabecalho)segunda=cliente.post("/pedidos",json=CORPO,headers=cabecalho)assertprimeira.status_code==201assertsegunda.status_code==200assertprimeira.json()["id"]==segunda.json()["id"]assertlen(cliente.get("/pedidos").json()["itens"])==1deftest_validacao_recusa_corpo_invalido(cliente:TestClient)->None:resposta=cliente.post("/pedidos",json={"cliente":"","itens":[]})assertresposta.status_code==422deftest_pedido_inexistente_devolve_problema(cliente:TestClient)->None:resposta=cliente.get("/pedidos/999")assertresposta.status_code==404assertresposta.headers["content-type"].startswith("application/problem+json")assertresposta.json()["titulo"]=="Pedido não encontrado"deftest_cancelar_duas_vezes_devolve_conflito(cliente:TestClient)->None:pedido_id=cliente.post("/pedidos",json=CORPO).json()["id"]assertcliente.post(f"/pedidos/{pedido_id}/cancelar").json()["status"]=="cancelado"assertcliente.post(f"/pedidos/{pedido_id}/cancelar").status_code==409deftest_paginacao_por_cursor(cliente:TestClient)->None:for_inrange(5):cliente.post("/pedidos",json=CORPO)primeira=cliente.get("/pedidos",params={"limite":2}).json()assertlen(primeira["itens"])==2assertprimeira["proximo_cursor"]==2ultima=cliente.get("/pedidos",params={"limite":2,"cursor":4}).json()assert[p["id"]forpinultima["itens"]]==[5]assertultima["proximo_cursor"]isNonedeftest_saude_e_metricas(cliente:TestClient)->None:assertcliente.get("/saude").json()=={"banco":"ok"}cliente.get("/pedidos")assert"api_requisicoes_total"incliente.get("/metricas").textdeftest_id_de_requisicao_e_devolvido(cliente:TestClient)->None:resposta=cliente.get("/saude",headers={"X-Request-ID":"req-42"})assertresposta.headers["x-request-id"]=="req-42"
O teste de serviço reproduz a corrida de duas requisições com a mesma chave: ele força a verificação a "não ver" o pedido existente, e prova que o IntegrityError do banco leva ao pedido original, em vez de um erro 500:
exemplos/api_pedidos/tests/test_servico.py
importpytestfromsqlalchemyimportEnginefromsqlalchemy.ormimportSessionfromapi_pedidos.esquemasimportItemCriar,PedidoCriarfromapi_pedidos.modelosimportPedidofromapi_pedidos.repositorioimportRepositorioPedidosfromapi_pedidos.servicoimportPedidoNaoEncontrado,ServicoPedidosDADOS=PedidoCriar(cliente="Ana",itens=[ItemCriar(produto="caneta",quantidade=1,preco_centavos=100)])deftest_obter_pedido_inexistente(engine:Engine)->None:withSession(engine)assessao,pytest.raises(PedidoNaoEncontrado):ServicoPedidos(sessao).obter(1)deftest_corrida_de_chaves_devolve_o_pedido_existente(engine:Engine,monkeypatch:pytest.MonkeyPatch)->None:withSession(engine)assessao:original,criado=ServicoPedidos(sessao).criar(DADOS,"chave-1")assertcriadooriginal_id=original.idchamadas={"total":0}por_chave_real=RepositorioPedidos.por_chavedefpor_chave_atrasada(self:RepositorioPedidos,chave:str)->Pedido|None:chamadas["total"]+=1ifchamadas["total"]==1:returnNone# simula a outra requisição ainda não ter gravadoreturnpor_chave_real(self,chave)monkeypatch.setattr(RepositorioPedidos,"por_chave",por_chave_atrasada)withSession(engine)assessao:pedido,criado=ServicoPedidos(sessao).criar(DADOS,"chave-1")assertcriadoisFalseassertpedido.id==original_id
O teste de migração garante que os modelos e as migrações não divergiram: ele aplica as migrações em um banco vazio e roda o equivalente a alembic check. Se alguém mudar um modelo sem criar a migração, a suíte falha:
exemplos/api_pedidos/tests/test_migracoes.py
frompathlibimportPathimportpytestfromalembicimportcommandfromalembic.configimportConfigfromapi_pedidos.configimportobter_configuracaoRAIZ=Path(__file__).resolve().parent.parentdeftest_migracoes_estao_sincronizadas_com_os_modelos(tmp_path:Path,monkeypatch:pytest.MonkeyPatch)->None:monkeypatch.setenv("API_DATABASE_URL",f"sqlite:///{tmp_path/'migracao.db'}")obter_configuracao.cache_clear()cfg=Config(str(RAIZ/"alembic.ini"))try:command.upgrade(cfg,"head")command.check(cfg)# falha se os modelos mudaram sem uma migração correspondentefinally:obter_configuracao.cache_clear()
Rodar tudo
Terminal
cdexemplos/api_pedidos
uvsync
uvrunpytest
Saída
........... [100%]
11 passed
Contra o PostgreSQL, com a mesma suíte (a variável aponta para um banco de teste descartável):
Um pedido inexistente devolve o erro no formato único, e cancelar duas vezes devolve 409:
Respostas (execução real, com PostgreSQL)
GET /pedidos/9999 -> 404 {"titulo":"Pedido não encontrado","status":404,"detalhe":"pedido 9999 não encontrado"}
POST /pedidos/1/cancelar -> 200 (status "cancelado")
POST /pedidos/1/cancelar -> 409
As decisões, e o porquê
Decisão
Alternativa
Por que escolhi assim
Dinheiro em centavos inteiros
float, ou Decimal
Sem erro de arredondamento e sem conversão no banco
Paginação por cursor
Por deslocamento
Estável sob escrita concorrente, e usa o índice da chave
Idempotência com chave e UNIQUE
Só verificar antes
A verificação sozinha tem corrida. Quem garante é o banco
Erro em problem+json
Texto livre por rota
O cliente trata por tipo, e não por texto
SQLAlchemy síncrono
async
O FastAPI roda rotas def em threads, e o driver síncrono é mais simples e basta para esta carga
Serviço sem FastAPI
Regra dentro da rota
Testável sem HTTP e reutilizável
Migração como etapa separada
Migrar na partida da API
Duas réplicas não disputam a migração
O que quebra primeiro com dez vezes mais carga
Eu aplico a revisão que faço em qualquer serviço antes de aprová-lo:
Conexões com o banco. O pool padrão do SQLAlchemy tem 5 conexões e 10 de excedente. Com muitas threads e consultas lentas, as requisições esperam por uma conexão. É o primeiro gargalo, e se mede observando o tempo de espera.
Tabela de chaves de idempotência. Ela só cresce. Em produção, as chaves precisam expirar (uma rotina que apaga as antigas).
Sem autenticação nem limite de requisições. Esta API é aberta. O próximo passo real é OAuth2 com JWT e um limitador de taxa.
Sem tempo limite nas consultas. Uma consulta lenta segura uma conexão. Configure statement_timeout no PostgreSQL.
Eventos fora da transação. Se o pedido precisar avisar outro sistema, gravar no banco e publicar uma mensagem não é atômico. O padrão para isso é a outbox: gravar o evento na mesma transação e publicá-lo depois.
O que este projeto não prova
Os testes mostram que o código está correto e coerente com o banco. Eles não mostram que a imagem Docker constrói no seu ambiente nem que o sistema aguenta a carga real. Para isso, o passo seguinte é um teste de carga (com locust ou k6) contra um ambiente parecido com o de produção.
Capítulo 63, parte Projetos
Projeto Básico: gerenciador de despesas
Os exercícios pequenos fixam a sintaxe. Um projeto de verdade ensina outra coisa: decidir o que vai em cada função, o que persiste e o que acontece quando a entrada é ruim. Faça este depois do capítulo 25.
Os arquivos deste capítulo estão em projetos/despesas/.
O que você vai construir
Um programa de terminal para registrar despesas, listar, ver o total por categoria e remover. Os dados ficam em um arquivo e sobrevivem entre as execuções. Tudo usa o que a parte Básico ensinou (funções, listas, dicionários, exceções, arquivos, módulos), mais o módulo json, que o capítulo 39 detalha.
Requisito
Conceito que treina
Registrar descrição, valor e categoria
Funções, validação, exceções
Guardar o valor em centavos
Tipos (nunca float para dinheiro)
Persistir em despesas.json
Arquivos, encoding, json
Resumo por categoria
Dicionários, ordenação
Recusar entrada inválida sem cair
try/except com mensagens claras
Um arquivo de autoteste
assert, funções como objetos
Como eu pensaria antes de digitar
Separe o que calcula do o que conversa com a pessoa. As funções ler_valor, adicionar, total_por_categoria e remover não usam input nem print: recebem dados e devolvem dados (ou levantam ValueError). Só mostrar_lista, mostrar_resumo e main interagem. Essa divisão é o que permite testá-las sem digitar nada, e é a mesma ideia de separar a regra do mundo externo que reaparece até no capítulo 51.
Três decisões que valem a pena notar no código:
Centavos inteiros.12,50 vira 1250. Somar 0,10 + 0,20 em float não dá 0,30, e em um sistema financeiro isso é bug.
Validar na entrada.adicionar recusa descrição vazia, categoria desconhecida e valor não positivo, e main mostra o motivo em vez de quebrar.
Gravar a cada mudança. Se o programa fechar de repente, nada se perde.
O código
projetos/despesas/despesas.py
"""Gerenciador de despesas em linha de comando (projeto da parte Básico).Usa o que foi visto até o capítulo 25, mais o módulo json da biblioteca padrão.Os valores ficam em centavos (inteiros), para nunca somar centavos com float."""importjsonfromdatetimeimportdatefrompathlibimportPathARQUIVO=Path("despesas.json")CATEGORIAS=["alimentação","transporte","moradia","lazer","outros"]defcarregar(caminho):"""Lê as despesas do arquivo. Se ele ainda não existir, devolve uma lista vazia."""try:withopen(caminho,encoding="utf-8")asarquivo:returnjson.load(arquivo)exceptFileNotFoundError:return[]defsalvar(caminho,despesas):withopen(caminho,"w",encoding="utf-8")asarquivo:json.dump(despesas,arquivo,ensure_ascii=False,indent=2)defler_valor(texto):"""Converte '12,50' em centavos (1250). Levanta ValueError se o valor for inválido."""try:valor=float(texto.replace(",","."))exceptValueErroraserro:raiseValueError(f"valor inválido: {texto!r}")fromerroifvalor<=0:raiseValueError("o valor deve ser positivo")returnround(valor*100)defformatar_reais(centavos):reais,resto=divmod(centavos,100)returnf"R$ {reais:,}".replace(",",".")+f",{resto:02d}"defadicionar(despesas,descricao,valor_texto,categoria,data=None):ifnotdescricao.strip():raiseValueError("a descrição não pode ser vazia")ifcategorianotinCATEGORIAS:raiseValueError(f"categoria inválida: {categoria!r}")despesa={"descricao":descricao.strip(),"centavos":ler_valor(valor_texto),"categoria":categoria,"data":dataordate.today().isoformat(),}despesas.append(despesa)returndespesadefremover(despesas,posicao):ifnot1<=posicao<=len(despesas):raiseValueError(f"não existe a despesa número {posicao}")returndespesas.pop(posicao-1)deftotal_por_categoria(despesas):totais={}fordespesaindespesas:categoria=despesa["categoria"]totais[categoria]=totais.get(categoria,0)+despesa["centavos"]returntotaisdefmostrar_lista(despesas):ifnotdespesas:print("Nenhuma despesa registrada.")returnforposicao,dinenumerate(despesas,start=1):print(f"{posicao}. {d['data']}{d['descricao']:<20}{formatar_reais(d['centavos']):>12} [{d['categoria']}]")defmostrar_resumo(despesas):totais=total_por_categoria(despesas)forcategoriainsorted(totais,key=totais.get,reverse=True):print(f"{categoria:<12}{formatar_reais(totais[categoria]):>12}")print(f"{'total':<12}{formatar_reais(sum(totais.values())):>12}")defmain():despesas=carregar(ARQUIVO)whileTrue:print("\n1) Adicionar 2) Listar 3) Resumo 4) Remover 0) Sair")opcao=input("Escolha: ").strip()ifopcao=="1":descricao=input("Descrição: ")valor=input("Valor (ex.: 12,50): ")print("Categorias:",", ".join(CATEGORIAS))categoria=input("Categoria: ").strip().lower()try:adicionar(despesas,descricao,valor,categoria)exceptValueErroraserro:print(f"Não foi possível adicionar: {erro}")else:salvar(ARQUIVO,despesas)print("Despesa registrada.")elifopcao=="2":mostrar_lista(despesas)elifopcao=="3":mostrar_resumo(despesas)elifopcao=="4":try:remover(despesas,int(input("Número da despesa: ")))exceptValueErroraserro:print(f"Não foi possível remover: {erro}")else:salvar(ARQUIVO,despesas)print("Despesa removida.")elifopcao=="0":breakelse:print("Opção inválida.")if__name__=="__main__":main()
O autoteste
Sem pytest ainda (ele só aparece no capítulo 40), o teste usa assert e uma pequena convenção: todo nome que começa com testar_ é uma função de teste, e o laço final as executa. Repare que ele usa uma pasta temporária, e por isso nunca mexe no seu despesas.json de verdade:
projetos/despesas/testar_despesas.py
"""Autoteste do gerenciador de despesas. Rode com: python testar_despesas.py"""importtempfilefrompathlibimportPathimportdespesasdeftestar_ler_valor():assertdespesas.ler_valor("12,50")==1250assertdespesas.ler_valor("3")==300forinvalidoin("abc","0","-5"):try:despesas.ler_valor(invalido)exceptValueError:passelse:raiseAssertionError(f"{invalido!r} deveria falhar")deftestar_formatar_reais():assertdespesas.formatar_reais(1250)=="R$ 12,50"assertdespesas.formatar_reais(123456)=="R$ 1.234,56"assertdespesas.formatar_reais(5)=="R$ 0,05"deftestar_adicionar_e_total():lista=[]despesas.adicionar(lista,"Almoço","32,90","alimentação","2026-10-06")despesas.adicionar(lista,"Jantar","20,00","alimentação","2026-10-06")despesas.adicionar(lista,"Ônibus","4,50","transporte","2026-10-06")assertdespesas.total_por_categoria(lista)=={"alimentação":5290,"transporte":450}deftestar_adicionar_recusa_dados_invalidos():lista=[]forargumentosin(("","10","lazer"),("Cinema","10","inexistente"),("Cinema","x","lazer")):try:despesas.adicionar(lista,*argumentos)exceptValueError:passelse:raiseAssertionError(f"{argumentos} deveria falhar")assertlista==[]deftestar_remover():lista=[{"descricao":"a","centavos":1,"categoria":"outros","data":"x"}]assertdespesas.remover(lista,1)["descricao"]=="a"assertlista==[]try:despesas.remover(lista,1)exceptValueError:passelse:raiseAssertionError("deveria falhar")deftestar_salvar_e_carregar():withtempfile.TemporaryDirectory()aspasta:caminho=Path(pasta)/"d.json"assertdespesas.carregar(caminho)==[]dados=[{"descricao":"Pão","centavos":850,"categoria":"alimentação","data":"2026-10-06"}]despesas.salvar(caminho,dados)assertdespesas.carregar(caminho)==dadosif__name__=="__main__":testes=[nomefornomeindir()ifnome.startswith("testar_")andcallable(globals()[nome])]fornomeintestes:globals()[nome]()print(f"ok {nome}")print(f"{len(testes)} testes passaram")
Rodar
Terminal
cdprojetos/despesas
python3testar_despesas.py
Saída
ok testar_adicionar_e_total
ok testar_adicionar_recusa_dados_invalidos
ok testar_formatar_reais
ok testar_ler_valor
ok testar_remover
ok testar_salvar_e_carregar
6 testes passaram
Terminal
python3despesas.py
Uma sessão real, depois de registrar um almoço, um ônibus e um jantar (a tentativa com o valor abc foi recusada com a mensagem Não foi possível adicionar: valor inválido: 'abc'):
Filtrar por mês. Peça um mês (2026-10) e liste só as despesas dele, usando o campo data como texto.
Orçamento. Guarde um limite mensal por categoria e avise, no resumo, quando uma categoria passou dele.
Exportar para CSV. Use o módulo csv (capítulo 39) para gerar despesas.csv com cabeçalho.
Trocar o menu por argumentos. Reescreva a interface com argparse (capítulo 41), de modo que python3 despesas.py adicionar "Almoço" 32,90 alimentação funcione sem menu.
Quando considero o projeto pronto
Se eu apago o despesas.json e o programa continua funcionando, se uma entrada inválida nunca derruba o programa, e se o autoteste passa depois de qualquer mudança que eu faça, o projeto está pronto. O passo seguinte, no capítulo 64, é reescrevê-lo como um pacote de verdade.
Capítulo 64, parte Projetos
Projeto Intermediário: CLI de tarefas
Aqui você escreve o mesmo tipo de programa do projeto anterior, agora como um profissional escreveria: um pacote instalável, com camadas, tipos, testes e logs. Faça depois do capítulo 41.
Os arquivos deste capítulo estão em projetos/tarefas/.
O que você vai construir
Uma CLI tarefas com os subcomandos adicionar, listar, concluir e remover, instalável como um comando de terminal. A diferença para o projeto Básico não está no que ela faz, e sim em como está organizada.
Requisito
Conceito que treina
Capítulo
Modelo Tarefa
dataclass, Self, asdict
36, 37
Armazenamento trocável (JSON ou memória)
Protocol, composição
44
Regras isoladas em um serviço
Camadas, exceções do domínio
23, 51
Subcomandos com argparse
CLI testável
41
Logs com -v
logging
41
Testes com pytest
Fixtures, tmp_path, capsys
40
Pacote com comando próprio
pyproject.toml, entry points
49
A arquitetura em três camadas
Quem conhece quem
cli.py lê argumentos, imprime, devolve o código de saída
servico.py regras: título não vazio, ids sequenciais, tarefa inexistente
armazenamento.py JSON em disco, ou memória (para testes)
modelo.py a dataclass Tarefa
O serviço recebe o armazenamento no construtor. Por isso os testes do serviço usam o ArmazenamentoEmMemoria, rápido e sem tocar o disco, e os testes de integração usam o ArmazenamentoJson com uma pasta temporária. Trocar o JSON por SQLite amanhã exige escrever uma classe nova, e nada mais muda.
O pacote
O pyproject.toml declara o comando tarefas em [project.scripts]. Depois de instalado, tarefas listar funciona em qualquer pasta:
projetos/tarefas/pyproject.toml
[build-system]requires=["hatchling"]build-backend="hatchling.build"[project]name="tarefas"version="0.1.0"description="Gerenciador de tarefas em linha de comando (projeto da parte Intermediário)"readme="README.md"requires-python=">=3.12"dependencies=[][project.scripts]tarefas="tarefas.cli:main"[dependency-groups]dev=["pytest>=8","mypy>=1.10","ruff>=0.6"][tool.ruff]line-length=100target-version="py312"[tool.ruff.lint]select=["E","F","I","B","UP"][tool.mypy]python_version="3.12"strict=truefiles=["src"]
O código
O modelo é uma dataclass com conversão de e para dicionário:
O armazenamento é descrito por um Protocol, com duas implementações. Repare no salvar do JSON: ele grava em um arquivo temporário e depois troca, e assim uma queda no meio da gravação nunca deixa o arquivo corrompido:
projetos/tarefas/src/tarefas/armazenamento.py
importjsonfrompathlibimportPathfromtypingimportProtocolfromtarefas.modeloimportTarefaclassArmazenamento(Protocol):defcarregar(self)->list[Tarefa]:...defsalvar(self,tarefas:list[Tarefa])->None:...classArmazenamentoJson:def__init__(self,caminho:Path)->None:self._caminho=caminhodefcarregar(self)->list[Tarefa]:ifnotself._caminho.exists():return[]dados=json.loads(self._caminho.read_text(encoding="utf-8"))return[Tarefa.de_dict(item)foritemindados]defsalvar(self,tarefas:list[Tarefa])->None:texto=json.dumps([t.para_dict()fortintarefas],ensure_ascii=False,indent=2)# Grava em um arquivo temporário e troca no final: se o programa cair no meio,# o arquivo original continua íntegro.temporario=self._caminho.with_suffix(".tmp")temporario.write_text(texto,encoding="utf-8")temporario.replace(self._caminho)classArmazenamentoEmMemoria:"""Usado nos testes: não toca o disco."""def__init__(self)->None:self._tarefas:list[Tarefa]=[]defcarregar(self)->list[Tarefa]:return[Tarefa.de_dict(t.para_dict())fortinself._tarefas]defsalvar(self,tarefas:list[Tarefa])->None:self._tarefas=[Tarefa.de_dict(t.para_dict())fortintarefas]
O serviço concentra as regras e registra o que fez em logs:
projetos/tarefas/src/tarefas/servico.py
importloggingfromtarefas.armazenamentoimportArmazenamentofromtarefas.modeloimportTarefalog=logging.getLogger(__name__)classTarefaNaoEncontrada(Exception):def__init__(self,tarefa_id:int)->None:super().__init__(f"tarefa {tarefa_id} não encontrada")self.tarefa_id=tarefa_idclassServicoTarefas:def__init__(self,armazenamento:Armazenamento)->None:self._armazenamento=armazenamentodefadicionar(self,titulo:str)->Tarefa:titulo=titulo.strip()ifnottitulo:raiseValueError("o título não pode ser vazio")tarefas=self._armazenamento.carregar()proximo_id=max((t.idfortintarefas),default=0)+1tarefa=Tarefa(id=proximo_id,titulo=titulo)self._armazenamento.salvar([*tarefas,tarefa])log.info("tarefa %d adicionada",tarefa.id)returntarefadeflistar(self,*,somente_pendentes:bool=False)->list[Tarefa]:tarefas=self._armazenamento.carregar()return[tfortintarefasifnott.concluida]ifsomente_pendenteselsetarefasdefconcluir(self,tarefa_id:int)->Tarefa:tarefas=self._armazenamento.carregar()tarefa=self._buscar(tarefas,tarefa_id)tarefa.concluida=Trueself._armazenamento.salvar(tarefas)log.info("tarefa %d concluída",tarefa_id)returntarefadefremover(self,tarefa_id:int)->None:tarefas=self._armazenamento.carregar()tarefa=self._buscar(tarefas,tarefa_id)tarefas.remove(tarefa)self._armazenamento.salvar(tarefas)log.info("tarefa %d removida",tarefa_id)@staticmethoddef_buscar(tarefas:list[Tarefa],tarefa_id:int)->Tarefa:fortarefaintarefas:iftarefa.id==tarefa_id:returntarefaraiseTarefaNaoEncontrada(tarefa_id)
A CLI só traduz entre o terminal e o serviço. A função main recebe argv e o armazenamento como parâmetros (padrão do capítulo 41), e devolve um código de saída: 0 para sucesso, 1 para erro do usuário. O argparse já devolve 2 para argumentos inválidos:
projetos/tarefas/src/tarefas/cli.py
importargparseimportloggingfrompathlibimportPathfromtarefas.armazenamentoimportArmazenamento,ArmazenamentoJsonfromtarefas.servicoimportServicoTarefas,TarefaNaoEncontradadefcriar_parser()->argparse.ArgumentParser:parser=argparse.ArgumentParser(prog="tarefas",description="Gerenciador de tarefas")parser.add_argument("--arquivo",type=Path,default=Path("tarefas.json"))parser.add_argument("-v","--verboso",action="store_true",help="mostra os logs")sub=parser.add_subparsers(dest="comando",required=True)adicionar=sub.add_parser("adicionar",help="cria uma tarefa")adicionar.add_argument("titulo")listar=sub.add_parser("listar",help="mostra as tarefas")listar.add_argument("--pendentes",action="store_true")concluir=sub.add_parser("concluir",help="marca como concluída")concluir.add_argument("id",type=int)remover=sub.add_parser("remover",help="apaga uma tarefa")remover.add_argument("id",type=int)returnparserdefmain(argv:list[str]|None=None,armazenamento:Armazenamento|None=None)->int:args=criar_parser().parse_args(argv)logging.basicConfig(level=logging.INFOifargs.verbosoelselogging.WARNING,format="%(levelname)s%(name)s: %(message)s",force=True,)servico=ServicoTarefas(armazenamentoorArmazenamentoJson(args.arquivo))try:ifargs.comando=="adicionar":tarefa=servico.adicionar(args.titulo)print(f"Tarefa {tarefa.id} criada: {tarefa.titulo}")elifargs.comando=="listar":tarefas=servico.listar(somente_pendentes=args.pendentes)ifnottarefas:print("Nenhuma tarefa.")fortintarefas:marca="x"ift.concluidaelse" "print(f"[{marca}] {t.id}. {t.titulo}")elifargs.comando=="concluir":print(f"Tarefa {servico.concluir(args.id).id} concluída.")elifargs.comando=="remover":servico.remover(args.id)print(f"Tarefa {args.id} removida.")except(TarefaNaoEncontrada,ValueError)aserro:print(f"Erro: {erro}")return1return0
projetos/tarefas/src/tarefas/__main__.py
fromtarefas.cliimportmainraiseSystemExit(main())
Os testes
Os testes de serviço usam o armazenamento em memória e a fixture servico. O teste do armazenamento JSON confirma a ida e a volta pelo disco e que o arquivo temporário não sobra:
Os testes da CLI chamam main diretamente, com capsys para capturar a saída e tmp_path para isolar o arquivo. Um deles confirma o código de saída 2 do argparse:
INFO tarefas.servico: tarefa 3 adicionada
Tarefa 3 criada: com log
Erro: tarefa 99 não encontrada
Desafios
Prioridade e prazo. Acrescente prioridade e prazo à Tarefa e ordene a listagem por eles. Isso muda o modelo e os arquivos já gravados: pense em como ler um JSON antigo sem esses campos (um valor padrão em de_dict).
Armazenamento SQLite. Escreva ArmazenamentoSqlite com a mesma interface e rode os mesmos testes do serviço contra ele. Se você precisar mudar o serviço para isso, a abstração estava vazando.
Desfazer. Um comando desfazer que reverte a última operação.
Publicar. Gere o wheel com uv build e instale-o em um ambiente limpo, como o capítulo 49 mostrou.
O que este projeto ensina que o anterior não ensinava
No projeto Básico, tudo estava em um arquivo e era testado por uma função caseira. Aqui cada responsabilidade tem um lugar, as dependências são injetadas, o programa é testável sem terminal e o comando é instalável. Essa é a diferença entre um script e um pacote.
Capítulo 65, parte Projetos
Projeto Avançado: processador concorrente de dados
Um pipeline que lê muitos arquivos com concorrência limitada, calcula em vários processos, tolera falhas e prova isso com testes. Faça depois do capítulo 51.
Os arquivos deste capítulo estão em projetos/processador/.
O problema
Uma pasta com muitos arquivos CSV de vendas (regiao,valor_centavos). Você precisa somar tudo por região. Cada arquivo pode ser grande, alguns podem estar corrompidos e o disco pode falhar de vez em quando. O programa deve ser rápido, limitar quantos arquivos lê ao mesmo tempo e nunca deixar um arquivo ruim derrubar os outros.
Requisito
Decisão
Capítulo
Ler muitos arquivos sem bloquear
asyncio com to_thread
47
Limitar leituras simultâneas
Semaphore
47
Calcular em paralelo de verdade
ProcessPoolExecutor
46
Tolerar falha de leitura
Retentativa com espera crescente
51
Não esperar para sempre
asyncio.timeout
47
Uma falha não derruba as outras
Resultado ou Falha, nunca exceção solta
23
Rastrear cada arquivo nos logs
ContextVar e filtro de log
51
Código testável
Injeção da espera e da leitura
51
O desenho
O gargalo tem dois tipos, e cada um recebe a ferramenta certa: ler é espera (disco), então uma thread por arquivo, orquestradas por asyncio. Calcular é CPU, então processos. O asyncio coordena as duas etapas em uma única thread, e não há trava nem estado compartilhado.
O caminho de cada arquivo
CSV -> [semáforo] -> ler (thread, com retentativas e timeout)
|
v
agregar (outro processo) -> Resultado
|
erro em qualquer etapa -> Falha
Uma regra de desenho vale mais do que o código: cada arquivo vira um Resultado ou uma Falha, e a função que o processa captura os erros esperados. Assim o asyncio.gather nunca vê uma exceção, e um arquivo corrompido vira uma linha no relatório, e não um programa interrompido.
Os modelos
Os dados que cruzam as etapas são dataclasses imutáveis. O Relatorio soma por região:
agregar é uma função pura: recebe texto, devolve números, e levanta ValueError com o número da linha quando algo está errado. Ela precisa estar no nível do módulo e usar argumentos e retorno serializáveis, porque é enviada a outro processo:
projetos/processador/src/processador/calculo.py
importcsvimportiodefagregar(texto:str)->tuple[int,int,dict[str,int]]:"""Etapa de CPU: roda em outro processo. Devolve (linhas, total, total por região). Precisa ser uma função do nível do módulo, com argumentos e retorno serializáveis, para poder ser enviada a um processo filho. """leitor=csv.reader(io.StringIO(texto))cabecalho=next(leitor,None)ifcabecalho!=["regiao","valor_centavos"]:raiseValueError(f"cabeçalho inesperado: {cabecalho}")linhas=total=0por_regiao:dict[str,int]={}fornumero,linhainenumerate(leitor,start=2):try:regiao,valor_texto=linhavalor=int(valor_texto)exceptValueErroraserro:raiseValueError(f"linha {numero} inválida: {linha}")fromerropor_regiao[regiao]=por_regiao.get(regiao,0)+valortotal+=valorlinhas+=1returnlinhas,total,por_regiao
A leitura (a etapa de espera)
A leitura bloqueante vai para uma thread com asyncio.to_thread, para não parar o laço de eventos. As retentativas aumentam a espera a cada falha, e a função de espera e a de leitura são parâmetros, o que permite aos testes simularem falhas sem esperar de verdade:
projetos/processador/src/processador/leitura.py
importasyncioimportloggingfromcollections.abcimportAwaitable,CallablefrompathlibimportPathlog=logging.getLogger(__name__)defler_texto(caminho:Path)->str:returncaminho.read_text(encoding="utf-8")asyncdefler_com_retentativas(caminho:Path,*,tentativas:int=3,base:float=0.1,dormir:Callable[[float],Awaitable[None]]=asyncio.sleep,ler:Callable[[Path],str]=ler_texto,)->str:"""Lê o arquivo em uma thread, para não bloquear o laço de eventos, com espera crescente."""fornumeroinrange(1,tentativas+1):try:returnawaitasyncio.to_thread(ler,caminho)exceptOSErroraserro:ifnumero==tentativas:raiseespera=base*2**(numero-1)log.warning("leitura falhou (%s), tentativa %d, nova tentativa em %.2fs",erro,numero,espera)awaitdormir(espera)raiseAssertionError("inalcançável")
Contexto de log
Cada arquivo é processado em uma tarefa própria. Uma ContextVar guarda o nome do arquivo em andamento, e um filtro o acrescenta a toda linha de log daquela tarefa, sem passar o nome por parâmetro em cada chamada:
projetos/processador/src/processador/contexto.py
importloggingfromcontextvarsimportContextVarid_arquivo:ContextVar[str]=ContextVar("id_arquivo",default="-")classFiltroArquivo(logging.Filter):"""Acrescenta o nome do arquivo em processamento a cada linha de log."""deffilter(self,record:logging.LogRecord)->bool:record.arquivo=id_arquivo.get()returnTrue
O pipeline
Aqui está o coração. Repare em três coisas. A função criar_pool escolhe o método de início spawn de forma explícita: com fork, o processo filho herda as threads do asyncio.to_thread em estado inconsistente (o Python 3.12 já emite um aviso sobre isso, e o padrão muda conforme o sistema e a versão), e o spawn se comporta igual em todos. O Semaphore limita as leituras e o timeout limita o tempo por arquivo. E o except converte cada falha esperada em uma Falha:
projetos/processador/src/processador/pipeline.py
importasyncioimportloggingimportmultiprocessingfromcollections.abcimportAwaitable,Callablefromconcurrent.futuresimportExecutor,ProcessPoolExecutorfrompathlibimportPathfromprocessador.calculoimportagregarfromprocessador.contextoimportid_arquivofromprocessador.leituraimportler_com_retentativas,ler_textofromprocessador.modelosimportFalha,Relatorio,Resultadolog=logging.getLogger(__name__)defcriar_pool(processos:int)->ProcessPoolExecutor:"""Pool de processos com o método de início 'spawn' escolhido de forma explícita. O fork copiaria para o filho threads em estado inconsistente (como as do asyncio.to_thread), e o método padrão muda conforme o sistema e a versão do Python. O spawn se comporta igual em todos, ao custo de iniciar cada processo importando o código de novo. """returnProcessPoolExecutor(max_workers=processos,mp_context=multiprocessing.get_context("spawn"))asyncdefprocessar_arquivo(caminho:Path,*,semaforo:asyncio.Semaphore,pool:Executor,timeout:float,dormir:Callable[[float],Awaitable[None]],ler:Callable[[Path],str],)->Resultado|Falha:token=id_arquivo.set(caminho.name)try:asyncwithsemaforo:asyncwithasyncio.timeout(timeout):texto=awaitler_com_retentativas(caminho,dormir=dormir,ler=ler)laco=asyncio.get_running_loop()linhas,total,por_regiao=awaitlaco.run_in_executor(pool,agregar,texto)log.info("processado: %d linhas",linhas)returnResultado(caminho.name,linhas,total,por_regiao)except(OSError,TimeoutError,ValueError)aserro:motivo=str(erro)ortype(erro).__name__log.error("falhou: %s",motivo)returnFalha(caminho.name,motivo)finally:id_arquivo.reset(token)asyncdefprocessar(pasta:Path,*,pool:Executor,leituras:int=4,timeout:float=5.0,dormir:Callable[[float],Awaitable[None]]=asyncio.sleep,ler:Callable[[Path],str]=ler_texto,)->Relatorio:"""Lê vários arquivos com concorrência limitada e calcula em processos separados. Uma falha em um arquivo vira um registro de Falha e não derruba os demais. """semaforo=asyncio.Semaphore(leituras)saidas=awaitasyncio.gather(*(processar_arquivo(caminho,semaforo=semaforo,pool=pool,timeout=timeout,dormir=dormir,ler=ler)forcaminhoinsorted(pasta.glob("*.csv"))))relatorio=Relatorio()forsaidainsaidas:ifisinstance(saida,Resultado):relatorio.resultados.append(saida)else:relatorio.falhas.append(saida)returnrelatorio
A linha de comando
Os dados de exemplo são determinísticos (sem aleatoriedade), então o resultado é o mesmo em qualquer máquina e dá para conferir. O código de saída é 1 se algum arquivo falhou, o que permite usar o programa em scripts e no CI:
projetos/processador/src/processador/cli.py
importargparseimportasyncioimportcsvimportloggingimportsysfrompathlibimportPathfromprocessador.contextoimportFiltroArquivofromprocessador.modelosimportRelatoriofromprocessador.pipelineimportcriar_pool,processarREGIOES=["norte","nordeste","centro-oeste","sudeste","sul"]defgerar_dados(pasta:Path,arquivos:int,linhas:int)->None:"""Gera arquivos CSV determinísticos (sem aleatoriedade, para o resultado ser repetível)."""pasta.mkdir(parents=True,exist_ok=True)foriinrange(arquivos):with(pasta/f"vendas_{i:02d}.csv").open("w",encoding="utf-8",newline="")asarquivo:escritor=csv.writer(arquivo)escritor.writerow(["regiao","valor_centavos"])forjinrange(linhas):escritor.writerow([REGIOES[(i+j)%5],(i*7919+j*104729)%50_000+100])defformatar_reais(centavos:int)->str:reais,resto=divmod(centavos,100)returnf"R$ {reais:,}".replace(",",".")+f",{resto:02d}"defimprimir(relatorio:Relatorio)->None:print(f"arquivos processados: {len(relatorio.resultados)} falhas: {len(relatorio.falhas)}")forregiao,totalinsorted(relatorio.por_regiao.items()):print(f" {regiao:<13}{formatar_reais(total):>16}")print(f" {'total':<13}{formatar_reais(relatorio.total_centavos):>16}")forfalhainrelatorio.falhas:print(f" FALHA {falha.arquivo}: {falha.motivo}")defconfigurar_logs(verboso:bool)->None:manipulador=logging.StreamHandler(sys.stderr)manipulador.addFilter(FiltroArquivo())manipulador.setFormatter(logging.Formatter("%(levelname)s [%(arquivo)s] %(message)s"))logging.basicConfig(level=logging.INFOifverbosoelselogging.WARNING,handlers=[manipulador],force=True)defcriar_parser()->argparse.ArgumentParser:parser=argparse.ArgumentParser(prog="processador")parser.add_argument("-v","--verboso",action="store_true")sub=parser.add_subparsers(dest="comando",required=True)gerar=sub.add_parser("gerar",help="gera arquivos CSV de exemplo")gerar.add_argument("pasta",type=Path)gerar.add_argument("--arquivos",type=int,default=6)gerar.add_argument("--linhas",type=int,default=50_000)proc=sub.add_parser("processar",help="processa todos os CSV de uma pasta")proc.add_argument("pasta",type=Path)proc.add_argument("--leituras",type=int,default=4,help="leituras simultâneas")proc.add_argument("--processos",type=int,default=2,help="processos de cálculo")proc.add_argument("--timeout",type=float,default=5.0,help="segundos por arquivo")returnparserdefmain(argv:list[str]|None=None)->int:args=criar_parser().parse_args(argv)configurar_logs(args.verboso)ifargs.comando=="gerar":gerar_dados(args.pasta,args.arquivos,args.linhas)print(f"{args.arquivos} arquivos gerados em {args.pasta}")return0withcriar_pool(args.processos)aspool:relatorio=asyncio.run(processar(args.pasta,pool=pool,leituras=args.leituras,timeout=args.timeout))imprimir(relatorio)return1ifrelatorio.falhaselse0
O teste do cálculo é puro. O de pipeline cobre o que importa em um sistema concorrente: o arquivo corrompido não derruba os outros, as esperas crescem como combinado (0,1 e depois 0,2), o timeout vira uma Falha e o limite de leituras simultâneas é respeitado de verdade (medido com um contador protegido por trava):
6 arquivos gerados em dados
arquivos processados: 6 falhas: 0
centro-oeste R$ 15.060.300,00
nordeste R$ 15.060.900,00
norte R$ 15.058.500,00
sudeste R$ 15.059.700,00
sul R$ 15.059.100,00
total R$ 75.298.500,00
Com um arquivo corrompido na pasta, o programa conclui o resto, lista a falha e sai com o código 1:
Resultado com um arquivo corrompido
arquivos processados: 6 falhas: 1
(as seis regiões, somando só os arquivos válidos)
FALHA quebrado.csv: linha 2 inválida: ['sul', 'abc']
Com -v, cada linha de log traz o nome do arquivo graças à ContextVar. A ordem das linhas muda de uma execução para outra, porque os arquivos terminam em tempos diferentes, e é isso mesmo:
Logs com -v
INFO [vendas_00.csv] processado: 50000 linhas
INFO [vendas_01.csv] processado: 50000 linhas
INFO [vendas_02.csv] processado: 50000 linhas
Desafios
Medir de verdade. Use o cProfile (capítulo 48) e compare --processos 1, 2 e 4 com arquivos maiores. Onde o ganho para de crescer, e por quê? (Dica: o custo de enviar o texto para outro processo e de iniciar cada um.)
Streaming. Hoje cada arquivo é lido inteiro na memória. Reescreva agregar para processar por linhas, sem carregar o arquivo todo, e confira com tracemalloc a diferença de pico.
Fila limitada. Troque o gather por uma fila com produtores e consumidores, de modo que a memória usada não cresça com o número de arquivos.
Cancelamento. Trate o Ctrl+C: cancele as tarefas pendentes, espere as que estão em andamento e imprima um relatório parcial.
Empacotar e publicar. Aplique o capítulo 49 e publique o pacote no TestPyPI.
Por que este programa funciona no Windows e no macOS
Esses sistemas criam processos com spawn, que reimporta o módulo principal. O __main__.py protege a execução com if __name__ == "__main__", e a função agregar está no nível do módulo. Sem os dois cuidados, cada processo filho tentaria rodar o programa inteiro de novo.
O que a falta de estado compartilhado ganha
Não há Lock neste programa. Cada tarefa produz um valor e devolve, e só o final os junta, em uma única thread. Quando você consegue desenhar a concorrência assim, evitando compartilhar memória, os bugs de corrida deixam de existir, em vez de serem tratados.