Testes verificam o que o código faz. Ferramentas de qualidade verificam como o código é escrito. Um código pode passar em todos os testes e ainda ser difícil de manter, inconsistente na formatação, cheio de variáveis não utilizadas ou propenso a bugs de tipo. Python tem um ecossistema rico de ferramentas que automatizam a verificação e formatação do código — tornando revisões de código mais produtivas e bases de código mais saudáveis.
Instalação
pip install ruff mypy black isort pylint bandit
Os exemplos foram conferidos com Ruff 0.16, mypy 2.3, Black 26.5, isort 9.0 e Bandit 1.9. Na prática, um projeto novo não precisa de todas: o ruff check cobre as regras do flake8 e do isort, e o ruff format produz a mesma formatação do Black. Este artigo mostra as ferramentas separadas porque elas ainda estão em muitos projetos, e é bom reconhecer a configuração de cada uma.
PEP 8: O Guia de Estilo do Python
PEP 8 é o documento que define as convenções de estilo oficiais do Python. As principais regras:
# ✗ Errado
x=1
y = x+1
def calcular(a,b,c):
return a+b+c
lista=[1,2,3,4,5]
if x==1 : print(x)
# ✓ Correto
x = 1
y = x + 1
def calcular(a, b, c):
return a + b + c
lista = [1, 2, 3, 4, 5]
if x == 1:
print(x)
# Nomeação
nome_variavel = "snake_case" # variáveis e funções
CONSTANTE_GLOBAL = 42 # constantes
class NomeClasse: ... # PascalCase
_protegido = "uso interno" # prefixo _
__privado = "name mangling" # prefixo __
# Comprimento de linha: a PEP 8 pede 79 caracteres; o Black usa 88
# Duas linhas em branco entre funções e classes no topo do módulo
# Uma linha em branco entre métodos dentro de uma classe
Black: Formatação Automática
Black é um formatador opinativo — não há configuração de estilo, apenas um formato canônico:
# Verificar sem modificar
black --check src/
# Formatar todos os arquivos
black src/ tests/
# Formatar arquivo específico
black src/escola/aluno.py
# Ver diferença sem aplicar
black --diff src/escola/aluno.py
# Antes do Black
def calcular_media(notas,pesos=None,arredondar=True):
if pesos==None:
return sum(notas)/len(notas)
else:
return sum(n*p for n,p in zip(notas,pesos))/sum(pesos)
# Depois do Black
def calcular_media(notas, pesos=None, arredondar=True):
if pesos == None:
return sum(notas) / len(notas)
else:
return sum(n * p for n, p in zip(notas, pesos)) / sum(pesos)
Repare no que o Black não mudou: o pesos == None continua lá, e o parâmetro arredondar segue sem uso. Formatador só mexe em espaços, quebras de linha e aspas; ele garante que o código tenha a mesma aparência, não que esteja certo. Os dois problemas são trabalho do linter: rodando o ruff check nesse mesmo trecho, saem E711 Comparison to `None` should be `cond is None` e ARG001 Unused function argument: `arredondar`.
# pyproject.toml
[tool.black]
line-length = 88
target-version = ["py312"]
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.venv
| migrations
)/
'''
isort: Ordenação de Imports
isort src/ tests/
isort --check-only src/
isort --diff src/
# Antes do isort
import os
from typing import List
import numpy as np
from pathlib import Path
import sys
from escola.aluno import Aluno
import json
from datetime import datetime
# Depois do isort — agrupa: stdlib, third-party, local
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import List
import numpy as np
from escola.aluno import Aluno
# pyproject.toml
[tool.isort]
profile = "black"
line_length = 88
known_first_party = ["escola"]
known_third_party = ["numpy", "pandas", "fastapi"]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
Ruff: Linter Moderno e Ultrarrápido
Ruff substitui o flake8 e o isort, e boa parte das regras do pylint, num único binário escrito em Rust — segundo o projeto, de 10 a 100 vezes mais rápido. Com o ruff format, substitui também o Black:
# Verificar
ruff check src/
# Corrigir automaticamente
ruff check --fix src/
# Verificar e formatar
ruff check src/ && ruff format src/
# pyproject.toml
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"N", # pep8-naming
"SIM", # flake8-simplify
"TC", # flake8-type-checking (antes "TCH")
"ANN", # flake8-annotations
"S", # flake8-bandit (segurança)
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific rules
]
ignore = [
# ANN101 e ANN102 (self e cls sem anotação) foram removidas do Ruff;
# listá-las aqui só gera um aviso
"S101", # use of assert detected (ok em testes)
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ANN", "S101"]
"migrations/**" = ["E501"]
O que o Ruff detecta
# F401 — import não utilizado
import os # nunca usado
# F841 — variável não utilizada
def func():
resultado = calcular() # nunca usada
return 42
# E711 — comparação com None errada
if x == None: # ✗
if x is None: # ✓
# B006 — argumento mutável padrão
def func(lista=[]): # ✗ — perigoso!
lista.append(1)
def func(lista=None): # ✓
if lista is None:
lista = []
# UP006 — use list ao invés de List (Python 3.9+)
from typing import List
def func(x: List[int]) -> List[str]: # ✗
def func(x: list[int]) -> list[str]: # ✓
# SIM108 — use operador ternário
if condicao: # ✗
x = 1
else:
x = 2
x = 1 if condicao else 2 # ✓
# PTH123 — use pathlib ao invés de open
f = open("arquivo.txt") # ✗
f = Path("arquivo.txt").open() # ✓
Type Hints: Anotações de Tipo
Type hints tornaram-se essenciais no Python moderno — documentam intenção, previnem bugs e habilitam ferramentas de análise estática:
# Básico
def somar(a: int, b: int) -> int:
return a + b
def saudar(nome: str) -> str:
return f"Olá, {nome}!"
# Coleções (Python 3.9+)
def processar(numeros: list[int]) -> dict[str, float]:
return {"media": sum(numeros) / len(numeros)}
# Optional — valor pode ser None
from typing import Optional
def buscar(id: int) -> Optional[str]:
return None
# Equivalente moderno (Python 3.10+)
def buscar(id: int) -> str | None:
return None
# Union — múltiplos tipos possíveis
def converter(valor: int | float | str) -> float:
return float(valor)
# Callable — funções como tipo
from typing import Callable
def aplicar(func: Callable[[int], int], valor: int) -> int:
return func(valor)
# TypeVar — genéricos
from typing import TypeVar, Generic
T = TypeVar("T")
def primeiro(lista: list[T]) -> T | None:
return lista[0] if lista else None
# Python 3.12+: o parâmetro de tipo vai direto na definição, sem TypeVar
def ultimo[T](lista: list[T]) -> T | None:
return lista[-1] if lista else None
# Literal — valores específicos
from typing import Literal
Ambiente = Literal["desenvolvimento", "producao", "testes"]
def configurar(env: Ambiente) -> None:
pass
# TypedDict — dicionários com tipos fixos
from typing import TypedDict
class ConfigDB(TypedDict):
host: str
porta: int
banco: str
usuario: str
def conectar(config: ConfigDB) -> None:
pass
# Protocol — duck typing com tipos
from typing import Protocol
class Serializavel(Protocol):
def serializar(self) -> str: ...
def deserializar(self, dados: str) -> None: ...
# dataclass com tipos
from dataclasses import dataclass
@dataclass
class Ponto:
x: float
y: float
label: str = ""
def distancia(self, outro: "Ponto") -> float:
return ((self.x - outro.x) ** 2 + (self.y - outro.y) ** 2) ** 0.5
Tipos Avançados
from typing import (
Any, ClassVar, Final,
Generator, Iterator, AsyncIterator,
overload, TYPE_CHECKING
)
from collections.abc import Sequence, Mapping
# Final — constantes imutáveis
MAX_TENTATIVAS: Final = 3
VERSAO: Final[str] = "1.0.0"
# ClassVar — atributos de classe
class Contador:
_instancias: ClassVar[int] = 0
def __init__(self):
Contador._instancias += 1
# Generator
def fibonacci() -> Generator[int, None, None]:
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# overload — múltiplas assinaturas
@overload
def processar(valor: int) -> int: ...
@overload
def processar(valor: str) -> str: ...
def processar(valor):
if isinstance(valor, int):
return valor * 2
return valor.upper()
# TYPE_CHECKING — imports apenas para type checking
if TYPE_CHECKING:
from escola.turma import Turma
class Aluno:
def get_turma(self) -> "Turma": # evita import circular
...
# ParamSpec — para decoradores que preservam assinatura
import functools
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def log_chamada(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func) # preserva __name__, __doc__ e a assinatura
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Chamando {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_chamada
def calcular(a: int, b: int) -> int:
return a + b
mypy: Verificação Estática de Tipos
# Verificação básica
mypy src/
# Com configuração estrita
mypy --strict src/
# Arquivo específico
mypy src/escola/aluno.py
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
show_error_codes = true
pretty = true
[[tool.mypy.overrides]]
module = ["numpy.*", "pandas.*", "sklearn.*"]
ignore_missing_imports = true
# Erros comuns detectados pelo mypy
# Tipo incompatível
def somar(a: int, b: int) -> int:
return a + b
somar(1, "dois")
# error: Argument 2 to "somar" has incompatible type "str"; expected "int" [arg-type]
# Retorno errado
def get_nome() -> str:
return None
# error: Incompatible return value type (got "None", expected "str") [return-value]
# Atributo possivelmente None
def processar(aluno: Aluno | None) -> str:
return aluno.nome
# error: Item "None" of "Aluno | None" has no attribute "nome" [union-attr]
# Correto:
def processar(aluno: Aluno | None) -> str:
if aluno is None:
return "Desconhecido"
return aluno.nome
# Operação em tipo errado
valores: list[int] = [1, 2, 3]
valores.append("quatro")
# error: Argument 1 to "append" of "list" has incompatible type "str";
# expected "int" [arg-type]
# Ignorando erro específico quando necessário
resultado = funcao_legada() # type: ignore[no-untyped-call]
As mensagens acima são as do mypy 2.3, e o código entre colchetes no fim de cada uma é o que se usa num # type: ignore[...]. Ignorar pelo código, e não com um # type: ignore genérico, faz o mypy continuar avisando de qualquer outro erro que apareça na mesma linha. Com warn_unused_ignores, ele avisa também quando o ignore deixou de ser necessário.
Bandit: Segurança no Código
pip install bandit
bandit -r src/
bandit -r src/ -f json -o relatorio_seguranca.json
# Vulnerabilidades que o Bandit detecta
# B105 — senha hardcoded
password = "admin123" # ✗ Bandit avisa
senha = "admin123" # passa sem aviso: o Bandit reconhece nomes
# como password, secret e token, não "senha"
# B306 — mktemp: o nome pode ser tomado antes de o arquivo ser criado
import tempfile
tempfile.mktemp() # ✗ use mkstemp() ou TemporaryFile()
# B108 — caminho fixo em diretório temporário compartilhado
open("/tmp/relatorio.txt", "w") # ✗
# B301 — pickle inseguro
import pickle
dados = pickle.loads(entrada_usuario) # ✗ pode executar código arbitrário
# B501 — SSL sem verificação
import requests
requests.get(url, verify=False) # ✗
# B506 — yaml.load sem Loader
import yaml
yaml.load(conteudo) # ✗
yaml.safe_load(conteudo) # ✓
# B608 — SQL injection potencial
query = f"SELECT * FROM alunos WHERE id = {id}" # ✗
cursor.execute("SELECT * FROM alunos WHERE id = ?", (id,)) # ✓
pre-commit: Automação antes do Commit
pip install pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: debug-statements # detecta breakpoint() e import pdb (não print)
- id: detect-private-key # detecta chaves privadas
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format # formata como o Black; um formatador basta
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.3.1
hooks:
- id: mypy
additional_dependencies: [types-requests]
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
# Instalando os hooks
pre-commit install
# Rodando manualmente em todos os arquivos
pre-commit run --all-files
# Rodando hook específico
pre-commit run black --all-files
# Atualizando versões dos hooks
pre-commit autoupdate
Os rev do arquivo são as versões de setembro de 2026 e vão envelhecer: o pre-commit autoupdate atualiza todas de uma vez, e vale rodá-lo de tempos em tempos, num commit separado. Rodar Black e ruff-format no mesmo hook é redundante, porque o segundo existe para produzir a mesma saída do primeiro; por isso o Black saiu da lista. E o debug-statements não pega print(), só breakpoint() e imports de depuradores como pdb. Para barrar print esquecido, a regra é a T201 do Ruff, do grupo T20.
Exemplo Completo: Código Bem Tipado
# src/escola/repositorio.py
from __future__ import annotations
import json
from pathlib import Path
from typing import ClassVar, Final, TypeVar
from escola.aluno import Aluno
from escola.exceptions import AlunoNaoEncontradoError, EmailDuplicadoError
T = TypeVar("T", bound="BaseRepositorio")
VERSAO_SCHEMA: Final[str] = "1.0"
class BaseRepositorio:
"""Classe base para repositórios."""
_instancia: ClassVar[BaseRepositorio | None] = None
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
class RepositorioAlunos(BaseRepositorio):
"""Repositório de alunos com persistência em JSON."""
def __init__(self, arquivo: Path | None = None) -> None:
self._dados: dict[int, Aluno] = {}
self._proximo_id: int = 1
self._arquivo = arquivo
if arquivo and arquivo.exists():
self._carregar(arquivo)
def salvar(self, aluno: Aluno) -> int:
"""Salva um aluno e retorna seu ID."""
email_existente = self.buscar_por_email(aluno.email)
if email_existente:
raise EmailDuplicadoError(aluno.email)
aluno_id = self._proximo_id
self._dados[aluno_id] = aluno
self._proximo_id += 1
if self._arquivo:
self._persistir()
return aluno_id
def buscar(self, aluno_id: int) -> Aluno:
"""Busca aluno por ID — levanta erro se não encontrado."""
aluno = self._dados.get(aluno_id)
if aluno is None:
raise AlunoNaoEncontradoError(aluno_id)
return aluno
def buscar_por_email(self, email: str) -> Aluno | None:
"""Retorna None se não encontrado."""
return next(
(a for a in self._dados.values() if a.email == email),
None
)
def listar(
self,
turma: str | None = None,
apenas_ativos: bool = True,
) -> list[Aluno]:
"""Lista alunos com filtros opcionais."""
resultado: list[Aluno] = list(self._dados.values())
if apenas_ativos:
resultado = [a for a in resultado if a.ativo]
if turma:
resultado = [a for a in resultado if a.turma == turma]
return sorted(resultado, key=lambda a: a.nome)
def deletar(self, aluno_id: int) -> None:
"""Remove aluno — levanta erro se não encontrado."""
self.buscar(aluno_id) # garante que existe
del self._dados[aluno_id]
if self._arquivo:
self._persistir()
def _persistir(self) -> None:
"""Persiste dados em arquivo JSON."""
if self._arquivo is None:
return
dados_json = {
"versao": VERSAO_SCHEMA,
"alunos": {
str(k): {
"nome": v.nome,
"email": v.email,
"turma": v.turma,
"notas": v.notas,
"ativo": v.ativo,
}
for k, v in self._dados.items()
}
}
self._arquivo.write_text(
json.dumps(dados_json, indent=2, ensure_ascii=False),
encoding="utf-8"
)
def _carregar(self, arquivo: Path) -> None:
"""Carrega dados de arquivo JSON."""
conteudo = json.loads(arquivo.read_text(encoding="utf-8"))
for id_str, dados in conteudo.get("alunos", {}).items():
aluno = Aluno(
nome= dados["nome"],
email= dados["email"],
turma= dados["turma"],
ativo= dados.get("ativo", True),
)
aluno.notas = dados.get("notas", [])
self._dados[int(id_str)] = aluno
self._proximo_id = max(self._proximo_id, int(id_str) + 1)
O código passa no mypy --strict sem nenhum erro, e mesmo assim tinha um defeito na primeira versão: o deletar removia o aluno da memória, mas não chamava _persistir(), ao contrário do salvar. O aluno sumia da listagem e voltava na próxima vez que o repositório era aberto a partir do arquivo — medido: depois de apagar a Ana, a listagem mostrava só Bruno; relido o JSON, os dois estavam lá. Tipos garantem que as peças encaixam, não que a sequência de operações faz sentido. Esse tipo de erro só um teste pega: salvar, apagar, reabrir e conferir.
# src/escola/exceptions.py
class EscolaBaseError(Exception):
"""Exceção base do domínio escola."""
class AlunoNaoEncontradoError(EscolaBaseError):
def __init__(self, aluno_id: int) -> None:
self.aluno_id = aluno_id
super().__init__(f"Aluno com id={aluno_id} não encontrado.")
class EmailDuplicadoError(EscolaBaseError):
def __init__(self, email: str) -> None:
self.email = email
super().__init__(f"E-mail já cadastrado: {email}")
class NotaInvalidaError(EscolaBaseError):
def __init__(self, nota: float) -> None:
self.nota = nota
super().__init__(f"Nota inválida: {nota}. Deve estar entre 0 e 10.")
Qualidade de código em Python é um trabalho em camadas, cada uma com a sua ferramenta. O formatador — Black, ou o ruff format — acaba com a discussão sobre aparência. O linter, hoje quase sempre o Ruff, aponta imports sem uso, comparações erradas, argumentos mutáveis e dezenas de outros padrões arriscados. As anotações de tipo, conferidas pelo mypy, pegam a chamada com o argumento errado e o None que ninguém tratou. O Bandit procura padrões inseguros conhecidos, e o pre-commit põe tudo isso para rodar antes de cada commit, sem depender de memória.
O que este artigo mostrou, pelos próprios defeitos, é o limite de cada camada. O Black formatou um == None sem corrigi-lo; o Bandit não reconhece uma senha chamada senha; um repositório passou no mypy --strict e ainda assim ressuscitava alunos apagados. Ferramenta estática vê a forma do código, não o que ele faz ao longo do tempo, e por isso ela complementa os testes de Testes com pytest: fixtures, mocks e cobertura em vez de substituí-los. E toda configuração envelhece: regras são removidas, códigos mudam de nome, e o aviso do próprio Ruff sobre ANN101 é o tipo de mensagem que vale ler em vez de ignorar.
Fontes e leituras recomendadas
- PEP 8 — guia de estilo — https://peps.python.org/pep-0008/
- Black — documentação — https://black.readthedocs.io/en/stable/
- Ruff — documentação — https://docs.astral.sh/ruff/
- mypy — documentação — https://mypy.readthedocs.io/en/stable/
- Bandit — documentação — https://bandit.readthedocs.io/en/latest/
- pre-commit — documentação — https://pre-commit.com/
- PEP 484 — Type Hints — https://peps.python.org/pep-0484/
- PEP 526 — Variable Annotations — https://peps.python.org/pep-0526/
- PERCIVAL, Harry; GREGORY, Bob. Architecture Patterns with Python. O'Reilly Media, 2020. — código limpo, tipado e testável em projetos Python profissionais.
Exercícios
Exercício 1
Uma equipe adota o Black e configura o CI para falhar se black --check encontrar diferenças. Meses depois, alguém encontra no código if pesos == None: e uma função com o parâmetro arredondar que nunca é usado. "Mas o Black está no CI", diz o líder. Por que isso passou, e o que faltava?
Ver resposta
✓ Resposta: Porque o Black é um formatador, não um linter. Ele reescreve espaços, quebras de linha, parênteses e aspas, e tem uma garantia de projeto: o código formatado produz exatamente a mesma árvore sintática do original, ou seja, ele nunca muda o comportamento. Trocar == None por is None mudaria (um __eq__ customizado pode responder diferente), e remover um parâmetro quebraria quem o passa. Por isso o Black mantém os dois, bem formatados. Quem acusa esse tipo de problema é o linter: o ruff check nesse trecho aponta E711 Comparison to `None` should be `cond is None` e ARG001 Unused function argument: `arredondar` (esta última só com o grupo ARG selecionado). O CI precisa das duas camadas: ruff format --check para a forma e ruff check para os padrões suspeitos.
Exercício 2
O relatório do Bandit num projeto brasileiro vem limpo, sem nenhum B105. Uma revisão manual, porém, encontra senha = "admin123" e chave_api = "sk-..." no código. Num teste, password = "admin123" no mesmo arquivo é acusado na hora. Explique a diferença e o que fazer.
Ver resposta
✓ Resposta: O B105 não analisa o valor, e sim o nome: ele procura strings atribuídas a variáveis cujo nome contém palavras como password, pass, pwd, secret e token, em inglês. senha e chave_api não casam com nada, e passam. Medido com o Bandit 1.9.4: password = "admin123" gera B105:hardcoded_password_string, e senha = "admin123" não gera nada. O relatório limpo dava uma segurança que não existia. Há três coisas a fazer. A primeira é tirar os segredos do código: variáveis de ambiente ou um cofre de segredos, e rotacionar os que já foram versionados, porque continuam no histórico do git. A segunda é somar ao Bandit um detector de segredos que olha o conteúdo, como o detect-secrets ou o gitleaks, que reconhecem o formato de chaves de API. A terceira é lembrar que ferramenta de análise estática procura padrões conhecidos; relatório vazio significa "nenhum padrão conhecido", não "nenhum problema".
Exercício 3
O decorador log_chamada, na primeira versão do artigo, usava ParamSpec para preservar a assinatura e passava no mypy estrito. Mesmo assim, depois de decorar calcular, calcular.__name__ vale "wrapper" e calcular.__doc__ vale None. O ParamSpec não deveria ter resolvido isso?
Ver resposta
✓ Resposta: O ParamSpec resolve a assinatura para o verificador de tipos: o mypy passa a saber que calcular decorada ainda recebe (a: int, b: int) e devolve int. Isso acontece só na análise estática. Em tempo de execução, o objeto que ficou no lugar de calcular é a função wrapper, com o nome, a docstring e o módulo dela. Medido: __name__ é "wrapper" e __doc__ é None. Isso atrapalha logs, mensagens de erro, o help(), o pytest e frameworks que registram funções pelo nome, como rotas. O @functools.wraps(func) sobre o wrapper copia __name__, __qualname__, __doc__ e __module__, e guarda a original em __wrapped__, que o inspect.signature usa para mostrar a assinatura verdadeira. As duas coisas se complementam: ParamSpec para o mypy, wraps para o Python.
Exercício 4
O RepositorioAlunos com persistência em JSON passa no mypy --strict e no Ruff sem nenhum aviso. Num teste, a Ana é salva, depois apagada com deletar, e a listagem mostra só o Bruno. Quando o programa é reiniciado e o repositório é aberto a partir do mesmo arquivo, a Ana volta. Onde está o defeito, e por que nenhuma ferramenta o pegou?
Ver resposta
✓ Resposta: Na primeira versão, o deletar removia do dicionário em memória, mas não chamava _persistir(), ao contrário do salvar. O arquivo continuava com a Ana, e ela era recarregada na abertura seguinte. Nenhuma ferramenta estática tinha como ver isso: os tipos estão todos corretos, cada linha faz o que diz, e o defeito é uma operação que faltou numa sequência — "toda mudança no estado precisa chegar ao disco". Esse tipo de invariante só aparece num teste que percorre o ciclo inteiro: salvar, apagar, reabrir do arquivo e conferir. Uma forma de tornar o erro mais difícil é concentrar a persistência num ponto só, por exemplo um método _alterou() chamado por toda operação que modifica o estado, ou gravar sempre no __exit__ de um gerenciador de contexto. Assim, esquecer a persistência passa a exigir esquecer uma chamada que está em todo lugar.
Exercício 5
Um projeto usa o hook debug-statements do pre-commit, com o comentário "detecta print() e breakpoint()". Mesmo assim, print("DEBUG", usuario) chegou à produção e passou a escrever dados de usuários no log do servidor. O hook falhou?
Ver resposta
✓ Resposta: Não, o comentário é que estava errado. O debug-statements procura chamadas a breakpoint() e imports de depuradores, como pdb, ipdb e pudb. Medido: num arquivo com print("debug") e breakpoint(), ele acusa só a linha do breakpoint. print é uma chamada legítima em muito código, como scripts e ferramentas de linha de comando, e o hook não tem como saber a intenção. Para barrar print esquecido, a ferramenta certa é o Ruff com o grupo T20: a regra T201 acusa `print` found. Nos arquivos em que o print é legítimo, ela é desligada por arquivo, com per-file-ignores. A lição mais geral é a mesma do Bandit: conhecer o que cada verificação cobre, e não confiar num comentário que descreve uma ferramenta — nem neste artigo, que tinha o comentário errado.