pyproject.toml, lockfile e layout src/ — la struttura moderna di un progetto
Da setup.py a pyproject.toml, build riproducibili con uv.lock, __init__.py e il layout src/ — e la conversione di una cartella di script piatta in un vero pacchetto con un comando.
Come vuoi imparare?
Il problema che stiamo risolvendo
Il progetto di smistamento dei ticket è nato come qualche script in una cartella sola: clean.py, score.py, run_triage.py. L'autore sta dentro la cartella, esegue python run_triage.py, e tutto funziona. Poi, nell'arco di tre mesi:
- Un collega ha messo uno script di report in una sottocartella
scripts/, e ha ottenutoModuleNotFoundError: No module named 'score'— anche sescore.pyè lì, nella cartella accanto. - Qualcuno ha creato nella cartella un file chiamato
secrets.pyper tenere una chiave API, e all'improvviso il modulosecretsdi Python, quello della libreria standard, si è "rotto" in un altro script. - È arrivata una nuova persona; il vecchio
setup.pye ilrequirements.txtelencavano i pacchetti in due modi diversi, e nessuno sapeva quale fosse quello giusto.
Nel codice non c'è nessun bug; il problema è nella struttura. In questo capitolo trasformiamo la cartella in un vero pacchetto Python — un pyproject.toml, un lockfile e un layout src/ — che è la forma che quasi ogni repository Python moderno di un'azienda ha.
Alla fine di questo capitolo sarai in grado di
- Spiegare perché Python è passato da
setup.pyapyproject.toml, e leggere le sezioni principali di unpyproject.toml - Spiegare cosa blocca
uv.lock, e usareuv lock,uv synceuv sync --lockednei punti giusti - Creare un progetto con layout
src/usandouv init --packagee--lib, e spiegare cosa fa__init__.py - Riconoscere le due trappole d'importazione di una cartella piatta, e mostrare perché il layout
src/le intercetta subito - Trasformare una cartella di script piatta in un pacchetto con un entry point da riga di comando
Prerequisiti: Git, branch e Pull Request — più uv init, uv add e uv run del capitolo precedente.
Da setup.py a pyproject.toml
Nei progetti Python più vecchi trovi spesso un file così:
from setuptools import find_packages, setup
setup(
name="ticket-triage",
version="0.1.0",
packages=find_packages(),
install_requires=["httpx"],
entry_points={"console_scripts": ["triage=ticket_triage.cli:main"]},
)E accanto un requirements.txt, spesso con un elenco diverso. Il problema di fondo di setup.py è che è un programma: anche solo per sapere il nome del progetto o le sue dipendenze devi eseguirlo, e dato che è Python, chiunque può metterci dentro qualsiasi cosa — leggere file, condizioni, perfino chiamate di rete. Gli strumenti non potevano dire nulla di certo su di lui.
pyproject.toml contiene le stesse informazioni come dichiarazione — un normale file TOML che viene letto, mai eseguito. Ormai è lo standard Python, e uv, gli editor, gli strumenti di test e i linter lo leggono tutti. Ecco cosa produce uv init --package ticket-triage:
[project]
name = "ticket-triage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nadia Karim", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ticket-triage = "ticket_triage:main"
[build-system]
requires = ["uv_build>=0.9.1,<0.10.0"]
build-backend = "uv_build"(uv prende nome ed email dell'autore dalle impostazioni di Git.) Tre sezioni:
[project]— l'identità del progetto e le sue dipendenze.uv addgestisce per te la listadependencies.[project.scripts]— i comandi da riga di comando.ticket-triage = "ticket_triage:main"significa: crea un comando chiamatoticket-triageche richiama la funzionemaindel pacchettoticket_triage.[build-system]— lo strumento che trasforma il progetto in un pacchetto installabile (il "build backend").uvusa il suouv_build; in altri progetti potresti trovarehatchlingosetuptools— stesso compito.
Nota che il progetto si chiama ticket-triage (con il trattino), ma il pacchetto Python è ticket_triage (con il trattino basso). I trattini non sono ammessi nei nomi Python — import ticket-triage sarebbe una sottrazione — quindi uv lo converte per te.
Lockfile: dal «funziona» al «funziona sempre allo stesso modo»
In pyproject.toml le dipendenze si scrivono come intervalli: "httpx>=0.28.1" significa 0.28.1 o qualsiasi versione successiva. È voluto — dichiari cosa funzionerà. Ma installare oggi e installare tra tre mesi può dare due versioni diverse, senza contare le dipendenze sotto.
uv.lock colma questo divario. Registra la versione esatta e gli hash dei file di ogni pacchetto — compresi quelli più in profondità. Se è nel repository, ogni macchina, la CI e il server di produzione installano esattamente la stessa cosa. È quello che si chiama "build riproducibile".
Quattro comandi, quattro compiti:
uv lock— leggepyproject.tomle crea o aggiorna il lockfile. Non installa nulla.uv sync— fa corrispondere.venval lockfile esattamente; se serve, aggiorna prima il lockfile.uv sync --locked— lo stesso, ma se il lockfile non è aggiornato si ferma invece di aggiornarlo.uv lock --check— non cambia nulla, si limita a dire se il lockfile corrisponde apyproject.toml.
uv add e uv remove aggiornano insieme i due file, quindi nel lavoro normale non serve chiamare questi comandi a parte. I guai iniziano quando qualcuno modifica pyproject.toml a mano. Supponi che qualcuno cambi dependencies = [] in dependencies = ["httpx>=0.28"] a mano e non esegua uv lock:
uv sync --locked
Resolved 8 packages in 57ms
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.Per questo la CI esegue sempre uv sync --locked: nessuna versione non revisionata può entrare di nascosto.
Unuv syncsemplice qui non dà errore. Eseguito senza--lockednella stessa situazione,uvricalcola silenziosamente le versioni, riscrive da solouv.locke prosegue. Sulla tua macchina è comodo; in CI significa che si sta testando qualcosa di diverso da quello che c'è nel repository.
Pacchetti, __init__.py e il layout src/
Un modulo è un singolo file .py. Un pacchetto è una cartella di moduli con dentro un __init__.py. __init__.py fa due cose: marca la cartella come pacchetto importabile, ed è il file che viene eseguito per primo quando scrivi import ticket_triage — quindi lì puoi mettere ciò che vuoi presentare come "porta d'ingresso" del pacchetto.
Tre forme di uv init:
uv init— un progetto di script:main.pyepyproject.toml, nessun build system. Per lavori di uno o due file e per gli esperimenti.uv init --package— un pacchetto applicativo: layoutsrc/, un build system e un comando in[project.scripts].uv init --lib— una libreria che altri importeranno: layoutsrc/e build system, nessun comando, più un filepy.typedvuoto che dice ai type checker che il pacchetto include informazioni sui tipi.
La struttura che nasce da uv init --package ticket-triage:
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyIl pacchetto non sta alla radice del progetto ma dentro una cartella src/. Per capire perché esiste quel livello in più, guarda prima cosa va storto in una cartella piatta.
Due trappole in una cartella piatta
Trappola uno: dove Python cerca. Quando esegui uno script, Python cerca le importazioni prima di tutto nella cartella dove si trova lo script — non dove ti trovi tu nel terminale. In una cartella piatta, from score import priority funziona solo perché score.py e lo script stanno nella stessa cartella. Ecco lo script scripts/report.py del collega:
from score import priority
print(priority("Database breach reported"))Stando dentro la cartella triage-scripts ed eseguendo python scripts/report.py:
Traceback (most recent call last):
File "scripts\report.py", line 1, in <module>
from score import priority
ModuleNotFoundError: No module named 'score'(Sulla tua macchina il traceback mostra il percorso completo; qui è stato accorciato.) Python ha cercato in scripts/, e lì non c'è nessun score.py. Se un'importazione funziona dipende da dove si trova il file — poche regole sono più fragili per un progetto.
Trappola due: collisioni di nomi. Per la stessa regola, i file nella cartella dello script vengono cercati prima dei moduli di Python. Qualcuno crea secrets.py nella cartella per tenere una chiave, e il new_id.py accanto vuole il secrets della libreria standard:
import secrets
print(len(secrets.token_hex(8)))Traceback (most recent call last):
File "new_id.py", line 3, in <module>
print(len(secrets.token_hex(8)))
^^^^^^^^^^^^^^^^^
AttributeError: module 'secrets' has no attribute 'token_hex'import secrets non ha dato errore — ha trovato il secrets.py del progetto e ha importato quello. L'errore arriva dopo, quando il codice chiede qualcosa che quel file non ha. Nei progetti di AI succede spesso con file sperimentali chiamati openai.py, anthropic.py o httpx.py, che nascondono proprio la libreria che stai cercando di usare.
Come il layout src/ intercetta subito questi errori
In un layout src/ cambiano due cose.
Primo, tutto vive sotto un unico nome univoco. I moduli non si chiamano più clean, score, secrets — si chiamano ticket_triage.clean, ticket_triage.score. Nessun file del progetto può nascondere la libreria standard o un altro pacchetto, e le importazioni stesse mostrano quale codice è tuo.
Secondo, il pacchetto non si può importare senza essere installato. Provando dalla radice del progetto con il python semplice:
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'Quell'errore è voluto. Se il pacchetto stesse alla radice del progetto, questo comando funzionerebbe — Python troverebbe la cartella direttamente — e non scopriresti mai se la configurazione in pyproject.toml è giusta. Dentro src/, c'è un solo modo per raggiungere il pacchetto: installarlo. E uv run lo fa ogni volta — installa il progetto nell'ambiente .venv in modalità "editable", cioè il pacchetto installato punta alla tua cartella src/, quindi non devi reinstallare a ogni modifica del codice:
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highQuindi ogni errore di packaging si vede sulla tua macchina, al primo uv run — non il giorno del deploy. Per esempio, se la cartella del pacchetto è scritta male o manca __init__.py:
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(Il percorso è stato accorciato.) Il build backend ricava il nome del pacchetto dal nome del progetto — ticket-triage diventa src/ticket_triage/ — e si ferma se lì non trova nulla. Se vuoi un nome diverso di proposito, dichiaralo con module-name sotto [tool.uv.build-backend] in pyproject.toml.
Un esempio completo
Ora convertiamo la cartella piatta dall'inizio del capitolo. Dopo aver eseguito uv init --package ticket-triage --python 3.12 in una cartella vicina e aver spostato i vecchi file dentro il pacchetto, la struttura diventa questa:
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pyUna modifica in pyproject.toml — un nome di comando più corto, che punta al nuovo modulo cli:
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — il vecchio clean.py, ora con type hint e una docstring:
import re
def clean_text(text: str) -> str:
"""Lowercase the text and collapse every run of whitespace to one space."""
return re.sub(r"\s+", " ", text).strip().lower()src/ticket_triage/score.py — l'unica vera modifica è la riga di importazione: from clean import ... diventa from ticket_triage.clean import ...:
from ticket_triage.clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"src/ticket_triage/__init__.py — la porta d'ingresso del pacchetto:
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — lo script che prima si rompeva, ora importa tramite il nome del pacchetto:
from ticket_triage import priority
print(priority("Database breach reported"))E src/ticket_triage/cli.py — il punto d'ingresso da riga di comando che sostituisce il vecchio run_triage.py:
import sys
from ticket_triage.score import priority
DEMO_TICKETS = ["Login page is slow", "Payments DOWN for all users"]
def main() -> None:
tickets = sys.argv[1:] or DEMO_TICKETS
for ticket in tickets:
print(f"{priority(ticket):<6} {ticket}")
if __name__ == "__main__":
main()Eseguito dalla radice del progetto:
uv run triage
uv run triage "Service down since morning" "Typo on the pricing page"
uv run scripts/report.py
uv lock --checknormal Login page is slow
high Payments DOWN for all users
high Service down since morning
normal Typo on the pricing page
high
Resolved 1 package in 2msIl primissimo uv run triage stampa alcune righe prima di questo — la creazione di .venv, poi Building ticket-triage e Built ticket-triage — perché uv sta installando il progetto per la prima volta; dopo, quelle righe non compaiono più. L'ultima riga viene da uv lock --check, e il suo codice di uscita è 0: il lockfile corrisponde a pyproject.toml.
Le scelte di questa conversione:
Importazioni assolute (from ticket_triage.clean import ...). Anche le importazioni relative funzionano dentro un pacchetto — from .clean import clean_text — ma quelle assolute si leggono allo stesso modo da qualsiasi file e sono più facili da cercare. Qualunque scegli, mantieni un solo stile in ogni progetto.
Solo priority in __init__.py. Il codice esterno (scripts/report.py, i test futuri, altri servizi) conosce solo from ticket_triage import priority. Se in seguito cambia la suddivisione tra score.py e clean.py, il codice esterno non si rompe. __all__ scrive esplicitamente questo elenco pubblico.
Un cli.py separato. Con la logica (score.py) e l'input/output (cli.py) separati, priority può essere chiamata da qualsiasi punto — un test, un'API — senza sys.argv o print. Quando sys.argv[1:] è vuoto girano i ticket dimostrativi, quindi eseguirlo senza argomenti mostra comunque qualcosa.
f"{priority(ticket):<6}". :<6 allinea a sinistra il testo su sei caratteri, così i ticket iniziano nella stessa colonna sia dopo high sia dopo normal.
Un limite è utile da conoscere: clean_text divide le parole solo sugli spazi bianchi, quindi in "The site is down." la parola è down. — con il punto finale — e non corrisponde a URGENT_WORDS. Modi migliori per estrarre le parole dal testo arriveranno un po' più avanti nel corso; questo capitolo riguarda la struttura, e poiché la struttura è giusta, quel miglioramento avverrà in clean.py e in nessun altro posto.
Quando si rompe
ModuleNotFoundError: No module named 'clean' I file sono stati spostati nel pacchetto, ma qualche file ha ancora la vecchia importazione from clean import clean_text. Dentro un pacchetto non esiste un modulo di primo livello chiamato clean; scrivi from ticket_triage.clean import clean_text. Dopo la conversione, cerca in tutto il progetto from clean e from score.
ModuleNotFoundError: No module named 'ticket_triage' È stato eseguito con il python semplice invece che con uv run, quindi non è stato usato l'ambiente con il pacchetto installato. Eseguilo con uv run .... È il layout src/ che si comporta come previsto, non un bug.
Expected a Python module at: src\ticket_triage\__init__.py Il pacchetto non si trova nella cartella che il build backend si aspetta dal nome del progetto — la cartella è scritta male (src/triage/, src/ticket-triage/), oppure manca __init__.py. Correggi il nome della cartella, aggiungi __init__.py, oppure dichiara il nome reale con module-name.
The lockfile at uv.lock needs to be updated, but --locked was provided. pyproject.toml è stato modificato a mano senza eseguire uv lock, oppure il lockfile non è stato committato. Esegui uv lock e committa insieme i due file. La prossima volta cambia le dipendenze con uv add e uv remove — tengono allineati entrambi i file.
AttributeError: module 'secrets' has no attribute 'token_hex' Il progetto contiene un file con il nome di un modulo della libreria standard (secrets.py, random.py, logging.py) che nasconde quello vero. Rinomina il file — e tieni i segreti nel .env, non in un file .py.
error: Failed to spawn: triag seguito da Caused by: program not found Il nome dato a uv run è diverso da quello in [project.scripts] — qui, un refuso. Controlla il nome in pyproject.toml; dopo averlo rinominato, uv run reinstalla da solo il progetto e crea il nuovo comando.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Il clean_text del capitolo viene eseguito su un ticket disordinato. Cosa viene stampato?
import re
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
print(repr(clean_text(" Payments DOWN\tsince 9 ")))- A`' payments down since 9 '`
- B`'payments down since 9'`
- C`'payments down\tsince 9'`
- D`'Payments DOWN since 9'`
Dopo il passaggio dalla cartella piatta al layout src/, uv run triage dà ModuleNotFoundError: No module named 'clean'. clean.py è proprio accanto, in src/ticket_triage/clean.py. Qual è il bug?
# src/ticket_triage/score.py
from clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"- A`clean.py` deve tornare alla radice del progetto, perché è lì che Python cerca
- BL'importazione è ancora nello stile della cartella piatta; dentro il pacchetto deve essere `from ticket_triage.clean import clean_text`
- CCon un `__init__.py` in `src/ticket_triage/`, i file vicini non si possono importare; va cancellato
- D`uv run triage` guarda solo `cli.py`; le importazioni in `score.py` non vengono mai eseguite
In una PR, qualcuno ha modificato pyproject.toml a mano per aggiungere un pacchetto. Il uv sync --locked della CI fallisce: The lockfile at uv.lock needs to be updated. Qual è la correzione giusta?
- ARimuovi `--locked` dalla CI, così la CI aggiorna da sola il lockfile
- BAggiungi `uv.lock` a `.gitignore`, così ognuno costruisce il proprio lockfile
- CEsegui `uv lock` in locale (oppure usa `uv add` fin dall'inizio) e committa il `uv.lock` aggiornato nella stessa PR
- DCancella `uv.lock`, perché senza lockfile questo errore non può comparire
Giocare fa parte di Pro
+30 XP ti aspettano
Gioca questo capitolo con Pro
Leggere è gratis e l’intero capitolo è aperto. I primi 5 capitoli di ogni corso gratuito si giocano gratis. Dal capitolo 6, rispondere alle domande, guadagnare XP e far crescere la serie fa parte di Pro.
Sblocca il gioco con ProTocca a te
Costruisci tu la cartella piatta dell'inizio del capitolo e convertila. Prima metti tre file in una cartella triage-scripts/: clean.py (il clean_text di sopra, senza type hint), score.py (che inizia con from clean import clean_text), e run_triage.py, che stampa la priorità di due ticket dimostrativi. Poi:
- Accanto, crea un nuovo progetto con
uv init --package ticket-triage --python 3.12, sposta i file dentrosrc/ticket_triage/, e scrivi ogni importazione conticket_triage. - Trasforma
run_triage.pyin una funzionemain()incli.py, come in questo capitolo (con gli stessi due ticket dimostrativi), e aggiungitriage = "ticket_triage.cli:main"a[project.scripts] - Esponi solo
priorityda__init__.py, e usala dascripts/report.pyconfrom ticket_triage import priority - Esegui
uv lock --checkper confermare che il lockfile corrisponde, e committapyproject.toml,uv.lock,src/escripts/in un unico commit — senza.venv
Dalla radice del progetto, uv run triage seguito da uv run scripts/report.py dovrebbe dare esattamente questo:
normal Login page is slow
high Payments DOWN for all users
highPoi rompilo di proposito, due volte. Una volta, riporta l'importazione in score.py a from clean import clean_text e guarda quale errore appare, e su quale riga. Un'altra volta, crea un secrets.py dentro src/ticket_triage/, aggiungi import secrets e print(len(secrets.token_hex(8))) a cli.py, ed esegui uv run triage — la trappola della cartella piatta funziona ancora qui, e perché no?
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Gioca con Pro