Python, dalle fondamenta Lezione 18 / 60

Build e publishing: PyPI, artifact store interni, semver

Come trasformare il tuo progetto in un wheel, spingerlo su PyPI o sull'index aziendale, ed evitare i footgun del versioning.

Un progetto Python è solo una cartella, finché non lo costruisci. Dopo il build è un file .whl: un singolo artefatto che gli altri possono installare con pip install, che gira identico su ogni macchina con il giusto Python e OS, che entra in un layer Docker, che vive in un artifact store. Questa lezione è il percorso da pyproject.toml a un package pubblicato e installabile: su PyPI per l’open source, su un registry privato per i progetti di lavoro, con la contabilità di versioning che evita di rompere i tuoi utenti.

Cosa viene costruito

Quando “buildi” un progetto Python produci una o entrambe queste cose:

  • Un wheel (.whl): un file zip con il codice del tuo package, un file METADATA e un RECORD dei contenuti. Pre-built, niente passo di compilazione al momento dell’install. È quello che vuoi.
  • Una source distribution (.tar.gz, talvolta sdist): il tuo source tree più abbastanza metadati perché qualcuno costruisca un wheel a partire da lì. Richiesta da alcuni packager, utile per progetti con estensioni C che devono compilare contro il sistema dell’utente.

Il filename del wheel codifica la sua compatibilità:

my_package-1.2.3-py3-none-any.whl
            ^^^^^ ^^^ ^^^^ ^^^
            ver   tag  abi  platform

py3-none-any significa Python puro, gira ovunque. Un package con codice nativo somiglia di più a cp313-cp313-manylinux_2_17_x86_64.whl: ABI di CPython 3.13, Linux x86_64.

Il build vero e proprio

Build moderno, un solo comando:

uv build
# oppure, con il tool standard:
pip install build
python -m build

Entrambi producono una cartella dist/ che contiene wheel e sdist:

dist/
  my_package-1.2.3-py3-none-any.whl
  my_package-1.2.3.tar.gz

Entrambi rispettano la tabella [build-system] del tuo pyproject.toml, che nomina il build backend (hatchling, setuptools, flit-core, poetry-core, uv-build). Il backend legge i tuoi metadati [project], raccoglie i file sorgente secondo le proprie convenzioni, e scrive gli artefatti.

Un pyproject.toml moderno tipico:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-package"
version = "1.2.3"
description = "Does the thing."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Narcis Miclaus", email = "hi@narcismiclaus.com" }]
dependencies = ["httpx>=0.28,<0.29"]

[project.urls]
Homepage = "https://github.com/me/my-package"

Tutto qui. uv build gestisce il resto.

Versioning: semantic versioning, in pratica

Il semantic versioning è MAJOR.MINOR.PATCH:

  • MAJOR sale quando fai un cambiamento non retrocompatibile. Hai rimosso una funzione, rinominato un parametro, cambiato un tipo di ritorno: è un major.
  • MINOR sale quando aggiungi funzionalità in modo retrocompatibile. Funzione nuova, argomento opzionale nuovo, modulo nuovo.
  • PATCH sale solo per bugfix retrocompatibili.

Le pre-release ricevono un suffisso:

1.0.0a1     # alpha 1
1.0.0b2     # beta 2
1.0.0rc1    # release candidate 1
1.0.0       # final
1.0.1.dev3  # development build

E c’è una sintassi local-version per build non pubbliche:

1.2.3+internal.42
1.2.3+gitsha.abc1234

PyPI rifiuta le local version in upload, ma gli index privati le accettano: utile per “questa è la 1.2.3 con la nostra patch sopra”.

I due errori di versioning più comuni che vedo nel 2026:

  1. Alzare il major per cambi banali perché c’è stato un release di marketing. Se nulla si rompe per gli utenti, è un minor.
  2. Non alzare il major quando hai davvero rotto qualcosa, perché “il cambio era piccolo”. Se il codice funzionante di un utente smette di funzionare dopo l’upgrade, era un major. Sii onesto a riguardo.

Tenere la versione in un solo posto

Due strategie, scegline una.

Strategia A: editare a mano pyproject.toml. Semplice, ovvia, facile da sbagliare se taggi anche su git e ti dimentichi di tenerli sincronizzati.

Strategia B: derivare la versione dai tag git. Tagghi v1.2.3, il build legge quel tag. Niente drift.

hatch-vcs (con hatchling) e setuptools_scm (con setuptools) fanno entrambi questo:

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "my-package"
dynamic = ["version"]    # la versione viene da git, non dal file

[tool.hatch.version]
source = "vcs"

Adesso il tuo flow di release è: tag, build, publish. La versione viene gratis.

Pubblicare su PyPI

PyPI è l’index pubblico dei package Python: la cosa che pip install legge di default. Per pubblicare lì:

  1. Registra un account su pypi.org.
  2. Genera un API token (Account settings, API tokens). Limitalo a un singolo progetto una volta che hai pubblicato la versione 1.
  3. Carica:
# publish nativo di uv
uv publish --token "$PYPI_TOKEN"

# oppure con twine (lo standby più vecchio)
pip install twine
twine upload dist/*

Testa sempre prima su TestPyPI. È un index separato su test.pypi.org esattamente per questo scopo:

uv publish --publish-url https://test.pypi.org/legacy/ --token "$TEST_TOKEN"

# installa da lì per verificare
uv pip install --index-url https://test.pypi.org/simple/ my-package

Un nome su PyPI è first-come-first-served e di fatto permanente. Scegli bene. Inoltre non puoi mai ricaricare la stessa versione: se la 1.2.3 aveva un bug, pubblichi la 1.2.4. Non provare a “sistemare” cancellando e ricaricando. PyPI ricorda.

Pubblicare su un artifact store interno

Sul lavoro di solito pubblicherai su un index privato. Tutti parlano la PyPI Simple API, quindi gli stessi comandi uv publish / twine upload funzionano: cambiano solo URL e autenticazione.

Quelli comuni nel 2026:

  • GCP Artifact Registry: https://<region>-python.pkg.dev/<project>/<repo>/. Auth via gcloud auth print-access-token o service account.
  • AWS CodeArtifact: aws codeartifact get-authorization-token scambia le tue credenziali AWS per una password a vita breve.
  • Azure DevOps Artifacts: usa i PAT.
  • JFrog Artifactory e Sonatype Nexus: la vecchia guardia on-prem, parlano entrambi lo standard.

Configurare uv (o pip) per installare da un index privato:

# pyproject.toml o uv.toml
[tool.uv]
index-url = "https://internal.example.com/simple/"

Oppure per shell:

export UV_INDEX_URL=https://internal.example.com/simple/

index-url vs extra-index-url, e dependency confusion

Ci sono due manopole di configurazione che sembrano simili e sono molto diverse:

  • index-url: sostituisce PyPI come default. La risoluzione guarda qui per prima cosa (e solo qui, a meno che tu non imposti anche degli extra).
  • extra-index-url: aggiunge un altro index in aggiunta al default. Il resolver prende la versione più alta tra tutti gli index.

Il footgun: se imposti extra-index-url = "https://internal.example.com/simple/" e il tuo package interno si chiama my-internal-pkg, un attaccante può pubblicare un my-internal-pkg con versione più alta sul PyPI pubblico, e il tuo build installerà silenziosamente quello pubblico. Questo è l’attacco di dependency confusion che ha colpito grandi aziende più volte.

La difesa:

  • Preferisci index-url (singola fonte di verità) quando il tuo index privato fa proxy a PyPI.
  • Se devi usare extra-index-url, configura anche un explicit index pinning: dici a uv da quale index deve venire un determinato package.

In uv:

[[tool.uv.index]]
name = "internal"
url = "https://internal.example.com/simple/"

[tool.uv.sources]
my-internal-pkg = { index = "internal" }

Ora my-internal-pkg si risolve solo dall’index interno, indipendentemente da cosa offra PyPI.

Il processo di release, dall’inizio alla fine

Un flow di release pulito per una libreria piccola:

# 1. Alza la versione (o affidati a tag git + hatch-vcs)
# 2. Aggiorna CHANGELOG.md
# 3. Commit, tag, push
git commit -am "Release 1.2.3"
git tag v1.2.3
git push --tags

# 4. Build
rm -rf dist/
uv build

# 5. (Opzionale) publish su TestPyPI e smoke test
uv publish --publish-url https://test.pypi.org/legacy/

# 6. Smoke test da un venv pulito
uv venv /tmp/smoke
uv pip install --python /tmp/smoke/bin/python \
  --index-url https://test.pypi.org/simple/ my-package==1.2.3
/tmp/smoke/bin/python -c "import my_package; print(my_package.__version__)"

# 7. Publish per davvero
uv publish

In CI questo diventa un singolo workflow innescato dal tag v*: checkout, setup di Python, uv build, uv publish con un token preso dai secret. GitHub Actions e GitLab CI hanno entrambi esempi di prima classe nei docs di uv.

Qualche dettaglio di quality-of-life

  • Includi un file LICENSE. PyPI lo mostra; gli utenti lo controllano; i team legali lo richiedono.
  • Includi un README.md con readme = "README.md" in [project]. Diventa la long description su PyPI.
  • Aggiungi classifiers e keywords a [project]. Rendono il tuo package trovabile.
  • Usa __version__ dentro il tuo package che corrisponde alla versione del build. from importlib.metadata import version; __version__ = version("my-package") lo tiene automatico.
  • Firma le tue release se il threat model del tuo progetto lo giustifica. PyPI ora supporta Sigstore per trusted publishing dalla CI senza token a lunga vita.

La somma di tutto questo è piccola. Una volta che hai spedito un package, i prossimi prendono dieci minuti. La versione, il lock, il build, il publish, lo smoke test: questo è l’intero ciclo, ed è lo stesso che tu stia spedendo a qualche collega o all’intero mondo Python.

Cerca