Qualidade de código Python na era da Gen AI (#dev #python #ai #codequality #testing)

Overview
Pergunta sincera: quantas linhas de Python um agente escreveu para você esta semana? Quantas delas são puro microslop, colado com cuspe e esperança? E quantas você leu de verdade, linha por linha? Pois é, foi o que imaginei. Gerar código ficou barato, revisar não ficou, e "o modelo parecia confiante" não é controle de qualidade.
Neste post vamos configurar as ferramentas que ajudam a manter uma codebase Python em forma quando metade dos commits vem do seu LLM e a outra metade vem dos colegas usando Gen AI "escondido".
Só para deixar claro: não estou aqui para sugerir que você pare de usar AI, muito pelo contrário: use à vontade! A ideia é mostrar (ou relembrar) ferramentas que ajudam a manter a codebase em forma, com sorte reduzir o impacto das alucinações e evitar aquele clássico "pedi para a IA consertar X, e ela consertou. Só que agora Y parou de funcionar".
Para que se preocupar? Agora quem escreve o código é a IA
Justamente por isso.
Quando cada linha era digitada por você, o código nascia na velocidade dos seus dedos. Ferramenta de qualidade era um luxo, mas dava para manter o projeto mais ou menos todo na cabeça, porque você tinha sofrido na pele escrevendo cada parte. Agora um agente produz um módulo inteiro, com docstrings e tudo, mais um resuminho animado dizendo o quanto aquilo está pronto para produção. Isso tudo enquanto você foi pegar mais café.
Gostando ou não, o gargalo saiu de escrever código e foi parar em julgar código.
Dá para adicionar "sempre escreva Python limpo e idiomático, seguindo a PEP8" nas instruções do agente. Com certeza vai ser levado muito a sério, ali do lado do "não alucine".
No melhor dos cenários, um prompt é uma sugestão; já um linter é uma catraca.
Ferramentas determinísticas dão o mesmo veredito para o mesmo código em toda execução, e fecham o ciclo: um agente que
recebe de volta B006 Do not use mutable data structures for argument defaults corrige de primeira. Um agente que
recebe "por favor, deixe mais limpo" devolve o mesmo código com três adjetivos novos nas docstrings e uns comentários
recheados de travessões.
Tem um segundo ganho que passa batido: toda ferramenta abaixo é configurada no pyproject.toml, dentro do
repositório. Humanos, CI e qualquer agente que abrir a pasta semana que vem herdam as mesmas regras. Ninguém precisa
lembrar de nada, o que é ótimo, porque ninguém ia lembrar mesmo.
Então, vamos começar do começo...
uv: pare de ser babá de venv
O ritual você já conhece: cria a venv, ativa, instala as coisas, esquece de ativar no mês seguinte e instala tudo no
Python do sistema e aí passa vinte minutos brincando de "por que esse import não funciona". E aquele
requirements.txt que você jurou que ia manter atualizado? (Não manteve.)
O uv é o meu gerenciador de pacotes e projetos favorito para Python, feito pela Astral (o pessoal do Ruff) e escrito em Rust. Ele substitui pip, virtualenv, pip-tools e pipx com um binário só, e a documentação promete de 10 a 100x mais velocidade que o pip. Na prática, a parte de gerenciar ambiente vira algo que você nem percebe mais.
Instale uma vez, de forma global:
1# Linux / macOS
2curl -LsSf https://astral.sh/uv/install.sh | sh
3
4# Windows
5powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Um projeto novo fica assim:
1uv init demo-api
2cd demo-api
3uv add httpx
4uv add --dev pytest ruff mypy black
5uv run python main.py
Repare no que não está ali: nada de python -m venv, nada de activate, nada de deactivate, nada de pip freeze. A
venv existe (uma .venv normal, criada sob demanda), você só não precisa mais mexer nela na mão.
As duas features principais para o nosso caso de uso:
uv.lock. Todouv addouuv removeatualiza um lockfile com a versão exata de cada dependência, incluindo as transitivas. Commite esse arquivo. Em qualquer outra máquina, no CI ou na sandbox de um agente, ouv syncrecria o ambiente idêntico, na mesma versão de tudo. As versões só mudam quando você pede (uv lock --upgrade), o que mata dois clássicos de uma vez: o "funciona na minha máquina" e o "o agente subiu a versão de uma biblioteca para corrigir um bug e ninguém percebeu por três semanas". (Pense nopackage-lock.jsonde um projeto ReactJS.)uv run. Roda qualquer comando dentro do ambiente do projeto, sincronizando antes se o lockfile mudou.uv run pytest,uv run mypy .,uv run python script.py. Não existe estado de ativação para errar, o que importa muito quando quem digita os comandos são um agente: "esqueci de ativar a venv" vira uma categoria inteira de falha que deixa de existir.
De quebra, uv python pin 3.13 fixa a versão do interpretador para o projeto (o uv baixa se a máquina não tiver), e
uvx <tool> roda ferramentas avulsas em um ambiente descartável, no estilo pipx.
Só com o uv você já está em um lugar melhor do que com pip + requirements.txt, mas sem muito esforço dá para
melhorar ainda mais.
Ruff
O Ruff é um linter de Python, também da Astral, também escrito em Rust (ou seja, você já sabe que é rápido). Ele reimplementa em uma ferramenta só as regras do Flake8, isort, pyupgrade, flake8-bugbear e de uma lista longa de outros plugins, e analisa uma codebase de verdade em milissegundos.
Essa velocidade muda o jeito de usar: um check barato assim roda depois de cada iteração do agente, e não uma vez só, de má vontade, logo antes do PR.
Caso tenha ficado curioso sobre as ferramentas que o Ruff cobre:
- isort: Ordena, agrupa e formata os imports do Python sozinho.
- pyupgrade: Reescreve o código para usar a sintaxe mais nova e enxuta que a versão mínima de Python do projeto permite.
- Flake8 É um linter extensível que procura erros de sintaxe, prováveis bugs, violações de estilo e problemas de complexidade.
- flake8-bugbear É um plugin do Flake8 com checagens extras para prováveis bugs e decisões de design questionáveis.
Vale dizer que nenhuma dessas ferramentas morreu, mas (na minha opinião) o Ruff tomou o lugar delas em boa parte dos projetos Python novos.
Antes que eu divague demais, bora voltar ao assunto: a configuração do Ruff vai no pyproject.toml.
1[tool.ruff]
2line-length = 120
3target-version = "py313"
4
5[tool.ruff.lint]
6select = [
7 "E", "W", # pycodestyle
8 "F", # pyflakes (undefined names, unused imports...)
9 "I", # isort (import ordering)
10 "B", # flake8-bugbear (likely bugs, like mutable default args)
11 "UP", # pyupgrade (modern syntax)
12 "SIM", # flake8-simplify (needlessly convoluted code)
13]
De fábrica, o Ruff habilita só um subconjunto pequeno (pyflakes mais alguns grupos do pycodestyle), então ele não
vai gritar com ninguém até você mandar. A seleção acima é (eu acho) um bom ponto de partida: pega bug de verdade sem
te afogar em picuinha. Ajuste a partir daí.
No dia a dia, são só dois comandos:
1uv run ruff check # lint everything
2uv run ruff check --fix # lint and auto-fix what's safe to fix
O formato de saída é feito para ciclos curtos de feedback: arquivo, linha, código da regra, mensagem. Deixe o próprio
agente rodar o comando e a correção costuma sair certa de primeira, porque não tem nada para interpretar. O B006
não tem modo "bem, na verdade...".
Mypy
Eu gosto bastante das type annotations do Python, mas conheço uns desenvolvedores muito bons que acham elas um saco. O Mypy meio que agrada os dois lados, porque checa tipos tanto por inferência quanto por anotação.
O Mypy lê as anotações e verifica se o que flui pelo código bate com o que as assinaturas prometem, sem executar nada.
É uma ferramenta que se paga na era da Gen AI, porque assinatura é exatamente onde os modelos escorregam: um argumento
nomeado que existia em uma versão antiga da biblioteca (ou nunca existiu), uma str entregue para algo que espera um
Path, um caminho do código que retorna None quando a anotação jura que não pode.
1[tool.mypy]
2python_version = "3.13"
3strict = true
4
5[[tool.mypy.overrides]]
6module = ["some_untyped_lib.*"]
7ignore_missing_imports = true
Para rodar:
1uv run mypy .
Em projeto novo, ligue o strict = true desde o primeiro dia; não custa nada enquanto ainda há zero erros. Em uma
codebase existente, o modo estrito vai te receber com uma muralha de erros, então comece com os padrões e vá apertando
módulo por módulo com [[tool.mypy.overrides]]. Dá menos drama e chega no mesmo lugar.
No modo estrito, o Mypy passa a exigir anotação de tipo explícita nas assinaturas das suas funções.
Obs.: as anotações pagam duas vezes quando tem agente no meio. São promessas que o Mypy verifica e são contexto que o modelo lê. Uma assinatura bem tipada diz ao agente o que a função aceita sem ele precisar adivinhar por exemplos de uso.
Black
O Black se apresenta como "the uncompromising code formatter" (o formatador de código que não faz concessões), e o argumento de venda é esse mesmo: ele quase não tem opções, então não sobra o que discutir. Você troca as suas opiniões de formatação por nunca mais ter uma discussão de formatação. Você sobrevive. Eu sobrevivi... (eu acho...)
1[tool.black]
2line-length = 120
3target-version = ["py313"]
1uv run black . # format the project
2uv run black --check . # CI mode: fail if anything would be reformatted
Formatação parece cosmética até você ter que revisar em volume o que os agentes produzem. Com um formatador na jogada, o espaço em branco já chega resolvido antes do diff existir, então a atenção que sobra vai para as partes que podem quebrar de verdade. Ele também normaliza as escolhas "criativas" de layout do modelo, e pode apostar que vai ter escolha criativa de layout.
Obs.: o Ruff também vem com um formatador (uv run ruff format), feito para ser um substituto direto do Black, com o
mesmo estilo. Se a ideia de um binário só fazendo lint e formatação te agrada, use o do Ruff e pule o Black de vez.
Dica: escolha um único formatador por repositório! Dois formatadores com opiniões diferentes ficam reformatando a saída um do outro até o fim dos tempos. Estou falando do Black aqui porque ele chegou primeiro, e é bem provável que seus projetos atuais já usem ele.
pytest
Deixei o mais importante para o final: o framework de testes.
Tudo acima checa o código sem rodar o código. Testes checam o que ele faz de fato e em uma codebase onde agente commita, a suíte ganha um segundo emprego: é o loop de feedback que avisa ao agente (e a você) se uma mudança quebrou alguma coisa. Se você está lendo o post na diagonal, esta é a única seção que eu pediria para levar a sério.
O pytest é o padrão de fato: assert puro, saída de falha legível, fixtures, parametrização e um plugin para
todo o resto.
1uv add --dev pytest
1[tool.pytest.ini_options]
2testpaths = ["tests"]
1# tests/test_pricing.py
2import pytest
3
4from demo_api.pricing import apply_discount
5
6
7def test_applies_percentage_discount():
8 assert apply_discount(100.0, 0.1) == 90.0
9
10
11def test_rejects_negative_discount():
12 with pytest.raises(ValueError):
13 apply_discount(100.0, -0.5)
1uv run pytest
Dica rápida (e boa prática) para quem usa agente de IA: fique de olho nos arquivos de teste. LLM tem o péssimo hábito de quebrar o código e alterar os testes para o código quebrado não gerar falha. Então, se aparecer uma mudança grande em arquivo de teste, questione e confira se o agente não está quebrando o código e mudando as regras do jogo.
Como fazer os agentes rodarem os testes de verdade
Agentes rodam testes direitinho... quando alguém pede. Então peça, por escrito, dentro do repositório. Seja qual for o
arquivo de instruções que a sua ferramenta lê (CLAUDE.md, AGENTS.md, CONTRIBUTING.md, um arquivo de regras),
coloque o contrato ali: rodar uv run pytest e uv run ruff check antes de declarar qualquer coisa pronta, e suíte
vermelha quer dizer que não está pronto (comentar as falhas só de passagem, lá no resumo, não conta).
Depois, exija os mesmos comandos no CI, porque agora o agente abre pull request, e o CI é o revisor que não cansa.
Uma suíte verde antes da mudança e verde depois é o que deixa a iteração com agente mais ou menos segura. Sem ela, o "agora deve funcionar" do agente é só uma sessão de vibe que (provavelmente) está adicionando mais microslop ao mundo.
O agente gerou o código, mas a responsabilidade é sua
Agora a armadilha: o agente também escreve os testes, e teste escrito pelo agente tem seus próprios modos de falhar. Os que eu mais encontro:
- Teste que não tem como falhar.
assert result is not Noneem uma função que sempre retorna alguma coisa. Verde para sempre, valendo nada. - Mocka tudo, testa o mock. Com patch suficiente no mundo, o teste passa a verificar os seus patches, e não o seu código.
- Teste soldado nos detalhes de implementação. Renomeou um helper privado, a suíte fica vermelha; introduziu um bug de verdade, ela continua verde.
- O golpe do verde a qualquer custo. Um teste que falha é "consertado" enfraquecendo a asserção ou apagando o teste. Esse é o perigoso (como eu já disse), porque a suíte continua parecendo saudável enquanto vai deixando de significar qualquer coisa.
Então aqui vai a regra que eu tatuaria em todo projeto assistido por um agente: quem assina embaixo dos testes é o humano. Revise os testes como código de produção, porque é o que eles são. Duas perguntas fazem a maior parte do trabalho: se eu quebrasse esse comportamento de propósito, algum teste falharia? E a suíte sobreviveria a um refactor que mantém o comportamento? Quando um agente quer apagar ou enfraquecer um teste, a decisão não é dele. Ele explica o porquê, e você decide.
E não corra atrás da porcentagem de cobertura. Cobertura diz quais linhas rodaram. Não diz se as asserções em volta daquelas linhas significam alguma coisa, e 90% de cobertura feita de asserção oca é pior do que 60% feita de asserção de verdade, porque os 90% passam uma sensação de segurança que não existe.
Fechando
Nada disso é exótico: um binário para o projeto, um pyproject.toml carregando a configuração de tudo e uma suíte de
testes com dono. Em projeto novo, é questão de minutos. Em um existente, talvez uma tarde. Depois disso, toda linha de
Python que entrar no repositório, digitada por você ou gerada entre um café e outro, vai ser medida pela mesma régua.
Espero ter ajudado! :)
Referências: