Se cinque anni fa aprivi un progetto Python, ti accoglieva uno zoo allegro: setup.py, setup.cfg, MANIFEST.in, requirements.txt, requirements-dev.txt, magari un tox.ini e, se chi lo manteneva era particolarmente meticoloso, un .flake8 e un pytest.ini per buona misura. Ognuno aveva il suo formato, le sue stranezze e le sue opinioni su come si scrive “dipendenza”.
Nel 2026 ti basta più o meno un solo file: pyproject.toml. Questa lezione è il tour.
Una storia breve (così il presente ha senso)
Lo strumento di packaging originale di Python era setuptools, configurato tramite uno script setup.py. Il problema sta proprio nel nome: è uno script. Per sapere qualunque cosa di un pacchetto (nome, dipendenze, versione) dovevi eseguire codice Python arbitrario. È sia un problema di sicurezza sia un incubo per l’introspezione. Vuoi uno strumento che legga i metadati di un progetto? Speriamo che nessuno abbia messo import requests in cima al suo setup.py.
setup.cfg è arrivato come aggiunta dichiarativa: stessi metadati, ma in formato INI così che gli strumenti potessero leggerli senza eseguire nulla. Meglio, ma adesso avevi due file che descrivevano la stessa cosa, e setup.py era ancora richiesto come stub.
Poi sono arrivate due PEP che hanno sistemato tutto in silenzio:
- PEP 518 (2016) ha introdotto
pyproject.tomle la tabella[build-system]. Finalmente, un modo per dire “questo progetto si costruisce con X” senza dover prima eseguire codice Python. - PEP 621 (2020) ha aggiunto la tabella
[project]: un posto standard, indipendente dagli strumenti, per i metadati: nome, versione, dipendenze, autori, tutto quanto.
Entro il 2024 ogni build backend moderno supportava la PEP 621, e setup.py è scivolato silenziosamente nella categoria legacy. Lo vedrai ancora nei progetti più vecchi (e puoi ancora scriverne uno se proprio vuoi), ma per qualunque cosa nuova nel 2026 la risposta è pyproject.toml.
I tre tipi di tabelle
Un pyproject.toml ha grossomodo tre tipi di sezioni:
[build-system]: come costruire il pacchetto. Obbligatoria se stai pubblicando.[project]: i metadati standardizzati del tuo pacchetto.[tool.*]: configurazione specifica per strumento. Qualunque cosa nel namespacetool.è terreno libero per i singoli strumenti. Ruff vive in[tool.ruff], pytest in[tool.pytest.ini_options], mypy in[tool.mypy], e così via.
Vediamo un file realistico.
Un pyproject.toml ragionevolmente realistico
[build-system]
requires = ["hatchling>=1.18"]
build-backend = "hatchling.build"
[project]
name = "weather-cli"
version = "0.3.1"
description = "A tiny CLI that fetches the weather for a city."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "Narcis Miclaus", email = "hello@example.com" }
]
keywords = ["weather", "cli", "openmeteo"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3.13",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"httpx>=0.27",
"click>=8.1",
"rich>=13.7",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov",
"ruff>=0.4",
"mypy>=1.10",
]
[project.scripts]
weather = "weather_cli.__main__:main"
[project.urls]
Homepage = "https://github.com/example/weather-cli"
Issues = "https://github.com/example/weather-cli/issues"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
[tool.mypy]
python_version = "3.11"
strict = true
Sezione per sezione:
[build-system]. Dice: “per costruirmi, installa hatchling e chiama hatchling.build”. I build frontend come pip, uv e build leggono questo e obbediscono. Non invocherai mai il backend direttamente.
[project]. La parte importante. name e version sono obbligatori. requires-python conta più di quanto pensi: impedisce che chi è su interpreti vecchi installi per sbaglio un pacchetto che non funzionerà, e dice a strumenti come uv quale Python scaricare.
dependencies è una lista di stringhe nel formato della PEP 508. Il formato classico: package>=1.2,<2.0; python_version >= "3.10". Puoi pinnare in modo lasco (httpx>=0.27), stretto (httpx==0.27.2) o con marker (pywin32; sys_platform == "win32").
optional-dependencies sono gli extra. pip install weather-cli[dev] installa il gruppo dev. Gruppi tipici: dev, test, docs. (Nota: la tabella dependency-groups della PEP 735 sta prendendo piede nel 2026 per le dipendenze di sviluppo non pubblicate. È la stessa idea ma non inquina i metadati del wheel pubblicato. Vale la pena conoscerla.)
project.scripts genera entry point per la console. Dopo pip install weather-cli, l’utente ha un comando weather nel PATH che chiama main() da weather_cli.__main__. Niente più script shell con chmod +x.
project.urls appare su PyPI come quei comodi link nella sidebar.
Le sezioni [tool.*] stanno dove i tuoi strumenti vogliono che stiano. Ruff, pytest, mypy, coverage, hatch: tutti leggono ormai da pyproject.toml. Un solo file. Una sola fonte di verità.
Build backend: chi costruisce davvero il wheel?
La tabella [build-system] sceglie un build backend. Ce ne sono quattro che incontrerai in giro:
hatchling: dal progetto Hatch. Veloce, semplice, default sensati, niente magia. Nel 2026 è la scelta consigliata per iniziare ed è quello che uv init sceglie per te. Se stai partendo da zero, usa questo.
setuptools: la vecchia guardia. Ancora onnipresente perché la maggior parte dei pacchetti esistenti lo usa. Funziona bene, ma la superficie di configurazione è enorme e piena di stranezze legacy (per esempio package_dir, find_packages, le stranezze dei namespace package). Sceglilo se mantieni qualcosa che già lo usa.
poetry-core: il build backend dietro Poetry. È opinato e dà per scontato che tu stia usando l’intero workflow di Poetry. Se non ti stai impegnando con Poetry come manager, scegli altro.
flit-core: minimale. Solo pacchetti puramente Python, niente estensioni C, niente passaggi di build elaborati. Se il tuo pacchetto è una singola directory di file .py, flit è una delizia.
Ce ne sono altri (pdm-backend, maturin per le estensioni Rust, scikit-build-core per progetti C++/CMake), ma quei quattro coprono la maggior parte del lavoro puramente Python.
Perché uv raccomanda hatchling? Velocità e prevedibilità. Hatchling non ha modalità implicite tipo “scopri tutto” che ti sorprendono al momento della build, la sua configurazione mappa quasi 1:1 sulla PEP 621 e costruisce wheel in millisecondi. Non c’è una buona ragione per scegliere altro per un nuovo progetto puramente Python.
Convertire un vecchio setup.py in pyproject.toml
Supponi di ereditare questo:
# setup.py
from setuptools import setup, find_packages
setup(
name="oldthing",
version="1.2.0",
description="An old thing.",
author="Someone",
author_email="someone@example.com",
packages=find_packages(),
install_requires=[
"requests>=2.28",
"pyyaml",
],
extras_require={
"dev": ["pytest", "black"],
},
entry_points={
"console_scripts": [
"oldthing=oldthing.cli:main",
],
},
python_requires=">=3.9",
)
La traduzione è in larga parte meccanica:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "oldthing"
version = "1.2.0"
description = "An old thing."
requires-python = ">=3.9"
authors = [{ name = "Someone", email = "someone@example.com" }]
dependencies = [
"requests>=2.28",
"pyyaml",
]
[project.optional-dependencies]
dev = ["pytest", "black"]
[project.scripts]
oldthing = "oldthing.cli:main"
Poi cancelli setup.py. Hatchling scoprirà automaticamente i pacchetti sotto src/ o nella radice del progetto; se il tuo layout è insolito, aggiungi una tabella [tool.hatch.build.targets.wheel] per indicarlo. Esegui python -m build (o meglio, uv build) e otterrai un wheel identico dall’altra parte.
Due trabocchetti quando migri:
- Versioni dinamiche. Se il tuo vecchio
setup.pyleggeva la versione da__init__.pyo da un tag Git, ti servedynamic = ["version"]in[project]e un blocco[tool.hatch.version]corrispondente che dica a hatchling dove guardare. MANIFEST.in. Il vecchio modo per includere file non Python nelle source distribution. Hatchling ha la sua configurazione[tool.hatch.build]per questo; il più delle volte i default fanno già la cosa giusta.
TOML in 30 secondi
Se non hai mai toccato TOML prima: è un formato a forma di INI ma con tipi appropriati. Stringhe tra virgolette doppie, array tra parentesi quadre, tabelle in [parentesi.quadre], sotto-tabelle con nomi puntati. La standard library di Python lo parsa nativamente dalla 3.11 (import tomllib), quindi qualsiasi strumento che vuole leggere la tua configurazione può farlo senza una dipendenza extra.
L’unico trabocchetto TOML che vale la pena segnalare: gli array di tabelle usano la doppia parentesi:
[[project.authors]]
name = "Alice"
email = "alice@example.com"
[[project.authors]]
name = "Bob"
email = "bob@example.com"
La PEP 621 ti permette di scriverlo in modo più compatto con tabelle inline (authors = [{ name = "Alice", email = "..." }, ...]), che è quello che fanno la maggior parte dei progetti. Entrambe le forme sono valide; scegli quella che si legge meglio.
Cosa pyproject.toml non è
Non è un lockfile. La lista dependencies dice “voglio httpx >= 0.27”; non dice “specificamente httpx 0.27.2 con queste esatte dipendenze transitive”. Per quello esiste il lockfile, e il tuo package manager lo genera separatamente (uv.lock, poetry.lock, l’output compilato di pip-tools).
Non è nemmeno magia. Gli strumenti devono accettare di leggerlo. Un linter che non supporta [tool.<nome>] non comincerà improvvisamente a farlo solo perché hai aggiunto la tabella. La maggior parte degli strumenti Python moderni lo fa; alcuni vecchi (parlo con te, flake8) hanno avuto bisogno di plugin per stare al passo.
Errori comuni
Alcune cose che colgono in fallo le persone al loro primo pyproject.toml:
- Pinnare troppo stretto nelle librerie. Se stai pubblicando una libreria,
dependencies = ["httpx==0.27.2"]è ostile verso i tuoi utenti: chiunque pinni una versione diversa di httpx non potrà installare il tuo pacchetto insieme alla sua. Usa range (>=0.27,<0.28o semplicemente>=0.27). I pin stretti vanno nei progetti applicativi, dove il lockfile gestisce la riproducibilità. - Dimenticare
requires-python. Senza, il tuo pacchetto si installa su Python 2.7 e stampa errori di sintassi criptici. Imposta sempre un floor sensato. - Mescolare tab e spazi in TOML. TOML tollera lo spazio bianco per l’indentazione, ma lo spazio bianco misto dentro array e tabelle inline produce errori di parser dall’aspetto strano (“Expected ’=’ after a key in a key/value pair”). Stai sugli spazi.
- Mettere commenti in mezzo a un array. TOML permette
# commentisu righe proprie o a fine riga, ma un commento tra due elementi di un array deve stare su una riga sua.["a", # nope, "b"]parsa ma mangia il secondo elemento.
Leggere pyproject.toml da Python
Ogni tanto vuoi leggere il file da te: per mostrare la versione del pacchetto, generare documentazione, o cablare qualche tooling personalizzato. Python include un parser TOML dalla 3.11:
import tomllib
from pathlib import Path
data = tomllib.loads(Path("pyproject.toml").read_text())
print(data["project"]["name"])
print(data["project"]["dependencies"])
Per versioni più vecchie di Python, installa tomli (la stessa libreria, vendorizzata nella stdlib). Nota che tomllib è di sola lettura per design: se devi scrivere TOML, usa tomli-w o tomlkit (quest’ultimo preserva commenti e formattazione, cosa che conta quando stai aggiornando un file esistente sul posto).
Un pattern utile: leggi la versione del pacchetto una sola volta, in una CLI o web app, e mostrala nell’output di --version. Con importlib.metadata non ti serve neanche parsare il file:
from importlib.metadata import version
print(version("weather-cli"))
Quello pesca dai metadati del pacchetto installato, che sono stati generati da pyproject.toml al momento della build. Singola fonte di verità, niente parsing richiesto.
Dove ci lascia tutto questo
pyproject.toml è la lingua franca. Ogni package manager moderno (pip, uv, Poetry, PDM, Hatch) lo legge. Ogni build backend moderno scrive wheel a partire da esso. Ogni linter e type checker moderno può essere configurato dentro di esso. Lo zoo frammentato di file di configurazione del 2018 è collassato in un unico file TOML ben specificato.
Nella prossima lezione conosceremo uv: il package manager di Astral che ha conquistato l’ecosistema nel 2024-2025 ed è, a partire dal 2026, la scelta di default per la maggior parte dei nuovi progetti Python. Legge pyproject.toml, ovviamente. Ormai lo fanno tutti.