Python Packaging #6 Publishing: Building Wheels and Uploading to PyPI
So far we’ve been on the receiving end of other people’s packages. Time to stand on the other side. If you want a utility module usable from your team’s other projects, or want the CLI from Automation #7 installable via uv tool install, your code has to become an installable package. What had to happen before pip install requests could work — that’s what this post explains.
The shape of a publishable project: the src layout #
A project meant for distribution keeps its code in a package directory under src/:
invoice-tool/
├── pyproject.toml
├── README.md
├── src/
│ └── invoice_tool/
│ ├── __init__.py
│ └── cli.py
└── tests/Starting with uv init --package invoice-tool produces this structure. Putting code at the root (the flat layout) works too, but the src layout became the modern standard for one reason: it prevents the accident of mixing up “the installed package” with “the source in the current directory.” With code at the root, tests import the source instead of the installed copy — so a file missing from the build still passes local tests. The hyphen becoming an underscore in the directory (invoice-tool → invoice_tool) is down to package naming rules.
The build backend: the machine that turns source into distributions #
The section we only learned to read in #3 now takes center stage:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"A build backend takes a source tree and produces distributions. A standard interface (PEP 517) means backends are swappable; hatchling is the safe default. It produces two kinds of distribution:
- sdist (source distribution): your source bundled as a tar.gz. The installing side builds it again.
- wheel: a finished build in zip form. Installing is close to just unpacking. This is what pip was downloading back in #2.
For a pure-Python package, one wheel covers every platform; C extensions require per-platform wheels. Convention is to upload both.
Building and local verification #
uv buildTwo files appear under dist/:
dist/
├── invoice_tool-0.1.0.tar.gz # sdist
└── invoice_tool-0.1.0-py3-none-any.whl # wheelThe py3-none-any in the wheel filename is a compatibility tag: “any Python 3, no ABI requirement, any platform.” Before uploading, rehearse the install locally:
uvx --with dist/invoice_tool-0.1.0-py3-none-any.whl invoice-tool --helpDuring development you don’t need this rehearsal every time: in a uv project, the project itself sits in the environment as an editable install (a link-style install where source edits take effect immediately), so uv run invoice-tool always runs the current source.
Rehearse on TestPyPI, go live on PyPI #
There is no undo on PyPI. A version number, once uploaded, can never be reused even after deletion — so the standard practice for a first release is rehearsing on the sandbox, TestPyPI:
# upload to TestPyPI (account and token from test.pypi.org)
uv publish --index testpypi --token $TESTPYPI_TOKEN
# once installation checks out, the real thing
uv publish --token $PYPI_TOKENIssue tokens as project-scoped tokens, not account-wide ones. To release a new version, bump version in pyproject.toml (semver convention: compatible fixes bump patch, added features bump minor, breaking changes bump major) and build again.
Trusted Publishing: tokenless deployment is the modern standard #
Tokens are a staple of leak incidents. The current standard is therefore Trusted Publishing — deploying from CI without tokens at all. Register in your PyPI project settings that “this workflow in this GitHub repository is the publisher,” and GitHub Actions proves its identity via OIDC at run time and receives short-lived credentials. There is simply no secret left to store in the repository.
# .github/workflows/publish.yml (the essential part)
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # OIDC identity proof
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv build
- run: uv publish # no token needed with Trusted PublishingThis picture continues the CI from Testing #7: cut a release, and code that passed the tests reaches PyPI without human hands.
If you don’t want it public: internal distribution options #
Not every package belongs on PyPI. For internal-only code, two options are practical:
- git dependencies: hang the repository directly as a dependency —
uv add "invoice-tool @ git+https://github.com/org/invoice-tool". No extra infrastructure to start, with versions pinned by tag. - A private index: as the organization grows, run a private package index (a cloud artifact registry and the like) and register it via
[[tool.uv.index]]. The workflow becomes identical to PyPI’s.
Summary #
What we covered in this post:
- Publishable projects use the src layout. It structurally prevents import accidents where the installed copy and the source get mixed up
- The build backend (hatchling and friends) turns source into an sdist and a wheel. A wheel is a finished build in zip form, and convention uploads both
- Build with
uv buildand rehearse by installing the wheel before uploading. During development, the editable install keepsuv runon the current source - PyPI version numbers cannot be reused. Rehearse first releases on TestPyPI, and scope tokens to the project
- The modern standard for CI deployment is Trusted Publishing: identity proven via OIDC, no tokens stored in the repository
- Internal-only packages skip PyPI via git dependencies or a private index
The next post (#7 team conventions and tool choice) wraps up the series: uv, poetry, and pip on one table with criteria for choosing, plus CI caching and Docker — binding the whole series into team-level conventions.