A Python project is just a folder until you build it. After you build it, it’s a .whl file: a single artifact that other people can pip install, that runs identically on every machine that has the right Python and OS, that fits in a Docker layer, that lives in an artifact store. This lesson is the path from pyproject.toml to a published, installable package — to PyPI for open source, to a private registry for work projects, with the version bookkeeping that prevents you from breaking your users.
What gets built
When you “build” a Python project you produce one or both of:
- A wheel (
.whl) — a zip file with your package code, aMETADATAfile, and aRECORDof contents. Pre-built, no compilation step at install time. This is what you want. - A source distribution (
.tar.gz, sometimessdist) — your source tree plus enough metadata for someone to build a wheel from it. Required for some packagers, useful for projects with C extensions that need to compile against the user’s system.
The wheel filename encodes its compatibility:
my_package-1.2.3-py3-none-any.whl
^^^^^ ^^^ ^^^^ ^^^
ver tag abi platform
py3-none-any means pure Python, runs anywhere. A package with native code looks more like cp313-cp313-manylinux_2_17_x86_64.whl — CPython 3.13 ABI, Linux x86_64.
Building it
Modern build, one command:
uv build
# or, with the standard tool:
pip install build
python -m build
Both produce a dist/ folder containing the wheel and sdist:
dist/
my_package-1.2.3-py3-none-any.whl
my_package-1.2.3.tar.gz
Both honour your pyproject.toml’s [build-system] table, which names the build backend (hatchling, setuptools, flit-core, poetry-core, uv-build). The backend reads your [project] metadata, gathers the source files according to its own conventions, and writes the artifacts.
A typical modern pyproject.toml:
[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"
That’s it. uv build handles the rest.
Versioning: semantic versioning, in practice
Semantic versioning is MAJOR.MINOR.PATCH:
- MAJOR bumps when you make a backwards-incompatible change. Removed a function, renamed a parameter, changed a return type — that’s a major.
- MINOR bumps when you add functionality in a backwards-compatible way. New function, new optional argument, new module.
- PATCH bumps for backwards-compatible bug fixes only.
Pre-releases get suffixes:
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
And there’s a local-version syntax for non-public builds:
1.2.3+internal.42
1.2.3+gitsha.abc1234
PyPI rejects local versions on upload, but private indexes accept them — useful for “this is 1.2.3 with our patch on top.”
The two most common versioning mistakes I see in 2026:
- Bumping major for trivial changes because a marketing release happened. If nothing breaks for users, it’s a minor.
- Not bumping major when you actually broke something because “the change was small.” If a user’s working code stops working after upgrading, it was major. Be honest about it.
Keeping the version in one place
Two strategies, pick one:
Strategy A: hand-edit pyproject.toml. Simple, obvious, easy to mess up if you also tag in git and forget to keep them in sync.
Strategy B: derive the version from git tags. Tag v1.2.3, the build reads that tag. No drift.
hatch-vcs (with hatchling) and setuptools_scm (with setuptools) both do this:
[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"
Now your release flow is: tag, build, publish. The version comes along for free.
Publishing to PyPI
PyPI is the public Python package index — the thing pip install reads from by default. To publish there:
- Register an account at pypi.org.
- Generate an API token (Account settings → API tokens). Scope it to a single project once you’ve published version 1.
- Upload:
# uv-native publish
uv publish --token "$PYPI_TOKEN"
# or with twine (the older standby)
pip install twine
twine upload dist/*
Always test on TestPyPI first. It’s a separate index at test.pypi.org for exactly this purpose:
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
A name on PyPI is first-come-first-served and effectively permanent. Pick well. You also can’t re-upload the same version, ever — if 1.2.3 had a bug, you publish 1.2.4. Don’t try to “fix” by deleting and re-uploading. PyPI remembers.
Publishing to an internal artifact store
At work you’ll usually publish to a private index. They all speak the PyPI Simple API, so the same uv publish / twine upload commands work — only the URL and authentication change.
Common ones in 2026:
- GCP Artifact Registry —
https://<region>-python.pkg.dev/<project>/<repo>/. Auth viagcloud auth print-access-tokenor service account. - AWS CodeArtifact —
aws codeartifact get-authorization-tokenexchanges your AWS creds for a short-lived password. - Azure DevOps Artifacts — uses PATs.
- JFrog Artifactory and Sonatype Nexus — the on-prem old guard, both speak the standard.
Configuring uv (or pip) to install from a private index:
# pyproject.toml or uv.toml
[tool.uv]
index-url = "https://internal.example.com/simple/"
Or per-shell:
export UV_INDEX_URL=https://internal.example.com/simple/
index-url vs extra-index-url, and dependency confusion
There are two configuration knobs that look similar and are very different:
index-url— replaces PyPI as the default. Resolution looks here first (and only here, unless you also set extras).extra-index-url— adds another index in addition to the default. The resolver picks the highest version across all indexes.
The footgun: if you set extra-index-url = "https://internal.example.com/simple/" and your internal package is named my-internal-pkg, an attacker can publish a higher-version my-internal-pkg to public PyPI and your build will silently install the public one. This is the dependency confusion attack that has hit large companies multiple times.
The defense:
- Prefer
index-url(single source of truth) when your private index proxies PyPI. - If you must use
extra-index-url, also configure explicit index pinning: telluvwhich index a given package must come from.
In uv:
[[tool.uv.index]]
name = "internal"
url = "https://internal.example.com/simple/"
[tool.uv.sources]
my-internal-pkg = { index = "internal" }
Now my-internal-pkg only resolves from the internal index, no matter what PyPI offers.
The release process, end to end
A clean release flow for a small library:
# 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
In CI this becomes a single workflow triggered by the v* tag: checkout, set up Python, uv build, uv publish with a token from secrets. GitHub Actions and GitLab CI both have first-class examples in the uv docs.
A few quality-of-life things
- Include a
LICENSEfile. PyPI shows it; users check it; legal teams require it. - Include a
README.mdwithreadme = "README.md"in[project]. It becomes the long description on PyPI. - Add
classifiersandkeywordsto[project]. They make your package findable. - Use
__version__inside your package that matches the build version.from importlib.metadata import version; __version__ = version("my-package")keeps it automatic. - Sign your releases if your project’s threat model warrants it. PyPI now supports Sigstore for trusted publishing from CI without long-lived tokens.
The sum of all this is small. Once you’ve shipped one package, the next ones take ten minutes. The version, the lock, the build, the publish, the smoke test — that’s the whole cycle, and it’s the same whether you’re shipping to a few coworkers or to the entire Python world.