Pular para o conteúdo
Python na Prática

Python na Prática

Do primeiro script à API em produção, em três níveis, com projetos integradores e um arquivo executável por capítulo.

Por Alexsander Valente

Para quem é este material

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.

Estrutura do repositório
python-notes/
  README.md
  pyproject.toml
  docs/
    python-notes.html
  ambiente/
    cap01_como_executa.py
    ...
  basico/
    cap09_variaveis.py
    ...
  intermediario/
    cap26_classes.py
    ...
  avancado/
    cap42_modelo_dados.py
    ...
  backend/
    cap52_http_apis.py
    ...
  projetos/
    despesas/
    tarefas/
    processador/
  exemplos/
    api_pedidos/
    pacote/
    uv/

Cada arquivo roda sozinho, a partir da raiz do repositório:

Terminal
python3 basico/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.

Logo de Alexsander Valente

Alexsander Valente

Software & AI Architecture, Product Engineering e Data Engineering.

Foto de Alexsander Valente

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

Capítulo 1, parte Ambiente

O que é Python e como ele executa

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 é:

  1. Lê o arquivo inteiro e compila para bytecode, uma representação intermediária.
  2. 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")
except SyntaxError as erro:
    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
def dividir(a, b):
    return a / b

print("esta linha roda normalmente")
try:
    dividir(1, 0)
except ZeroDivisionError as erro:
    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
import dis


def soma(a, b):
    return a + b


dis.dis(soma)

Onde cada erro aparece

Fase O que acontece Erros típicos
Compilação O arquivo inteiro é lido e traduzido para bytecode SyntaxError, IndentationError
Execução O bytecode roda, uma instrução por vez NameError, TypeError, ValueError, ZeroDivisionError

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
brew install python
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.

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:

ambiente/cap02_instalacao.pylinhas 10 a 16
import platform
import sys

print("Versão:", platform.python_version())
print("Implementação:", platform.python_implementation())
print("Executável:", sys.executable)
print("Sistema:", platform.system())

Saída

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 + 2
4
>>> "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
brew install pyenv
xcode-select --install

Se as ferramentas de linha de comando da Apple já estiverem instaladas, o segundo comando avisa e não faz nada.

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:

~/.zshrc ou ~/.bashrc
export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

Instalar e escolher versões

Terminal
pyenv install --list | grep -E "^\s*3\.1[234]\."
pyenv install 3.13
pyenv versions
pyenv global 3.13

Dentro da pasta de um projeto, pyenv local grava o arquivo .python-version, que deve ir para o Git:

Terminal
cd meu-projeto
pyenv local 3.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
import sys

assert sys.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:

Terminal
python3 -m venv .venv
source .venv/bin/activate
python --version

Para sair do ambiente, rode 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
import sys

dentro_de_venv = sys.prefix != sys.base_prefix
print("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:

Terminal
python -m pip install requests
python -m pip install "requests>=2.32,<3"
python -m pip list
python -m pip show requests
python -m pip uninstall requests

Para registrar as dependências e reinstalá-las em outra máquina:

Terminal
python -m pip freeze > requirements.txt
python -m pip install -r requirements.txt

Especificadores de versão

Especificador Significado
==2.32.3 Exatamente essa versão
>=2.32 Essa versão ou qualquer mais nova
>=2.32,<3 Qualquer 2.x a partir da 2.32
~=2.32 Compatível: >=2.32 e <3
!=2.33.0 Qualquer uma, menos essa

O problema do pip freeze

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
brew install pipx
pipx ensurepath

O pipx ensurepath adiciona a pasta dos executáveis do pipx ao seu PATH. Feche e abra o terminal depois.

Uso no dia a dia

Terminal
pipx install ruff
pipx list
pipx upgrade ruff
pipx upgrade-all
pipx uninstall ruff

Para rodar uma ferramenta uma única vez, sem instalar, use pipx run. Ela vive num ambiente temporário:

Terminal
pipx run ruff --version

Para escolher a versão do Python que a ferramenta usa:

Terminal
pipx install --python 3.13 ruff

Para adicionar um plugin ao ambiente de uma ferramenta já instalada, use pipx inject:

Terminal
pipx inject poetry poetry-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
import shutil

for ferramenta in ["python3", "pipx", "uv", "poetry", "pyenv"]:
    caminho = shutil.which(ferramenta)
    situacao = f"instalado em {caminho}" if caminho else "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:

Terminal
pipx install poetry
poetry --version

O instalador oficial também funciona:

Terminal
curl -sSL https://install.python-poetry.org | python3 -

Criar um projeto

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
poetry new meu-projeto
cd meu-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:

exemplos/poetry/pyproject.toml
[project]
name = "meu-projeto"
version = "0.1.0"
description = ""
authors = [
    {name = "Your Name",email = "you@example.com"}
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "requests (>=2.34.2,<3.0.0)"
]

[tool.poetry]
packages = [{include = "meu_projeto", from = "src"}]

[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

[dependency-groups]
dev = [
    "pytest (>=9.1.1,<10.0.0)"
]

Os comandos do dia a dia

Terminal
poetry add requests
poetry add --group dev pytest
poetry remove requests
poetry install
poetry sync
poetry run python -m meu_projeto
poetry run pytest
poetry show --tree
poetry update
poetry lock

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:

Terminal
poetry env activate
poetry env info --path
poetry env use 3.13

Saída

. /caminho/do/projeto/.venv/bin/activate

O bloco acima mostra o que o primeiro comando imprime em um projeto com .venv dentro da pasta.

Para que o Poetry crie o .venv dentro da pasta do projeto (o que os editores detectam sozinhos), configure uma vez:

Terminal
poetry config virtualenvs.in-project true

Ler o pyproject.toml pelo Python

Desde o Python 3.11 existe o módulo tomllib na biblioteca padrão, que lê TOML:

ambiente/cap06_poetry.pylinhas 10 a 20
import tomllib

texto = """
[project]
name = "meu-projeto"
dependencies = ["requests>=2.32"]
"""

dados = tomllib.loads(texto)
print(dados["project"]["name"])
print(dados["project"]["dependencies"])

Saída

meu-projeto
['requests>=2.32']

Faça commit do poetry.lock

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.

Instalar

Terminal
curl -LsSf https://astral.sh/uv/install.sh | sh

Com Homebrew: brew install uv.

Também é possível instalar com pipx install uv ou pip install uv. Se você instalou pelo instalador oficial, atualize com:

Terminal
uv self update

Gerenciar versões de Python

O uv baixa e guarda as versões que você pedir. Não precisa de compilador nem de Python prévio:

Terminal
uv python install 3.12 3.13 3.14
uv python list
uv python pin 3.13

O uv python pin grava o arquivo .python-version na pasta, o mesmo arquivo que o pyenv usa.

Criar e gerenciar um projeto

Terminal
uv init meu-app
cd meu-app
uv add requests
uv add --dev pytest
uv run python main.py
uv run pytest

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
uv remove requests
uv sync
uv lock
uv tree

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"]
# ///
from rich import print

print("[bold]Olá[/bold] de um script com dependências embutidas")
Terminal
uv run exemplos/uv/script_inline.py

Para adicionar uma dependência a um script existente: uv add --script script.py requests.

Ferramentas de linha de comando

Terminal
uv tool install ruff
uvx ruff check .
uv tool list
uv tool upgrade --all

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
uv venv
uv pip install -r requirements.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.

A estrutura mínima de um projeto

Estrutura recomendada
meu-projeto/
  pyproject.toml
  README.md
  .python-version
  .gitignore
  src/
    meu_projeto/
      __init__.py
      __main__.py
  tests/
    test_basico.py

Todo ponto de entrada deve poder ser importado sem executar nada. O padrão é um main() protegido:

ambiente/cap08_estrutura_projeto.pylinhas 10 a 15
def main() -> None:
    print("projeto funcionando")


if __name__ == "__main__":
    main()

Saída

projeto funcionando

Explico o motivo dessa proteção no capítulo de módulos.

Exercício 1

Monte o seu ambiente de trabalho

Crie uma pasta meu-primeiro-projeto, fixe o Python 3.13, crie o ambiente, instale requests e confirme dentro do Python que o ambiente está ativo.

Ver solução
Com uv
uv init meu-primeiro-projeto
cd meu-primeiro-projeto
uv python pin 3.13
uv add requests
uv run python -c "import sys; print(sys.prefix != sys.base_prefix)"
Com pip e venv (macOS e Linux)
mkdir meu-primeiro-projeto && cd meu-primeiro-projeto
python3 -m venv .venv
source .venv/bin/activate
python -m pip install requests
python -c "import sys; print(sys.prefix != sys.base_prefix)"

Capítulo 9, parte Básico

Comentários e variáveis

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 linha
idade = 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
def area_quadrado(lado):
    """Devolve a área de um quadrado de lado dado."""
    return lado * lado


print(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:

basico/cap09_variaveis.pylinhas 29 a 37
nome = "Ana"
idade = 30
cidade = "Recife"
print(nome, idade, cidade)

valor = 10
print(type(valor))
valor = "dez"
print(type(valor))

Saída

Ana 30 Recife
<class 'int'>
<class 'str'>

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, 2
a, b = b, a
print(a, b)

x = y = 0
print(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:

basico/cap09_variaveis.pylinhas 49 a 53
import keyword

print(keyword.iskeyword("class"))
print(keyword.iskeyword("nome"))
print(len(keyword.kwlist) > 30)

Saída

True
False
True
Elemento Convenção (PEP 8) Exemplo
Variáveis e funções snake_case preco_total
Constantes MAIUSCULAS_COM_SUBLINHADO LIMITE_DE_TENTATIVAS
Classes PascalCase ContaBancaria
Uso interno prefixo _ _cache

Python não tem constantes de verdade

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, 7
a, b = b, a
assert (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.

Código deste capítulo: basico/cap10_tipos.py

Os tipos básicos

Tipo O que representa Exemplos
int Números inteiros, sem limite de tamanho 42, -7, 1_000_000
float Números decimais de precisão limitada 3.14, -0.5
complex Números complexos 3 + 4j
str Texto "olá"
bool Verdadeiro ou falso True, False
NoneType Ausência de valor None
bytes Sequência de bytes b"abc"
basico/cap10_tipos.pylinha 10
print(type(42), type(3.14), type(3 + 4j), type("oi"), type(True), type(None))

Saída

<class 'int'> <class 'float'> <class 'complex'> <class 'str'> <class 'bool'> <class 'NoneType'>

Inteiros sem limite, decimais com limite

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 ==:

basico/cap10_tipos.pylinhas 18 a 23
from decimal import Decimal
import math

print(0.1 + 0.2)
print(math.isclose(0.1 + 0.2, 0.3))
print(Decimal("0.1") + Decimal("0.2"))

Saída

0.30000000000000004
True
0.3

Para comparar decimais use math.isclose. Para dinheiro, use Decimal (ou trabalhe com centavos em inteiros).

bool é um tipo de int

Por razões históricas, bool é uma subclasse de int. Isso explica uma pegadinha clássica e importa ao testar tipos:

basico/cap10_tipos.pylinhas 28 a 29
print(True + True)
print(isinstance(True, int))

Saída

2
True

None

None é o único valor do tipo NoneType e significa "sem valor". A comparação correta é sempre com is:

basico/cap10_tipos.pylinhas 34 a 35
resultado = None
print(resultado is None)

Saída

True

Exercício 1

Classifique o tipo de um valor

Escreva tipo_de(valor) que devolva "nulo", "booleano", "inteiro", "decimal", "texto" ou "outro". Lembre-se de que True também é int.

Ver solução
basico/cap10_tipos.pylinhas 40 a 60
def tipo_de(valor):
    if valor is None:
        return "nulo"
    if isinstance(valor, bool):
        return "booleano"
    if isinstance(valor, int):
        return "inteiro"
    if isinstance(valor, float):
        return "decimal"
    if isinstance(valor, str):
        return "texto"
    return "outro"


assert tipo_de(True) == "booleano"
assert tipo_de(7) == "inteiro"
assert tipo_de(2.5) == "decimal"
assert tipo_de("a") == "texto"
assert tipo_de(None) == "nulo"
assert tipo_de([1]) == "outro"
print("ok")

Saída

ok

Capítulo 11, parte Básico

Strings

Uma string é uma sequência imutável de caracteres Unicode. A palavra que mais importa nessa frase é "imutável".

Código deste capítulo: basico/cap11_strings.py

Índices e fatias

Cada caractere tem uma posição, que começa em zero. Índices negativos contam a partir do fim. A fatia [início:fim:passo] exclui o índice final:

basico/cap11_strings.pylinhas 10 a 11
texto = "Python"
print(texto[0], texto[-1], texto[1:4], texto[::-1])

Saída

P n yth nohtyP

Strings não mudam

Nenhuma operação altera uma string existente. Elas sempre criam uma nova:

basico/cap11_strings.pylinhas 16 a 22
try:
    texto[0] = "J"
except TypeError as erro:
    print(erro)

novo = "J" + texto[1:]
print(novo)

Saída

'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" in frase)

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:

basico/cap11_strings.pylinhas 42 a 49
nome = "Ana"
preco = 1234.5
quantidade = 3
print(f"{nome} comprou {quantidade} itens")
print(f"Preço: {preco:,.2f}")
print(f"Total: {preco * quantidade:.2f}")
print(f"[{nome:>8}] [{nome:<8}] [{nome:^8}]")
print(f"{quantidade=}")

Saída

Ana comprou 3 itens
Preço: 1,234.50
Total: 3703.50
[     Ana] [Ana     ] [  Ana   ]
quantidade=3

O formato :,.2f usa vírgula para milhares e ponto para decimais, que é o padrão americano. Para o formato brasileiro, troque os símbolos:

basico/cap11_strings.pylinhas 51 a 54
preco = 1234.5
americano = f"{preco:,.2f}"
brasileiro = americano.replace(",", "_").replace(".", ",").replace("_", ".")
print(brasileiro)

Saída

1.234,50

Para aplicações reais, o módulo locale ou a biblioteca Babel fazem isso por região. Para um script, a troca acima resolve.

Unicode e bytes

Uma string é uma sequência de pontos de código Unicode. Os bytes só entram quando você grava em disco ou envia pela rede, e aí a codificação importa:

basico/cap11_strings.pylinhas 59 a 65
print(ord("A"), chr(97))
palavra = "ação"
print(len(palavra))
dados = palavra.encode("utf-8")
print(dados)
print(len(dados))
print(dados.decode("utf-8"))

Saída

65 a
4
b'a\xc3\xa7\xc3\xa3o'
6
ação

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
def eh_palindromo(texto):
    limpo = texto.lower().replace(" ", "")
    return limpo == limpo[::-1]


assert eh_palindromo("Anotaram a data da maratona")
assert eh_palindromo("radar")
assert not eh_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
def inverter_palavras(frase):
    return " ".join(frase.split()[::-1])


assert inverter_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:

basico/cap12_conversao_falsy.pylinhas 10 a 14
print(int("42") + 1)
print(float("3.5") * 2)
print(str(10) + " anos")
print(int(3.9), int(-3.9))
print(round(3.5), round(4.5), round(3.14159, 2))

Saída

43
7.0
10 anos
3 -3
4 4 3.14

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")
except ValueError as erro:
    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 //:

basico/cap12_conversao_falsy.pylinhas 24 a 26
print(1 + 2.5)
print(True + 1)
print(type(6 / 2), type(6 // 2))

Saída

3.5
2
<class 'float'> <class 'int'>

Valores falsy

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:

basico/cap12_conversao_falsy.pylinhas 31 a 32
valores = [None, False, 0, 0.0, "", [], (), {}, set(), range(0)]
print([bool(v) for v in valores])

Saída

[False, False, False, False, False, False, False, False, False, False]

Todos dão False. Repare que None está na lista, e é o falso mais frequente em código real. Já estes são verdadeiros, e surpreendem:

basico/cap12_conversao_falsy.pylinha 34
print(bool("0"), bool("False"), bool([0]), bool(" "))

Saída

True True True True

A string "0", a string "False", uma lista que contém zero e uma string com espaço são todas verdadeiras, porque não estão vazias.

Falsy não é o mesmo que None

O idioma if not valor mistura "vazio" com "zero" com "ausente". Quando a distinção importa, compare com None explicitamente:

basico/cap12_conversao_falsy.pylinhas 39 a 49
def descricao(quantidade):
    if quantidade is None:
        return "não informado"
    if not quantidade:
        return "zero"
    return f"{quantidade} unidades"


print(descricao(None))
print(descricao(0))
print(descricao(5))

Saída

não informado
zero
5 unidades

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.

Ver solução
basico/cap12_conversao_falsy.pylinhas 54 a 60
def para_float(texto):
    return float(texto.replace(".", "").replace(",", "."))


assert para_float("12,5") == 12.5
assert para_float("1.234,56") == 1234.56
print("ok")

Saída

ok

Capítulo 13, parte Básico

Entrada, saída e operadores

Com `print`, `input` e os operadores você já escreve programas que conversam com a pessoa e fazem contas.

Código deste capítulo: basico/cap13_entrada_operadores.py

print

O print aceita vários valores e dois parâmetros que eu uso muito: sep (separador) e end (o que vem no fim, a quebra de linha por padrão):

basico/cap13_entrada_operadores.pylinhas 10 a 12
print("a", "b", "c", sep="-")
print("sem quebra", end=" ")
print("de linha")

Saída

a-b-c
sem quebra de linha

input

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.

Operadores aritméticos

Operador Operação Exemplo Resultado
+ - * Soma, subtração, multiplicação 10 - 3 7
/ Divisão (sempre float) 10 / 4 2.5
// Divisão inteira (arredonda para baixo) 10 // 4 2
% Resto 10 % 4 2
** Potência 2 ** 8 256
basico/cap13_entrada_operadores.pylinhas 24 a 29
print(10 / 3)
print(10 // 3)
print(-7 // 2)
print(10 % 3)
print(2 ** 8)
print(divmod(17, 5))

Saída

3.3333333333333335
3
-4
1
256
(3, 2)

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 = 7
print(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.

basico/cap13_entrada_operadores.pylinhas 39 a 43
nome = "" or "anônimo"
print(nome)
print(0 and 5)
print(3 and 5)
print(not [])

Saída

anônimo
0
5
True

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:

basico/cap13_entrada_operadores.pylinhas 48 a 50
print("py" in "python", 3 not in [1, 2])
valor = None
print(valor is None, valor is not None)

Saída

True True
True False

Atribuição composta e o operador morsa

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:

basico/cap13_entrada_operadores.pylinhas 55 a 62
total = 10
total += 5
total *= 2
total //= 4
print(total)

if (n := len("python")) > 5:
    print(f"{n} caracteres")

Saída

7
6 caracteres

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

Ver solução
basico/cap13_entrada_operadores.pylinhas 67 a 75
def decompor(segundos):
    horas, resto = divmod(segundos, 3600)
    minutos, segundos = divmod(resto, 60)
    return horas, minutos, segundos


assert decompor(3725) == (1, 2, 5)
assert decompor(59) == (0, 0, 59)
print("ok")

Saída

ok

Capítulo 14, parte Básico

Condicionais

Programas úteis tomam decisões. O `if` é a ferramenta, e escrever condições limpas é a habilidade.

Código deste capítulo: basico/cap14_condicionais.py

if, elif e else

O Python testa as condições de cima para baixo e executa somente o primeiro bloco verdadeiro. A indentação define o bloco:

basico/cap14_condicionais.pylinhas 10 a 24
def classificar(nota):
    if nota >= 9:
        return "excelente"
    elif nota >= 7:
        return "bom"
    elif nota >= 5:
        return "regular"
    else:
        return "insuficiente"


print(classificar(9.5))
print(classificar(7))
print(classificar(5))
print(classificar(2))

Saída

excelente
bom
regular
insuficiente

Expressão condicional

Para escolher entre dois valores em uma linha, existe a forma valor_se_verdadeiro if condicao else valor_se_falso:

basico/cap14_condicionais.pylinhas 29 a 31
idade = 20
situacao = "adulto" if idade >= 18 else "menor"
print(situacao)

Saída

adulto

match: casamento de padrões

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
def responder(comando):
    match comando.split():
        case ["parar"]:
            return "parando"
        case ["mover", direcao]:
            return f"movendo para {direcao}"
        case ["mover", direcao, passos] if passos.isdigit():
            return f"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:

basico/cap14_condicionais.pylinhas 56 a 64
def desconto(preco, cliente_vip):
    if preco <= 0:
        return 0
    if not cliente_vip:
        return 0
    return preco * 0.1


print(desconto(200, True))

Saída

20.0

Exercício 1

Ano bissexto

Escreva eh_bissexto(ano). Um ano é bissexto se for divisível por 4, exceto os divisíveis por 100, a menos que também sejam divisíveis por 400.

Ver solução
basico/cap14_condicionais.pylinhas 69 a 77
def eh_bissexto(ano):
    return ano % 400 == 0 or (ano % 4 == 0 and ano % 100 != 0)


assert eh_bissexto(2024)
assert eh_bissexto(2000)
assert not eh_bissexto(1900)
assert not eh_bissexto(2023)
print("ok")

Saída

ok

Exercício 2

Escada de temperatura

Escreva descrever(temperatura) que devolva "frio" abaixo de 10 graus, "agradável" abaixo de 28 e "quente" a partir daí.

Ver solução
basico/cap14_condicionais.pylinhas 82 a 93
def descrever(temperatura):
    if temperatura < 10:
        return "frio"
    elif temperatura < 28:
        return "agradável"
    return "quente"


assert descrever(-5) == "frio"
assert descrever(25) == "agradável"
assert descrever(45) == "quente"
print("ok")

Saída

ok

Capítulo 15, parte Básico

Laços com for

O `for` percorre qualquer coleção. Quase sempre que você pensa em contar índices, existe uma forma melhor de escrever.

Código deste capítulo: basico/cap15_laco_for.py

for em qualquer iterável

O for do Python não conta: ele percorre os itens de qualquer objeto iterável (string, lista, arquivo, dicionário):

basico/cap15_laco_for.pylinhas 10 a 14
for letra in "abc":
    print(letra)

for fruta in ["maçã", "pera"]:
    print(fruta)

Saída

a
b
c
maçã
pera

range

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:

basico/cap15_laco_for.pylinhas 19 a 22
print(list(range(5)))
print(list(range(1, 6)))
print(list(range(0, 10, 2)))
print(list(range(5, 0, -1)))

Saída

[0, 1, 2, 3, 4]
[1, 2, 3, 4, 5]
[0, 2, 4, 6, 8]
[5, 4, 3, 2, 1]

enumerate e zip

Não escreva for i in range(len(lista)) para obter a posição. O enumerate entrega posição e item juntos. O zip percorre duas sequências em paralelo:

basico/cap15_laco_for.pylinhas 27 a 33
nomes = ["Ana", "Bia", "Caio"]
for posicao, nome in enumerate(nomes, start=1):
    print(posicao, nome)

notas = [9, 8, 7]
for nome, nota in zip(nomes, notas):
    print(f"{nome}: {nota}")

Saída

1 Ana
2 Bia
3 Caio
Ana: 9
Bia: 8
Caio: 7

break, continue e else

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
for numero in range(1, 11):
    if numero == 3:
        continue
    if numero == 6:
        break
    print(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 = 17
for divisor in range(2, numero):
    if numero % divisor == 0:
        print(f"{numero} não é primo")
        break
else:
    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
for linhas in range(1, 5):
    print("*" * linhas)

Saída

*
**
***
****
basico/cap15_laco_for.pylinhas 61 a 64
for i in range(1, 4):
    for j in range(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.

Ver solução
basico/cap15_laco_for.pylinhas 69 a 78
def fatorial(n):
    resultado = 1
    for i in range(2, n + 1):
        resultado *= i
    return resultado


assert fatorial(5) == 120
assert fatorial(0) == 1
print("ok")

Saída

ok

Exercício 2

Losango de asteriscos

Escreva diamante(n) que devolva uma lista de linhas. Para n = 3, o resultado deve ser [" *", " ***", "*****", " ***", " *"].

Ver solução
basico/cap15_laco_for.pylinhas 83 a 93
def diamante(n):
    linhas = []
    for i in range(n):
        linhas.append(" " * (n - i - 1) + "*" * (2 * i + 1))
    for i in range(n - 2, -1, -1):
        linhas.append(" " * (n - i - 1) + "*" * (2 * i + 1))
    return linhas


assert diamante(3) == ["  *", " ***", "*****", " ***", "  *"]
print("\n".join(diamante(4)))

Saída

   *
  ***
 *****
*******
 *****
  ***
   *

Exercício 3

Primos até n

Escreva primos_ate(n) com um laço dentro de outro e o else do for.

Ver solução
basico/cap15_laco_for.pylinhas 98 a 110
def primos_ate(n):
    primos = []
    for candidato in range(2, n + 1):
        for divisor in range(2, candidato):
            if candidato % divisor == 0:
                break
        else:
            primos.append(candidato)
    return primos


assert primos_ate(20) == [2, 3, 5, 7, 11, 13, 17, 19]
print("ok")

Saída

ok

Capítulo 16, parte Básico

Laços com while

Use `for` quando você sabe o que vai percorrer e `while` quando você só sabe a condição de parada.

Código deste capítulo: basico/cap16_laco_while.py

while

O while repete enquanto a condição for verdadeira. A condição precisa, em algum momento, virar falsa, caso contrário o programa nunca termina:

basico/cap16_laco_while.pylinhas 10 a 14
contagem = 3
while contagem > 0:
    print(contagem)
    contagem -= 1
print("Fogo!")

Saída

3
2
1
Fogo!

Laço infinito

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
while True:
    texto = input("Digite um número positivo: ")
    if texto.isdigit() and int(texto) > 0:
        break
    print("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:

basico/cap16_laco_while.pylinhas 29 a 32
numero = 1234
while numero > 0:
    numero, digito = divmod(numero, 10)
    print(digito)

Saída

4
3
2
1
basico/cap16_laco_while.pylinhas 34 a 39
original = 12345
invertido = 0
while original > 0:
    original, digito = divmod(original, 10)
    invertido = invertido * 10 + digito
print(invertido)

Saída

54321

Um jogo de adivinhação

O módulo random sorteia números. O randint(1, 100) inclui os dois extremos:

basico/cap16_laco_while.pylinhas 44 a 46
import random

print(random.randint(1, 100) in range(1, 101))

Saída

True

No jogo abaixo eu fixei o segredo em 63 para a saída ser previsível. Troque por random.randint(1, 100) para jogar de verdade:

basico/cap16_laco_while.pylinhas 48 a 59
segredo = 63
tentativas = 0
while True:
    palpite = int(input("Seu palpite: "))
    tentativas += 1
    if palpite < segredo:
        print("Muito baixo")
    elif palpite > segredo:
        print("Muito alto")
    else:
        print(f"Acertou em {tentativas} tentativas")
        break

Saída

Seu palpite: 50
Muito baixo
Seu palpite: 75
Muito alto
Seu palpite: 63
Acertou em 3 tentativas

Exercício 1

Soma dos dígitos

Escreva soma_digitos(n) com while. Para 1234 o resultado é 10.

Ver solução
basico/cap16_laco_while.pylinhas 64 a 74
def soma_digitos(n):
    total = 0
    while n > 0:
        n, digito = divmod(n, 10)
        total += digito
    return total


assert soma_digitos(1234) == 10
assert soma_digitos(0) == 0
print("ok")

Saída

ok

Exercício 2

Conjectura de Collatz

Parta de n. Se for par, divida por 2. Se for ímpar, multiplique por 3 e some 1. Conte quantos passos levam até 1. Escreva collatz(n).

Ver solução
basico/cap16_laco_while.pylinhas 79 a 89
def collatz(n):
    passos = 0
    while n != 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        passos += 1
    return passos


assert collatz(6) == 8
assert collatz(1) == 0
print("ok")

Saída

ok

Capítulo 17, parte Básico

Funções

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
def saudacao(nome):
    """Devolve uma saudação."""
    return f"Olá, {nome}!"


def sem_retorno():
    pass


print(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.
basico/cap17_funcoes.pylinhas 25 a 34
def criar_usuario(nome, /, email, *, ativo=True):
    return {"nome": nome, "email": email, "ativo": ativo}


print(criar_usuario("Ana", "ana@exemplo.com"))
print(criar_usuario("Bia", email="bia@exemplo.com", ativo=False))
try:
    criar_usuario(nome="Caio", email="c@exemplo.com")
except TypeError as erro:
    print(erro)

Saída

{'nome': 'Ana', 'email': 'ana@exemplo.com', 'ativo': True}
{'nome': 'Bia', 'email': 'bia@exemplo.com', 'ativo': False}
criar_usuario() got some positional-only arguments passed as keyword arguments: 'nome'

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:

basico/cap17_funcoes.pylinhas 39 a 54
def somar(*numeros):
    return sum(numeros)


def descrever(**atributos):
    for chave, valor in atributos.items():
        print(f"{chave}: {valor}")


print(somar(1, 2, 3, 4))
descrever(nome="Ana", cidade="Recife")

valores = [10, 20, 30]
print(somar(*valores))
dados = {"nome": "Bia", "cidade": "Natal"}
descrever(**dados)

Saída

10
nome: Ana
cidade: Recife
60
nome: Bia
cidade: Natal

Vários valores de retorno

Uma função pode devolver uma tupla, e quem chama desempacota na hora:

basico/cap17_funcoes.pylinhas 59 a 64
def minimo_maximo(numeros):
    return min(numeros), max(numeros)


menor, maior = minimo_maximo([4, 8, 2, 9])
print(menor, maior)

Saída

2 9

Funções são objetos

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:

basico/cap17_funcoes.pylinhas 69 a 74
def aplicar(funcao, valor):
    return funcao(valor)


print(aplicar(len, "python"))
print(aplicar(str.upper, "python"))

Saída

6
PYTHON

Documentação e type hints

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
def soma(a: int, b: int) -> int:
    """Soma dois inteiros e devolve o resultado."""
    return a + b


print(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.

Ver solução
basico/cap17_funcoes.pylinhas 90 a 96
def media(*numeros):
    return sum(numeros) / len(numeros)


assert media(10, 20, 30) == 20
assert media(5) == 5
print("ok")

Saída

ok

Exercício 2

Nome completo com opção

Escreva formatar_nome(nome, sobrenome, *, maiusculo=False).

Ver solução
basico/cap17_funcoes.pylinhas 101 a 108
def formatar_nome(nome, sobrenome, *, maiusculo=False):
    completo = f"{nome} {sobrenome}"
    return completo.upper() if maiusculo else completo


assert formatar_nome("Ana", "Lima") == "Ana Lima"
assert formatar_nome("Ana", "Lima", maiusculo=True) == "ANA LIMA"
print("ok")

Saída

ok

Capítulo 18, parte Básico

Escopo e armadilhas das funções

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"


def externa():
    mensagem = "local da externa"

    def interna():
        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:

basico/cap18_escopo.pylinhas 26 a 36
contador = 0


def incrementar():
    global contador
    contador += 1


incrementar()
incrementar()
print(contador)

Saída

2

UnboundLocalError

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:

basico/cap18_escopo.pylinhas 41 a 51
total = 10


def quebrado():
    total += 1


try:
    quebrado()
except UnboundLocalError as erro:
    print(erro)

Saída

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:

basico/cap18_escopo.pylinhas 56 a 63
def adicionar(item, lista=[]):
    lista.append(item)
    return lista


print(adicionar(1))
print(adicionar(2))
print(adicionar.__defaults__)

Saída

[1]
[1, 2]
([1, 2],)

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:

basico/cap18_escopo.pylinhas 65 a 73
def adicionar_correto(item, lista=None):
    if lista is None:
        lista = []
    lista.append(item)
    return lista


print(adicionar_correto(1))
print(adicionar_correto(2))

Saída

[1]
[2]

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:

basico/cap18_escopo.pylinhas 78 a 90
def ordenar_no_lugar(lista):
    lista.sort()


def ordenada(lista):
    return sorted(lista)


original = [3, 1, 2]
nova = ordenada(original)
print(original, nova)
ordenar_no_lugar(original)
print(original)

Saída

[3, 1, 2] [1, 2, 3]
[1, 2, 3]

Recursão

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
import sys


def fatorial(n):
    return 1 if n <= 1 else n * fatorial(n - 1)


print(fatorial(5))
print(sys.getrecursionlimit())


def sem_fim(n):
    return sem_fim(n + 1)


try:
    sem_fim(0)
except RecursionError:
    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.

Texto
def registrar(evento, historico=[]):
    historico.append(evento)
    return historico
Ver solução
basico/cap18_escopo.pylinhas 118 a 128
def registrar(evento, historico=None):
    if historico is None:
        historico = []
    historico.append(evento)
    return historico


assert registrar("a") == ["a"]
assert registrar("b") == ["b"]
assert registrar("c", ["x"]) == ["x", "c"]
print("ok")

Saída

ok

Capítulo 19, parte Básico

Listas

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:

basico/cap19_listas.pylinhas 10 a 13
frutas = ["maçã", "banana", "manga"]
print(frutas[0], frutas[-1], frutas[0:2])
frutas[1] = "uva"
print(frutas)

Saída

maçã manga ['maçã', 'banana']
['maçã', 'uva', 'manga']

Métodos principais

basico/cap19_listas.pylinhas 18 a 26
numeros = [3, 1, 4, 1, 5]
numeros.append(9)
numeros.insert(0, 0)
numeros.extend([2, 6])
print(numeros)
numeros.remove(1)
ultimo = numeros.pop()
print(numeros, ultimo)
print(numeros.index(4), numeros.count(1))

Saída

[0, 3, 1, 4, 1, 5, 9, 2, 6]
[0, 3, 4, 1, 5, 9, 2] 6
2 1
Método O que faz Devolve
append(x) Adiciona no fim None
extend(iteravel) Adiciona vários itens no fim None
insert(i, x) Insere na posição i None
remove(x) Remove a primeira ocorrência (ou ValueError) None
pop(i) Remove e devolve o item (o último, por padrão) O item
sort() Ordena no lugar None
reverse() Inverte no lugar None

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:

basico/cap19_listas.pylinhas 28 a 34
valores = [3, 1, 2]
print(sorted(valores))
print(valores)
resultado = valores.sort()
print(resultado, valores)
valores.sort(reverse=True)
print(valores)

Saída

[1, 2, 3]
[3, 1, 2]
None [1, 2, 3]
[3, 2, 1]

Atribuir não é copiar

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:

basico/cap19_listas.pylinhas 39 a 45
a = [1, 2, 3]
b = a
b.append(4)
print(a)
c = a.copy()
c.append(5)
print(a, c)

Saída

[1, 2, 3, 4]
[1, 2, 3, 4] [1, 2, 3, 4, 5]
basico/cap19_listas.pylinhas 47 a 55
import copy

matriz = [[1, 2], [3, 4]]
rasa = matriz.copy()
rasa[0][0] = 99
print(matriz)
profunda = copy.deepcopy(matriz)
profunda[0][0] = 0
print(matriz, profunda)

Saída

[[99, 2], [3, 4]]
[[99, 2], [3, 4]] [[0, 2], [3, 4]]

Operações úteis

basico/cap19_listas.pylinhas 60 a 68
n = [4, 8, 2, 9]
print(len(n), min(n), max(n), sum(n))
print(n + [1], n * 2)
print(8 in n)
print(list(reversed(n)))
del n[0]
print(n)
n[1:3] = [0, 0, 0]
print(n)

Saída

4 2 9 23
[4, 8, 2, 9, 1] [4, 8, 2, 9, 4, 8, 2, 9]
True
[9, 2, 8, 4]
[8, 2, 9]
[8, 0, 0, 0]

Pilha e fila

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:

basico/cap19_listas.pylinhas 73 a 82
from collections import deque

pilha = []
pilha.append("a")
pilha.append("b")
print(pilha.pop())

fila = deque(["x", "y", "z"])
fila.append("w")
print(fila.popleft(), list(fila))

Saída

b
x ['y', 'z', 'w']

Exercício 1

Segundo maior

Escreva segundo_maior(numeros) que ignore repetições. Para [5, 5, 3] o resultado é 3. O próximo capítulo apresenta o set, que ajuda aqui.

Ver solução
basico/cap19_listas.pylinhas 87 a 94
def segundo_maior(numeros):
    unicos = sorted(set(numeros))
    return unicos[-2]


assert segundo_maior([4, 8, 2, 9, 1]) == 8
assert segundo_maior([5, 5, 3]) == 3
print("ok")

Saída

ok

Exercício 2

A lista está ordenada?

Escreva esta_ordenada(numeros) sem usar sort() na lista original.

Ver solução
basico/cap19_listas.pylinhas 99 a 106
def esta_ordenada(numeros):
    return numeros == sorted(numeros)


assert esta_ordenada([1, 3, 5])
assert not esta_ordenada([3, 1, 4])
assert esta_ordenada([])
print("ok")

Saída

ok

Capítulo 20, parte Básico

Tuplas e conjuntos

A tupla é a lista que não muda. O conjunto é a coleção que não repete. Cada uma resolve um problema que a lista resolve mal.

Código deste capítulo: basico/cap20_tuplas_conjuntos.py

Tuplas

Uma tupla é uma sequência imutável. Use para dados que formam um registro fixo (coordenadas, pares de valores, retornos de função):

basico/cap20_tuplas_conjuntos.pylinhas 10 a 15
dias = ("seg", "ter", "qua")
print(dias[0], len(dias))
try:
    dias[0] = "dom"
except TypeError as erro:
    print(erro)

Saída

seg 3
'tuple' object does not support item assignment

Uma pegadinha de sintaxe: o que forma a tupla é a vírgula, não o parêntese. Uma tupla de um elemento precisa da vírgula final:

basico/cap20_tuplas_conjuntos.pylinhas 17 a 19
um = (1,)
nao_tupla = (1)
print(type(um), type(nao_tupla))

Saída

<class 'tuple'> <class 'int'>

O desempacotamento é onde a tupla mais aparece. O * captura "o resto" em uma lista:

basico/cap20_tuplas_conjuntos.pylinhas 21 a 25
ponto = (3, 4)
x, y = ponto
primeiro, *resto = [10, 20, 30, 40]
print(x, y)
print(primeiro, resto)

Saída

3 4
10 [20, 30, 40]

Quando você quer uma tupla com nomes nos campos, use namedtuple:

basico/cap20_tuplas_conjuntos.pylinhas 27 a 31
from collections import namedtuple

Ponto = namedtuple("Ponto", ["x", "y"])
p = Ponto(3, 4)
print(p, p.x + p.y)

Saída

Ponto(x=3, y=4) 7

A imutabilidade da tupla é rasa: ela garante que os itens não sejam trocados, mas se um item for mutável, ele continua podendo mudar:

basico/cap20_tuplas_conjuntos.pylinhas 33 a 35
registro = ("ana", [1, 2])
registro[1].append(3)
print(registro)

Saída

('ana', [1, 2, 3])

Conjuntos

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:

basico/cap20_tuplas_conjuntos.pylinhas 40 a 47
s = {1, 2, 2, 3, 3, 3}
print(s, len(s))
vazio = set()
print(type({}), type(vazio))
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b, a & b, a - b, a ^ b)
print(3 in a)

Saída

{1, 2, 3} 3
<class 'dict'> <class 'set'>
{1, 2, 3, 4, 5, 6} {3, 4} {1, 2} {1, 2, 5, 6}
True

{} 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:

basico/cap20_tuplas_conjuntos.pylinhas 49 a 55
try:
    {[1, 2]}
except TypeError as erro:
    print(erro)

congelado = frozenset([1, 2])
print(congelado)

Saída

unhashable type: 'list'
frozenset({1, 2})

A ordem de um conjunto não é garantida

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

Ver solução
basico/cap20_tuplas_conjuntos.pylinhas 60 a 71
def sem_duplicados(itens):
    vistos = set()
    resultado = []
    for item in itens:
        if item not in vistos:
            vistos.add(item)
            resultado.append(item)
    return resultado


assert sem_duplicados([3, 1, 3, 2, 1]) == [3, 1, 2]
print("ok")

Saída

ok

Exercício 2

Itens em comum

Escreva em_comum(a, b) que devolva, ordenados, os itens presentes nas duas listas.

Ver solução
basico/cap20_tuplas_conjuntos.pylinhas 76 a 81
def em_comum(a, b):
    return sorted(set(a) & set(b))


assert em_comum([1, 2, 3, 4], [3, 4, 5]) == [3, 4]
print("ok")

Saída

ok

Capítulo 21, parte Básico

Dicionários

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:

basico/cap21_dicionarios.pylinhas 10 a 15
pessoa = {"nome": "Ana", "idade": 30}
print(pessoa["nome"])
pessoa["cidade"] = "Recife"
pessoa["idade"] = 31
del pessoa["nome"]
print(pessoa)

Saída

Ana
{'idade': 31, 'cidade': 'Recife'}

Acessar com segurança

Ler uma chave que não existe levanta KeyError. O método get devolve um valor padrão no lugar, e o operador in pergunta se a chave existe:

basico/cap21_dicionarios.pylinhas 20 a 26
try:
    pessoa["email"]
except KeyError as erro:
    print("KeyError:", erro)
print(pessoa.get("email"))
print(pessoa.get("email", "sem email"))
print("idade" in pessoa)

Saída

KeyError: 'email'
None
sem email
True

Percorrer e alterar

basico/cap21_dicionarios.pylinhas 31 a 39
estoque = {"maçã": 10, "pera": 0, "uva": 25}
for fruta, quantidade in estoque.items():
    print(f"{fruta}: {quantidade}")
print(list(estoque.keys()))
print(list(estoque.values()))
estoque.update({"pera": 5, "kiwi": 3})
print(estoque.pop("kiwi"))
print(estoque.setdefault("manga", 0))
print(estoque)

Saída

maçã: 10
pera: 0
uva: 25
['maçã', 'pera', 'uva']
[10, 0, 25]
3
0
{'maçã': 10, 'pera': 5, 'uva': 25, 'manga': 0}

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:

basico/cap21_dicionarios.pylinhas 41 a 44
a = {"x": 1, "y": 2}
b = {"y": 20, "z": 30}
print(a | b)
print({**a, **b})

Saída

{'x': 1, 'y': 20, 'z': 30}
{'x': 1, 'y': 20, 'z': 30}

Contar e agrupar

Contar ocorrências é uma das tarefas mais comuns. A versão manual usa get. A biblioteca padrão tem ferramentas prontas, Counter e defaultdict:

basico/cap21_dicionarios.pylinhas 49 a 64
from collections import Counter, defaultdict

palavras = ["a", "b", "a", "c", "b", "a"]

contagem = {}
for palavra in palavras:
    contagem[palavra] = contagem.get(palavra, 0) + 1
print(contagem)

print(Counter(palavras))
print(Counter(palavras).most_common(1))

por_inicial = defaultdict(list)
for nome in ["Ana", "Alice", "Bia", "Caio", "Beto"]:
    por_inicial[nome[0]].append(nome)
print(dict(por_inicial))

Saída

{'a': 3, 'b': 2, 'c': 1}
Counter({'a': 3, 'b': 2, 'c': 1})
[('a', 3)]
{'A': ['Ana', 'Alice'], 'B': ['Bia', 'Beto'], 'C': ['Caio']}

Dados aninhados

Dicionários e listas se combinam livremente. É assim que chegam os dados de uma API em JSON:

basico/cap21_dicionarios.pylinhas 69 a 79
pedido = {
    "cliente": "Ana",
    "itens": [
        {"produto": "caneta", "qtd": 2},
        {"produto": "caderno", "qtd": 1},
    ],
}
total_itens = 0
for item in pedido["itens"]:
    total_itens += item["qtd"]
print(total_itens)

Saída

3

Exercício 1

Frequência de cada elemento

Escreva frequencia(itens) que devolva um dicionário com a contagem de cada item, sem usar Counter.

Ver solução
basico/cap21_dicionarios.pylinhas 84 a 92
def frequencia(itens):
    resultado = {}
    for item in itens:
        resultado[item] = resultado.get(item, 0) + 1
    return resultado


assert frequencia(["a", "b", "a", "c", "b", "a"]) == {"a": 3, "b": 2, "c": 1}
print("ok")

Saída

ok

Exercício 2

Somar dicionários

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

Ver solução
basico/cap21_dicionarios.pylinhas 97 a 105
def somar_dicts(d1, d2):
    resultado = dict(d1)
    for chave, valor in d2.items():
        resultado[chave] = resultado.get(chave, 0) + valor
    return resultado


assert somar_dicts({"a": 5, "b": 3}, {"b": 4, "c": 2}) == {"a": 5, "b": 7, "c": 2}
print("ok")

Saída

ok

Capítulo 22, parte Básico

Mutabilidade e identidade

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:

basico/cap22_mutabilidade.pylinhas 10 a 14
a = [1, 2]
b = [1, 2]
c = a
print(a == b, a is b, a is c)
print(id(a) == id(c))

Saída

True False True
True

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ê:

basico/cap22_mutabilidade.pylinhas 19 a 31
def anexar(lista):
    lista.append(99)


def reatribuir(lista):
    lista = [0]


dados = [1, 2]
anexar(dados)
print(dados)
reatribuir(dados)
print(dados)

Saída

[1, 2, 99]
[1, 2, 99]

A armadilha da matriz

Multiplicar uma lista por um número repete a referência, não o objeto. Por isso a "matriz" abaixo tem três linhas que são, na verdade, a mesma lista:

basico/cap22_mutabilidade.pylinhas 36 a 42
errada = [[0] * 3] * 3
errada[0][0] = 1
print(errada)

certa = [[0] * 3 for _ in range(3)]
certa[0][0] = 1
print(certa)

Saída

[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
[[1, 0, 0], [0, 0, 0], [0, 0, 0]]

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.

Ver solução
basico/cap22_mutabilidade.pylinhas 47 a 56
def copiar_matriz(matriz):
    return [linha.copy() for linha in matriz]


original = [[1, 2], [3, 4]]
copia = copiar_matriz(original)
copia[0][0] = 99
assert original == [[1, 2], [3, 4]]
assert copia == [[99, 2], [3, 4]]
print("ok")

Saída

ok

Capítulo 23, parte Básico

Exceções

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:

basico/cap23_excecoes.pylinha 10
print([c.__name__ for c in ZeroDivisionError.__mro__])

Saída

['ZeroDivisionError', 'ArithmeticError', 'Exception', 'BaseException', 'object']

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
def converter(texto):
    try:
        valor = int(texto)
    except ValueError:
        print(f"'{texto}' não é um inteiro")
        return None
    else:
        print("conversão feita")
        return valor
    finally:
        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
def dividir(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return float("inf")
    except TypeError as erro:
        raise ValueError("os dois valores devem ser números") from erro


print(dividir(1, 0))
try:
    dividir(1, "a")
except ValueError as erro:
    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
class SaldoInsuficiente(Exception):
    def __init__(self, saldo, valor):
        super().__init__(f"saldo {saldo} insuficiente para sacar {valor}")
        self.saldo = saldo
        self.valor = valor


def sacar(saldo, valor):
    if valor > saldo:
        raise SaldoInsuficiente(saldo, valor)
    return saldo - valor


try:
    sacar(100, 150)
except SaldoInsuficiente as erro:
    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:

basico/cap23_excecoes.pylinhas 73 a 85
config = {"porta": 8080}

if "host" in config:
    host = config["host"]
else:
    host = "localhost"

try:
    host = config["host"]
except KeyError:
    host = "localhost"

print(host)

Saída

localhost

assert não é validação de entrada

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.

Ver solução
basico/cap23_excecoes.pylinhas 90 a 99
def dividir_seguro(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None


assert dividir_seguro(10, 4) == 2.5
assert dividir_seguro(1, 0) is None
print("ok")

Saída

ok

Exercício 2

Validar uma idade

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
def ler_idade(texto):
    try:
        idade = int(texto)
    except ValueError as erro:
        raise ValueError(f"idade inválida: {texto!r}") from erro
    if idade < 0:
        raise ValueError("idade não pode ser negativa")
    return idade


assert ler_idade("30") == 30
for entrada in ("abc", "-1"):
    try:
        ler_idade(entrada)
    except ValueError:
        pass
    else:
        raise AssertionError(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
with open("notas.txt", "w", encoding="utf-8") as arquivo:
    arquivo.write("primeira linha\n")
    arquivo.write("segunda linha com acentuação\n")

with open("notas.txt", "r", encoding="utf-8") as arquivo:
    conteudo = arquivo.read()
print(conteudo)

with open("notas.txt", "a", encoding="utf-8") as arquivo:
    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:
    with open("notas.txt", "x", encoding="utf-8") as arquivo:
        arquivo.write("não vai acontecer")
except FileExistsError:
    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:

basico/cap24_arquivos.pylinhas 33 a 35
with open("notas.txt", encoding="utf-8") as arquivo:
    for numero, linha in enumerate(arquivo, start=1):
        print(numero, linha.rstrip("\n"))

Saída

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:

basico/cap24_arquivos.pylinhas 40 a 50
from pathlib import Path

pasta = Path("saida")
pasta.mkdir(exist_ok=True)
arquivo = pasta / "relatorio.txt"
arquivo.write_text("total: 42\n", encoding="utf-8")
print(arquivo.read_text(encoding="utf-8").strip())
print(arquivo.name, arquivo.stem, arquivo.suffix, arquivo.parent)
print(arquivo.exists(), (pasta / "nao_existe.txt").exists())
for item in sorted(pasta.glob("*.txt")):
    print(item)

Saída

total: 42
relatorio.txt relatorio .txt saida
True False
saida/relatorio.txt

Erros comuns

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__:

basico/cap24_arquivos.pylinhas 55 a 61
try:
    open("nao_existe.txt", encoding="utf-8")
except FileNotFoundError as erro:
    print(erro)

base = Path(__file__).parent
print((base / "dados").name)

Saída

[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
def estatisticas(caminho):
    linhas = palavras = 0
    with open(caminho, encoding="utf-8") as arquivo:
        for linha in arquivo:
            linhas += 1
            palavras += len(linha.split())
    return linhas, palavras


Path("exemplo.txt").write_text("um dois\ntrês quatro cinco\n", encoding="utf-8")
assert estatisticas("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.

Código deste capítulo: basico/cap25_modulos.py

Formas de importar

basico/cap25_modulos.pylinhas 10 a 16
import math
from math import sqrt, pi
import datetime as dt

print(math.sqrt(16), sqrt(25))
print(f"{pi:.4f}")
print(math.floor(2.7), math.ceil(2.1), math.gcd(12, 18))

Saída

4.0 5.0
3.1416
2 3 6

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:

basico/cap25_modulos.pylinhas 21 a 29
import statistics
from datetime import date, timedelta

notas = [7, 8, 9, 10]
print(statistics.mean(notas), statistics.median(notas))
hoje = date(2026, 10, 6)
print(hoje.strftime("%d/%m/%Y"))
print(hoje + timedelta(days=30))
print((date(2026, 12, 25) - hoje).days)

Saída

8.5 8.5
06/10/2026
2026-11-05
80
basico/cap25_modulos.pylinhas 31 a 34
import random

print(random.randint(1, 6) in range(1, 7))
print(random.choice(["a", "b", "c"]) in "abc")

Saída

True
True

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


def saudacao(nome: str) -> str:
    return f"Olá, {nome}!"


if __name__ == "__main__":
    print(saudacao("teste do módulo"))
exemplos/modulos/principal.py
from util import saudacao

print(saudacao("Ana"))
Terminal
python3 exemplos/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
def main():
    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á:

basico/cap25_modulos.pylinhas 49 a 51
import sys

print(type(sys.path).__name__, "math" in sys.modules)

Saída

list True

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.

Ver solução
basico/cap25_modulos.pylinhas 56 a 65
from datetime import date


def dias_entre(inicio, fim):
    return (date.fromisoformat(fim) - date.fromisoformat(inicio)).days


assert dias_entre("2026-01-01", "2026-10-06") == 278
assert dias_entre("2026-10-06", "2026-10-06") == 0
print("ok")

Saída

ok

Capítulo 26, parte Intermediário

Classes e objetos

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
class Cachorro:
    def __init__(self, nome, idade):
        self.nome = nome
        self.idade = idade

    def latir(self):
        return f"{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:

intermediario/cap26_classes.pylinhas 27 a 28
print(Cachorro.latir(mel))
print(type(rex).__name__, isinstance(rex, Cachorro))

Saída

Mel diz: au au
Cachorro True

Uma representação que ajuda a depurar

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:

intermediario/cap26_classes.pylinhas 33 a 44
class Ponto:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Ponto(x={self.x}, y={self.y})"


p = Ponto(1, 2)
print(p)
print(vars(p))

Saída

Ponto(x=1, y=2)
{'x': 1, 'y': 2}

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
class ContaBancaria:
    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo = saldo

    def depositar(self, valor):
        if valor <= 0:
            raise ValueError("o depósito deve ser positivo")
        self.saldo += valor

    def sacar(self, valor):
        if valor > self.saldo:
            raise ValueError("saldo insuficiente")
        self.saldo -= valor


conta = ContaBancaria("Ana", 100)
conta.depositar(50)
conta.sacar(30)
assert conta.saldo == 120
try:
    conta.sacar(500)
except ValueError:
    pass
else:
    raise AssertionError("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
class Contador:
    total_criados = 0

    def __init__(self, nome):
        self.nome = nome
        Contador.total_criados += 1


a = Contador("a")
b = Contador("b")
print(Contador.total_criados, a.total_criados)

Saída

2 2

Ao ler a.total_criados, o Python procura primeiro no objeto, depois na classe, depois nas classes pai. Ao atribuir a.nivel = ..., ele sempre cria o atributo no objeto, sem tocar na classe:

intermediario/cap27_atributos_metodos.pylinhas 22 a 29
class Config:
    nivel = "padrão"


c = Config()
print(c.nivel)
c.nivel = "personalizado"
print(c.nivel, Config.nivel)

Saída

padrão
personalizado padrão

A armadilha do atributo de classe mutável

É o mesmo problema do valor padrão mutável do capítulo 18. Uma lista no corpo da classe é uma só para todas as instâncias:

intermediario/cap27_atributos_metodos.pylinhas 34 a 43
class Turma:
    alunos = []

    def adicionar(self, nome):
        self.alunos.append(nome)


t1, t2 = Turma(), Turma()
t1.adicionar("Ana")
print(t2.alunos)

Saída

['Ana']

A segunda turma "ganhou" a Ana sem ninguém pedir. A correção é criar a lista dentro do __init__, para cada instância ter a sua:

intermediario/cap27_atributos_metodos.pylinhas 45 a 55
class TurmaCorreta:
    def __init__(self):
        self.alunos = []

    def adicionar(self, nome):
        self.alunos.append(nome)


t1, t2 = TurmaCorreta(), TurmaCorreta()
t1.adicionar("Ana")
print(t2.alunos)

Saída

[]

Métodos de instância, de classe e estáticos

Tipo Decorador Primeiro parâmetro Quando usar
De instância nenhum self Usa ou altera os dados do objeto
De classe @classmethod cls Construtores alternativos e lógica da classe
Estático @staticmethod nenhum Função utilitária que pertence ao tema da classe

O uso mais valioso do @classmethod é o construtor alternativo: criar um objeto a partir de outra representação, como um texto:

intermediario/cap27_atributos_metodos.pylinhas 60 a 79
class Pessoa:
    def __init__(self, nome, idade):
        self.nome = nome
        self.idade = idade

    @classmethod
    def de_texto(cls, texto):
        nome, idade = texto.split(",")
        return cls(nome.strip(), int(idade))

    @staticmethod
    def eh_maior_de_idade(idade):
        return idade >= 18

    def __repr__(self):
        return f"Pessoa({self.nome!r}, {self.idade})"


p = Pessoa.de_texto("Ana, 30")
print(p, Pessoa.eh_maior_de_idade(p.idade))

Saída

Pessoa('Ana', 30) True

Repare no cls(...) em vez de Pessoa(...): assim, uma subclasse que usar de_texto recebe um objeto da subclasse, e não da classe base.

Exercício 1

Produto a partir de um dicionário

Crie Produto(nome, preco) com o construtor alternativo de_dict(dados), que receba {"nome": ..., "preco": ...}.

Ver solução
intermediario/cap27_atributos_metodos.pylinhas 84 a 96
class Produto:
    def __init__(self, nome, preco):
        self.nome = nome
        self.preco = preco

    @classmethod
    def de_dict(cls, dados):
        return cls(dados["nome"], dados["preco"])


p = Produto.de_dict({"nome": "caneta", "preco": 3.5})
assert (p.nome, p.preco) == ("caneta", 3.5)
print("ok")

Saída

ok

Capítulo 28, parte Intermediário

Herança e polimorfismo

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
class Animal:
    def __init__(self, nome):
        self.nome = nome

    def falar(self):
        return "..."

    def apresentar(self):
        return f"{self.nome} diz {self.falar()}"


class Cachorro(Animal):
    def falar(self):
        return "au"


class Gato(Animal):
    def falar(self):
        return "miau"


for animal in [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():

intermediario/cap28_heranca.pylinhas 37 a 56
class Funcionario:
    def __init__(self, nome, salario):
        self.nome = nome
        self.salario = salario

    def bonus(self):
        return self.salario * 0.1


class Gerente(Funcionario):
    def __init__(self, nome, salario, equipe):
        super().__init__(nome, salario)
        self.equipe = equipe

    def bonus(self):
        return super().bonus() + 100 * len(self.equipe)


g = Gerente("Ana", 5000, ["Bia", "Caio"])
print(g.bonus())

Saída

700.0

Esquecer o super().__init__(...) é um erro clássico: o pai nunca inicializa os seus atributos, e o erro só aparece mais tarde.

Hierarquias e a ordem de busca

Herança pode ter vários níveis. A ordem em que o Python procura um método chama-se MRO (method resolution order) e pode ser consultada:

intermediario/cap28_heranca.pylinhas 61 a 74
class A:
    pass


class B(A):
    pass


class C(B):
    pass


print(issubclass(C, A), isinstance(C(), B))
print([k.__name__ for k in C.__mro__])

Saída

True True
['C', 'B', 'A', 'object']

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":

intermediario/cap28_heranca.pylinhas 79 a 90
class Pato:
    def falar(self):
        return "quack"


class Robo:
    def falar(self):
        return "bip"


for coisa in (Pato(), Robo()):
    print(coisa.falar())

Saída

quack
bip

Composição antes de herança

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:

intermediario/cap28_heranca.pylinhas 95 a 108
class Motor:
    def ligar(self):
        return "motor ligado"


class Carro:
    def __init__(self):
        self.motor = Motor()

    def partir(self):
        return self.motor.ligar()


print(Carro().partir())

Saída

motor ligado

Por que eu prefiro composição

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

Ver solução
intermediario/cap28_heranca.pylinhas 113 a 144
import math


class Forma:
    def area(self):
        raise NotImplementedError


class Retangulo(Forma):
    def __init__(self, largura, altura):
        self.largura = largura
        self.altura = altura

    def area(self):
        return self.largura * self.altura


class Circulo(Forma):
    def __init__(self, raio):
        self.raio = raio

    def area(self):
        return math.pi * self.raio ** 2


def area_total(formas):
    return sum(forma.area() for forma in formas)


assert area_total([Retangulo(2, 3), Retangulo(1, 1)]) == 7
assert round(Circulo(1).area(), 2) == 3.14
print("ok")

Saída

ok

Capítulo 29, parte Intermediário

Encapsulamento e @property

Python não tem atributos privados de verdade. Tem convenções, e uma ferramenta, a `@property`, que permite começar simples e proteger depois.

Código deste capítulo: intermediario/cap29_encapsulamento.py

Convenções, não cadeados

Em Python, o controle de acesso é uma convenção entre adultos:

  • titular: público, qualquer um pode usar.
  • _saldo: com um sublinhado, significa "uso interno, não mexa se não for da classe".
  • __pin: com dois sublinhados, o Python faz o name mangling: renomeia o atributo para _Classe__pin, para evitar colisão de nomes em subclasses.
intermediario/cap29_encapsulamento.pylinhas 10 a 23
class Conta:
    def __init__(self):
        self.titular = "Ana"
        self._saldo = 100
        self.__pin = 1234


conta = Conta()
print(conta.titular, conta._saldo)
try:
    conta.__pin
except AttributeError as erro:
    print(erro)
print(conta._Conta__pin)

Saída

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 @property sem mudar nada em quem usa a classe, porque o acesso continua sendo conta.saldo:

intermediario/cap29_encapsulamento.pylinhas 28 a 49
class ContaBancaria:
    def __init__(self, saldo=0):
        self._saldo = saldo

    @property
    def saldo(self):
        return self._saldo

    @saldo.setter
    def saldo(self, valor):
        if valor < 0:
            raise ValueError("saldo não pode ser negativo")
        self._saldo = valor


c = ContaBancaria(50)
c.saldo = 80
print(c.saldo)
try:
    c.saldo = -1
except ValueError as erro:
    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
class Retangulo:
    def __init__(self, largura, altura):
        self.largura = largura
        self.altura = altura

    @property
    def area(self):
        return self.largura * self.altura


r = Retangulo(3, 4)
print(r.area)
try:
    r.area = 10
except AttributeError as erro:
    print(erro)

Saída

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
class Temperatura:
    def __init__(self, celsius=0):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, valor):
        if valor < -273.15:
            raise ValueError("abaixo do zero absoluto")
        self._celsius = valor

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, valor):
        self.celsius = (valor - 32) * 5 / 9


t = Temperatura(100)
assert t.fahrenheit == 212
t.fahrenheit = 32
assert t.celsius == 0
print("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
from abc import ABC, abstractmethod
import math


class Forma(ABC):
    @abstractmethod
    def area(self):
        ...

    def descrever(self):
        return f"{type(self).__name__} com área {self.area():.2f}"


class Circulo(Forma):
    def __init__(self, raio):
        self.raio = raio

    def area(self):
        return math.pi * self.raio ** 2


class Quadrado(Forma):
    def __init__(self, lado):
        self.lado = lado

    def area(self):
        return self.lado ** 2


for forma in (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:

intermediario/cap30_abstracao.pylinhas 45 a 58
try:
    Forma()
except TypeError as erro:
    print(erro)


class Incompleta(Forma):
    pass


try:
    Incompleta()
except TypeError as erro:
    print(erro)

Saída

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.

Ver solução
intermediario/cap30_abstracao.pylinhas 63 a 87
from abc import ABC, abstractmethod


class Notificador(ABC):
    @abstractmethod
    def enviar(self, mensagem):
        ...


class Email(Notificador):
    def enviar(self, mensagem):
        return f"email: {mensagem}"


class SMS(Notificador):
    def enviar(self, mensagem):
        return f"sms: {mensagem}"


def avisar(notificadores, mensagem):
    return [n.enviar(mensagem) for n in notificadores]


assert avisar([Email(), SMS()], "oi") == ["email: oi", "sms: oi"]
print("ok")

Saída

ok

Capítulo 31, parte Intermediário

Métodos especiais

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__:

intermediario/cap31_dunders.pylinhas 10 a 51
class Vetor:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"Vetor({self.x}, {self.y})"

    def __add__(self, outro):
        if not isinstance(outro, Vetor):
            return NotImplemented
        return Vetor(self.x + outro.x, self.y + outro.y)

    def __mul__(self, escalar):
        if not isinstance(escalar, (int, float)):
            return NotImplemented
        return Vetor(self.x * escalar, self.y * escalar)

    __rmul__ = __mul__

    def __eq__(self, outro):
        if not isinstance(outro, Vetor):
            return NotImplemented
        return (self.x, self.y) == (outro.x, outro.y)

    def __hash__(self):
        return hash((self.x, self.y))

    def __bool__(self):
        return bool(self.x or self.y)

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


v1, v2 = Vetor(1, 2), Vetor(3, 4)
print(v1 + v2, v1 * 3, 3 * v1)
print(v1 == Vetor(1, 2), bool(Vetor(0, 0)), abs(v2))
print(len({v1, Vetor(1, 2)}))
try:
    v1 + 5
except TypeError as erro:
    print(erro)

Saída

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:

intermediario/cap31_dunders.pylinhas 56 a 72
class Playlist:
    def __init__(self, *musicas):
        self._musicas = list(musicas)

    def __len__(self):
        return len(self._musicas)

    def __getitem__(self, indice):
        return self._musicas[indice]

    def __contains__(self, musica):
        return musica in self._musicas


p = Playlist("a", "b", "c")
print(len(p), p[0], p[-1], "b" in p)
print(list(p), list(reversed(p)))

Saída

3 a c True
['a', 'b', 'c'] ['c', 'b', 'a']

Objetos chamáveis

Com __call__, a instância pode ser chamada como uma função. Isso é útil quando a "função" precisa carregar estado:

intermediario/cap31_dunders.pylinhas 77 a 86
class Multiplicador:
    def __init__(self, fator):
        self.fator = fator

    def __call__(self, valor):
        return valor * self.fator


dobro = Multiplicador(2)
print(dobro(21), callable(dobro))

Saída

42 True

Ordenação sem escrever seis métodos

Para tornar objetos ordenáveis, o decorador total_ordering completa os operadores de comparação a partir de __eq__ e __lt__:

intermediario/cap31_dunders.pylinhas 91 a 110
from functools import total_ordering


@total_ordering
class Versao:
    def __init__(self, texto):
        self.partes = tuple(int(p) for p in texto.split("."))

    def __eq__(self, outro):
        return self.partes == outro.partes

    def __lt__(self, outro):
        return self.partes < outro.partes

    def __repr__(self):
        return "Versao(" + ".".join(map(str, self.partes)) + ")"


print(sorted([Versao("1.10.0"), Versao("1.2.0"), Versao("1.9.5")]))
print(Versao("2.0") > Versao("1.9"))

Saída

[Versao(1.2.0), Versao(1.9.5), Versao(1.10.0)]
True

Repare por que comparar versões como texto dá errado: "1.10" vem antes de "1.9" na ordem alfabética. Comparar tuplas de inteiros resolve.

Exercício 1

Uma classe Dinheiro

Crie Dinheiro(centavos) que suporte +, igualdade, ordenação e str() no formato "R$ 10,50".

Ver solução
intermediario/cap31_dunders.pylinhas 115 a 149
from functools import total_ordering


@total_ordering
class Dinheiro:
    def __init__(self, centavos):
        self.centavos = centavos

    def __add__(self, outro):
        if not isinstance(outro, Dinheiro):
            return NotImplemented
        return Dinheiro(self.centavos + outro.centavos)

    def __eq__(self, outro):
        if not isinstance(outro, Dinheiro):
            return NotImplemented
        return self.centavos == outro.centavos

    def __lt__(self, outro):
        if not isinstance(outro, Dinheiro):
            return NotImplemented
        return self.centavos < outro.centavos

    def __hash__(self):
        return hash(self.centavos)

    def __str__(self):
        reais, centavos = divmod(self.centavos, 100)
        return f"R$ {reais},{centavos:02d}"


assert str(Dinheiro(1050)) == "R$ 10,50"
assert Dinheiro(100) + Dinheiro(250) == Dinheiro(350)
assert Dinheiro(100) < Dinheiro(101)
print("ok")

Saída

ok

Capítulo 32, parte Intermediário

Comprehensions

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
quadrados = [n ** 2 for n in range(6)]
pares = [n for n in range(10) if n % 2 == 0]
print(quadrados)
print(pares)

palavras = ["python", "go", "rust"]
tamanhos = {p: len(p) for p in palavras}
unicos = {len(p) for p in palavras}
print(tamanhos, sorted(unicos))

Saída

[0, 1, 4, 9, 16, 25]
[0, 2, 4, 6, 8]
{'python': 6, 'go': 2, 'rust': 4} [2, 4, 6]

Quando a transformação tem uma escolha, a expressão condicional vai antes do for. O if do final só filtra:

intermediario/cap32_comprehensions.pylinhas 20 a 21
rotulos = ["par" if n % 2 == 0 else "ímpar" for n in range(4)]
print(rotulos)

Saída

['par', 'ímpar', 'par', 'ímpar']

Compreensões aninhadas

Dois for na mesma compreensão percorrem as duas dimensões, do externo para o interno, na mesma ordem em que você escreveria os laços:

intermediario/cap32_comprehensions.pylinhas 26 a 30
matriz = [[1, 2, 3], [4, 5, 6]]
achatada = [n for linha in matriz for n in linha]
transposta = [[linha[i] for linha in matriz] for i in range(3)]
print(achatada)
print(transposta)

Saída

[1, 2, 3, 4, 5, 6]
[[1, 4], [2, 5], [3, 6]]

Expressões geradoras

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
soma = sum(n ** 2 for n in range(1000))
print(soma)

nomes = ["Ana", "Bia", "Caio"]
print(any(len(n) > 3 for n in nomes), all(n[0].isupper() for n in nomes))

Saída

332833500
True True

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
def transpor(matriz):
    return [list(coluna) for coluna in zip(*matriz)]


assert transpor([[1, 2, 3], [4, 5, 6]]) == [[1, 4], [2, 5], [3, 6]]
print("ok")

Saída

ok

Exercício 2

Inverter chaves e valores

Escreva inverter(d) que troque chaves e valores de um dicionário.

Ver solução
intermediario/cap32_comprehensions.pylinhas 54 a 59
def inverter(d):
    return {valor: chave for chave, valor in d.items()}


assert inverter({"a": 1, "b": 2}) == {1: "a", 2: "b"}
print("ok")

Saída

ok

Capítulo 33, parte Intermediário

Funções de ordem superior

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
pessoas = [("Ana", 31), ("Bia", 25), ("Caio", 31)]
print(sorted(pessoas, key=lambda p: p[1]))
print(sorted(pessoas, key=lambda p: (-p[1], p[0])))
print(max(pessoas, key=lambda p: p[1]))

Saída

[('Bia', 25), ('Ana', 31), ('Caio', 31)]
[('Ana', 31), ('Caio', 31), ('Bia', 25)]
('Ana', 31)

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
from operator import itemgetter

produtos = [
    {"nome": "caneta", "preco": 3.5},
    {"nome": "caderno", "preco": 18.9},
    {"nome": "lápis", "preco": 1.2},
]
print([p["nome"] for p in sorted(produtos, key=itemgetter("preco"))])

Saída

['lápis', 'caneta', 'caderno']

map, filter e zip

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
numeros = [1, 2, 3, 4, 5]
dobrados = list(map(lambda n: n * 2, numeros))
pares = list(filter(lambda n: n % 2 == 0, numeros))
print(dobrados, pares)
print(type(map(str, numeros)).__name__)

Saída

[2, 4, 6, 8, 10] [2, 4]
map

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
nomes = ["Ana", "Bia", "Caio"]
notas = [9, 8]
print(list(zip(nomes, notas)))
try:
    list(zip(nomes, notas, strict=True))
except ValueError as erro:
    print(erro)

Saída

[('Ana', 9), ('Bia', 8)]
zip() argument 2 is shorter than argument 1

reduce e partial

O reduce acumula uma sequência em um único valor. O partial "congela" alguns argumentos de uma função e devolve uma nova:

intermediario/cap33_ordem_superior.pylinhas 44 a 55
from functools import partial, reduce
import operator


def potencia(base, expoente):
    return base ** expoente


quadrado = partial(potencia, expoente=2)
cubo = partial(potencia, expoente=3)
print(reduce(operator.mul, [1, 2, 3, 4], 1))
print(quadrado(5), cubo(2))

Saída

24
25 8

Não atribua uma lambda a um nome

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
def ordenar_alunos(alunos):
    return sorted(alunos, key=lambda a: (-a["nota"], a["nome"]))


alunos = [
    {"nome": "Caio", "nota": 8},
    {"nome": "Ana", "nota": 9},
    {"nome": "Bia", "nota": 8},
]
assert [a["nome"] for a in ordenar_alunos(alunos)] == ["Ana", "Bia", "Caio"]
print("ok")

Saída

ok

Capítulo 34, parte Intermediário

Iteradores e geradores

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:

intermediario/cap34_geradores.pylinhas 10 a 16
lista = [1, 2, 3]
iterador = iter(lista)
print(next(iterador), next(iterador), next(iterador))
try:
    next(iterador)
except StopIteration:
    print("acabou")

Saída

1 2 3
acabou

Você pode criar o seu próprio iterador com uma classe que tenha __iter__ e __next__:

intermediario/cap34_geradores.pylinhas 18 a 33
class Contagem:
    def __init__(self, limite):
        self.limite = limite
        self.atual = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.atual >= self.limite:
            raise StopIteration
        self.atual += 1
        return self.atual


print(list(Contagem(4)))

Saída

[1, 2, 3, 4]

Funções geradoras com yield

Escrever uma classe só para isso é trabalhoso. Uma função com yield vira um gerador automaticamente. Ela pausa a cada yield e retoma de onde parou:

intermediario/cap34_geradores.pylinhas 38 a 47
def contagem(limite):
    atual = 1
    while atual <= limite:
        yield atual
        atual += 1


print(list(contagem(4)))
gerador = contagem(2)
print(next(gerador), next(gerador))

Saída

[1, 2, 3, 4]
1 2

Preguiça é economia

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:

intermediario/cap34_geradores.pylinhas 52 a 57
import sys

lista = [n for n in range(100_000)]
gerador = (n for n in range(100_000))
print(sys.getsizeof(gerador) < sys.getsizeof(lista))
print(sum(gerador))

Saída

True
4999950000

Como o gerador não tem fim definido, ele pode até ser infinito, desde que quem consome pare no momento certo:

intermediario/cap34_geradores.pylinhas 59 a 69
from itertools import islice


def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b


print(list(islice(fibonacci(), 10)))

Saída

[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Pipelines

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:

intermediario/cap34_geradores.pylinhas 74 a 86
def sem_vazias(linhas):
    for linha in linhas:
        if linha.strip():
            yield linha


def numerar(linhas):
    for numero, linha in enumerate(linhas, start=1):
        yield f"{numero}: {linha}"


texto = ["primeira", "", "segunda", "  ", "terceira"]
print(list(numerar(sem_vazias(texto))))

Saída

['1: primeira', '2: segunda', '3: terceira']

Um gerador só pode ser percorrido uma vez

Depois de esgotado, o gerador não volta. Esta é a pegadinha mais comum:

intermediario/cap34_geradores.pylinhas 91 a 92
g = (n for n in range(3))
print(list(g), list(g))

Saída

[0, 1, 2] []

Se você precisa percorrer duas vezes, guarde em uma lista ou recrie o gerador.

Em lotes

Desde o Python 3.12, itertools.batched divide qualquer iterável em grupos de tamanho fixo, o que é útil para enviar dados a uma API em blocos:

intermediario/cap34_geradores.pylinhas 97 a 99
from itertools import batched

print(list(batched("abcdefg", 3)))

Saída

[('a', 'b', 'c'), ('d', 'e', 'f'), ('g',)]

Exercício 1

Dividir em lotes

Escreva o gerador lotes(iteravel, tamanho) que devolva listas de até tamanho itens, usando islice.

Ver solução
intermediario/cap34_geradores.pylinhas 104 a 117
from itertools import islice


def lotes(iteravel, tamanho):
    iterador = iter(iteravel)
    while True:
        lote = list(islice(iterador, tamanho))
        if not lote:
            return
        yield lote


assert list(lotes(range(7), 3)) == [[0, 1, 2], [3, 4, 5], [6]]
print("ok")

Saída

ok

Capítulo 35, parte Intermediário

Closures e decoradores

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:

intermediario/cap35_decoradores.pylinhas 10 a 24
def criar_contador():
    total = 0

    def contar():
        nonlocal total
        total += 1
        return total

    return contar


c = criar_contador()
print(c(), c(), c())
outro = criar_contador()
print(outro())

Saída

1 2 3
1

Cada chamada de criar_contador cria um total próprio. É um jeito leve de guardar estado sem escrever uma classe.

O decorador mais simples, e o erro que quase todo mundo comete

Um decorador troca a função por um wrapper. Se o wrapper não aceitar os argumentos da função original, a função decorada deixa de funcionar:

intermediario/cap35_decoradores.pylinhas 29 a 43
def decorador_ruim(funcao):
    def wrapper():
        return funcao()
    return wrapper


@decorador_ruim
def soma(a, b):
    return a + b


try:
    soma(2, 3)
except TypeError as erro:
    print(erro)

Saída

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
import functools


def registrar(funcao):
    @functools.wraps(funcao)
    def wrapper(*args, **kwargs):
        resultado = funcao(*args, **kwargs)
        print(f"{funcao.__name__}{args} -> {resultado}")
        return resultado
    return wrapper


@registrar
def multiplicar(a, b):
    """Multiplica dois números."""
    return a * b


multiplicar(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:

intermediario/cap35_decoradores.pylinhas 69 a 86
def repetir(vezes):
    def decorador(funcao):
        @functools.wraps(funcao)
        def wrapper(*args, **kwargs):
            resultado = None
            for _ in range(vezes):
                resultado = funcao(*args, **kwargs)
            return resultado
        return wrapper
    return decorador


@repetir(3)
def avisar(texto):
    print(texto)


avisar("olá")

Saída

olá
olá
olá

Um exemplo útil: tentar de novo

Chamadas de rede falham de vez em quando. Um decorador de tentativas separa essa política do código de negócio:

intermediario/cap35_decoradores.pylinhas 91 a 118
def tentar_novamente(tentativas):
    def decorador(funcao):
        @functools.wraps(funcao)
        def wrapper(*args, **kwargs):
            ultimo_erro = None
            for numero in range(1, tentativas + 1):
                try:
                    return funcao(*args, **kwargs)
                except ConnectionError as erro:
                    ultimo_erro = erro
                    print(f"tentativa {numero} falhou")
            raise ultimo_erro
        return wrapper
    return decorador


chamadas = {"total": 0}


@tentar_novamente(3)
def buscar():
    chamadas["total"] += 1
    if chamadas["total"] < 3:
        raise ConnectionError("rede fora")
    return "dados"


print(buscar())

Saída

tentativa 1 falhou
tentativa 2 falhou
dados

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
from functools import cache


@cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)


print(fib(80))
print(fib.cache_info().hits > 0)

Saída

23416728348467685
True

Cache só para funções puras

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
def contar_chamadas(funcao):
    @functools.wraps(funcao)
    def wrapper(*args, **kwargs):
        wrapper.chamadas += 1
        return funcao(*args, **kwargs)

    wrapper.chamadas = 0
    return wrapper


@contar_chamadas
def ola():
    return "oi"


ola()
ola()
assert ola.chamadas == 2
assert ola.__name__ == "ola"
print("ok")

Saída

ok

Capítulo 36, parte Intermediário

dataclasses

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:

intermediario/cap36_dataclasses.pylinhas 10 a 22
from dataclasses import dataclass, field, asdict, replace


@dataclass
class Produto:
    nome: str
    preco: float
    tags: list[str] = field(default_factory=list)


p = Produto("caneta", 3.5)
print(p)
print(p == Produto("caneta", 3.5))

Saída

Produto(nome='caneta', preco=3.5, tags=[])
True

O valor padrão mutável, outra vez

O mesmo problema do capítulo 18 volta aqui, e a dataclass o impede com um erro claro. Para campos mutáveis, use field(default_factory=...):

intermediario/cap36_dataclasses.pylinhas 27 a 32
try:
    @dataclass
    class Ruim:
        itens: list = []
except ValueError as erro:
    print(erro)

Saída

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:

intermediario/cap36_dataclasses.pylinhas 37 a 48
@dataclass(frozen=True, order=True, slots=True)
class Versao:
    major: int
    minor: int = 0


v1, v2 = Versao(1, 2), Versao(1, 10)
print(v1 < v2, sorted([v2, v1]))
try:
    v1.major = 5
except Exception as erro:
    print(type(erro).__name__)

Saída

True [Versao(major=1, minor=2), Versao(major=1, minor=10)]
FrozenInstanceError

Validação, cópia e conversão

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
@dataclass
class Pedido:
    cliente: str
    itens: list[tuple[str, float]] = field(default_factory=list)

    def __post_init__(self):
        if not self.cliente:
            raise ValueError("cliente é obrigatório")

    @property
    def total(self):
        return sum(preco for _, preco in self.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
@dataclass
class Livro:
    titulo: str
    paginas: int

    def __post_init__(self):
        if self.paginas <= 0:
            raise ValueError("páginas devem ser positivas")


assert Livro("Dom Casmurro", 256).paginas == 256
try:
    Livro("Vazio", 0)
except ValueError:
    pass
else:
    raise AssertionError("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:

intermediario/cap37_type_hints.pylinhas 10 a 16
def saudar(nome: str, vezes: int = 1) -> str:
    return (f"Olá, {nome}! " * vezes).strip()


print(saudar("Ana", 2))
print(saudar.__annotations__)
print(saudar(123))

Saída

Olá, Ana! Olá, Ana!
{'nome': <class 'str'>, 'vezes': <class 'int'>, 'return': <class 'str'>}
Olá, 123!

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:

intermediario/cap37_type_hints.pylinhas 21 a 29
def media(valores: list[float]) -> float:
    return sum(valores) / len(valores)


def buscar(config: dict[str, int], chave: str) -> int | None:
    return config.get(chave)


print(media([1.0, 2.0, 3.0]), buscar({"porta": 80}, "host"))

Saída

2.0 None

Aliases, funções e estruturas

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:

intermediario/cap37_type_hints.pylinhas 34 a 56
from collections.abc import Callable
from typing import NamedTuple, TypedDict

type Predicado = Callable[[int], bool]


def filtrar(numeros: list[int], teste: Predicado) -> list[int]:
    return [n for n in numeros if teste(n)]


class Ponto(NamedTuple):
    x: int
    y: int


class Usuario(TypedDict):
    nome: str
    idade: int


u: Usuario = {"nome": "Ana", "idade": 30}
print(filtrar([1, 2, 3, 4], lambda n: n % 2 == 0))
print(Ponto(1, 2), u)

Saída

[2, 4]
Ponto(x=1, y=2) {'nome': 'Ana', 'idade': 30}

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:

Terminal
uv add --dev mypy
uv run mypy exemplos/tipos/erro_de_tipo.py

O arquivo a verificar chama uma função com o tipo errado:

exemplos/tipos/erro_de_tipo.py
def dobrar(valor: int) -> int:
    return valor * 2


dobrar("3")

Saída

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.

Ver solução
intermediario/cap37_type_hints.pylinhas 61 a 69
def agrupar_por_inicial(palavras: list[str]) -> dict[str, list[str]]:
    grupos: dict[str, list[str]] = {}
    for palavra in palavras:
        grupos.setdefault(palavra[0].lower(), []).append(palavra)
    return grupos


assert agrupar_por_inicial(["Ana", "aba", "Bia"]) == {"a": ["Ana", "aba"], "b": ["Bia"]}
print("ok")

Saída

ok

Capítulo 38, parte Intermediário

Gerenciadores de contexto

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
import time


class Cronometro:
    def __enter__(self):
        self.inicio = time.perf_counter()
        return self

    def __exit__(self, tipo_exc, valor_exc, traceback):
        self.duracao = time.perf_counter() - self.inicio
        return False


with Cronometro() as cron:
    sum(range(100_000))
print(cron.duracao >= 0)

Saída

True

O valor que o __exit__ devolve decide o destino da exceção: False deixa ela seguir, True a suprime. O __exit__ roda mesmo quando o bloco falha:

intermediario/cap38_context_managers.pylinhas 27 a 38
class Ignorar:
    def __enter__(self):
        return self

    def __exit__(self, tipo_exc, valor_exc, traceback):
        print("saindo, erro:", tipo_exc.__name__ if tipo_exc else None)
        return tipo_exc is ZeroDivisionError


with Ignorar():
    1 / 0
print("continuou")

Saída

saindo, erro: ZeroDivisionError
continuou

A forma curta: contextmanager

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
from contextlib import contextmanager


@contextmanager
def secao(titulo):
    print(f"[início] {titulo}")
    try:
        yield
    finally:
        print(f"[fim] {titulo}")


with secao("importação"):
    print("trabalhando")

Saída

[início] importação
trabalhando
[fim] importação

Ferramentas prontas

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
from contextlib import suppress
from pathlib import Path
import tempfile

with suppress(FileNotFoundError):
    Path("nao_existe.txt").unlink()
print("sem erro")

with tempfile.TemporaryDirectory() as pasta:
    arquivo = Path(pasta) / "tmp.txt"
    arquivo.write_text("oi", encoding="utf-8")
    print(arquivo.exists())
print(Path(pasta).exists())

Saída

sem erro
True
False

Exercício 1

Mudar de pasta e voltar

Escreva mudar_pasta(destino), um gerenciador de contexto que mude a pasta de trabalho e sempre a restaure no fim, mesmo se o bloco falhar.

Ver solução
intermediario/cap38_context_managers.pylinhas 78 a 96
import os


@contextmanager
def mudar_pasta(destino):
    original = os.getcwd()
    os.chdir(destino)
    try:
        yield
    finally:
        os.chdir(original)


antes = os.getcwd()
with tempfile.TemporaryDirectory() as pasta:
    with mudar_pasta(pasta):
        assert os.getcwd() == os.path.realpath(pasta)
assert os.getcwd() == antes
print("ok")

Saída

ok

Capítulo 39, parte Intermediário

Biblioteca padrão essencial

Antes de instalar um pacote, procure na biblioteca padrão. Estes são os módulos que eu uso toda semana.

Código deste capítulo: intermediario/cap39_stdlib.py

collections e itertools

O deque com maxlen é a estrutura natural para "os últimos N itens". O itertools combina e agrupa iteráveis sem criar listas intermediárias:

intermediario/cap39_stdlib.pylinhas 10 a 24
from collections import deque
from itertools import chain, combinations, groupby, product

ultimos = deque(maxlen=3)
for n in range(6):
    ultimos.append(n)
print(list(ultimos))

print(list(chain([1, 2], [3])))
print(list(combinations("abc", 2)))
print(list(product([0, 1], repeat=2)))

vendas = [("sul", 10), ("sul", 5), ("norte", 7)]
for regiao, itens in groupby(sorted(vendas), key=lambda v: v[0]):
    print(regiao, sum(valor for _, valor in itens))

Saída

[3, 4, 5]
[1, 2, 3]
[('a', 'b'), ('a', 'c'), ('b', 'c')]
[(0, 0), (0, 1), (1, 0), (1, 1)]
norte 7
sul 15

O groupby só agrupa itens consecutivos iguais, por isso o sorted antes. Esquecer isso é o erro mais comum com ele.

json

O JSON é o formato de troca de dados da internet. O ensure_ascii=False mantém os acentos legíveis, e o indent formata:

intermediario/cap39_stdlib.pylinhas 29 a 35
import json

dados = {"nome": "Ana", "idade": 30, "tags": ["a", "b"], "ativo": True, "nota": None}
texto = json.dumps(dados, ensure_ascii=False, indent=2)
print(texto)
volta = json.loads(texto)
print(volta == dados)

Saída

{
  "nome": "Ana",
  "idade": 30,
  "tags": [
    "a",
    "b"
  ],
  "ativo": true,
  "nota": null
}
True

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=""):

intermediario/cap39_stdlib.pylinhas 40 a 52
import csv
import io

conteudo = "produto,preco\ncaneta,3.5\ncaderno,18.9\n"
leitor = csv.DictReader(io.StringIO(conteudo))
total = sum(float(linha["preco"]) for linha in leitor)
print(round(total, 2))

saida = io.StringIO()
escritor = csv.DictWriter(saida, fieldnames=["produto", "preco"], lineterminator="\n")
escritor.writeheader()
escritor.writerow({"produto": "lápis", "preco": 1.2})
print(saida.getvalue().strip())

Saída

22.4
produto,preco
lápis,1.2

datetime e fusos horários

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:

intermediario/cap39_stdlib.pylinhas 57 a 62
from datetime import datetime, timedelta, timezone

agora = datetime(2026, 10, 6, 14, 30, tzinfo=timezone.utc)
print(agora.isoformat())
brasilia = timezone(timedelta(hours=-3))
print(agora.astimezone(brasilia).strftime("%d/%m/%Y %H:%M"))

Saída

2026-10-06T14:30:00+00:00
06/10/2026 11:30

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:

intermediario/cap39_stdlib.pylinhas 67 a 73
import re

texto = "Contato: ana@exemplo.com, bia@teste.org"
print(re.findall(r"[\w.]+@[\w.]+", texto))
m = re.search(r"(?P<usuario>\w+)@(?P<dominio>[\w.]+)", texto)
print(m["usuario"], m["dominio"])
print(re.sub(r"\d", "#", "tel 1234-5678"))

Saída

['ana@exemplo.com', 'bia@teste.org']
ana exemplo.com
tel ####-####

enum

Um Enum dá nome a um conjunto fixo de valores e evita as "strings mágicas" espalhadas pelo código:

intermediario/cap39_stdlib.pylinhas 78 a 86
from enum import Enum, auto


class Status(Enum):
    ABERTO = auto()
    FECHADO = auto()


print(Status.ABERTO, Status.ABERTO.name, Status["FECHADO"].value)

Saída

Status.ABERTO ABERTO 2

Exercício 1

Total por região a partir de um CSV

Escreva total_por_regiao(texto) que receba um CSV com as colunas regiao e valor e devolva um dicionário com a soma de cada região.

Ver solução
intermediario/cap39_stdlib.pylinhas 91 a 102
from collections import defaultdict


def total_por_regiao(texto):
    totais = defaultdict(float)
    for linha in csv.DictReader(io.StringIO(texto)):
        totais[linha["regiao"]] += float(linha["valor"])
    return dict(totais)


assert total_por_regiao("regiao,valor\nsul,10\nnorte,5\nsul,2.5\n") == {"sul": 12.5, "norte": 5.0}
print("ok")

Saída

ok

Capítulo 40, parte Intermediário

Testes com pytest

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
uv add --dev pytest

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
import pytest


def eh_primo(n: int) -> bool:
    if n < 2:
        return False
    for divisor in range(2, int(n ** 0.5) + 1):
        if n % divisor == 0:
            return False
    return True


def dividir(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("divisor não pode ser zero")
    return a / b

Agora os testes. O pytest.raises verifica que uma exceção acontece, e o parametrize roda o mesmo teste com vários dados:

intermediario/cap40_pytest.pylinhas 27 a 46
def test_primo_simples():
    assert eh_primo(7)
    assert not eh_primo(8)


def test_dividir():
    assert dividir(10, 4) == 2.5


def test_dividir_por_zero():
    with pytest.raises(ZeroDivisionError, match="zero"):
        dividir(1, 0)


@pytest.mark.parametrize(
    "numero, esperado",
    [(0, False), (1, False), (2, True), (17, True), (18, False)],
)
def test_eh_primo_parametrizado(numero, esperado):
    assert eh_primo(numero) is esperado

Fixtures

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):

intermediario/cap40_pytest.pylinhas 51 a 68
@pytest.fixture
def carrinho():
    return {"itens": [], "total": 0.0}


def test_carrinho_comeca_vazio(carrinho):
    assert carrinho["itens"] == []


def test_tmp_path(tmp_path):
    arquivo = tmp_path / "dados.txt"
    arquivo.write_text("oi", encoding="utf-8")
    assert arquivo.read_text(encoding="utf-8") == "oi"


def test_capsys(capsys):
    print("olá")
    assert capsys.readouterr().out == "olá\n"
intermediario/cap40_pytest.pylinhas 70 a 84
import os


def ler_ambiente():
    return os.environ.get("AMBIENTE", "desenvolvimento")


def test_ambiente_padrao(monkeypatch):
    monkeypatch.delenv("AMBIENTE", raising=False)
    assert ler_ambiente() == "desenvolvimento"


def test_ambiente_producao(monkeypatch):
    monkeypatch.setenv("AMBIENTE", "producao")
    assert ler_ambiente() == "producao"

O que testar

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.

Ver solução
intermediario/cap40_pytest.pylinhas 89 a 96
def celsius_para_fahrenheit(c):
    return c * 9 / 5 + 32


def test_conversao():
    assert celsius_para_fahrenheit(100) == 212
    assert celsius_para_fahrenheit(-40) == -40
    assert celsius_para_fahrenheit(36.6) == pytest.approx(97.88)

Rodar os testes

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
import logging
import sys

logging.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:

intermediario/cap41_logging_cli.pylinhas 26 a 27
logger = logging.getLogger(__name__)
logger.info("módulo %s", __name__)

Saída

INFO __main__: módulo __main__

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 / 0
except ZeroDivisionError:
    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:

intermediario/cap41_logging_cli.pylinhas 37 a 58
import json


class FormatoJson(logging.Formatter):
    def format(self, record):
        return json.dumps(
            {
                "nivel": record.levelname,
                "logger": record.name,
                "mensagem": record.getMessage(),
            },
            ensure_ascii=False,
        )


manipulador = logging.StreamHandler(sys.stdout)
manipulador.setFormatter(FormatoJson())
auditoria = logging.getLogger("auditoria")
auditoria.addHandler(manipulador)
auditoria.propagate = False
auditoria.setLevel(logging.INFO)
auditoria.info("login realizado")

Saída

{"nivel": "INFO", "logger": "auditoria", "mensagem": "login realizado"}

Argumentos de linha de comando

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
import argparse


def criar_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")
    return parser


def main(argv=None):
    args = criar_parser().parse_args(argv)
    texto = f"Olá, {args.nome}!"
    if args.gritar:
        texto = texto.upper()
    for _ in range(args.vezes):
        print(texto)
    return 0


main(["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__":
    raise SystemExit(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"])
except SystemExit as codigo:
    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:

intermediario/cap41_logging_cli.pylinhas 91 a 100
def parser_com_subcomandos():
    parser = argparse.ArgumentParser(prog="tarefas")
    sub = parser.add_subparsers(dest="comando", required=True)
    adicionar = sub.add_parser("adicionar")
    adicionar.add_argument("titulo")
    sub.add_parser("listar")
    return parser


print(parser_com_subcomandos().parse_args(["adicionar", "estudar"]))

Saída

Namespace(comando='adicionar', titulo='estudar')

Exercício 1

Somar números pela linha de comando

Escreva main_soma(argv) que receba um ou mais números como argumentos e devolva a soma.

Ver solução
intermediario/cap41_logging_cli.pylinhas 105 a 113
def main_soma(argv=None):
    parser = argparse.ArgumentParser()
    parser.add_argument("numeros", nargs="+", type=float)
    args = parser.parse_args(argv)
    return sum(args.numeros)


assert main_soma(["1", "2.5"]) == 3.5
print("ok")

Saída

ok

Capítulo 42, parte Avançado

O modelo de dados e os protocolos

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:

avancado/cap42_modelo_dados.pylinhas 10 a 34
import random
from collections import namedtuple

Carta = namedtuple("Carta", ["valor", "naipe"])


class Baralho:
    valores = [str(n) for n in range(2, 11)] + list("JQKA")
    naipes = ["paus", "ouros", "copas", "espadas"]

    def __init__(self):
        self._cartas = [Carta(v, n) for n in self.naipes for v in self.valores]

    def __len__(self):
        return len(self._cartas)

    def __getitem__(self, posicao):
        return self._cartas[posicao]


baralho = Baralho()
print(len(baralho), baralho[0], baralho[-1])
print(baralho[:3])
print(Carta("Q", "copas") in baralho)
print(random.choice(baralho) in baralho)

Saída

52 Carta(valor='2', naipe='paus') Carta(valor='A', naipe='espadas')
[Carta(valor='2', naipe='paus'), Carta(valor='3', naipe='paus'), Carta(valor='4', naipe='paus')]
True
True

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:

avancado/cap42_modelo_dados.pylinhas 39 a 41
from collections.abc import Container, Iterable, Sequence, Sized

print(isinstance(baralho, Sized), isinstance(baralho, Iterable), isinstance(baralho, Container))

Saída

True False False

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:

avancado/cap42_modelo_dados.pylinhas 43 a 56
class BaralhoSeq(Sequence):
    def __init__(self):
        self._cartas = Baralho()._cartas

    def __len__(self):
        return len(self._cartas)

    def __getitem__(self, posicao):
        return self._cartas[posicao]


b = BaralhoSeq()
print(isinstance(b, Sequence), Carta("A", "copas") in b)
print(b.index(Carta("3", "paus")), b.count(Carta("2", "paus")))

Saída

True True
1 1

O contrato entre __eq__ e __hash__

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:

avancado/cap42_modelo_dados.pylinhas 61 a 89
class Chave:
    def __init__(self, valor):
        self.valor = valor

    def __eq__(self, outro):
        return self.valor == outro.valor


try:
    {Chave(1)}
except TypeError as erro:
    print(erro)


class Mutavel:
    def __init__(self, v):
        self.v = v

    def __eq__(self, outro):
        return self.v == outro.v

    def __hash__(self):
        return hash(self.v)


m = Mutavel(1)
conjunto = {m}
m.v = 2
print(m in conjunto)

Saída

unhashable type: 'Chave'
False

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:

avancado/cap42_modelo_dados.pylinhas 94 a 117
class Config:
    def __init__(self, **dados):
        self._dados = dados

    def __getattr__(self, nome):
        try:
            return self._dados[nome]
        except KeyError:
            raise AttributeError(nome) from None


c = Config(porta=8080)
print(c.porta, getattr(c, "host", "padrão"), hasattr(c, "porta"))


class Dinheiro:
    def __init__(self, centavos):
        self.centavos = centavos

    def __format__(self, formato):
        return format(self.centavos / 100, formato or ".2f")


print(f"{Dinheiro(1050)}", f"{Dinheiro(1050):.1f}")

Saída

8080 padrão True
10.50 10.5

Cuidado com __getattr__ e atributos internos

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

Ver solução
avancado/cap42_modelo_dados.pylinhas 122 a 145
class Intervalo:
    def __init__(self, inicio, fim):
        self.inicio = inicio
        self.fim = fim

    def __len__(self):
        return max(0, self.fim - self.inicio)

    def __getitem__(self, indice):
        if indice < 0:
            indice += len(self)
        if not 0 <= indice < len(self):
            raise IndexError(indice)
        return self.inicio + indice

    def __contains__(self, valor):
        return self.inicio <= valor < self.fim


assert list(Intervalo(3, 6)) == [3, 4, 5]
assert 4 in Intervalo(3, 6)
assert 6 not in Intervalo(3, 6)
assert Intervalo(3, 6)[-1] == 5
print("ok")

Saída

ok

Capítulo 43, parte Avançado

MRO, super cooperativo e mixins

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:

avancado/cap43_mro_mixins.pylinhas 10 a 31
class Base:
    def ola(self):
        return ["Base"]


class Esquerda(Base):
    def ola(self):
        return ["Esquerda"] + super().ola()


class Direita(Base):
    def ola(self):
        return ["Direita"] + super().ola()


class Topo(Esquerda, Direita):
    def ola(self):
        return ["Topo"] + super().ola()


print(Topo().ola())
print([c.__name__ for c in Topo.__mro__])

Saída

['Topo', 'Esquerda', 'Direita', 'Base']
['Topo', 'Esquerda', 'Direita', 'Base', 'object']

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):

avancado/cap43_mro_mixins.pylinhas 36 a 53
class Nomeavel:
    def __init__(self, *, nome, **kwargs):
        super().__init__(**kwargs)
        self.nome = nome


class Datavel:
    def __init__(self, *, data, **kwargs):
        super().__init__(**kwargs)
        self.data = data


class Evento(Nomeavel, Datavel):
    pass


e = Evento(nome="reunião", data="2026-10-06")
print(e.nome, e.data)

Saída

reunião 2026-10-06

Mixins

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:

avancado/cap43_mro_mixins.pylinhas 58 a 80
import json


class JsonMixin:
    def para_json(self):
        return json.dumps(vars(self), ensure_ascii=False)


class ReprMixin:
    def __repr__(self):
        campos = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
        return f"{type(self).__name__}({campos})"


class Usuario(JsonMixin, ReprMixin):
    def __init__(self, nome, idade):
        self.nome = nome
        self.idade = idade


u = Usuario("Ana", 30)
print(u)
print(u.para_json())

Saída

Usuario(nome='Ana', idade=30)
{"nome": "Ana", "idade": 30}

Ganchos de subclasse

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:

avancado/cap43_mro_mixins.pylinhas 85 a 101
class Comando:
    comandos = {}

    def __init_subclass__(cls, nome=None, **kwargs):
        super().__init_subclass__(**kwargs)
        Comando.comandos[nome or cls.__name__.lower()] = cls


class Listar(Comando, nome="ls"):
    pass


class Remover(Comando):
    pass


print(sorted(Comando.comandos))

Saída

['ls', 'remover']

Quando o MRO não fecha

Se a ordem das bases contradiz a hierarquia, o Python recusa e diz por quê:

avancado/cap43_mro_mixins.pylinhas 106 a 118
class X:
    pass


class Y(X):
    pass


try:
    class Z(X, Y):
        pass
except TypeError as erro:
    print(erro)

Saída

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.

Ver solução
avancado/cap43_mro_mixins.pylinhas 123 a 125
assert set(Comando.comandos) == {"ls", "remover"}
assert Comando.comandos["ls"] is Listar
print("ok")

Saída

ok

Capítulo 44, parte Avançado

Tipagem avançada

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:

avancado/cap44_tipagem_avancada.pylinhas 10 a 34
from typing import Protocol, runtime_checkable


@runtime_checkable
class Fechavel(Protocol):
    def fechar(self) -> None: ...


class Arquivo:
    def fechar(self) -> None:
        print("arquivo fechado")


class Conexao:
    def fechar(self) -> None:
        print("conexão fechada")


def encerrar(recurso: Fechavel) -> None:
    recurso.fechar()


for recurso in (Arquivo(), Conexao()):
    encerrar(recurso)
print(isinstance(Arquivo(), Fechavel), isinstance(42, Fechavel))

Saída

arquivo fechado
conexão fechada
True False

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
from typing import Protocol


class Fechavel(Protocol):
    def fechar(self) -> None: ...


class SemFechar:
    pass


def encerrar(recurso: Fechavel) -> None:
    recurso.fechar()


encerrar(SemFechar())
Terminal
uv run mypy exemplos/tipos/protocolo_erro.py

Saída

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:

avancado/cap44_tipagem_avancada.pylinhas 39 a 65
class Pilha[T]:
    def __init__(self) -> None:
        self._itens: list[T] = []

    def empilhar(self, item: T) -> None:
        self._itens.append(item)

    def desempilhar(self) -> T:
        return self._itens.pop()

    def __len__(self) -> int:
        return len(self._itens)


def primeiro[T](itens: list[T]) -> T:
    return itens[0]


def maximo[T: (int, float, str)](a: T, b: T) -> T:
    return a if a >= b else b


pilha = Pilha[int]()
pilha.empilhar(1)
pilha.empilhar(2)
print(pilha.desempilhar(), len(pilha), primeiro(["a", "b"]))
print(maximo(3, 7), maximo("a", "b"))

Saída

2 1 a
7 b

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
from typing import Final, Literal, NotRequired, Self, TypedDict

Modo = Literal["leitura", "escrita"]
LIMITE: Final = 3


class Opcoes(TypedDict):
    modo: Modo
    tentativas: NotRequired[int]


def abrir(opcoes: Opcoes) -> str:
    return f"{opcoes['modo']} com {opcoes.get('tentativas', LIMITE)} tentativas"


class Construtor:
    def __init__(self) -> None:
        self.partes: list[str] = []

    def adicionar(self, parte: str) -> Self:
        self.partes.append(parte)
        return self


print(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
import functools
from collections.abc import Callable
from typing import overload


@overload
def dobrar(x: int) -> int: ...
@overload
def dobrar(x: str) -> str: ...
def dobrar(x):
    return x * 2


def registrar[**P, R](funcao: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(funcao)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"chamando {funcao.__name__}")
        return funcao(*args, **kwargs)

    return wrapper


@registrar
def somar(a: int, b: int) -> int:
    return a + b


print(dobrar(4), dobrar("ab"))
print(somar(1, 2))

Saída

8 abab
chamando somar
3

Tipagem gradual

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
class Repositorio[T]:
    def __init__(self) -> None:
        self._itens: dict[int, T] = {}

    def salvar(self, id_: int, item: T) -> None:
        self._itens[id_] = item

    def buscar(self, id_: int) -> T | None:
        return self._itens.get(id_)


repo = Repositorio[str]()
repo.salvar(1, "a")
assert repo.buscar(1) == "a"
assert repo.buscar(2) is None
print("ok")

Saída

ok

Capítulo 45, parte Avançado

Descritores, __slots__ e metaclasses

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
class Positivo:
    def __set_name__(self, dono, nome):
        self.nome = "_" + nome

    def __get__(self, instancia, dono=None):
        if instancia is None:
            return self
        return getattr(instancia, self.nome)

    def __set__(self, instancia, valor):
        if valor <= 0:
            raise ValueError(f"{self.nome[1:]} deve ser positivo")
        setattr(instancia, self.nome, valor)


class Item:
    preco = Positivo()
    quantidade = Positivo()

    def __init__(self, preco, quantidade):
        self.preco = preco
        self.quantidade = quantidade


item = Item(10, 2)
print(item.preco * item.quantidade)
try:
    item.quantidade = 0
except ValueError as erro:
    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:

avancado/cap45_descritores_slots.pylinhas 42 a 48
class A:
    def metodo(self):
        pass


bruto = A.__dict__["metodo"]
print(type(bruto).__name__, hasattr(bruto, "__get__"))

Saída

function True

__slots__

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:

avancado/cap45_descritores_slots.pylinhas 53 a 82
import tracemalloc


class Comum:
    def __init__(self, x, y):
        self.x, self.y = x, y


class Enxuta:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x, self.y = x, y


def medir(classe):
    tracemalloc.start()
    objetos = [classe(i, i) for i in range(50_000)]
    atual, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return atual


print(medir(Enxuta) < medir(Comum))
e = Enxuta(1, 2)
print(hasattr(e, "__dict__"))
try:
    e.z = 3
except AttributeError as erro:
    print(erro)

Saída

True
False
'Enxuta' object has no attribute 'z'

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
Ponto = type("Ponto", (), {"x": 0, "ola": lambda self: "oi"})
print(Ponto().ola(), type(Ponto).__name__)


class Singleton(type):
    _instancias = {}

    def __call__(cls, *args, **kwargs):
        if cls not in Singleton._instancias:
            Singleton._instancias[cls] = super().__call__(*args, **kwargs)
        return Singleton._instancias[cls]


class Configuracao(metaclass=Singleton):
    def __init__(self):
        self.valores = {}


print(Configuracao() is Configuracao())

Saída

oi type
True

Evite metaclasses

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
class TextoCurto:
    def __init__(self, limite):
        self.limite = limite

    def __set_name__(self, dono, nome):
        self.nome = "_" + nome

    def __get__(self, instancia, dono=None):
        return self if instancia is None else getattr(instancia, self.nome)

    def __set__(self, instancia, valor):
        if len(valor) > self.limite:
            raise ValueError("texto longo demais")
        setattr(instancia, self.nome, valor)


class Perfil:
    bio = TextoCurto(5)


p = Perfil()
p.bio = "oi"
assert p.bio == "oi"
try:
    p.bio = "texto enorme"
except ValueError:
    pass
else:
    raise AssertionError("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:

avancado/cap46_concorrencia.pylinhas 10 a 27
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor


def baixar(identificador):
    time.sleep(0.2)
    return f"recurso {identificador}"


def contar_primos(limite):
    total = 0
    for n in range(2, limite):
        for divisor in range(2, int(n ** 0.5) + 1):
            if n % divisor == 0:
                break
        else:
            total += 1
    return total
avancado/cap46_concorrencia.pylinhas 29 a 40
if __name__ == "__main__":
    inicio = time.perf_counter()
    sequencial = [baixar(i) for i in range(5)]
    t_seq = time.perf_counter() - inicio

    inicio = time.perf_counter()
    with ThreadPoolExecutor(max_workers=5) as pool:
        concorrente = list(pool.map(baixar, range(5)))
    t_conc = time.perf_counter() - inicio

    print(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:

avancado/cap46_concorrencia.pylinhas 45 a 49
if __name__ == "__main__":
    limites = [20_000, 20_000, 20_000, 20_000]
    with ProcessPoolExecutor() as pool:
        resultados = list(pool.map(contar_primos, limites))
    print(resultados)

Saída

[2262, 2262, 2262, 2262]

Estado compartilhado exige trava

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:

avancado/cap46_concorrencia.pylinhas 54 a 73
import threading

contador = 0
trava = threading.Lock()


def incrementar(vezes):
    global contador
    for _ in range(vezes):
        with trava:
            contador += 1


if __name__ == "__main__":
    threads = [threading.Thread(target=incrementar, args=(10_000,)) for _ in range(4)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(contador)

Saída

40000

Comunicar por fila, em vez de compartilhar

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:

avancado/cap46_concorrencia.pylinhas 78 a 101
import queue


def produtor(fila, n):
    for i in range(n):
        fila.put(i)
    fila.put(None)


def consumidor(fila, resultados):
    while (item := fila.get()) is not None:
        resultados.append(item * item)


if __name__ == "__main__":
    fila = queue.Queue()
    resultados = []
    t1 = threading.Thread(target=produtor, args=(fila, 5))
    t2 = threading.Thread(target=consumidor, args=(fila, resultados))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(resultados)

Saída

[0, 1, 4, 9, 16]

O None no final é uma sentinela: o produtor avisa ao consumidor que acabou. É o padrão mais simples para encerrar uma fila.

Exercício 1

Baixar vários recursos com um limite de threads

Escreva baixar_todos(ids, max_workers=4) que use ThreadPoolExecutor e devolva os resultados na ordem dos ids.

Ver solução
avancado/cap46_concorrencia.pylinhas 106 a 113
def baixar_todos(ids, max_workers=4):
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        return list(pool.map(baixar, ids))


if __name__ == "__main__":
    assert baixar_todos([1, 2, 3]) == ["recurso 1", "recurso 2", "recurso 3"]
    print("ok")

Saída

ok

Capítulo 47, parte Avançado

asyncio

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:

avancado/cap47_assincrono.pylinhas 10 a 22
import asyncio


async def buscar(nome, atraso):
    await asyncio.sleep(atraso)
    return f"{nome} pronto"


async def principal():
    print(await buscar("a", 0.1))


asyncio.run(principal())

Saída

a pronto

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:

avancado/cap47_assincrono.pylinhas 27 a 36
async def varios():
    resultados = await asyncio.gather(
        buscar("lento", 0.3),
        buscar("rápido", 0.1),
        buscar("médio", 0.2),
    )
    print(resultados)


asyncio.run(varios())

Saída

['lento pronto', 'rápido pronto', 'médio pronto']

Quando você quer tratar cada resultado conforme ele chega, use as_completed:

avancado/cap47_assincrono.pylinhas 38 a 44
async def por_ordem_de_chegada():
    tarefas = [buscar("lento", 0.3), buscar("rápido", 0.1), buscar("médio", 0.2)]
    for futura in asyncio.as_completed(tarefas):
        print(await futura)


asyncio.run(por_ordem_de_chegada())

Saída

rápido pronto
médio pronto
lento pronto

TaskGroup e tempo limite

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
async def com_grupo():
    async with asyncio.TaskGroup() as grupo:
        t1 = grupo.create_task(buscar("x", 0.1))
        t2 = grupo.create_task(buscar("y", 0.2))
    print(t1.result(), t2.result())


async def com_timeout():
    try:
        async with asyncio.timeout(0.1):
            await buscar("demorado", 1)
    except TimeoutError:
        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:

avancado/cap47_assincrono.pylinhas 67 a 81
async def falha(nome):
    await asyncio.sleep(0.05)
    raise ValueError(nome)


async def grupo_com_erros():
    try:
        async with asyncio.TaskGroup() as grupo:
            grupo.create_task(falha("a"))
            grupo.create_task(falha("b"))
    except* ValueError as grupo_de_erros:
        print(sorted(str(e) for e in grupo_de_erros.exceptions))


asyncio.run(grupo_com_erros())

Saída

['a', 'b']

Limitar a concorrência

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:

avancado/cap47_assincrono.pylinhas 86 a 103
async def limitado():
    limite = asyncio.Semaphore(2)
    ativos = 0
    maximo = 0

    async def trabalho(i):
        nonlocal ativos, maximo
        async with limite:
            ativos += 1
            maximo = max(maximo, ativos)
            await asyncio.sleep(0.05)
            ativos -= 1

    await asyncio.gather(*(trabalho(i) for i in range(6)))
    print("máximo simultâneo:", maximo)


asyncio.run(limitado())

Saída

máximo simultâneo: 2

O erro mais caro: bloquear o laço

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:

avancado/cap47_assincrono.pylinhas 108 a 121
import time


def pesado():
    time.sleep(0.2)
    return "terminou"


async def sem_bloquear():
    resultado, _ = await asyncio.gather(asyncio.to_thread(pesado), asyncio.sleep(0.05))
    print(resultado)


asyncio.run(sem_bloquear())

Saída

terminou

Threads ou asyncio

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.

Ver solução
avancado/cap47_assincrono.pylinhas 126 a 138
async def buscar_todos(nomes, limite=2):
    semaforo = asyncio.Semaphore(limite)

    async def uma(nome):
        async with semaforo:
            return nome, await buscar(nome, 0.01)

    return dict(await asyncio.gather(*(uma(n) for n in nomes)))


resultado = asyncio.run(buscar_todos(["a", "b", "c"]))
assert resultado == {"a": "a pronto", "b": "b pronto", "c": "c pronto"}
print("ok")

Saída

ok

Capítulo 48, parte Avançado

Desempenho e profiling

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
import timeit

setup = "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):

avancado/cap48_desempenho.pylinhas 20 a 41
import cProfile
import io
import pstats


def lento():
    return sum(i * i for i in range(200_000))


def principal():
    for _ in range(5):
        lento()


perfil = cProfile.Profile()
perfil.enable()
principal()
perfil.disable()

relatorio = io.StringIO()
pstats.Stats(perfil, stream=relatorio).sort_stats("cumulative").print_stats(5)
print(relatorio.getvalue())

Para perfilar um script inteiro, sem alterar o código:

Terminal
python -m cProfile -o perfil.out meu_script.py
python -m pstats perfil.out

O algoritmo vence a micro-otimização

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
def tem_duplicado_lento(itens):
    for i, a in enumerate(itens):
        for b in itens[i + 1:]:
            if a == b:
                return True
    return False


def tem_duplicado_rapido(itens):
    return len(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
import tracemalloc


def pico(funcao):
    tracemalloc.start()
    funcao()
    _, maximo = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return maximo


pico_lista = pico(lambda: sum([n for n in range(200_000)]))
pico_gerador = pico(lambda: sum(n for n in range(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.

Ver solução
avancado/cap48_desempenho.pylinhas 86 a 97
def intersecao_lenta(a, b):
    return [x for x in a if x in b]


def intersecao_rapida(a, b):
    conjunto = set(b)
    return [x for x in a if x in conjunto]


assert intersecao_rapida([1, 2, 3, 4], [3, 4, 5]) == [3, 4]
assert intersecao_rapida([1, 2, 3, 4], [3, 4, 5]) == intersecao_lenta([1, 2, 3, 4], [3, 4, 5])
print("ok")

Saída

ok

Capítulo 49, parte Avançado

Empacotamento e estrutura de projeto

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.

Estrutura do pacote de exemplo
exemplos/pacote/
  pyproject.toml
  README.md
  src/
    calc_notes/
      __init__.py
      operacoes.py
      cli.py
  tests/
    test_operacoes.py

O pyproject.toml

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
def somar(a: float, b: float) -> float:
    return a + b


def dividir(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("divisor não pode ser zero")
    return a / b
exemplos/pacote/src/calc_notes/__init__.py
"""Pacote de exemplo do livro Python na Prática."""

from importlib.metadata import PackageNotFoundError, version

from calc_notes.operacoes import dividir, somar

try:
    __version__ = version("calc-notes")
except PackageNotFoundError:
    __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.

exemplos/pacote/src/calc_notes/cli.py
import argparse

from calc_notes.operacoes import somar


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="calc-notes")
    parser.add_argument("a", type=float)
    parser.add_argument("b", type=float)
    args = parser.parse_args(argv)
    print(somar(args.a, args.b))
    return 0
exemplos/pacote/tests/test_operacoes.py
import pytest

from calc_notes import dividir, somar


def test_somar():
    assert somar(2, 3) == 5


def test_dividir_por_zero():
    with pytest.raises(ZeroDivisionError):
        dividir(1, 0)
exemplos/pacote/README.md
calc-notes

Calculadora de exemplo do livro Python na Prática.

Instalar em modo editável e usar

O modo editável instala o pacote apontando para o seu código-fonte, então cada alteração vale na hora, sem reinstalar:

Terminal
cd exemplos/pacote
uv pip install -e .
Terminal
calc-notes 2 3

Saída

5.0

Sem o uv, dentro de um ambiente virtual: python -m pip install -e .. Para testar, rode o pytest dentro da pasta do pacote.

O módulo importlib.metadata consulta os metadados de qualquer pacote instalado, e levanta PackageNotFoundError se ele não existir:

avancado/cap49_empacotamento.pylinhas 10 a 15
from importlib.metadata import PackageNotFoundError, version

try:
    print(version("pacote-que-nao-existe"))
except PackageNotFoundError as erro:
    print("não instalado:", erro)

Saída

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
uv build

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:

Terminal
uv publish --publish-url https://test.pypi.org/legacy/ --token "$TOKEN"

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.

Ver solução
src/calc_notes/__main__.py
from calc_notes.cli import main

raise SystemExit(main())

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
import os, sys


def adicionar(item, lista=[]):
    try:
        lista.append(item)
    except:
        pass
    return lista
Terminal
uv run ruff check exemplos/qualidade/antes.py --output-format concise

Saída

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
def adicionar(item, lista=None):
    if lista is None:
        lista = []
    lista.append(item)
    return lista


assert adicionar(1) == [1]
assert adicionar(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:

exemplos/qualidade/pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

[tool.mypy]
python_version = "3.12"
strict = true
files = ["src"]
Grupo O que verifica
E Estilo e erros de sintaxe (pycodestyle)
F 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:

exemplos/qualidade/.pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.12.0   # atualize com: pre-commit autoupdate
    hooks:
      - id: ruff-check
        args: [--fix]
      - id: ruff-format

Depois do push: integração contínua

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: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  qualidade:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with:
          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:

Terminal
uv add --dev pytest-cov
uv run pytest --cov=calc_notes --cov-report=term-missing

Exercício 1

Teste de regressão

Para a função adicionar corrigida, escreva um teste que falharia na versão com valor padrão mutável.

Ver solução
avancado/cap50_qualidade.pylinhas 24 a 30
def teste_adicionar_nao_compartilha_estado():
    assert adicionar(1) == [1]
    assert adicionar(2) == [2]


teste_adicionar_nao_compartilha_estado()
print("ok")

Saída

ok

Capítulo 51, parte Avançado

Arquitetura e erros em produção

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:

avancado/cap51_arquitetura.pylinhas 10 a 43
from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True)
class Pedido:
    id: str
    cliente: str
    total: float


class RepositorioPedidos(Protocol):
    def salvar(self, pedido: Pedido) -> None: ...
    def existe(self, pedido_id: str) -> bool: ...


class Notificador(Protocol):
    def enviar(self, destinatario: str, mensagem: str) -> None: ...


class PedidoDuplicado(Exception):
    pass


class ServicoPedidos:
    def __init__(self, repositorio: RepositorioPedidos, notificador: Notificador) -> None:
        self._repositorio = repositorio
        self._notificador = notificador

    def registrar(self, pedido: Pedido) -> None:
        if self._repositorio.existe(pedido.id):
            raise PedidoDuplicado(pedido.id)
        self._repositorio.salvar(pedido)
        self._notificador.enviar(pedido.cliente, f"pedido {pedido.id} recebido")

O serviço não sabe se o repositório é PostgreSQL ou um dicionário. Por isso, testá-lo não exige subir nada:

avancado/cap51_arquitetura.pylinhas 45 a 71
class RepositorioEmMemoria:
    def __init__(self) -> None:
        self._dados: dict[str, Pedido] = {}

    def salvar(self, pedido: Pedido) -> None:
        self._dados[pedido.id] = pedido

    def existe(self, pedido_id: str) -> bool:
        return pedido_id in self._dados


class NotificadorFalso:
    def __init__(self) -> None:
        self.enviadas: list[tuple[str, str]] = []

    def enviar(self, destinatario: str, mensagem: str) -> None:
        self.enviadas.append((destinatario, mensagem))


notificador = NotificadorFalso()
servico = ServicoPedidos(RepositorioEmMemoria(), notificador)
servico.registrar(Pedido("p1", "ana", 50.0))
print(notificador.enviadas)
try:
    servico.registrar(Pedido("p1", "ana", 50.0))
except PedidoDuplicado as erro:
    print("duplicado:", erro)

Saída

[('ana', 'pedido p1 recebido')]
duplicado: p1

Verificar e depois gravar não é atômico

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:

avancado/cap51_arquitetura.pylinhas 76 a 104
import os
from collections.abc import Mapping
from dataclasses import dataclass


@dataclass(frozen=True)
class Configuracao:
    ambiente: str
    timeout_segundos: float
    url_banco: str

    @classmethod
    def do_ambiente(cls, env: Mapping[str, str] | None = None) -> "Configuracao":
        env = os.environ if env is None else env
        return cls(
            ambiente=env.get("APP_AMBIENTE", "desenvolvimento"),
            timeout_segundos=float(env.get("APP_TIMEOUT", "5")),
            url_banco=env["APP_URL_BANCO"],
        )


config = Configuracao.do_ambiente(
    {"APP_URL_BANCO": "postgresql://localhost/app", "APP_TIMEOUT": "2.5"}
)
print(config)
try:
    Configuracao.do_ambiente({})
except KeyError as erro:
    print("variável obrigatória ausente:", erro)

Saída

Configuracao(ambiente='desenvolvimento', timeout_segundos=2.5, url_banco='postgresql://localhost/app')
variável obrigatória ausente: 'APP_URL_BANCO'

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:

avancado/cap51_arquitetura.pylinhas 109 a 145
import random
import time
from collections.abc import Callable


def com_retentativas[T](
    operacao: Callable[[], T],
    *,
    tentativas: int = 4,
    base: float = 0.5,
    dormir: Callable[[float], None] = time.sleep,
    jitter: Callable[[], float] = random.random,
) -> T:
    for numero in range(1, tentativas + 1):
        try:
            return operacao()
        except ConnectionError:
            if numero == tentativas:
                raise
            espera = base * 2 ** (numero - 1) * (0.5 + jitter() / 2)
            dormir(espera)
    raise AssertionError("inalcançável")


esperas: list[float] = []
chamadas = {"total": 0}


def instavel() -> str:
    chamadas["total"] += 1
    if chamadas["total"] < 3:
        raise ConnectionError("falha")
    return "ok"


resultado = com_retentativas(instavel, dormir=esperas.append, jitter=lambda: 1.0)
print(resultado, esperas)

Saída

ok [0.5, 1.0]

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
import contextvars
import json
import logging
import sys

id_requisicao = contextvars.ContextVar("id_requisicao", default="-")


class FiltroDeContexto(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        setattr(record, "id_requisicao", id_requisicao.get())
        return True


class FormatoJson(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        return json.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 = False


def tratar(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")

Saída

{"nivel": "INFO", "mensagem": "pedido processado", "id_requisicao": "req-1"}
{"nivel": "INFO", "mensagem": "pedido processado", "id_requisicao": "req-2"}
{"nivel": "INFO", "mensagem": "fora de requisição", "id_requisicao": "-"}

Erros: traduza na fronteira

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
def para_resposta(excecao: Exception) -> tuple[int, str]:
    if isinstance(excecao, PedidoDuplicado):
        return 409, "pedido já registrado"
    if isinstance(excecao, ValueError):
        return 422, str(excecao)
    return 500, "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.

Ver solução
avancado/cap51_arquitetura.pylinhas 213 a 227
def testar_duplicidade():
    notificador = NotificadorFalso()
    servico = ServicoPedidos(RepositorioEmMemoria(), notificador)
    servico.registrar(Pedido("a", "bia", 10.0))
    try:
        servico.registrar(Pedido("a", "bia", 10.0))
    except PedidoDuplicado:
        pass
    else:
        raise AssertionError("deveria levantar PedidoDuplicado")
    assert len(notificador.enviadas) == 1


testar_duplicidade()
print("ok")

Saída

ok

Capítulo 52, parte Backend

HTTP e consumo de APIs

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:

backend/cap52_http_apis.pylinhas 10 a 61
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

import httpx2

contador_instavel = {"n": 0}


class Manipulador(BaseHTTPRequestHandler):
    def responder(self, status, corpo):
        dados = json.dumps(corpo).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(dados)))
        self.end_headers()
        self.wfile.write(dados)

    def do_GET(self):
        url = urlparse(self.path)
        if url.path == "/ok":
            self.responder(200, {"mensagem": "olá"})
        elif url.path == "/instavel":
            contador_instavel["n"] += 1
            if contador_instavel["n"] < 3:
                self.responder(503, {"erro": "indisponível"})
            else:
                self.responder(200, {"tentativa": contador_instavel["n"]})
        elif url.path == "/lento":
            time.sleep(1)
            self.responder(200, {})
        elif url.path == "/itens":
            pagina = int(parse_qs(url.query).get("pagina", ["1"])[0])
            proxima = pagina + 1 if pagina < 3 else None
            self.responder(200, {"itens": list(range((pagina - 1) * 3, pagina * 3)), "proxima": proxima})
        else:
            self.responder(404, {"erro": "não encontrado"})

    def do_POST(self):
        tamanho = int(self.headers.get("Content-Length", 0))
        corpo = json.loads(self.rfile.read(tamanho) or b"{}")
        self.responder(201, {"recebido": corpo})

    def log_message(self, *args):
        pass


servidor = ThreadingHTTPServer(("127.0.0.1", 0), Manipulador)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_address[1]}"

Uma requisição com httpx2

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:

backend/cap52_http_apis.pylinhas 66 a 68
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
    resposta = cliente.get("/ok")
    print(resposta.status_code, resposta.headers["content-type"], resposta.json())

Saída

200 application/json {'mensagem': 'olá'}

Erro HTTP não é exceção

Um 404 ou um 503 nã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:

backend/cap52_http_apis.pylinhas 73 a 79
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
    resposta = cliente.get("/nao-existe")
    print(resposta.status_code, resposta.is_success)
    try:
        resposta.raise_for_status()
    except httpx2.HTTPStatusError as erro:
        print("erro HTTP:", erro.response.status_code)

Saída

404 False
erro HTTP: 404

Timeout: sempre

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)
except httpx2.TimeoutException as erro:
    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:

backend/cap52_http_apis.pylinhas 92 a 110
def get_com_retentativas(cliente, caminho, *, tentativas=4, base=0.1, dormir=time.sleep):
    ultima = None
    for numero in range(1, tentativas + 1):
        try:
            ultima = cliente.get(caminho)
            if ultima.status_code < 500:
                return ultima
        except httpx2.TransportError:
            if numero == tentativas:
                raise
        if numero < tentativas:
            dormir(base * 2 ** (numero - 1))
    return ultima


esperas = []
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
    resposta = get_com_retentativas(cliente, "/instavel", dormir=esperas.append)
print(resposta.status_code, resposta.json(), esperas)

Saída

200 {'tentativa': 3} [0.1, 0.2]

Só repita o que é seguro repetir

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:

backend/cap52_http_apis.pylinhas 115 a 124
def todos_os_itens(cliente):
    pagina = 1
    while pagina is not None:
        dados = cliente.get("/itens", params={"pagina": pagina}).json()
        yield from dados["itens"]
        pagina = dados["proxima"]


with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
    print(list(todos_os_itens(cliente)))

Saída

[0, 1, 2, 3, 4, 5, 6, 7, 8]

Enviar JSON

O argumento json= serializa o dicionário e já define o cabeçalho Content-Type: application/json:

backend/cap52_http_apis.pylinhas 129 a 131
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
    resposta = cliente.post("/pedidos", json={"cliente": "Ana", "itens": 2})
    print(resposta.status_code, resposta.json())

Saída

201 {'recebido': {'cliente': 'Ana', 'itens': 2}}

Várias requisições ao mesmo tempo

Para disparar muitas chamadas independentes, o AsyncClient com asyncio.gather (capítulo 47) trabalha em uma única thread:

backend/cap52_http_apis.pylinhas 136 a 145
import asyncio


async def buscar_varios():
    async with httpx2.AsyncClient(base_url=BASE, timeout=2.0) as cliente:
        respostas = await asyncio.gather(*(cliente.get("/ok") for _ in range(3)))
    return [r.status_code for r in respostas]


print(asyncio.run(buscar_varios()))

Saída

[200, 200, 200]

O httpx e o httpx2

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.

Ver solução
backend/cap52_http_apis.pylinhas 150 a 171
class ErroDeApi(Exception):
    def __init__(self, status, caminho):
        super().__init__(f"{caminho} respondeu {status}")
        self.status = status


def buscar_json(cliente, caminho):
    resposta = cliente.get(caminho)
    if not resposta.is_success:
        raise ErroDeApi(resposta.status_code, caminho)
    return resposta.json()


with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
    assert buscar_json(cliente, "/ok") == {"mensagem": "olá"}
    try:
        buscar_json(cliente, "/nao-existe")
    except ErroDeApi as erro:
        assert erro.status == 404
    else:
        raise AssertionError("deveria falhar")
print("ok")

Saída

ok

Capítulo 53, parte Backend

REST e fundamentos de APIs

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))


def por_deslocamento(itens, deslocamento, limite):
    return itens[deslocamento : deslocamento + limite]


pagina_1 = por_deslocamento(itens, 0, 3)
itens.insert(0, 0)  # alguém cria um item no começo enquanto você navega
pagina_2 = por_deslocamento(itens, 3, 3)
print(pagina_1, pagina_2)

Saída

[1, 2, 3] [3, 4, 5]

O 3 apareceu duas vezes. Com cursor:

backend/cap53_rest_contratos.pylinhas 22 a 34
itens = list(range(1, 11))


def por_cursor(itens, depois_de, limite):
    pagina = [i for i in itens if i > depois_de][:limite]
    proximo = pagina[-1] if len(pagina) == limite else None
    return pagina, proximo


pagina_1, cursor = por_cursor(itens, 0, 3)
itens.insert(0, 0)
pagina_2, cursor = por_cursor(itens, cursor, 3)
print(pagina_1, pagina_2)

Saída

[1, 2, 3] [4, 5, 6]

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
def problema(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:

backend/cap53_rest_contratos.pylinhas 54 a 67
resultados = {}


def criar_pedido(chave, dados):
    if chave in resultados:
        return 200, resultados[chave]
    pedido = {"id": len(resultados) + 1, **dados}
    resultados[chave] = pedido
    return 201, pedido


print(criar_pedido("k1", {"cliente": "Ana"}))
print(criar_pedido("k1", {"cliente": "Ana"}))
print(criar_pedido("k2", {"cliente": "Bia"}))

Saída

(201, {'id': 1, 'cliente': 'Ana'})
(200, {'id': 1, 'cliente': 'Ana'})
(201, {'id': 2, 'cliente': 'Bia'})

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.

Ver solução
backend/cap53_rest_contratos.pylinhas 72 a 80
def paginar(itens, cursor, limite):
    pagina = [i for i in itens if i > cursor][: limite + 1]
    proximo = pagina[limite - 1] if len(pagina) > limite else None
    return {"itens": pagina[:limite], "proximo_cursor": proximo}


assert paginar(list(range(1, 8)), 0, 3) == {"itens": [1, 2, 3], "proximo_cursor": 3}
assert paginar(list(range(1, 8)), 6, 3) == {"itens": [7], "proximo_cursor": None}
print("ok")

Saída

ok

Capítulo 54, parte Backend

FastAPI

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
uv add fastapi uvicorn
uv add --dev httpx2 pytest

Com um arquivo main.py que defina app, o servidor sobe assim, e --reload recarrega ao salvar (só em desenvolvimento):

Terminal
uv run uvicorn main: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
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI(title="Biblioteca")


class LivroCriar(BaseModel):
    titulo: str = Field(min_length=1, max_length=100)
    paginas: int = Field(gt=0)


class Livro(LivroCriar):
    id: int


banco: dict[int, Livro] = {}


@app.post("/livros", response_model=Livro, status_code=201)
def criar(dados: LivroCriar) -> Livro:
    livro = Livro(id=len(banco) + 1, **dados.model_dump())
    banco[livro.id] = livro
    return livro


@app.get("/livros/{livro_id}", response_model=Livro)
def obter(livro_id: int) -> Livro:
    if livro_id not in banco:
        raise HTTPException(status_code=404, detail="livro não encontrado")
    return banco[livro_id]


@app.get("/livros")
def listar(minimo_paginas: Annotated[int, Query(ge=0)] = 0) -> list[Livro]:
    return [livro for livro in banco.values() if livro.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:

backend/cap54_fastapi_basico.pylinhas 58 a 60
resposta = cliente.post("/livros", json={"titulo": "", "paginas": -1})
print(resposta.status_code)
print([(erro["loc"], erro["type"]) for erro in resposta.json()["detail"]])

Saída

422
[(['body', 'titulo'], 'string_too_short'), (['body', 'paginas'], 'greater_than')]

Injeção de dependências

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:

backend/cap54_fastapi_basico.pylinhas 65 a 82
def obter_banco() -> dict[int, Livro]:
    return banco


BancoDep = Annotated[dict[int, Livro], Depends(obter_banco)]


@app.get("/total")
def total(base: BancoDep) -> dict[str, int]:
    return {"total": len(base)}


print(cliente.get("/total").json())

app.dependency_overrides[obter_banco] = lambda: {i: Livro(id=i, titulo="x", paginas=1) for i in range(5)}
print(cliente.get("/total").json())
app.dependency_overrides.clear()
print(sorted(app.openapi()["paths"]))

Saída

{'total': 1}
{'total': 5}
['/livros', '/livros/{livro_id}', '/total']

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
from fastapi import Response


@app.delete("/livros/{livro_id}", status_code=204)
def remover(livro_id: int) -> Response:
    if banco.pop(livro_id, None) is None:
        raise HTTPException(status_code=404, detail="livro não encontrado")
    return Response(status_code=204)


assert cliente.delete("/livros/1").status_code == 204
assert cliente.delete("/livros/1").status_code == 404
print("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
import sqlite3

conexao = sqlite3.connect(":memory:")
conexao.row_factory = sqlite3.Row
conexao.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"] for linha in conexao.execute(consulta)])

juncao = """
SELECT c.nome, p.total_centavos
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
ORDER BY p.id
"""
print([tuple(linha) for linha in conexao.execute(juncao)])

Saída

['Ana', 'Caio']
[('Ana', 5000), ('Ana', 2500), ('Bia', 10000)]

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 total
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
GROUP BY c.nome
ORDER BY c.nome
"""
print([tuple(linha) for linha in conexao.execute(agregacao)])

com_filtro = """
SELECT cliente_id, COUNT(*) AS quantidade
FROM pedidos
GROUP BY cliente_id
HAVING COUNT(*) > 1
"""
print([tuple(linha) for linha in conexao.execute(com_filtro)])

Saída

[('Ana', 7500), ('Bia', 10000), ('Caio', 0)]
[(1, 2)]

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()


def transferir(origem, destino, valor):
    with conexao:
        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)
except sqlite3.IntegrityError as erro:
    print("recusado:", erro)
print([tuple(linha) for linha in conexao.execute("SELECT id, saldo FROM contas ORDER BY id")])

Saída

recusado: CHECK constraint failed: saldo >= 0
[(1, 70), (2, 80)]

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
def usa_indice(sql):
    plano = conexao.execute("EXPLAIN QUERY PLAN " + sql).fetchall()
    return any("INDEX" in linha["detail"] for linha in plano)


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)
import os

import psycopg
from psycopg.types.json import Jsonb

with psycopg.connect(os.environ["DATABASE_URL"]) as conexao:
    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
import os

import psycopg

with psycopg.connect(os.environ["DATABASE_URL"]) as conexao:
    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())

with psycopg.connect(os.environ["DATABASE_URL"]) as conexao:
    try:
        conexao.execute("INSERT INTO produtos (nome, preco_centavos) VALUES (%s, %s)", ("caneta", 1))
    except psycopg.errors.UniqueViolation as erro:
        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
def total_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) for linha in conexao.execute(consulta)]


assert total_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:

backend/cap56_sqlalchemy_orm.pylinhas 10 a 46
from sqlalchemy import ForeignKey, String, create_engine, func, select
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    Session,
    mapped_column,
    relationship,
    selectinload,
)


class Base(DeclarativeBase):
    pass


class Autor(Base):
    __tablename__ = "autores"

    id: Mapped[int] = mapped_column(primary_key=True)
    nome: Mapped[str] = mapped_column(String(80), unique=True)
    livros: Mapped[list["Livro"]] = relationship(
        back_populates="autor", cascade="all, delete-orphan"
    )


class Livro(Base):
    __tablename__ = "livros"

    id: Mapped[int] = mapped_column(primary_key=True)
    titulo: Mapped[str] = mapped_column(String(120))
    paginas: Mapped[int]
    autor_id: Mapped[int] = mapped_column(ForeignKey("autores.id"))
    autor: Mapped[Autor] = relationship(back_populates="livros")


engine = create_engine("sqlite://")
Base.metadata.create_all(engine)

Sessão: a unidade de trabalho

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
with Session(engine) as sessao:
    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:

backend/cap56_sqlalchemy_orm.pylinhas 65 a 72
with Session(engine) as sessao:
    consulta = select(Livro).where(Livro.paginas > 100).order_by(Livro.titulo)
    print([livro.titulo for livro in sessao.scalars(consulta)])
    print(sessao.scalar(select(func.sum(Livro.paginas))))
    por_autor = sessao.execute(
        select(Autor.nome, func.count(Livro.id)).join(Livro).group_by(Autor.nome).order_by(Autor.nome)
    ).all()
    print(por_autor)

Saída

['Dom Casmurro', 'Quincas Borba', 'Vidas Secas']
820
[('Clarice Lispector', 1), ('Graciliano Ramos', 1), ('Machado de Assis', 2)]

O problema N+1

É 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:

backend/cap56_sqlalchemy_orm.pylinhas 77 a 96
from sqlalchemy import event

consultas = []
event.listen(
    engine,
    "before_cursor_execute",
    lambda conexao, cursor, comando, parametros, contexto, varias: consultas.append(comando),
)

with Session(engine) as sessao:
    consultas.clear()
    for autor in sessao.scalars(select(Autor)):
        len(autor.livros)
    print("carregamento preguiçoso:", len(consultas), "consultas")

with Session(engine) as sessao:
    consultas.clear()
    for autor in sessao.scalars(select(Autor).options(selectinload(Autor.livros))):
        len(autor.livros)
    print("com selectinload:", len(consultas), "consultas")

Saída

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
with Session(engine) as sessao:
    livro = sessao.scalars(select(Livro).where(Livro.titulo == "Vidas Secas")).one()
    livro.paginas = 180
    sessao.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:
    with Session(engine) as sessao, sessao.begin():
        sessao.add(Autor(nome="Machado de Assis"))
except Exception as erro:
    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:

backend/cap56_sqlalchemy_orm.pylinhas 119 a 133
class RepositorioLivros:
    def __init__(self, sessao: Session) -> None:
        self._sessao = sessao

    def por_titulo(self, titulo: str) -> Livro | None:
        return self._sessao.scalars(select(Livro).where(Livro.titulo == titulo)).first()

    def mais_longos(self, quantidade: int) -> list[Livro]:
        consulta = select(Livro).order_by(Livro.paginas.desc()).limit(quantidade)
        return list(self._sessao.scalars(consulta))


with Session(engine) as sessao:
    repositorio = RepositorioLivros(sessao)
    print([livro.titulo for livro in repositorio.mais_longos(2)])

Saída

['Quincas Borba', 'Dom Casmurro']

O mesmo modelo no PostgreSQL

Só a URL muda. Com o driver psycopg (versão 3), ela começa com postgresql+psycopg://:

O mesmo código no PostgreSQL
import os

from sqlalchemy import String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column


class Base(DeclarativeBase):
    pass


class Produto(Base):
    __tablename__ = "produtos_sa"

    id: Mapped[int] = mapped_column(primary_key=True)
    nome: Mapped[str] = mapped_column(String(80))


url = os.environ["DATABASE_URL"].replace("postgresql://", "postgresql+psycopg://", 1)
engine = create_engine(url)
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
with Session(engine) as sessao:
    sessao.add(Produto(nome="caneta"))
    sessao.commit()
    print(engine.dialect.name, sessao.scalars(select(Produto.nome)).all())
Base.metadata.drop_all(engine)

Saída

postgresql ['caneta']

create_all não é migração

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
def titulos_do_autor(sessao, nome):
    consulta = select(Livro.titulo).join(Autor).where(Autor.nome == nome).order_by(Livro.titulo)
    return list(sessao.scalars(consulta))


with Session(engine) as sessao:
    assert titulos_do_autor(sessao, "Machado de Assis") == ["Dom Casmurro", "Quincas Borba"]
    assert titulos_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:

exemplos/api_pedidos/alembic.ini
[alembic]
script_location = %(here)s/migrations
prepend_sys_path = src
path_separator = os
file_template = %%(rev)s_%%(slug)s

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
exemplos/api_pedidos/migrations/env.py
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from api_pedidos.config import obter_configuracao
from api_pedidos.modelos import Base

config = context.config
if config.config_file_name is not None:
    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.metadata


def run_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"},
    )
    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -> None:
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    with connectable.connect() as conexao:
        context.configure(connection=conexao, target_metadata=target_metadata)
        with context.begin_transaction():
            context.run_migrations()


if context.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:

Terminal
uv run alembic revision --autogenerate -m "criar pedidos" --rev-id 0001

Saída

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):

exemplos/api_pedidos/migrations/versions/0001_criar_pedidos.py
"""criar pedidos

Revision ID: 0001
Revises:
Create Date: 2026-10-06 15:46:53

"""

import sqlalchemy as sa
from alembic import op

revision: str = "0001"
down_revision: str | None = None
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
    op.create_table(
        "pedidos",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("cliente", sa.String(length=120), nullable=False),
        sa.Column("status", sa.String(length=20), nullable=False),
        sa.Column("chave_idempotencia", sa.String(length=80), nullable=True),
        sa.Column("criado_em", sa.DateTime(timezone=True), nullable=False),
        sa.PrimaryKeyConstraint("id"),
        sa.UniqueConstraint("chave_idempotencia"),
    )
    op.create_index("ix_pedidos_cliente", "pedidos", ["cliente"], unique=False)
    op.create_table(
        "itens_pedido",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("pedido_id", sa.Integer(), nullable=False),
        sa.Column("produto", sa.String(length=120), nullable=False),
        sa.Column("quantidade", sa.Integer(), nullable=False),
        sa.Column("preco_centavos", sa.Integer(), nullable=False),
        sa.ForeignKeyConstraint(["pedido_id"], ["pedidos.id"], ondelete="CASCADE"),
        sa.PrimaryKeyConstraint("id"),
    )


def downgrade() -> None:
    op.drop_table("itens_pedido")
    op.drop_index("ix_pedidos_cliente", table_name="pedidos")
    op.drop_table("pedidos")

Aplicar, conferir e desfazer

Terminal
export API_DATABASE_URL="postgresql+psycopg://notes:notes@localhost:5432/notes_mig"
uv run alembic upgrade head

Saída

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:

Terminal
uv run alembic current
uv run alembic check
uv run alembic downgrade base
uv run alembic upgrade head --sql

Saída

0001 (head)

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
def upgrade() -> 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)


def downgrade() -> 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 única head (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:

Terminal
uv add pydantic-settings
backend/cap58_configuracao.pylinhas 10 a 23
import os
from pathlib import Path
from typing import Literal

from pydantic import SecretStr, ValidationError, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Configuracao(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="LOJA_", extra="ignore")

    ambiente: Literal["dev", "test", "prod"] = "dev"
    porta: int = 8000
    chave_api: SecretStr

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:

backend/cap58_configuracao.pylinhas 25 a 28
try:
    Configuracao(_env_file=None)
except ValidationError as erro:
    print([(e["loc"], e["type"]) for e in erro.errors()])

Saída

[(('chave_api',), 'missing')]

Definindo as variáveis, a conversão acontece sozinha. A porta chegou como texto e virou int. E o SecretStr esconde o valor ao imprimir, o que protege a chave de vazar em logs e em mensagens de erro:

backend/cap58_configuracao.pylinhas 30 a 35
os.environ["LOJA_CHAVE_API"] = "segredo-123"
os.environ["LOJA_PORTA"] = "9000"
config = Configuracao(_env_file=None)
print(config)
print(config.porta + 1, type(config.porta).__name__)
print(config.chave_api.get_secret_value()[:7])

Saída

ambiente='dev' porta=9000 chave_api=SecretStr('**********')
9001 int
segredo

Um valor impossível de converter é recusado com um erro claro, em vez de explodir lá na frente com um ValueError sem contexto:

backend/cap58_configuracao.pylinhas 37 a 42
os.environ["LOJA_PORTA"] = "abc"
try:
    Configuracao(_env_file=None)
except ValidationError as erro:
    print(erro.errors()[0]["type"])
os.environ["LOJA_PORTA"] = "9000"

Saída

int_parsing

Quem vence: a ordem de precedência

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:

backend/cap58_configuracao.pylinhas 47 a 51
Path(".env.demo").write_text("LOJA_PORTA=7000\nLOJA_AMBIENTE=test\n", encoding="utf-8")
print(Configuracao(_env_file=".env.demo").porta)
del os.environ["LOJA_PORTA"]
print(Configuracao(_env_file=".env.demo").porta)
print(Configuracao(_env_file=".env.demo", porta=1234).porta)

Saída

9000
7000
1234

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
class ConfiguracaoSegura(Configuracao):
    @model_validator(mode="after")
    def producao_exige_chave_forte(self):
        if self.ambiente == "prod" and len(self.chave_api.get_secret_value()) < 16:
            raise ValueError("em produção a chave precisa ter pelo menos 16 caracteres")
        return self


os.environ["LOJA_AMBIENTE"] = "prod"
try:
    ConfiguracaoSegura(_env_file=None)
except ValidationError as erro:
    print(erro.errors()[0]["msg"])
del os.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.

Ver solução
backend/cap58_configuracao.pylinhas 74 a 88
class ConfiguracaoComDebug(Configuracao):
    debug: bool = False


os.environ["LOJA_DEBUG"] = "1"
assert ConfiguracaoComDebug(_env_file=None).debug is True
os.environ["LOJA_DEBUG"] = "talvez"
try:
    ConfiguracaoComDebug(_env_file=None)
except ValidationError:
    pass
else:
    raise AssertionError("deveria recusar")
del os.environ["LOJA_DEBUG"]
print("ok")

Saída

ok

Capítulo 59, parte Backend

Testes de aplicações

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:

backend/cap59_testes_aplicacao.pylinhas 10 a 44
from dataclasses import dataclass
from typing import Annotated, Protocol
from unittest.mock import Mock

import pytest
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column


class PagamentoRecusado(Exception):
    pass


class GatewayPagamento(Protocol):
    def cobrar(self, valor_centavos: int) -> str: ...


class RepositorioPedidos(Protocol):
    def salvar(self, cliente: str, total_centavos: int, transacao: str) -> int: ...


class ServicoCheckout:
    def __init__(self, gateway: GatewayPagamento, repositorio: RepositorioPedidos) -> None:
        self._gateway = gateway
        self._repositorio = repositorio

    def finalizar(self, cliente: str, precos: list[int]) -> int:
        if not precos:
            raise ValueError("carrinho vazio")
        total = sum(precos)
        transacao = self._gateway.cobrar(total)
        return self._repositorio.salvar(cliente, total, transacao)

Fakes: implementações simples e honestas

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:

backend/cap59_testes_aplicacao.pylinhas 49 a 67
class GatewayFalso:
    def __init__(self, recusar: bool = False) -> None:
        self.recusar = recusar
        self.cobrancas: list[int] = []

    def cobrar(self, valor_centavos: int) -> str:
        if self.recusar:
            raise PagamentoRecusado("cartão recusado")
        self.cobrancas.append(valor_centavos)
        return f"tx-{len(self.cobrancas)}"


class RepositorioEmMemoria:
    def __init__(self) -> None:
        self.pedidos: list[tuple[str, int, str]] = []

    def salvar(self, cliente: str, total_centavos: int, transacao: str) -> int:
        self.pedidos.append((cliente, total_centavos, transacao))
        return len(self.pedidos)

Testes unitários

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:

backend/cap59_testes_aplicacao.pylinhas 72 a 98
def test_finaliza_cobrando_e_salvando():
    gateway, repositorio = GatewayFalso(), RepositorioEmMemoria()
    pedido_id = ServicoCheckout(gateway, repositorio).finalizar("Ana", [1000, 550])
    assert pedido_id == 1
    assert gateway.cobrancas == [1550]
    assert repositorio.pedidos == [("Ana", 1550, "tx-1")]


def test_pagamento_recusado_nao_grava_pedido():
    repositorio = RepositorioEmMemoria()
    with pytest.raises(PagamentoRecusado):
        ServicoCheckout(GatewayFalso(recusar=True), repositorio).finalizar("Ana", [500])
    assert repositorio.pedidos == []


def test_carrinho_vazio_nao_cobra():
    gateway = GatewayFalso()
    with pytest.raises(ValueError, match="vazio"):
        ServicoCheckout(gateway, RepositorioEmMemoria()).finalizar("Ana", [])
    assert gateway.cobrancas == []


@pytest.mark.parametrize("precos, esperado", [([100], 100), ([100, 200, 300], 600)])
def test_total_cobrado(precos, esperado):
    gateway = GatewayFalso()
    ServicoCheckout(gateway, RepositorioEmMemoria()).finalizar("Ana", precos)
    assert gateway.cobrancas == [esperado]

Quando um Mock faz sentido

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:

backend/cap59_testes_aplicacao.pylinhas 103 a 110
def test_com_mock_verifica_a_interacao():
    gateway = Mock()
    gateway.cobrar.return_value = "tx-9"
    repositorio = Mock()
    repositorio.salvar.return_value = 7
    assert ServicoCheckout(gateway, repositorio).finalizar("Bia", [500]) == 7
    gateway.cobrar.assert_called_once_with(500)
    repositorio.salvar.assert_called_once_with("Bia", 500, "tx-9")

Testar a rota HTTP

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:

backend/cap59_testes_aplicacao.pylinhas 115 a 154
class PedidoEntrada(BaseModel):
    cliente: str
    precos: list[int]


def montar_app(servico: ServicoCheckout) -> FastAPI:
    app = FastAPI()

    def obter_servico() -> ServicoCheckout:
        return servico

    @app.post("/checkout")
    def checkout(
        entrada: PedidoEntrada, svc: Annotated[ServicoCheckout, Depends(obter_servico)]
    ) -> dict[str, int]:
        try:
            return {"pedido_id": svc.finalizar(entrada.cliente, entrada.precos)}
        except PagamentoRecusado as erro:
            raise HTTPException(status_code=402, detail=str(erro)) from erro
        except ValueError as erro:
            raise HTTPException(status_code=422, detail=str(erro)) from erro

    return app


def test_rota_feliz():
    cliente = TestClient(montar_app(ServicoCheckout(GatewayFalso(), RepositorioEmMemoria())))
    resposta = cliente.post("/checkout", json={"cliente": "Ana", "precos": [100, 200]})
    assert resposta.status_code == 200
    assert resposta.json() == {"pedido_id": 1}


def test_rota_pagamento_recusado_devolve_402():
    cliente = TestClient(montar_app(ServicoCheckout(GatewayFalso(recusar=True), RepositorioEmMemoria())))
    assert cliente.post("/checkout", json={"cliente": "Ana", "precos": [100]}).status_code == 402


def test_rota_carrinho_vazio_devolve_422():
    cliente = TestClient(montar_app(ServicoCheckout(GatewayFalso(), RepositorioEmMemoria())))
    assert cliente.post("/checkout", json={"cliente": "Ana", "precos": []}).status_code == 422

Integração com o banco

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:

backend/cap59_testes_aplicacao.pylinhas 159 a 196
class Base(DeclarativeBase):
    pass


class PedidoLinha(Base):
    __tablename__ = "pedidos_checkout"

    id: Mapped[int] = mapped_column(primary_key=True)
    cliente: Mapped[str]
    total_centavos: Mapped[int]
    transacao: Mapped[str]


class RepositorioSql:
    def __init__(self, sessao: Session) -> None:
        self._sessao = sessao

    def salvar(self, cliente: str, total_centavos: int, transacao: str) -> int:
        linha = PedidoLinha(cliente=cliente, total_centavos=total_centavos, transacao=transacao)
        self._sessao.add(linha)
        self._sessao.commit()
        return linha.id


@pytest.fixture
def sessao():
    engine = create_engine("sqlite://")
    Base.metadata.create_all(engine)
    with Session(engine) as sessao_aberta:
        yield sessao_aberta


def test_repositorio_sql_grava_de_verdade(sessao):
    servico = ServicoCheckout(GatewayFalso(), RepositorioSql(sessao))
    servico.finalizar("Ana", [100, 200])
    servico.finalizar("Bia", [50])
    assert sessao.scalar(select(func.sum(PedidoLinha.total_centavos))) == 350
    assert sessao.scalar(select(func.count(PedidoLinha.id))) == 2

O que torna um teste confiável

Princípio Na prática
Determinístico 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.

Ver solução
backend/cap59_testes_aplicacao.pylinhas 201 a 207
def test_gateway_recusa_e_repositorio_nao_e_chamado():
    gateway = Mock()
    gateway.cobrar.side_effect = PagamentoRecusado("sem saldo")
    repositorio = Mock()
    with pytest.raises(PagamentoRecusado, match="sem saldo"):
        ServicoCheckout(gateway, repositorio).finalizar("Ana", [100])
    repositorio.salvar.assert_not_called()

Rodar os testes

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 uv
FROM python:3.13-slim AS construcao
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app

# Dependências primeiro: esta camada só é refeita quando o uv.lock muda
COPY pyproject.toml uv.lock README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-install-project --no-dev

COPY src ./src
COPY migrations ./migrations
COPY alembic.ini ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

# Estágio 2: imagem final, sem o uv e sem ferramentas de construção
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=construcao --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
  CMD python -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.lock antes 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:

exemplos/api_pedidos/.dockerignore
.venv
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
.git
.env
*.db
tests

Construir e rodar

Terminal
docker build -t api-pedidos .
docker run --rm -p 8000:8000 -e API_AMBIENTE=dev api-pedidos

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:

exemplos/api_pedidos/compose.yaml
services:
  banco:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: pedidos
    volumes:
      - dados_pg:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d pedidos"]
      interval: 5s
      timeout: 3s
      retries: 10

  migracao:
    build: .
    command: ["alembic", "upgrade", "head"]
    environment:
      API_AMBIENTE: prod
      API_DATABASE_URL: postgresql+psycopg://app:app@banco:5432/pedidos
    depends_on:
      banco:
        condition: service_healthy

  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      API_AMBIENTE: prod
      API_DATABASE_URL: postgresql+psycopg://app:app@banco:5432/pedidos
    depends_on:
      banco:
        condition: service_healthy
      migracao:
        condition: service_completed_successfully

volumes:
  dados_pg:
Terminal
docker compose up --build
docker compose logs -f api
docker compose down -v

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:

backend/cap61_observabilidade.pylinhas 10 a 64
import contextvars
import json
import logging
import sys
from uuid import uuid4

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

id_requisicao = contextvars.ContextVar("id_requisicao", default="-")


class FormatoJson(logging.Formatter):
    def format(self, record):
        return json.dumps(
            {
                "nivel": record.levelname,
                "mensagem": record.getMessage(),
                "id_requisicao": id_requisicao.get(),
            },
            ensure_ascii=False,
        )


manipulador = logging.StreamHandler(sys.stdout)
manipulador.setFormatter(FormatoJson())
log = logging.getLogger("loja")
log.handlers = [manipulador]
log.setLevel(logging.INFO)
log.propagate = False

app = FastAPI()


@app.middleware("http")
async def correlacionar(request: Request, call_next):
    identificador = request.headers.get("X-Request-ID") or uuid4().hex[:8]
    token = id_requisicao.set(identificador)
    try:
        resposta = await call_next(request)
    finally:
        id_requisicao.reset(token)
    resposta.headers["X-Request-ID"] = identificador
    return resposta


@app.get("/pedido/{numero}")
def pedido(numero: int):
    log.info("buscando pedido %d", numero)
    return {"numero": numero}


cliente = TestClient(app)
resposta = cliente.get("/pedido/7", headers={"X-Request-ID": "abc123"})
print(resposta.headers["x-request-id"])

Saída

{"nivel": "INFO", "mensagem": "buscando pedido 7", "id_requisicao": "abc123"}
abc123

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)
{"nivel": "INFO", "mensagem": "requisição concluída", "id_requisicao": "req-demo", "metodo": "POST", "rota": "/pedidos", "status": 201, "duracao_ms": 24.08}
{"nivel": "INFO", "mensagem": "requisição concluída", "id_requisicao": "1e6049ebb0f048509d7bce3a280f1972", "metodo": "POST", "rota": "/pedidos", "status": 200, "duracao_ms": 4.29}

O que nunca vai para um log

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
uv add prometheus-client
backend/cap61_observabilidade.pylinhas 69 a 83
from prometheus_client import CollectorRegistry, Counter, Histogram, generate_latest

registro = 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(linha for linha in texto.splitlines() if linha.startswith(interessantes)))

Saída

pedidos_total{status="aprovado"} 2.0
pedidos_total{status="recusado"} 1.0
checkout_segundos_bucket{le="0.1"} 1.0
checkout_segundos_bucket{le="0.5"} 2.0
checkout_segundos_bucket{le="1.0"} 2.0
checkout_segundos_bucket{le="+Inf"} 2.0
checkout_segundos_count 2.0

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:

Terminal
uv add opentelemetry-sdk
backend/cap61_observabilidade.pylinhas 88 a 108
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

exportador = InMemorySpanExporter()
provedor = TracerProvider()
provedor.add_span_processor(SimpleSpanProcessor(exportador))
rastreador = provedor.get_tracer("loja")

with rastreador.start_as_current_span("checkout") as raiz:
    raiz.set_attribute("cliente", "Ana")
    with rastreador.start_as_current_span("cobrar_cartao"):
        pass
    with rastreador.start_as_current_span("gravar_pedido") as etapa:
        etapa.set_attribute("itens", 3)

spans = exportador.get_finished_spans()
nomes = {s.context.span_id: s.name for s in spans}
for s in spans:
    pai = nomes.get(s.parent.span_id) if s.parent else None
    print(f"{s.name:<14} pai={pai} atributos={dict(s.attributes)}")

Saída

cobrar_cartao  pai=checkout atributos={}
gravar_pedido  pai=checkout atributos={'itens': 3}
checkout       pai=None atributos={'cliente': 'Ana'}

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.

Ver solução
backend/cap61_observabilidade.pylinhas 113 a 136
registro_exercicio = CollectorRegistry()
REQUISICOES = Counter("req", "Requisições", ["rota"], registry=registro_exercicio)

app_exercicio = FastAPI()


@app_exercicio.middleware("http")
async def contar(request: Request, call_next):
    resposta = await call_next(request)
    rota = getattr(request.scope.get("route"), "path", "desconhecida")
    REQUISICOES.labels(rota).inc()
    return resposta


@app_exercicio.get("/ping")
def ping():
    return {"ok": True}


cliente_exercicio = TestClient(app_exercicio)
cliente_exercicio.get("/ping")
cliente_exercicio.get("/ping")
assert registro_exercicio.get_sample_value("req_total", {"rota": "/ping"}) == 2.0
print("ok")

Saída

ok

Capítulo 62, parte Backend

API completa: do contrato ao deploy

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 = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["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
from functools import lru_cache
from typing import Literal, Self

from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Configuracao(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")
    def exigir_banco_de_servidor_em_producao(self) -> Self:
        if self.ambiente == "prod" and self.database_url.get_secret_value().startswith("sqlite"):
            raise ValueError("produção exige um banco de dados de servidor, não SQLite")
        return self


@lru_cache
def obter_configuracao() -> Configuracao:
    return Configuracao()

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:

exemplos/api_pedidos/src/api_pedidos/modelos.py
from datetime import UTC, datetime

from sqlalchemy import DateTime, ForeignKey, Index, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


def agora() -> datetime:
    return datetime.now(UTC)


class Pedido(Base):
    __tablename__ = "pedidos"
    __table_args__ = (Index("ix_pedidos_cliente", "cliente"),)

    id: Mapped[int] = mapped_column(primary_key=True)
    cliente: Mapped[str] = mapped_column(String(120))
    status: Mapped[str] = mapped_column(String(20), default="aberto")
    chave_idempotencia: Mapped[str | None] = mapped_column(String(80), unique=True)
    criado_em: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=agora)

    itens: Mapped[list["ItemPedido"]] = relationship(
        back_populates="pedido", cascade="all, delete-orphan"
    )

    @property
    def total_centavos(self) -> int:
        return sum(item.quantidade * item.preco_centavos for item in self.itens)


class ItemPedido(Base):
    __tablename__ = "itens_pedido"

    id: Mapped[int] = mapped_column(primary_key=True)
    pedido_id: Mapped[int] = mapped_column(ForeignKey("pedidos.id", ondelete="CASCADE"))
    produto: Mapped[str] = mapped_column(String(120))
    quantidade: Mapped[int]
    preco_centavos: Mapped[int]

    pedido: Mapped[Pedido] = relationship(back_populates="itens")
exemplos/api_pedidos/src/api_pedidos/db.py
from collections.abc import Iterator
from typing import Any

from fastapi import Request
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker


def criar_engine(url: str) -> Engine:
    argumentos: dict[str, Any] = {}
    if url.startswith("sqlite"):
        argumentos["check_same_thread"] = False
    return create_engine(url, connect_args=argumentos, pool_pre_ping=True)


def criar_fabrica_de_sessoes(engine: Engine) -> sessionmaker[Session]:
    return sessionmaker(engine, expire_on_commit=False)


def obter_sessao(request: Request) -> Iterator[Session]:
    with request.app.state.fabrica() as sessao:
        yield sessao

O contrato

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:

exemplos/api_pedidos/src/api_pedidos/esquemas.py
from datetime import datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field


class ItemCriar(BaseModel):
    produto: str = Field(min_length=1, max_length=120)
    quantidade: int = Field(gt=0, le=1000)
    preco_centavos: int = Field(gt=0)


class PedidoCriar(BaseModel):
    cliente: str = Field(min_length=1, max_length=120)
    itens: list[ItemCriar] = Field(min_length=1)


class ItemLer(ItemCriar):
    model_config = ConfigDict(from_attributes=True)


class PedidoLer(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    cliente: str
    status: Literal["aberto", "cancelado"]
    total_centavos: int
    criado_em: datetime
    itens: list[ItemLer]


class Pagina(BaseModel):
    itens: list[PedidoLer]
    proximo_cursor: int | None

Repositório e serviço

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:

exemplos/api_pedidos/src/api_pedidos/repositorio.py
from sqlalchemy import select
from sqlalchemy.orm import Session

from api_pedidos.modelos import Pedido


class RepositorioPedidos:
    def __init__(self, sessao: Session) -> None:
        self._sessao = sessao

    def adicionar(self, pedido: Pedido) -> Pedido:
        self._sessao.add(pedido)
        self._sessao.flush()
        return pedido

    def obter(self, pedido_id: int) -> Pedido | None:
        return self._sessao.get(Pedido, pedido_id)

    def por_chave(self, chave: str) -> Pedido | None:
        consulta = select(Pedido).where(Pedido.chave_idempotencia == chave)
        return self._sessao.scalars(consulta).first()

    def listar(self, depois_de: int, limite: int) -> list[Pedido]:
        consulta = select(Pedido).where(Pedido.id > depois_de).order_by(Pedido.id).limit(limite)
        return list(self._sessao.scalars(consulta))
exemplos/api_pedidos/src/api_pedidos/servico.py
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from api_pedidos.esquemas import PedidoCriar
from api_pedidos.modelos import ItemPedido, Pedido
from api_pedidos.repositorio import RepositorioPedidos


class PedidoNaoEncontrado(Exception):
    def __init__(self, pedido_id: int) -> None:
        super().__init__(f"pedido {pedido_id} não encontrado")
        self.pedido_id = pedido_id


class PedidoJaCancelado(Exception):
    def __init__(self, pedido_id: int) -> None:
        super().__init__(f"pedido {pedido_id} já está cancelado")
        self.pedido_id = pedido_id


class ServicoPedidos:
    def __init__(self, sessao: Session) -> None:
        self._sessao = sessao
        self._repositorio = RepositorioPedidos(sessao)

    def criar(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)."""
        if chave and (existente := self._repositorio.por_chave(chave)):
            return existente, False
        pedido = Pedido(
            cliente=dados.cliente,
            chave_idempotencia=chave,
            itens=[ItemPedido(**item.model_dump()) for item in dados.itens],
        )
        try:
            self._repositorio.adicionar(pedido)
            self._sessao.commit()
        except IntegrityError:
            # 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()
            if chave and (existente := self._repositorio.por_chave(chave)):
                return existente, False
            raise
        return pedido, True

    def obter(self, pedido_id: int) -> Pedido:
        pedido = self._repositorio.obter(pedido_id)
        if pedido is None:
            raise PedidoNaoEncontrado(pedido_id)
        return pedido

    def cancelar(self, pedido_id: int) -> Pedido:
        pedido = self.obter(pedido_id)
        if pedido.status == "cancelado":
            raise PedidoJaCancelado(pedido_id)
        pedido.status = "cancelado"
        self._sessao.commit()
        return pedido

    def listar(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].id if len(encontrados) > limite else None
        return pagina, 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:

exemplos/api_pedidos/src/api_pedidos/observabilidade.py
import json
import logging
import sys
from collections.abc import Awaitable, Callable
from contextvars import ContextVar
from time import perf_counter
from uuid import uuid4

from fastapi import FastAPI, Request, Response
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest

id_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")


class FormatoJson(logging.Formatter):
    CAMPOS_EXTRAS = ("metodo", "rota", "status", "duracao_ms")

    def format(self, record: logging.LogRecord) -> str:
        dados: dict[str, object] = {
            "nivel": record.levelname,
            "mensagem": record.getMessage(),
            "id_requisicao": id_requisicao.get(),
        }
        for campo in self.CAMPOS_EXTRAS:
            if hasattr(record, campo):
                dados[campo] = getattr(record, campo)
        return json.dumps(dados, ensure_ascii=False)


def configurar_logs(nivel: str = "INFO") -> None:
    manipulador = logging.StreamHandler(sys.stdout)
    manipulador.setFormatter(FormatoJson())
    log.handlers = [manipulador]
    log.setLevel(nivel)
    log.propagate = False


def instalar_observabilidade(app: FastAPI) -> None:
    @app.middleware("http")
    async def medir(
        request: Request, call_next: Callable[[Request], Awaitable[Response]]
    ) -> Response:
        identificador = request.headers.get("X-Request-ID") or uuid4().hex
        token = id_requisicao.set(identificador)
        inicio = perf_counter()
        status = 500
        try:
            resposta = await call_next(request)
            status = resposta.status_code
        finally:
            duracao = perf_counter() - inicio
            rota_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"] = identificador
        return resposta

    @app.get("/metricas", include_in_schema=False)
    def metricas() -> Response:
        return Response(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
from typing import Annotated

from fastapi import Depends, FastAPI, Header, Query, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy import Engine, text
from sqlalchemy.orm import Session

from api_pedidos.config import obter_configuracao
from api_pedidos.db import criar_engine, criar_fabrica_de_sessoes, obter_sessao
from api_pedidos.esquemas import Pagina, PedidoCriar, PedidoLer
from api_pedidos.observabilidade import configurar_logs, instalar_observabilidade
from api_pedidos.servico import PedidoJaCancelado, PedidoNaoEncontrado, ServicoPedidos

SessaoDep = Annotated[Session, Depends(obter_sessao)]


def obter_servico(sessao: SessaoDep) -> ServicoPedidos:
    return ServicoPedidos(sessao)


ServicoDep = Annotated[ServicoPedidos, Depends(obter_servico)]


def problema(status: int, titulo: str, detalhe: str) -> JSONResponse:
    """Formato de erro único para toda a API (inspirado no RFC 9457)."""
    return JSONResponse(
        {"titulo": titulo, "status": status, "detalhe": detalhe},
        status_code=status,
        media_type="application/problem+json",
    )


def criar_app(engine: Engine | None = None) -> FastAPI:
    config = obter_configuracao()
    configurar_logs(config.log_nivel)
    engine = engine or criar_engine(config.database_url.get_secret_value())
    app = FastAPI(title="API de pedidos", version="0.1.0")
    app.state.engine = engine
    app.state.fabrica = criar_fabrica_de_sessoes(engine)
    instalar_observabilidade(app)

    @app.exception_handler(PedidoNaoEncontrado)
    def nao_encontrado(_: Request, erro: PedidoNaoEncontrado) -> JSONResponse:
        return problema(404, "Pedido não encontrado", str(erro))

    @app.exception_handler(PedidoJaCancelado)
    def ja_cancelado(_: Request, erro: PedidoJaCancelado) -> JSONResponse:
        return problema(409, "Conflito de estado", str(erro))

    @app.post("/pedidos", response_model=PedidoLer, status_code=201)
    def criar_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)
        if not criado:
            resposta.status_code = 200
        return pedido

    @app.get("/pedidos/{pedido_id}", response_model=PedidoLer)
    def obter_pedido(pedido_id: int, servico: ServicoDep) -> object:
        return servico.obter(pedido_id)

    @app.get("/pedidos", response_model=Pagina)
    def listar_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)
    def cancelar_pedido(pedido_id: int, servico: ServicoDep) -> object:
        return servico.cancelar(pedido_id)

    @app.get("/saude")
    def saude(sessao: SessaoDep) -> JSONResponse:
        try:
            sessao.execute(text("SELECT 1"))
        except Exception:
            return JSONResponse({"banco": "indisponível"}, status_code=503)
        return JSONResponse({"banco": "ok"})

    return app

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
import os
from collections.abc import Iterator

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import Engine, create_engine
from sqlalchemy.pool import StaticPool

from api_pedidos.app import criar_app
from api_pedidos.modelos import Base


@pytest.fixture
def engine() -> Iterator[Engine]:
    """SQLite em memória por padrão. Defina TEST_DATABASE_URL para testar contra PostgreSQL."""
    url = os.environ.get("TEST_DATABASE_URL")
    if url:
        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)
    yield eng
    Base.metadata.drop_all(eng)
    eng.dispose()


@pytest.fixture
def cliente(engine: Engine) -> TestClient:
    return TestClient(criar_app(engine))
exemplos/api_pedidos/tests/test_api.py
from fastapi.testclient import TestClient

CORPO = {
    "cliente": "Ana",
    "itens": [
        {"produto": "caneta", "quantidade": 2, "preco_centavos": 350},
        {"produto": "caderno", "quantidade": 1, "preco_centavos": 1890},
    ],
}


def test_criar_pedido(cliente: TestClient) -> None:
    resposta = cliente.post("/pedidos", json=CORPO)
    assert resposta.status_code == 201
    dados = resposta.json()
    assert dados["total_centavos"] == 2590
    assert dados["status"] == "aberto"
    assert len(dados["itens"]) == 2


def test_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)
    assert primeira.status_code == 201
    assert segunda.status_code == 200
    assert primeira.json()["id"] == segunda.json()["id"]
    assert len(cliente.get("/pedidos").json()["itens"]) == 1


def test_validacao_recusa_corpo_invalido(cliente: TestClient) -> None:
    resposta = cliente.post("/pedidos", json={"cliente": "", "itens": []})
    assert resposta.status_code == 422


def test_pedido_inexistente_devolve_problema(cliente: TestClient) -> None:
    resposta = cliente.get("/pedidos/999")
    assert resposta.status_code == 404
    assert resposta.headers["content-type"].startswith("application/problem+json")
    assert resposta.json()["titulo"] == "Pedido não encontrado"


def test_cancelar_duas_vezes_devolve_conflito(cliente: TestClient) -> None:
    pedido_id = cliente.post("/pedidos", json=CORPO).json()["id"]
    assert cliente.post(f"/pedidos/{pedido_id}/cancelar").json()["status"] == "cancelado"
    assert cliente.post(f"/pedidos/{pedido_id}/cancelar").status_code == 409


def test_paginacao_por_cursor(cliente: TestClient) -> None:
    for _ in range(5):
        cliente.post("/pedidos", json=CORPO)
    primeira = cliente.get("/pedidos", params={"limite": 2}).json()
    assert len(primeira["itens"]) == 2
    assert primeira["proximo_cursor"] == 2
    ultima = cliente.get("/pedidos", params={"limite": 2, "cursor": 4}).json()
    assert [p["id"] for p in ultima["itens"]] == [5]
    assert ultima["proximo_cursor"] is None


def test_saude_e_metricas(cliente: TestClient) -> None:
    assert cliente.get("/saude").json() == {"banco": "ok"}
    cliente.get("/pedidos")
    assert "api_requisicoes_total" in cliente.get("/metricas").text


def test_id_de_requisicao_e_devolvido(cliente: TestClient) -> None:
    resposta = cliente.get("/saude", headers={"X-Request-ID": "req-42"})
    assert resposta.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
import pytest
from sqlalchemy import Engine
from sqlalchemy.orm import Session

from api_pedidos.esquemas import ItemCriar, PedidoCriar
from api_pedidos.modelos import Pedido
from api_pedidos.repositorio import RepositorioPedidos
from api_pedidos.servico import PedidoNaoEncontrado, ServicoPedidos

DADOS = PedidoCriar(
    cliente="Ana", itens=[ItemCriar(produto="caneta", quantidade=1, preco_centavos=100)]
)


def test_obter_pedido_inexistente(engine: Engine) -> None:
    with Session(engine) as sessao, pytest.raises(PedidoNaoEncontrado):
        ServicoPedidos(sessao).obter(1)


def test_corrida_de_chaves_devolve_o_pedido_existente(
    engine: Engine, monkeypatch: pytest.MonkeyPatch
) -> None:
    with Session(engine) as sessao:
        original, criado = ServicoPedidos(sessao).criar(DADOS, "chave-1")
        assert criado
        original_id = original.id

    chamadas = {"total": 0}
    por_chave_real = RepositorioPedidos.por_chave

    def por_chave_atrasada(self: RepositorioPedidos, chave: str) -> Pedido | None:
        chamadas["total"] += 1
        if chamadas["total"] == 1:
            return None  # simula a outra requisição ainda não ter gravado
        return por_chave_real(self, chave)

    monkeypatch.setattr(RepositorioPedidos, "por_chave", por_chave_atrasada)
    with Session(engine) as sessao:
        pedido, criado = ServicoPedidos(sessao).criar(DADOS, "chave-1")
        assert criado is False
        assert pedido.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
from pathlib import Path

import pytest
from alembic import command
from alembic.config import Config

from api_pedidos.config import obter_configuracao

RAIZ = Path(__file__).resolve().parent.parent


def test_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 correspondente
    finally:
        obter_configuracao.cache_clear()

Rodar tudo

Terminal
cd exemplos/api_pedidos
uv sync
uv run pytest

Saída

...........                                                              [100%]
11 passed

Contra o PostgreSQL, com a mesma suíte (a variável aponta para um banco de teste descartável):

Terminal
TEST_DATABASE_URL="postgresql+psycopg://usuario:senha@localhost:5432/pedidos_teste" uv run pytest

E as verificações estáticas:

Terminal
uv run mypy
uv run ruff check .
uv run ruff format --check .

Saída

Success: no issues found in 9 source files
All checks passed!

Usar a API

Aplique as migrações e suba o servidor. Com o Idempotency-Key, a segunda chamada devolve o pedido original, com status 200 (a primeira foi 201):

Terminal
uv run alembic upgrade head
uv run uvicorn api_pedidos.app:criar_app --factory --port 8000
Terminal
curl -s -X POST localhost:8000/pedidos \
  -H "Idempotency-Key: k1" -H "Content-Type: application/json" \
  -d '{"cliente": "Ana", "itens": [{"produto": "caneta", "quantidade": 2, "preco_centavos": 350}]}'

Saída

{"id":1,"cliente":"Ana","status":"aberto","total_centavos":700,"criado_em":"2026-10-06T15:48:21.782501Z","itens":[{"produto":"caneta","quantidade":2,"preco_centavos":350}]}

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

import json
from datetime import date
from pathlib import Path

ARQUIVO = Path("despesas.json")
CATEGORIAS = ["alimentação", "transporte", "moradia", "lazer", "outros"]


def carregar(caminho):
    """Lê as despesas do arquivo. Se ele ainda não existir, devolve uma lista vazia."""
    try:
        with open(caminho, encoding="utf-8") as arquivo:
            return json.load(arquivo)
    except FileNotFoundError:
        return []


def salvar(caminho, despesas):
    with open(caminho, "w", encoding="utf-8") as arquivo:
        json.dump(despesas, arquivo, ensure_ascii=False, indent=2)


def ler_valor(texto):
    """Converte '12,50' em centavos (1250). Levanta ValueError se o valor for inválido."""
    try:
        valor = float(texto.replace(",", "."))
    except ValueError as erro:
        raise ValueError(f"valor inválido: {texto!r}") from erro
    if valor <= 0:
        raise ValueError("o valor deve ser positivo")
    return round(valor * 100)


def formatar_reais(centavos):
    reais, resto = divmod(centavos, 100)
    return f"R$ {reais:,}".replace(",", ".") + f",{resto:02d}"


def adicionar(despesas, descricao, valor_texto, categoria, data=None):
    if not descricao.strip():
        raise ValueError("a descrição não pode ser vazia")
    if categoria not in CATEGORIAS:
        raise ValueError(f"categoria inválida: {categoria!r}")
    despesa = {
        "descricao": descricao.strip(),
        "centavos": ler_valor(valor_texto),
        "categoria": categoria,
        "data": data or date.today().isoformat(),
    }
    despesas.append(despesa)
    return despesa


def remover(despesas, posicao):
    if not 1 <= posicao <= len(despesas):
        raise ValueError(f"não existe a despesa número {posicao}")
    return despesas.pop(posicao - 1)


def total_por_categoria(despesas):
    totais = {}
    for despesa in despesas:
        categoria = despesa["categoria"]
        totais[categoria] = totais.get(categoria, 0) + despesa["centavos"]
    return totais


def mostrar_lista(despesas):
    if not despesas:
        print("Nenhuma despesa registrada.")
        return
    for posicao, d in enumerate(despesas, start=1):
        print(f"{posicao}. {d['data']}  {d['descricao']:<20} {formatar_reais(d['centavos']):>12}  [{d['categoria']}]")


def mostrar_resumo(despesas):
    totais = total_por_categoria(despesas)
    for categoria in sorted(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}")


def main():
    despesas = carregar(ARQUIVO)
    while True:
        print("\n1) Adicionar  2) Listar  3) Resumo  4) Remover  0) Sair")
        opcao = input("Escolha: ").strip()
        if opcao == "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)
            except ValueError as erro:
                print(f"Não foi possível adicionar: {erro}")
            else:
                salvar(ARQUIVO, despesas)
                print("Despesa registrada.")
        elif opcao == "2":
            mostrar_lista(despesas)
        elif opcao == "3":
            mostrar_resumo(despesas)
        elif opcao == "4":
            try:
                remover(despesas, int(input("Número da despesa: ")))
            except ValueError as erro:
                print(f"Não foi possível remover: {erro}")
            else:
                salvar(ARQUIVO, despesas)
                print("Despesa removida.")
        elif opcao == "0":
            break
        else:
            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"""

import tempfile
from pathlib import Path

import despesas


def testar_ler_valor():
    assert despesas.ler_valor("12,50") == 1250
    assert despesas.ler_valor("3") == 300
    for invalido in ("abc", "0", "-5"):
        try:
            despesas.ler_valor(invalido)
        except ValueError:
            pass
        else:
            raise AssertionError(f"{invalido!r} deveria falhar")


def testar_formatar_reais():
    assert despesas.formatar_reais(1250) == "R$ 12,50"
    assert despesas.formatar_reais(123456) == "R$ 1.234,56"
    assert despesas.formatar_reais(5) == "R$ 0,05"


def testar_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")
    assert despesas.total_por_categoria(lista) == {"alimentação": 5290, "transporte": 450}


def testar_adicionar_recusa_dados_invalidos():
    lista = []
    for argumentos in (("", "10", "lazer"), ("Cinema", "10", "inexistente"), ("Cinema", "x", "lazer")):
        try:
            despesas.adicionar(lista, *argumentos)
        except ValueError:
            pass
        else:
            raise AssertionError(f"{argumentos} deveria falhar")
    assert lista == []


def testar_remover():
    lista = [{"descricao": "a", "centavos": 1, "categoria": "outros", "data": "x"}]
    assert despesas.remover(lista, 1)["descricao"] == "a"
    assert lista == []
    try:
        despesas.remover(lista, 1)
    except ValueError:
        pass
    else:
        raise AssertionError("deveria falhar")


def testar_salvar_e_carregar():
    with tempfile.TemporaryDirectory() as pasta:
        caminho = Path(pasta) / "d.json"
        assert despesas.carregar(caminho) == []
        dados = [{"descricao": "Pão", "centavos": 850, "categoria": "alimentação", "data": "2026-10-06"}]
        despesas.salvar(caminho, dados)
        assert despesas.carregar(caminho) == dados


if __name__ == "__main__":
    testes = [nome for nome in dir() if nome.startswith("testar_") and callable(globals()[nome])]
    for nome in testes:
        globals()[nome]()
        print(f"ok  {nome}")
    print(f"{len(testes)} testes passaram")

Rodar

Terminal
cd projetos/despesas
python3 testar_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
python3 despesas.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'):

Sessão real
1. 2026-10-06  Almoço                   R$ 32,90  [alimentação]
2. 2026-10-06  Ônibus                    R$ 4,50  [transporte]
3. 2026-10-06  Jantar                   R$ 20,00  [alimentação]

alimentação      R$ 52,90
transporte        R$ 4,50
total            R$ 57,40

Desafios, em ordem de dificuldade

  1. Filtrar por mês. Peça um mês (2026-10) e liste só as despesas dele, usando o campo data como texto.
  2. Orçamento. Guarde um limite mensal por categoria e avise, no resumo, quando uma categoria passou dele.
  3. Exportar para CSV. Use o módulo csv (capítulo 39) para gerar despesas.csv com cabeçalho.
  4. 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 = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

[tool.mypy]
python_version = "3.12"
strict = true
files = ["src"]

O código

O modelo é uma dataclass com conversão de e para dicionário:

projetos/tarefas/src/tarefas/modelo.py
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Any, Self


def agora_iso() -> str:
    return datetime.now(UTC).isoformat(timespec="seconds")


@dataclass
class Tarefa:
    id: int
    titulo: str
    concluida: bool = False
    criada_em: str = field(default_factory=agora_iso)

    def para_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def de_dict(cls, dados: dict[str, Any]) -> Self:
        return cls(**dados)

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
import json
from pathlib import Path
from typing import Protocol

from tarefas.modelo import Tarefa


class Armazenamento(Protocol):
    def carregar(self) -> list[Tarefa]: ...

    def salvar(self, tarefas: list[Tarefa]) -> None: ...


class ArmazenamentoJson:
    def __init__(self, caminho: Path) -> None:
        self._caminho = caminho

    def carregar(self) -> list[Tarefa]:
        if not self._caminho.exists():
            return []
        dados = json.loads(self._caminho.read_text(encoding="utf-8"))
        return [Tarefa.de_dict(item) for item in dados]

    def salvar(self, tarefas: list[Tarefa]) -> None:
        texto = json.dumps([t.para_dict() for t in tarefas], 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)


class ArmazenamentoEmMemoria:
    """Usado nos testes: não toca o disco."""

    def __init__(self) -> None:
        self._tarefas: list[Tarefa] = []

    def carregar(self) -> list[Tarefa]:
        return [Tarefa.de_dict(t.para_dict()) for t in self._tarefas]

    def salvar(self, tarefas: list[Tarefa]) -> None:
        self._tarefas = [Tarefa.de_dict(t.para_dict()) for t in tarefas]

O serviço concentra as regras e registra o que fez em logs:

projetos/tarefas/src/tarefas/servico.py
import logging

from tarefas.armazenamento import Armazenamento
from tarefas.modelo import Tarefa

log = logging.getLogger(__name__)


class TarefaNaoEncontrada(Exception):
    def __init__(self, tarefa_id: int) -> None:
        super().__init__(f"tarefa {tarefa_id} não encontrada")
        self.tarefa_id = tarefa_id


class ServicoTarefas:
    def __init__(self, armazenamento: Armazenamento) -> None:
        self._armazenamento = armazenamento

    def adicionar(self, titulo: str) -> Tarefa:
        titulo = titulo.strip()
        if not titulo:
            raise ValueError("o título não pode ser vazio")
        tarefas = self._armazenamento.carregar()
        proximo_id = max((t.id for t in tarefas), default=0) + 1
        tarefa = Tarefa(id=proximo_id, titulo=titulo)
        self._armazenamento.salvar([*tarefas, tarefa])
        log.info("tarefa %d adicionada", tarefa.id)
        return tarefa

    def listar(self, *, somente_pendentes: bool = False) -> list[Tarefa]:
        tarefas = self._armazenamento.carregar()
        return [t for t in tarefas if not t.concluida] if somente_pendentes else tarefas

    def concluir(self, tarefa_id: int) -> Tarefa:
        tarefas = self._armazenamento.carregar()
        tarefa = self._buscar(tarefas, tarefa_id)
        tarefa.concluida = True
        self._armazenamento.salvar(tarefas)
        log.info("tarefa %d concluída", tarefa_id)
        return tarefa

    def remover(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)

    @staticmethod
    def _buscar(tarefas: list[Tarefa], tarefa_id: int) -> Tarefa:
        for tarefa in tarefas:
            if tarefa.id == tarefa_id:
                return tarefa
        raise TarefaNaoEncontrada(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
import argparse
import logging
from pathlib import Path

from tarefas.armazenamento import Armazenamento, ArmazenamentoJson
from tarefas.servico import ServicoTarefas, TarefaNaoEncontrada


def criar_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)
    return parser


def main(argv: list[str] | None = None, armazenamento: Armazenamento | None = None) -> int:
    args = criar_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.INFO if args.verboso else logging.WARNING,
        format="%(levelname)s %(name)s: %(message)s",
        force=True,
    )
    servico = ServicoTarefas(armazenamento or ArmazenamentoJson(args.arquivo))
    try:
        if args.comando == "adicionar":
            tarefa = servico.adicionar(args.titulo)
            print(f"Tarefa {tarefa.id} criada: {tarefa.titulo}")
        elif args.comando == "listar":
            tarefas = servico.listar(somente_pendentes=args.pendentes)
            if not tarefas:
                print("Nenhuma tarefa.")
            for t in tarefas:
                marca = "x" if t.concluida else " "
                print(f"[{marca}] {t.id}. {t.titulo}")
        elif args.comando == "concluir":
            print(f"Tarefa {servico.concluir(args.id).id} concluída.")
        elif args.comando == "remover":
            servico.remover(args.id)
            print(f"Tarefa {args.id} removida.")
    except (TarefaNaoEncontrada, ValueError) as erro:
        print(f"Erro: {erro}")
        return 1
    return 0
projetos/tarefas/src/tarefas/__main__.py
from tarefas.cli import main

raise SystemExit(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:

projetos/tarefas/tests/test_servico.py
import pytest

from tarefas.armazenamento import ArmazenamentoEmMemoria, ArmazenamentoJson
from tarefas.servico import ServicoTarefas, TarefaNaoEncontrada


@pytest.fixture
def servico() -> ServicoTarefas:
    return ServicoTarefas(ArmazenamentoEmMemoria())


def test_adicionar_gera_ids_sequenciais(servico: ServicoTarefas) -> None:
    assert servico.adicionar("a").id == 1
    assert servico.adicionar("b").id == 2


def test_titulo_vazio_e_recusado(servico: ServicoTarefas) -> None:
    with pytest.raises(ValueError, match="vazio"):
        servico.adicionar("   ")


def test_concluir_e_filtrar_pendentes(servico: ServicoTarefas) -> None:
    servico.adicionar("a")
    servico.adicionar("b")
    servico.concluir(1)
    assert [t.titulo for t in servico.listar(somente_pendentes=True)] == ["b"]
    assert len(servico.listar()) == 2


def test_remover_e_nao_reutilizar_apos_remocao_do_ultimo(servico: ServicoTarefas) -> None:
    servico.adicionar("a")
    servico.remover(1)
    assert servico.listar() == []
    with pytest.raises(TarefaNaoEncontrada):
        servico.remover(1)


def test_armazenamento_json_ida_e_volta(tmp_path) -> None:  # type: ignore[no-untyped-def]
    caminho = tmp_path / "t.json"
    ServicoTarefas(ArmazenamentoJson(caminho)).adicionar("persistida")
    outra_instancia = ServicoTarefas(ArmazenamentoJson(caminho))
    assert [t.titulo for t in outra_instancia.listar()] == ["persistida"]
    assert not caminho.with_suffix(".tmp").exists()

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:

projetos/tarefas/tests/test_cli.py
from pathlib import Path

import pytest

from tarefas.cli import main


def rodar(arquivo: Path, *argumentos: str) -> int:
    return main(["--arquivo", str(arquivo), *argumentos])


def test_fluxo_completo(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
    arquivo = tmp_path / "t.json"
    assert rodar(arquivo, "adicionar", "Estudar Python") == 0
    assert rodar(arquivo, "adicionar", "Escrever testes") == 0
    assert rodar(arquivo, "concluir", "1") == 0
    capsys.readouterr()
    assert rodar(arquivo, "listar") == 0
    saida = capsys.readouterr().out
    assert "[x] 1. Estudar Python" in saida
    assert "[ ] 2. Escrever testes" in saida


def test_tarefa_inexistente_devolve_codigo_1(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    assert rodar(tmp_path / "t.json", "concluir", "99") == 1
    assert "Erro: tarefa 99 não encontrada" in capsys.readouterr().out


def test_argumento_invalido_sai_com_codigo_2(tmp_path: Path) -> None:
    with pytest.raises(SystemExit) as codigo:
        rodar(tmp_path / "t.json", "concluir", "abc")
    assert codigo.value.code == 2

Rodar

Terminal
cd projetos/tarefas
uv sync
uv run pytest
uv run mypy
uv run ruff check .

Saída

........                                                                 [100%]
8 passed
Success: no issues found in 6 source files
All checks passed!

Usando a CLI (o primeiro argumento antes do subcomando escolhe o arquivo):

Terminal
uv run tarefas adicionar "Estudar Python"
uv run tarefas adicionar "Escrever testes"
uv run tarefas concluir 1
uv run tarefas listar

Saída

Tarefa 1 criada: Estudar Python
Tarefa 2 criada: Escrever testes
Tarefa 1 concluída.
[x] 1. Estudar Python
[ ] 2. Escrever testes

Com -v, o serviço mostra o que fez, e um erro do usuário não gera traceback, só uma mensagem e o código 1:

Terminal
uv run tarefas -v adicionar "com log"
uv run tarefas concluir 99

Saída

INFO tarefas.servico: tarefa 3 adicionada
Tarefa 3 criada: com log
Erro: tarefa 99 não encontrada

Desafios

  1. 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).
  2. 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.
  3. Desfazer. Um comando desfazer que reverte a última operação.
  4. 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:

projetos/processador/src/processador/modelos.py
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Resultado:
    arquivo: str
    linhas: int
    total_centavos: int
    por_regiao: dict[str, int]


@dataclass(frozen=True)
class Falha:
    arquivo: str
    motivo: str


@dataclass
class Relatorio:
    resultados: list[Resultado] = field(default_factory=list)
    falhas: list[Falha] = field(default_factory=list)

    @property
    def total_centavos(self) -> int:
        return sum(r.total_centavos for r in self.resultados)

    @property
    def por_regiao(self) -> dict[str, int]:
        totais: dict[str, int] = {}
        for resultado in self.resultados:
            for regiao, valor in resultado.por_regiao.items():
                totais[regiao] = totais.get(regiao, 0) + valor
        return totais

O cálculo (a etapa de CPU)

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
import csv
import io


def agregar(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)
    if cabecalho != ["regiao", "valor_centavos"]:
        raise ValueError(f"cabeçalho inesperado: {cabecalho}")
    linhas = total = 0
    por_regiao: dict[str, int] = {}
    for numero, linha in enumerate(leitor, start=2):
        try:
            regiao, valor_texto = linha
            valor = int(valor_texto)
        except ValueError as erro:
            raise ValueError(f"linha {numero} inválida: {linha}") from erro
        por_regiao[regiao] = por_regiao.get(regiao, 0) + valor
        total += valor
        linhas += 1
    return linhas, 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
import asyncio
import logging
from collections.abc import Awaitable, Callable
from pathlib import Path

log = logging.getLogger(__name__)


def ler_texto(caminho: Path) -> str:
    return caminho.read_text(encoding="utf-8")


async def ler_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."""
    for numero in range(1, tentativas + 1):
        try:
            return await asyncio.to_thread(ler, caminho)
        except OSError as erro:
            if numero == tentativas:
                raise
            espera = base * 2 ** (numero - 1)
            log.warning(
                "leitura falhou (%s), tentativa %d, nova tentativa em %.2fs", erro, numero, espera
            )
            await dormir(espera)
    raise AssertionError("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
import logging
from contextvars import ContextVar

id_arquivo: ContextVar[str] = ContextVar("id_arquivo", default="-")


class FiltroArquivo(logging.Filter):
    """Acrescenta o nome do arquivo em processamento a cada linha de log."""

    def filter(self, record: logging.LogRecord) -> bool:
        record.arquivo = id_arquivo.get()
        return True

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
import asyncio
import logging
import multiprocessing
from collections.abc import Awaitable, Callable
from concurrent.futures import Executor, ProcessPoolExecutor
from pathlib import Path

from processador.calculo import agregar
from processador.contexto import id_arquivo
from processador.leitura import ler_com_retentativas, ler_texto
from processador.modelos import Falha, Relatorio, Resultado

log = logging.getLogger(__name__)


def criar_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.
    """
    return ProcessPoolExecutor(
        max_workers=processos, mp_context=multiprocessing.get_context("spawn")
    )


async def processar_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:
        async with semaforo:
            async with asyncio.timeout(timeout):
                texto = await ler_com_retentativas(caminho, dormir=dormir, ler=ler)
        laco = asyncio.get_running_loop()
        linhas, total, por_regiao = await laco.run_in_executor(pool, agregar, texto)
        log.info("processado: %d linhas", linhas)
        return Resultado(caminho.name, linhas, total, por_regiao)
    except (OSError, TimeoutError, ValueError) as erro:
        motivo = str(erro) or type(erro).__name__
        log.error("falhou: %s", motivo)
        return Falha(caminho.name, motivo)
    finally:
        id_arquivo.reset(token)


async def processar(
    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 = await asyncio.gather(
        *(
            processar_arquivo(
                caminho, semaforo=semaforo, pool=pool, timeout=timeout, dormir=dormir, ler=ler
            )
            for caminho in sorted(pasta.glob("*.csv"))
        )
    )
    relatorio = Relatorio()
    for saida in saidas:
        if isinstance(saida, Resultado):
            relatorio.resultados.append(saida)
        else:
            relatorio.falhas.append(saida)
    return relatorio

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
import argparse
import asyncio
import csv
import logging
import sys
from pathlib import Path

from processador.contexto import FiltroArquivo
from processador.modelos import Relatorio
from processador.pipeline import criar_pool, processar

REGIOES = ["norte", "nordeste", "centro-oeste", "sudeste", "sul"]


def gerar_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)
    for i in range(arquivos):
        with (pasta / f"vendas_{i:02d}.csv").open("w", encoding="utf-8", newline="") as arquivo:
            escritor = csv.writer(arquivo)
            escritor.writerow(["regiao", "valor_centavos"])
            for j in range(linhas):
                escritor.writerow([REGIOES[(i + j) % 5], (i * 7919 + j * 104729) % 50_000 + 100])


def formatar_reais(centavos: int) -> str:
    reais, resto = divmod(centavos, 100)
    return f"R$ {reais:,}".replace(",", ".") + f",{resto:02d}"


def imprimir(relatorio: Relatorio) -> None:
    print(f"arquivos processados: {len(relatorio.resultados)}  falhas: {len(relatorio.falhas)}")
    for regiao, total in sorted(relatorio.por_regiao.items()):
        print(f"  {regiao:<13}{formatar_reais(total):>16}")
    print(f"  {'total':<13}{formatar_reais(relatorio.total_centavos):>16}")
    for falha in relatorio.falhas:
        print(f"  FALHA {falha.arquivo}: {falha.motivo}")


def configurar_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.INFO if verboso else logging.WARNING, handlers=[manipulador], force=True
    )


def criar_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")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = criar_parser().parse_args(argv)
    configurar_logs(args.verboso)
    if args.comando == "gerar":
        gerar_dados(args.pasta, args.arquivos, args.linhas)
        print(f"{args.arquivos} arquivos gerados em {args.pasta}")
        return 0
    with criar_pool(args.processos) as pool:
        relatorio = asyncio.run(
            processar(args.pasta, pool=pool, leituras=args.leituras, timeout=args.timeout)
        )
    imprimir(relatorio)
    return 1 if relatorio.falhas else 0
projetos/processador/src/processador/__main__.py
from processador.cli import main

if __name__ == "__main__":
    raise SystemExit(main())

Os testes

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):

projetos/processador/tests/test_calculo.py
import pytest

from processador.calculo import agregar


def test_agrega_por_regiao() -> None:
    texto = "regiao,valor_centavos\nsul,100\nnorte,50\nsul,25\n"
    assert agregar(texto) == (3, 175, {"sul": 125, "norte": 50})


def test_cabecalho_errado_e_recusado() -> None:
    with pytest.raises(ValueError, match="cabeçalho"):
        agregar("a,b\n1,2\n")


def test_linha_invalida_informa_o_numero_da_linha() -> None:
    with pytest.raises(ValueError, match="linha 3"):
        agregar("regiao,valor_centavos\nsul,100\nsul,abc\n")
projetos/processador/tests/test_pipeline.py
import asyncio
import threading
import time
from pathlib import Path

from processador.cli import gerar_dados
from processador.pipeline import criar_pool, processar


def rodar(pasta: Path, **opcoes):  # type: ignore[no-untyped-def]
    async def principal():  # type: ignore[no-untyped-def]
        with criar_pool(2) as pool:
            return await processar(pasta, pool=pool, **opcoes)

    return asyncio.run(principal())


def test_processa_todos_e_soma(tmp_path: Path) -> None:
    gerar_dados(tmp_path, arquivos=3, linhas=100)
    relatorio = rodar(tmp_path)
    assert len(relatorio.resultados) == 3
    assert relatorio.falhas == []
    assert sum(r.linhas for r in relatorio.resultados) == 300


def test_arquivo_corrompido_nao_derruba_os_outros(tmp_path: Path) -> None:
    gerar_dados(tmp_path, arquivos=2, linhas=10)
    (tmp_path / "quebrado.csv").write_text("regiao,valor_centavos\nsul,abc\n", encoding="utf-8")
    relatorio = rodar(tmp_path)
    assert len(relatorio.resultados) == 2
    assert [f.arquivo for f in relatorio.falhas] == ["quebrado.csv"]
    assert "linha 2" in relatorio.falhas[0].motivo


def test_retentativas_com_espera_crescente(tmp_path: Path) -> None:
    gerar_dados(tmp_path, arquivos=1, linhas=5)
    esperas: list[float] = []
    falhas_restantes = {"n": 2}

    async def dormir_falso(segundos: float) -> None:
        esperas.append(segundos)

    def ler_instavel(caminho: Path) -> str:
        if falhas_restantes["n"] > 0:
            falhas_restantes["n"] -= 1
            raise OSError("disco ocupado")
        return caminho.read_text(encoding="utf-8")

    relatorio = rodar(tmp_path, dormir=dormir_falso, ler=ler_instavel)
    assert len(relatorio.resultados) == 1
    assert esperas == [0.1, 0.2]


def test_timeout_vira_falha(tmp_path: Path) -> None:
    gerar_dados(tmp_path, arquivos=1, linhas=5)

    def ler_lento(caminho: Path) -> str:
        time.sleep(0.3)
        return caminho.read_text(encoding="utf-8")

    relatorio = rodar(tmp_path, timeout=0.05, ler=ler_lento)
    assert relatorio.resultados == []
    assert relatorio.falhas[0].motivo == "TimeoutError"


def test_limite_de_leituras_simultaneas(tmp_path: Path) -> None:
    gerar_dados(tmp_path, arquivos=6, linhas=5)
    trava = threading.Lock()
    ativas = {"agora": 0, "maximo": 0}

    def ler_medindo(caminho: Path) -> str:
        with trava:
            ativas["agora"] += 1
            ativas["maximo"] = max(ativas["maximo"], ativas["agora"])
        time.sleep(0.05)
        with trava:
            ativas["agora"] -= 1
        return caminho.read_text(encoding="utf-8")

    rodar(tmp_path, leituras=2, ler=ler_medindo)
    assert ativas["maximo"] <= 2
projetos/processador/tests/test_cli.py
from pathlib import Path

import pytest

from processador.cli import main


def test_gerar_e_processar(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
    pasta = str(tmp_path / "dados")
    assert main(["gerar", pasta, "--arquivos", "3", "--linhas", "50"]) == 0
    assert main(["processar", pasta, "--processos", "2"]) == 0
    saida = capsys.readouterr().out
    assert "arquivos processados: 3  falhas: 0" in saida
    assert "total" in saida


def test_codigo_de_saida_1_quando_ha_falha(tmp_path: Path) -> None:
    (tmp_path / "ruim.csv").write_text("x,y\n", encoding="utf-8")
    assert main(["processar", str(tmp_path), "--processos", "1"]) == 1

Rodar

Terminal
cd projetos/processador
uv sync
uv run pytest
uv run mypy
uv run ruff check .

Saída

..........                                                               [100%]
10 passed
Success: no issues found in 8 source files
All checks passed!

Gerando seis arquivos de cinquenta mil linhas e processando:

Terminal
uv run processador gerar dados --arquivos 6 --linhas 50000
uv run processador processar dados

Saída

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

  1. 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.)
  2. 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.
  3. 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.
  4. Cancelamento. Trate o Ctrl+C: cancele as tarefas pendentes, espere as que estão em andamento e imprima um relatório parcial.
  5. 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.