Artigo 44 — Qualidade de Código: linting, type hints e mypy
Prof. Ricardo Matos Módulo 8 · Testes, Qualidade e Boas Práticas · Artigo 44 de 52
Introdução
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
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: máximo 88 caracteres (black padrão)
# Dois espaços em branco entre funções e classes no topo do módulo
# Um espaço 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)
# 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 flake8, pylint e isort em um único binário escrito em Rust — 10-100x mais rápido:
# 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
"TCH", # flake8-type-checking
"ANN", # flake8-annotations
"S", # flake8-bandit (segurança)
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific rules
]
ignore = [
"ANN101", # missing type annotation for self
"ANN102", # missing type annotation for cls
"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
# 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
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def log_chamada(func: Callable[P, R]) -> Callable[P, R]:
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 has incompatible type "str"; expected "int"
# Retorno errado
def get_nome() -> str:
return None # error: Incompatible return value type (got "None", expected "str")
# Atributo possivelmente None
def processar(aluno: Aluno | None) -> str:
return aluno.nome # error: Item "None" of "Aluno | None" has no attribute "nome"
# 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 has incompatible type "str"
# Ignorando erro específico quando necessário
resultado = funcao_legada() # type: ignore[no-untyped-call]
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
senha = "admin123" # ✗ Bandit avisa
# B108 — diretório temp inseguro
import tempfile
tempfile.mktemp() # ✗ use mkstemp() ou TemporaryFile()
# 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: v4.5.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 print() e breakpoint()
- id: detect-private-key # detecta chaves privadas
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.9.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.8
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
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]
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)
# 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.")
Resumo
- PEP 8 define as convenções de estilo — Black e Ruff as aplicam automaticamente
- Black formata sem configuração de estilo — elimina debates sobre formatação em equipes
- Ruff substitui flake8 + isort + pylint em um único binário 100x mais rápido
- Type hints documentam a intenção do código e habilitam análise estática com mypy
- mypy detecta erros de tipo antes da execução — especialmente valioso em bases grandes
- Bandit analisa o código em busca de vulnerabilidades de segurança comuns
- pre-commit automatiza todas as verificações antes de cada commit — zero esforço manual
- Código bem tipado com exceções específicas de domínio é autoexplicativo e fácil de testar
Referências e Leituras Complementares
- 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.
Prof. Ricardo Matos — Dominando o Python em 1 Ano · Artigo 44 de 52 Próximo: Artigo 45 — CI/CD com GitHub Actions
you asked
Sim