Python, dalle fondamenta Lezione 21 / 60

mypy / pyright + ruff: lo stack di analisi statica

Type checking per intercettare bug, ruff per formattazione e linting, e il workflow che fa girare entrambi in fretta.

I test intercettano bug a runtime. L’analisi statica li intercetta prima ancora che il codice giri, idealmente al momento del salvataggio nell’editor. Ci sono due domande distinte a cui un tool di analisi statica può rispondere:

  1. Questo codice ha senso vista l’annotazione di tipo? (il type checker)
  2. Questo codice segue lo stile ed evita errori comuni? (il linter / formatter)

Nel 2026 la risposta alla prima è pyright o mypy, e la risposta alla seconda è in modo schiacciante ruff. Questa lezione è come configurare tutti e tre, come sono fatti i loro file di config, e i fallimenti che vedrai davvero.

Perché il type checking ripaga

Abbiamo coperto i type hint nella lezione 2: def add(a: int, b: int) -> int. Gli hint sono no-op a runtime, Python li ignora. Contano solo quando un tool li legge e ti dice che qualcosa non torna.

Cosa vuol dire “non torna”? Esempi reali da codebase reali:

  • Una funzione annotata -> User restituisce None sul percorso di errore. I test non l’hanno preso perché il percorso di errore era raro. Il type checker sì.
  • Un refactor ha rinominato un parametro da user_id a uid in tre posti su quattro. Il quarto chiamante passa ancora user_id=. Senza tipi, lo prendono i test a runtime in CI. Con i tipi, il sottolineato dell’editor lo prende nell’istante in cui salvi.
  • Una funzione prende list[str] e qualcuno passa tuple[str, str]. “Funziona” finché qualcuno non chiama .append.

I type checker trovano questa classe di bug istantaneamente, su ogni file, a ogni salvataggio. Ti costano il tempo per aggiungere gli hint e poco altro.

pyright vs mypy

Due type checker principali nel 2026:

pyright è di Microsoft, scritto in TypeScript. Veloce (può ricontrollare un file grande in decine di millisecondi), alimenta di default l’estensione Pylance di VS Code, ha l’inferenza più aggressiva. Il default per i progetti nuovi.

mypy è l’implementazione di riferimento, scritto in Python dal team che ha guidato il PEP 484. Più lento di pyright ma più configurabile, con una lunga coda di leve per i casi limite e una base installata enorme. Ancora lo standard in molte aziende grandi.

Puoi farli girare entrambi. Ogni tanto sono in disaccordo sui casi limite, e quel disaccordo a volte è utile come segnale che il codice sta facendo qualcosa di insolito. Per un progetto nuovo, scegli pyright; per una codebase esistente che già fa girare mypy, continua con mypy.

Configurare pyright

Installazione:

uv add --dev pyright

Configurazione in pyproject.toml:

[tool.pyright]
include = ["src", "tests"]
exclude = ["**/__pycache__", "**/.venv"]
pythonVersion = "3.13"
typeCheckingMode = "standard"
reportMissingImports = "error"
reportMissingTypeStubs = "warning"

Il typeCheckingMode è la leva più importante:

  • off: solo controlli sintattici, niente vera inferenza di tipo.
  • basic: leggero, intercetta le cose ovvie.
  • standard: il nuovo default dal 2025; severità ragionevole per la maggior parte dei progetti.
  • strict: segnala ogni funzione non tipizzata, ogni Any, ogni Optional implicito. Fantastico per codice nuovo; doloroso da retrofittare.

Eseguilo:

pyright

L’output è simile a:

src/my_app/users.py:42:5 - error: Argument of type "str | None" cannot be assigned to parameter "name" of type "str" in function "create_user"
  Type "str | None" is not assignable to type "str"
    "None" is not assignable to "str"

Quello è il type checker che ti dice che un valore che potrebbe essere None viene passato dove None non è ammesso. La soluzione è restringere prima della chiamata, oppure cambiare il parametro a str | None.

Configurare mypy

Installazione:

uv add --dev mypy

Configurazione:

[tool.mypy]
python_version = "3.13"
files = ["src", "tests"]
strict = true
warn_return_any = true
warn_unused_ignores = true

# Override per modulo, librerie di terze parti senza type stub
[[tool.mypy.overrides]]
module = ["legacy_lib.*", "old_internal.*"]
ignore_missing_imports = true

strict = true è scorciatoia per una decina di flag individuali (disallow_untyped_defs, disallow_any_generics, warn_redundant_casts, …). I flag granulari esistono se devi disattivarne uno per un modulo specifico.

Eseguilo:

mypy src/

Il formato dell’output è simile a quello di pyright: file, riga, colonna, codice errore, messaggio.

Errori comuni e correzioni

Una manciata che vedrai di continuo.

Optional non ristretto:

def greet(name: str | None) -> str:
    return f"Hello {name.upper()}"  # error: name might be None

Correzione: restringi prima.

def greet(name: str | None) -> str:
    if name is None:
        return "Hello stranger"
    return f"Hello {name.upper()}"

Tipo di ritorno che non combacia:

def find_user(uid: int) -> User:
    user = db.get(uid)
    return user  # error: db.get returns User | None, declared User

Correzione: cambia la firma a -> User | None, oppure solleva un’eccezione nel caso mancante.

Drift di firma dopo un rename:

def create_user(name: str, email: str) -> User: ...

create_user(name="Ada", mail="ada@example.com")
# error: unexpected keyword argument "mail"; did you mean "email"?

Questo è il tipo di bug che da solo ripaga il type checking. Il testing puro a runtime lo intercetta solo se un test esercita esattamente quel call site. Il type checker lo intercetta istantaneamente.

Avvelenamento da Any:

import json

config: dict = json.load(open("config.json"))  # config inferred as dict[Any, Any]
port: str = config["port"]  # no error, but config["port"] is actually int

Any combacia con tutto, il che vuol dire che disabilita il type checking. I flag strict avvisano quando Any entra nel tuo codice senza annotazione. Correggi dando un tipo vero al load, o usando un parser come Pydantic che restituisce un modello tipizzato.

Integrazione con CI

Entrambi i tool escono con codice non zero in caso di errori, che è tutto quello che ti serve:

# .github/workflows/ci.yml
- run: uv run pyright
- run: uv run pytest

Per mypy:

- run: uv run mypy src/

In una codebase grande, configura mypy --install-types o caching pre-commit per tenerlo veloce. Pyright è abbastanza veloce di default che puoi farlo girare a ogni push senza pensarci.

Entra in scena ruff: il formatter e linter

Fino al 2023, il toolchain di stile Python era così: black per formattazione, isort per ordinare gli import, flake8 per linting (con cinque plugin), pylint se volevi controlli più profondi. Quattro tool, quattro config, quattro invocazioni. Erano tutti scritti in Python e tutti lenti.

ruff ha rimpiazzato quasi tutto. Scritto in Rust dal team Astral (la stessa gente dietro uv), fa l’equivalente di black + isort + flake8 + la maggior parte di pylint in millisecondi su un progetto tipico. Nel 2026 è il default de facto per il lavoro Python nuovo.

Installazione:

uv add --dev ruff

Due comandi:

ruff check .       # lint
ruff format .      # format (il rimpiazzo di black)

Punto. Niente più ricordarsi quale tool fa cosa.

Configurare ruff

Tutto in pyproject.toml:

[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src", "tests"]

[tool.ruff.lint]
# Famiglie di regole da abilitare
select = [
    "E",      # pycodestyle errors
    "W",      # pycodestyle warnings
    "F",      # pyflakes
    "I",      # isort (import order)
    "N",      # pep8-naming
    "UP",     # pyupgrade (modernize syntax)
    "B",      # flake8-bugbear (likely bugs)
    "C4",     # flake8-comprehensions
    "SIM",    # flake8-simplify
    "RUF",    # ruff's own rules
]
ignore = [
    "E501",   # line too long (formatter handles this)
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["N802", "N803"]    # pytest test/fixture names

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

Il linguaggio per selezionare le regole è la feature killer. Ogni prefisso di due o tre lettere è una famiglia di regole: E è pycodestyle, F è pyflakes, B è bugbear, UP modernizza la vecchia sintassi. Selezioni famiglie intere ("B"), sotto-regole specifiche ("B008"), o tutto in un tool ("ALL", e poi ignori quello che non vuoi).

Un set di partenza pratico è ["E", "W", "F", "I", "B", "UP"]. Aggiungi "SIM" e "C4" quando ti ci sei abituato.

Cosa intercetta davvero ruff

Esempi da output reali di ruff check:

def make_widgets(items=[]):  # B006: mutable default argument
    items.append(...)
    return items

import os, sys  # E401: multiple imports on one line

x = lambda: 5   # E731: do not assign a lambda; def is clearer

if x == None:   # E711: use `is None`

for i in range(len(things)):  # B007 / SIM-style: enumerate is cleaner
    print(i, things[i])

import json
import json  # F811: redefinition of unused import

La maggior parte di questi ha correzioni automatiche:

ruff check --fix .

Ruff riscriverà il file. Il flag --unsafe-fixes abilita correzioni che cambiano il comportamento; rivedi la diff prima di committare.

ruff format: il rimpiazzo di black

ruff format è byte-compatible con l’output di black per oltre il 99% degli input (il team Astral traccia esplicitamente le divergenze). Se il tuo progetto usa black, puoi sostituirlo con ruff format e la diff sarà vuota.

ruff format .          # riformatta sul posto
ruff format --check .  # modalità CI: esci con 1 se cambierebbe qualcosa

Integrazione con i pre-commit hook

Il pattern standard è far girare ruff (e il tuo type checker) prima di ogni commit. Con pre-commit:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.7.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/RobertCraigie/pyright-python
    rev: v1.1.380
    hooks:
      - id: pyright

Poi:

uv run pre-commit install

Adesso git commit fa girare ruff e pyright sui file modificati e rifiuta i commit che non passano. Combinalo con pytest in CI, e hai tre livelli di protezione: sottolineature dell’editor, gate pre-commit, run completo di CI.

Mettiamo tutto insieme

Una sezione [tool.*] completa di pyproject.toml per un progetto Python moderno, nel 2026:

[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.13"
typeCheckingMode = "standard"

[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "B", "UP", "C4", "SIM", "RUF"]
ignore = ["E501"]

[tool.ruff.format]
quote-style = "double"

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"

Tre tool, un file di config, un environment virtuale via uv. L’intero loop, ruff format, ruff check, pyright, pytest, gira su un progetto tipico piccolo in un paio di secondi totali. Quella è la differenza tra intercettare i bug alla tastiera e intercettarli in produzione.

Lo stack di analisi statica e lo stack di test sono complementari: l’analisi statica ti dice che il codice ha senso a se stesso, i test ti dicono che fa quello che intendevi. Il modulo 4 ha coperto entrambi. Modulo successivo: workflow potenziati dall’AI, dove il 2026 ha un aspetto genuinamente diverso dal 2022.

Citazioni: pyright documentation, mypy documentation, ruff documentation, pytest documentation. Retrieval 2026-05-01.

Cerca