Python, de la zero Lecția 18 / 60

Build și publicare: PyPI, magazine de artefacte interne, semver

Cum transformi proiectul într-un wheel, îl împingi pe PyPI sau pe indexul companiei tale și eviți capcanele de versionare.

Un proiect Python e doar un folder până-l construiești. După ce-l construiești, e un fișier .whl: un singur artefact pe care alți oameni îl pot pip install, care rulează identic pe orice mașină ce are Python-ul și OS-ul potrivit, care încape într-un layer Docker, care trăiește într-un magazin de artefacte. Lecția asta e drumul de la pyproject.toml la un pachet publicat, instalabil: către PyPI pentru open source, către un registry privat pentru proiectele de la lucru, cu evidența de versiuni care te ferește să-ți strici utilizatorii.

Ce se construiește

Când „construiești” un proiect Python, produci unul sau ambele dintre:

  • Un wheel (.whl): un fișier zip cu codul pachetului tău, un fișier METADATA și un RECORD al conținutului. Pre-construit, fără pas de compilare la instalare. Ăsta e ce vrei.
  • O distribuție sursă (.tar.gz, uneori sdist): arborele tău de surse plus suficiente metadate ca cineva să poată construi un wheel din ele. Necesar pentru unii packageri, util pentru proiecte cu extensii C care trebuie compilate pe sistemul utilizatorului.

Numele fișierului wheel codifică compatibilitatea:

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

py3-none-any înseamnă Python pur, rulează oriunde. Un pachet cu cod nativ arată mai degrabă a cp313-cp313-manylinux_2_17_x86_64.whl: ABI CPython 3.13, Linux x86_64.

Construirea

Build modern, o singură comandă:

uv build
# or, with the standard tool:
pip install build
python -m build

Ambele produc un folder dist/ care conține wheel-ul și sdist-ul:

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

Ambele respectă tabelul [build-system] din pyproject.toml-ul tău, care numește build backend-ul (hatchling, setuptools, flit-core, poetry-core, uv-build). Backend-ul citește metadatele tale [project], adună fișierele sursă conform propriilor convenții și scrie artefactele.

Un pyproject.toml modern tipic:

[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"

Asta-i tot. uv build se ocupă de restul.

Versionare: semantic versioning, în practică

Semantic versioning e MAJOR.MINOR.PATCH:

  • MAJOR se bumpează când faci o schimbare incompatibilă în spate. Ai eliminat o funcție, ai redenumit un parametru, ai schimbat un tip de retur: ăsta-i un major.
  • MINOR se bumpează când adaugi funcționalitate într-un mod compatibil în spate. Funcție nouă, argument opțional nou, modul nou.
  • PATCH se bumpează doar pentru bug fix-uri compatibile în spate.

Pre-release-urile primesc sufixe:

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

Și există o sintaxă de versiune locală pentru build-uri non-publice:

1.2.3+internal.42
1.2.3+gitsha.abc1234

PyPI respinge versiunile locale la upload, dar indexurile private le acceptă, util pentru „ăsta e 1.2.3 cu patch-ul nostru pe deasupra”.

Cele mai frecvente două greșeli de versionare pe care le văd în 2026:

  1. Bumparea de major pentru schimbări triviale pentru că s-a întâmplat un release de marketing. Dacă nu se strică nimic pentru utilizatori, e minor.
  2. Lipsa bumpului de major când ai stricat efectiv ceva pentru că „schimbarea era mică”. Dacă codul funcțional al unui utilizator încetează să mai meargă după upgrade, era major. Fii cinstit.

Ține versiunea într-un singur loc

Două strategii, alege una:

Strategia A: editează manual pyproject.toml. Simplu, evident, ușor de greșit dacă mai și pui tag în git și uiți să le ții sincronizate.

Strategia B: derivă versiunea din tag-urile git. Tag-uiești v1.2.3, build-ul citește acel tag. Fără drift.

hatch-vcs (cu hatchling) și setuptools_scm (cu setuptools) fac amândouă asta:

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

[project]
name = "my-package"
dynamic = ["version"]    # version comes from git, not the file

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

Acum fluxul tău de release e: tag, build, publish. Versiunea vine la pachet, gratis.

Publicare pe PyPI

PyPI este indexul public de pachete Python: chestia de unde citește pip install în mod implicit. Ca să publici acolo:

  1. Înregistrează-ți un cont la pypi.org.
  2. Generează un token API (Account settings -> API tokens). Restrânge-i scope-ul la un singur proiect odată ce ai publicat versiunea 1.
  3. Upload:
# uv-native publish
uv publish --token "$PYPI_TOKEN"

# or with twine (the older standby)
pip install twine
twine upload dist/*

Testează mereu pe TestPyPI întâi. E un index separat la test.pypi.org exact pentru asta:

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

# install from there to verify
uv pip install --index-url https://test.pypi.org/simple/ my-package

Un nume pe PyPI e first-come-first-served și efectiv permanent. Alege bine. Nu poți nici reîncărca aceeași versiune, niciodată: dacă 1.2.3 a avut un bug, publici 1.2.4. Nu încerca să „repari” prin ștergere și reîncărcare. PyPI ține minte.

Publicare pe un magazin de artefacte intern

La lucru o să publici de obicei pe un index privat. Toate vorbesc PyPI Simple API, deci aceleași comenzi uv publish / twine upload merg: doar URL-ul și autentificarea se schimbă.

Cele comune în 2026:

  • GCP Artifact Registry: https://<region>-python.pkg.dev/<project>/<repo>/. Auth via gcloud auth print-access-token sau service account.
  • AWS CodeArtifact: aws codeartifact get-authorization-token schimbă credențialele AWS pe o parolă cu durată scurtă.
  • Azure DevOps Artifacts: folosește PAT-uri.
  • JFrog Artifactory și Sonatype Nexus: vechea gardă on-prem, ambele vorbesc standardul.

Configurarea uv (sau pip) ca să instaleze dintr-un index privat:

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

Sau per-shell:

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

index-url vs extra-index-url și dependency confusion

Există două butoane de configurare care arată la fel și sunt foarte diferite:

  • index-url: înlocuiește PyPI ca default. Rezoluția caută aici întâi (și doar aici, dacă nu setezi și extras).
  • extra-index-url: adaugă încă un index în plus față de default. Resolverul alege versiunea cea mai mare din toate indexurile.

Capcana: dacă setezi extra-index-url = "https://internal.example.com/simple/" și pachetul tău intern se cheamă my-internal-pkg, un atacator poate publica un my-internal-pkg cu versiune mai mare pe PyPI public, iar build-ul tău va instala tăcut versiunea publică. Ăsta e atacul de tip dependency confusion care a lovit companii mari de mai multe ori.

Apărarea:

  • Preferă index-url (singură sursă de adevăr) când indexul tău privat face proxy peste PyPI.
  • Dacă trebuie să folosești extra-index-url, configurează și fixarea explicită a indexului: spune-i lui uv din care index trebuie să vină un anumit pachet.

În uv:

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

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

Acum my-internal-pkg se rezolvă doar din indexul intern, indiferent ce oferă PyPI.

Procesul de release, cap-coadă

Un flux curat de release pentru o bibliotecă mică:

# 1. Bump the version (or rely on git tags + hatch-vcs)
# 2. Update 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. (Optional) publish to TestPyPI and smoke-test
uv publish --publish-url https://test.pypi.org/legacy/

# 6. Smoke test from a fresh venv
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 for real
uv publish

În CI asta devine un singur workflow declanșat de tag-ul v*: checkout, setup Python, uv build, uv publish cu un token din secrets. GitHub Actions și GitLab CI au amândouă exemple de prim rang în documentația uv.

Câteva chestii de quality-of-life

  • Include un fișier LICENSE. PyPI îl arată; utilizatorii îl verifică; echipele de legal îl cer.
  • Include un README.md cu readme = "README.md" în [project]. Devine descrierea lungă de pe PyPI.
  • Adaugă classifiers și keywords în [project]. Fac pachetul tău găsibil.
  • Folosește __version__ în pachetul tău care se potrivește cu versiunea de build. from importlib.metadata import version; __version__ = version("my-package") îl ține automat.
  • Semnează release-urile dacă modelul de amenințare al proiectului tău o cere. PyPI suportă acum Sigstore pentru publicare de încredere din CI fără tokenuri cu durată lungă.

Suma tuturor lucrurilor astora e mică. Odată ce ai livrat un pachet, următoarele iau zece minute. Versiunea, lockfile-ul, build-ul, publicarea, smoke test-ul: ăsta e tot ciclul, și e același fie că livrezi către câțiva colegi, fie către întreaga lume Python.

Caută