Puoi scrivere Python che gira senza pensare mai a dove vanno i file. Un singolo script.py funziona benissimo. Ma nel momento in cui vuoi testarlo, fare il package, condividerlo o semplicemente smettere di inciampare su te stesso, il layout inizia a contare.
Esistono convenzioni per tutto questo, ed entro il 2026 la polvere si è posata. Questa lezione è la mappa.
Due layout che vedrai
Apri cento repository Python e vedrai due pattern.
Flat layout:
weather-cli/
├── pyproject.toml
├── README.md
├── weather_cli/
│ ├── __init__.py
│ ├── api.py
│ └── cli.py
└── tests/
├── conftest.py
└── test_api.py
La directory del pacchetto sta nella radice del progetto, fratello di tests/ e pyproject.toml. Semplice. Visibile a colpo d’occhio.
src/ layout:
weather-cli/
├── pyproject.toml
├── README.md
├── src/
│ └── weather_cli/
│ ├── __init__.py
│ ├── api.py
│ └── cli.py
└── tests/
├── conftest.py
└── test_api.py
Il pacchetto vive un livello più in profondità, dentro una directory src/. Un po’ più digitazione. Diverso in un punto importante.
Perché ha vinto src/
Il flat layout ha una trappola silenziosa: quando esegui python dalla radice del progetto, la directory corrente è in sys.path. Quindi import weather_cli funziona che il pacchetto sia installato o no. Sembra comodo, finché non spedisci un wheel a cui manca un file. I tuoi test locali passano. La CI passa (importa anche dall’albero di lavoro). Carichi su PyPI, un utente installa, e sorpresa: ImportError, perché quel file che ti sei dimenticato di aggiungere al pacchetto non è mai finito nel wheel.
Il layout src/ rimuove la directory di lavoro dall’equazione degli import. src/ non è un pacchetto (nessun __init__.py direttamente al suo interno) e weather_cli è un livello troppo profondo per essere scoperto automaticamente dal path di Python. Per fare import weather_cli, devi installare il pacchetto prima, di solito con uv sync o pip install -e ., che lo costruisce nello stesso modo in cui sarà costruito il wheel. Se manca qualcosa nella tua configurazione di packaging, lo scopri immediatamente, sulla tua macchina, invece che in un bug report.
È tutta qui la ragione. Cattura i bug “ho dimenticato di spedire questo file” prima che lascino il tuo portatile. Entro il 2026 questo è il default raccomandato dalla Python Packaging Authority, ed è quello che produce uv init --package.
Ci sono ancora posti dove flat va bene: script che non sono pacchetti, progettini che non pubblicherai mai, notebook. Ma per qualunque cosa contro cui faresti girare i test e che chiameresti “libreria”, vai su src/.
L’anatomia di un progetto reale
Ecco l’albero di directory di un progetto Python di medie dimensioni credibile, pensa a una piccola pipeline dati o a uno strumento CLI:
weather-cli/
├── .github/
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
├── uv.lock
├── src/
│ └── weather_cli/
│ ├── __init__.py
│ ├── __main__.py
│ ├── api.py
│ ├── cli.py
│ ├── config.py
│ └── py.typed
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_api.py
│ ├── test_cli.py
│ └── fixtures/
│ └── sample_response.json
├── scripts/
│ └── regenerate_fixtures.py
├── notebooks/
│ └── exploration.ipynb
├── data/
│ └── .gitkeep
└── docs/
└── usage.md
Vediamo pezzo per pezzo.
A cosa serve __init__.py
Dentro src/weather_cli/, il file __init__.py fa tre cose:
1. Marca la directory come pacchetto. È la ragione storica. Python moderno supporta anche i “namespace package” (directory senza __init__.py), ma per un pacchetto regolare vuoi il file lì.
2. Cura l’API pubblica. Quello che metti in __init__.py è ciò che gli utenti vedono quando fanno import weather_cli. Un pattern pulito:
# src/weather_cli/__init__.py
"""Tiny weather CLI."""
from weather_cli.api import fetch_forecast, Forecast
from weather_cli.cli import main
__all__ = ["fetch_forecast", "Forecast", "main"]
__version__ = "0.3.1"
Adesso gli utenti scrivono from weather_cli import fetch_forecast invece di from weather_cli.api import fetch_forecast. La struttura interna dei moduli è tua da rifattorizzare; la superficie pubblica è stabile.
3. Espone una costante di versione. O hard-codata come sopra, o letta dinamicamente:
from importlib.metadata import version
__version__ = version("weather-cli")
La seconda forma ha il vantaggio di tenere la versione in un’unica fonte: pesca dai metadati del pacchetto installato, che vengono da pyproject.toml.
Cosa fa __main__.py
Se crei src/weather_cli/__main__.py, puoi eseguire il pacchetto come modulo:
python -m weather_cli --city Rome
Utile quando vuoi l’invocazione python -m accanto (o al posto di) uno script di console. Un tipico __main__.py:
# src/weather_cli/__main__.py
from weather_cli.cli import main
if __name__ == "__main__":
raise SystemExit(main())
Questo è lo stesso entry point a cui faresti riferimento in pyproject.toml:
[project.scripts]
weather = "weather_cli.__main__:main"
così weather --city Rome e python -m weather_cli --city Rome fanno esattamente la stessa cosa. Cintura e bretelle.
Il marker py.typed
Se il tuo pacchetto ha type hints e vuoi che i type checker degli utenti a valle li vedano davvero, aggiungi un file vuoto chiamato py.typed accanto a __init__.py. Questa è la PEP 561. Senza, mypy e pyright trattano il tuo pacchetto come non tipizzato, anche se ogni funzione ha annotazioni. È una di quelle vittorie da una riga che ci mette dieci minuti a essere scoperta la prima volta.
La directory dei test
tests/ rispecchia il pacchetto. Se hai src/weather_cli/api.py, hai tests/test_api.py. Non è imposto da nessuna parte, è solo molto più facile trovare le cose.
conftest.py in cima a tests/ contiene le fixture condivise. pytest le scopre automaticamente; non le importi. Una tipica:
# tests/conftest.py
import json
from pathlib import Path
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def sample_response():
return json.loads((FIXTURES / "sample_response.json").read_text())
tests/ deve essere a sua volta un pacchetto (con un suo __init__.py)? La vecchia saggezza diceva di sì; quella moderna dice che dipende. Con il layout src/ e la logica di “rootdir” di pytest, di solito non ti serve. Aggiungilo solo se hai collisioni di nomi tra i file di test (due test_utils.py in sottocartelle diverse): pytest a quel punto ha bisogno di __init__.py per disambiguare.
Dove vanno gli scripts/ (e perché non nel pacchetto)
scripts/ è per le automazioni una tantum che usano il pacchetto ma non sono parte della sua API pubblica. Cose come:
- rigenerare le fixture dei test
- migrazioni dati in massa che hai eseguito una volta
- benchmark
# scripts/regenerate_fixtures.py
"""Run from project root: uv run python scripts/regenerate_fixtures.py"""
from weather_cli.api import fetch_forecast
import json
forecast = fetch_forecast("Rome")
print(json.dumps(forecast, indent=2))
Questi non vengono installati con il pacchetto. Non sono in [project.scripts]. Stanno semplicemente lì così che tu e i tuoi collaboratori abbiate un posto ovvio dove cercare “quella cosa che ho scritto una volta”. Metterli dentro il pacchetto li spedirebbe a ogni utente che lo installa, il che è scortese.
Notebook e dati
notebooks/ è dove vanno i Jupyter notebook. I test non ci girano sopra, i type checker li ignorano e il tuo pyproject.toml dovrebbe escluderli da qualunque tooling. [tool.ruff] exclude = ["notebooks"] è una riga comune.
data/ è per file che il tuo codice legge o scrive localmente. Se sono grandi o generati, gitignora il contenuto e committa un .gitkeep così la directory esiste. Se sono piccoli e canonici (test fixtures, CSV di esempio), committali, ma rifletti se non appartengano invece a tests/fixtures/.
Non mettere dati dentro la directory del pacchetto a meno che non siano package data, file che devono essere spediti col wheel perché il tuo codice li legge a runtime. Per quello, usa importlib.resources:
from importlib.resources import files
config_text = (files("weather_cli") / "default_config.toml").read_text()
e dì al tuo build backend di includerli ([tool.hatch.build] o equivalente).
Import dentro al pacchetto
Usa sempre import assoluti dentro al tuo pacchetto:
# bene
from weather_cli.api import Forecast
# da evitare (import relativi: funzionano, ma offuscano da dove vengono le cose)
from .api import Forecast
Entrambi sono Python valido. Gli import assoluti sono più facili da grep, più facili da spostare (non devi contare i punti quando riorganizzi) e più chiari quando leggi codice in isolamento.
Import condizionali e lazy
Il più delle volte, importa tutto in cima al file. Ma due pattern vale la pena conoscerli:
Import condizionali per dipendenze opzionali:
try:
import polars as pl
HAS_POLARS = True
except ImportError:
HAS_POLARS = False
def to_dataframe(rows):
if not HAS_POLARS:
raise ImportError("Install with `pip install weather-cli[polars]` to use this.")
return pl.DataFrame(rows)
Import lazy quando il tempo di import conta, soprattutto per le CLI. Se il tuo pacchetto importa pandas in cima, ogni invocazione della CLI paga una tassa di startup di un secondo anche quando vuoi solo --help. Sposta gli import pesanti dentro le funzioni che ne hanno bisogno:
def export_to_excel(rows, path):
import openpyxl # importato solo quando questa funzione viene eseguita
...
Per le librerie pubblicate su PyPI puoi anche esporre attributi lazy tramite il __getattr__ a livello di modulo della PEP 562, ma è esagerato finché non sei sicuro di averne bisogno.
Un noxfile.py o un Makefile?
Bello da avere. Entrambi ti permettono di scrivere nox -s tests o make lint così non memorizzi i comandi lunghi. In un progetto basato su uv puoi anche semplicemente mettere i comandi in pyproject.toml e lasciare che uv run faccia il lavoro: uv run pytest, uv run ruff check ., uv run mypy src/. Per la maggior parte dei progetti basta.
Un pattern che mi piace: tenere un Makefile di alto livello con tre o quattro target che incartano i comandi veri, così i nuovi arrivati possono semplicemente digitare make test e non preoccuparsi del toolchain.
.PHONY: test lint fmt typecheck
test:
uv run pytest
lint:
uv run ruff check .
fmt:
uv run ruff format .
typecheck:
uv run mypy src
Banale, ma documenta i comandi attesi del progetto in un solo posto e rimuove l’attrito del “qual era quel comando?”. Gli utenti Windows senza make possono usare just per lo stesso effetto.
Un esempio in salsa data engineering
I layout cambiano leggermente per dominio. Per un progetto di data engineering, il tipo di cosa che pesca da un warehouse, trasforma con polars o duckdb ed emette Parquet, vedrai spesso un albero leggermente più ricco:
sales-pipeline/
├── pyproject.toml
├── uv.lock
├── src/
│ └── sales_pipeline/
│ ├── __init__.py
│ ├── extract.py
│ ├── transform.py
│ ├── load.py
│ └── sql/
│ ├── __init__.py
│ └── queries/
│ ├── orders.sql
│ └── customers.sql
├── tests/
│ ├── conftest.py
│ ├── test_transform.py
│ └── fixtures/
│ └── orders_sample.parquet
├── dbt/ # se hai anche un progetto dbt
├── airflow/ # definizioni di DAG
├── notebooks/
└── data/
├── raw/ # gitignored, popolata localmente
└── processed/ # gitignored
Il punto non è che ogni progetto abbia bisogno di una directory dbt/ o airflow/, è che la roba specifica del dominio vive al livello più alto, accanto (non dentro) al tuo pacchetto Python. Tieni src/sales_pipeline/ piccolo e focalizzato; lascia che orchestrazione, lavori di scratch e dati grezzi si spargano altrove.
Non lottare con le convenzioni
La tentazione quando si inizia un progetto è inventare qualcosa di astuto: una gerarchia di pacchetti profondamente annidata, nomi di directory custom, layout di test originali. Resisti. Ogni sviluppatore Python che guarda il tuo repo (incluso te, tra sei mesi) ha la memoria muscolare di src/<package>/, tests/ e pyproject.toml. Mettere le cose dove ci si aspetta non costa niente e risparmia mille piccoli attriti.
Il riassunto
- src/ layout è il default nel 2026. Cattura i bug di packaging in anticipo.
__init__.pycura la tua API pubblica;__main__.pyabilitapython -m.- I test rispecchiano la struttura del pacchetto.
conftest.pycontiene le fixture condivise. - Script, notebook e dati vivono nelle loro directory di alto livello, mai dentro il pacchetto.
- Import assoluti dentro al tuo pacchetto. Import lazy solo quando il tempo di startup fa male.
- Aggiungi
py.typedse il tuo pacchetto ha type hints e vuoi che gli utenti ne beneficino.
Insieme alle lezioni precedenti, pyproject.toml come configurazione e uv come manager, adesso hai il quadro moderno completo per organizzare e spedire un progetto Python. Il prossimo modulo passa al deep cut della standard library: argparse, logging, subprocess e i pezzi della stdlib che vale la pena conoscere a memoria.