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șierMETADATAși unRECORDal conținutului. Pre-construit, fără pas de compilare la instalare. Ăsta e ce vrei. - O distribuție sursă (
.tar.gz, uneorisdist): 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:
- 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.
- 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:
- Înregistrează-ți un cont la pypi.org.
- Generează un token API (Account settings -> API tokens). Restrânge-i scope-ul la un singur proiect odată ce ai publicat versiunea 1.
- 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 viagcloud auth print-access-tokensau service account. - AWS CodeArtifact:
aws codeartifact get-authorization-tokenschimbă 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 luiuvdin 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.mdcureadme = "README.md"în[project]. Devine descrierea lungă de pe PyPI. - Adaugă
classifiersșikeywordsî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.