Qualidade de Código: linting, type hints e mypy

Qualidade de Código: linting, type hints e mypy

Black, isort, Ruff, mypy, Bandit e pre-commit na prática, conferidos nas versões de 2026 — e o que cada ferramenta não vê: o == None que o formatador mantém, a senha que o Bandit não reconhece e o repositório que passa no mypy --strict e ressuscita alunos apagados.
Python

• • 21 min de leitura

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

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.

Comentários

Mais em Python

Trabalhando com Datas e Horas
Trabalhando com Datas e Horas

Datas e horas em Python com datetime, timedelta, zoneinfo e dateutil. Com as…

Strings em Profundidade: métodos, formatação e expressões regulares
Strings em Profundidade: métodos, formatação e expressões regulares

Texto é o dado que mais chega torto. Além do repertório de métodos, formatação…

FastAPI: APIs modernas com tipagem e documentação automática
FastAPI: APIs modernas com tipagem e documentação automática

O FastAPI valida, converte e documenta a partir dos tipos, e os erros moram no…