Topic 6.9
Building and Publishing a Package
In one line
To share a package you build it into a wheel (a ready-to-unpack zip) and an sdist (source archive) with a build backend, then upload both to PyPI, trying TestPyPI first. As a user, treat every dependency as code you are running: pin versions, use hashes, check names and review new packages.
Think of it like this
Sending a parcel. You pack the goods in a sturdy box (the package), write the name and version on the label, seal it, and give it to a courier (PyPI). The receiver checks that the label is right and the seal is not broken. Building and publishing a package is the same, and so is receiving one: always check the label and the seal.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Build backend
- The program that turns your source tree into a wheel and an sdist, named in
[build-system]. - Wheel (.whl)
- A ready-to-install package file. It is a zip with a fixed layout.
- sdist
- A source distribution: an archive (.tar.gz) of the source code and metadata.
- dist-info
- The metadata folder inside a wheel or installed package (name, version, files).
- TestPyPI
- A practice copy of PyPI for trying out publishing safely.
- API token
- A limited-power password for uploading, which can be revoked without touching your account.
- Trusted publishing
- PyPI trusting one specific CI workflow to upload, with no stored secret.
- Hash
- A fingerprint of a file's bytes. Any change to the file changes the hash.
- Typosquatting
- Publishing a malicious package under a name close to a popular one, hoping people misspell.
- Supply chain
- All the code your project depends on, direct and indirect, and the people who publish it.
Step by step
01Declare a build system
Add the [build-system] table next to [project]. The backend is a package listed in requires, and build-backend names the function that Python tools call. Hatchling and Setuptools are two common choices; both produce standard wheels.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "tinyhello"
version = "0.1.0"
description = "A tiny example package"
requires-python = ">=3.11"
dependencies = []02Build
uv build (or python -m build) creates dist/ with two files. Always look inside what you are about to publish; a wheel is just a zip, so any zip tool can open it. Check that tests, secrets and .env files are *not* inside.
uv build
ls dist/
# tinyhello-0.1.0-py3-none-any.whl
# tinyhello-0.1.0.tar.gz03What is inside a wheel
A wheel holds your package folders and a .dist-info folder. The METADATA file there is generated from [project]; WHEEL records the format and tags; RECORD lists every file with a hash. "Installing" a pure-Python wheel is unzipping it into site-packages. The runnable example below builds a minimal one by hand and installs it that way.
04Try TestPyPI first
Create an account on test.pypi.org, create an API token there, and upload. Then install from TestPyPI into a clean environment and import the package. If the metadata is wrong or a file is missing, you find out here, and the mistake does not pollute the real index (nor use up a version number there).
The upload address and options below are the usual ones; check the current uv and PyPI documentation, because token handling and trusted publishing options have been changing.
uv publish --publish-url https://test.pypi.org/legacy/ --token <your-testpypi-token>05Publish for real, without a stored secret
uv publish uploads everything in dist/ to PyPI. In CI, set up trusted publishing on pypi.org for your repository and workflow; the workflow then proves its identity to PyPI and no token is kept in your repo settings at all. Never paste a token into code, a README or a terminal history you share.
uv build
uv publish06Versioning rules for maintainers
PyPI never lets you overwrite a version. A bug fix means a new patch version (0.1.1), a new backwards-compatible feature a minor version (0.2.0), a breaking change a major version (1.0.0 to 2.0.0). Write a changelog, and tag the release in git (git tag v0.1.1). If you uploaded something harmful by mistake, PyPI lets you *yank* a release (hide it from new installs without breaking existing pins) rather than delete it.
07As a user: pin, hash, verify
A lock file from uv records a hash for every file. pip can enforce hashes too: lines in a requirements file carry --hash=sha256:..., and pip install --require-hashes -r requirements.txt rejects any file whose fingerprint does not match. A tampered or swapped package is rejected before it runs.
The runnable examples show both ideas in miniature: a hash changes when a single byte changes, and a spelling check can flag a name that is suspiciously close to a popular one.
requests==2.32.3 \
--hash=sha256:<64 hex characters from the lock>08Reviewing a new dependency
Before adding a package ask: Is the name spelled exactly as in the project's own documentation? Does the page link to a real repository? How old is it, how many people use it, is it maintained? What does it depend on? Is there a standard-library way (Topic 6.4) that needs no dependency at all? A short check takes two minutes and is the cheapest security measure you have.
Try it yourself
- 1
Open a real wheel
On your computer run
python -m pip download requests --no-deps -d wheels, then open the downloaded.whlfile with any zip tool (orpython -m zipfile -l wheels/<file>.whl). Find theMETADATAfile inside and read it. A wheel has no magic in it. - 2
Tamper with a hash
In the second example, change
b"package contents v1!"tob"package contents v1"and run it. The second line now saysTrue, because the bytes are identical again. Change it back, and then change one letter inside theexpectedbytes instead. Now both lines sayFalse, because neither set of bytes matches the changed fingerprint. A hash is a fingerprint of *exactly the bytes you give it*, so any change gives a different fingerprint. - 3
Check your own dependencies
Open a
pyproject.tomlorrequirements.txtof a project you have (or an example from this phase) and, for each package, spell-check the name against its project page. Notice how few lines it takes, and how many seconds.
Code & diagrams
The program builds a minimal wheel-shaped zip by hand (a real build backend also adds a `RECORD` file and more metadata). `with zipfile.ZipFile(...) as wheel:` opens the zip and closes it at the end of the block (Topic 7.4). The program lists its contents, unzips it into a `site-packages` folder, and imports from there. That unzip is the heart of installing a pure-Python wheel.
Expected output
file name: tinyhello-0.1.0-py3-none-any.whl
inside: tinyhello/__init__.py
inside: tinyhello-0.1.0.dist-info/METADATA
inside: tinyhello-0.1.0.dist-info/WHEEL
hello from a wheel
sha256 fingerprint length: 64 hex characters`difflib` finds names that are close to a popular one but not equal to it, the pattern typosquatting relies on. The second half shows why a lock file with hashes protects you: one extra byte gives a different hash, so the file is rejected. This is a teaching sketch, not a replacement for your installer's own checks.
Expected output
requests -> known name
reqeusts -> WARNING: looks like 'requests', check the spelling and the publisher
numpyy -> WARNING: looks like 'numpy', check the spelling and the publisher
left-pad-lite -> unknown name: read its page before installing
same bytes hash matches the lock file: True
tampered bytes hash matches the lock file: FalseOption names and token handling can change between uv versions. Check the uv and PyPI documentation before your first upload.
# 1. bump version in pyproject.toml, update the changelog
# 2. run the tests and the linter
uv build # 3. build the wheel and sdist
# 4. open the wheel and look inside (it is a zip)
uv publish --publish-url https://test.pypi.org/legacy/ --token <testpypi-token>
# 5. install from TestPyPI in a clean venv and import it
uv publish # 6. publish for real (ideally via trusted publishing in CI)
git tag v0.1.0 && git push --tagsBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Reinstall tampered bytes with hash checking on
Change one byte of a package file, or change the hash in a requirements file, and install with --require-hashes.
Myth vs fact
Myth
Everything on PyPI has been checked and is safe.
Fact
Anyone can publish. PyPI removes malware when it is found, but you can install it before then. Treat packages as code you are choosing to run.
Myth
A wheel is a compiled program.
Fact
A pure-Python wheel contains your .py files, unchanged. Only packages with C, Rust or other extensions contain compiled parts, and then there is one wheel per platform.
Myth
I can fix a bad release by re-uploading the same version.
Fact
PyPI never allows replacing a published file. Publish a new version (and yank the bad one if needed).
Myth
Pinning versions means I never need to update.
Fact
Pinning stops surprises; it also freezes known vulnerabilities in place. Update on purpose, regularly, with tests.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Real supply-chain incidents have included malicious look-alike names, hijacked maintainer accounts, and a build script that ran code at install time. A wheel does not run a build script on install, while installing from an sdist can, which is another reason to prefer wheels and to use
--only-binary :all:in sensitive environments. - ▸
Dependency confusion is an attack on teams with private packages. If an installer is told to look at the public index as well as a private one (pip's
--extra-index-urldoes exactly that), it considers both and may pick a public package that has the same name as your internal one but a higher version, uploaded by an attacker. Defences: reserve your internal names on the public index, use a single index that proxies approved packages, and install with hashes so an unexpected file is refused. The same care applies to the TestPyPI trick: it is a practice index, so install from it with the real PyPI as a second index only for a test, in a throw-away environment, because the package names there are not vetted. - ▸
Two-factor authentication is required for publishing on PyPI, and trusted publishing removes long-lived tokens. For your own releases, use both, give tokens the narrowest scope (a single project), and protect the branch or tag that triggers the release workflow. Newer features such as attestations let consumers verify where a file was built; check the current PyPI documentation for their status.
- ▸
Run a vulnerability audit routinely:
pip-auditoruv-compatible scanners compare your pinned versions with public advisories. Combine it with automatic update pull requests (Dependabot, Renovate), so that fixing a vulnerable dependency is a small, tested change. - ▸
Two build habits catch mistakes before users do. List the contents of both files (
python -m zipfile -l dist/*.whlandtar -tzf dist/*.tar.gz) to check that nothing private went in and nothing needed is missing. Installing your own wheel into a clean virtual environment and importing it proves the package works without your source folder next to it. uv builds the sdist first and then, by default, builds the wheel from that sdist, which exposes files that were forgotten from the sdist. - ▸
Binary wheels for compiled libraries are built in CI for many platforms (the
cibuildwheelproject exists for this). Python 3.13's free-threaded build needs its own wheels, tagged differently, which is one reason library support for it arrives gradually.
Remember this
- 1
Publishing starts with building. A build backend is the program that turns your source tree into distributable files. You choose it in the
[build-system]table ofpyproject.toml:requires = ["hatchling"]andbuild-backend = "hatchling.build"for Hatchling, orrequires = ["setuptools>=61"]andbuild-backend = "setuptools.build_meta"for Setuptools. Tools likeuv buildorpython -m buildread that table, install the backend in a temporary environment, and ask it to build. - 2
A build produces two files in
dist/. The sdist (source distribution),name-1.0.0.tar.gz, is an archive of the source plus metadata; installing it may need a build step. The wheel,name-1.0.0-py3-none-any.whl, is a ready-made zip file containing exactly what gets copied intosite-packages, plus a.dist-infofolder with metadata. Installing a wheel is just unpacking, which is why it is fast and whypipprefers it. - 3
The wheel file name has tags:
name-version-pythontag-abitag-platformtag.whl. (The ABI tag says which binary format the compiled code in the wheel expects, so a wheel is never loaded by a Python it does not fit.)py3-none-anymeans pure Python that runs on any Python 3 and any operating system. Packages with compiled code (NumPy, Pydantic's core) publish many wheels, one per Python version and platform, such ascp313for CPython 3.13 andmanylinux,macosxorwintags. If no wheel matches, pip falls back to the sdist and tries to compile, which needs a compiler. - 4
Before you publish: pick a unique name (PyPI names are first come, first served; check pypi.org), a version that follows PEP 440 (and, ideally, semver rules), and complete the
[project]metadata. Never reuse a version number: PyPI refuses to let you replace a published file, so every fix is a new version. Bump the version, rebuild, and upload again. - 5
The flow is:
uv buildcreatesdist/*.whlanddist/*.tar.gz;uv publishuploads them. Test the process first on TestPyPI (test.pypi.org), a separate practice index with its own accounts and tokens, then install from it into a clean environment to check it works. Then publish to the real PyPI. For authentication use a scoped API token, never your account password, and prefer trusted publishing, where PyPI trusts a specific automated job in your repository (a GitHub Actions workflow, one of the CI jobs of Topic 11.8 that run on their own when you push code), so no long-lived token is stored at all. The CI/CD course at /cicd teaches this in full. - 6
Supply-chain safety is the other half of this topic. Every package you install is code that runs on your machine, and attackers publish malicious look-alikes. Recall from Topic 6.6: a lock file stores a hash (a fingerprint) of each file. The defences below are cheap: pin versions and use a lock file with hashes, so a changed or tampered file is rejected; check the spelling of a package name before installing it (typosquatting means registering names close to popular ones, such as
reqeusts); look at the project page (age, downloads, repository link, maintainers) for anything unfamiliar; install few dependencies; update on purpose, with tests; and run vulnerability scans such aspip-auditin CI. Review new dependencies the way you review new code.
Explain it without notes
What is the difference between a wheel and an sdist?
What does the [build-system] table do?
Why publish to TestPyPI first, and why can't you re-upload the same version?
List practical defences against malicious or compromised packages.
Practice
Write the [build-system] table of a pyproject.toml that uses Hatchling as the build backend.
Write a function is_lookalike(name, popular). popular is a list of package names. Return True if name is not in popular but is very close to one of its entries (use difflib.get_close_matches with a cutoff of 0.8). Otherwise return False.
Given the wheel file name numpy-2.0.0-cp313-cp313-manylinux_x86_64.whl, say in plain words what each of the five parts means.
Trade-offs
- ↔
Publishing a package vs keeping code in the repository. A published package lets others install and version it with one command, but you take on maintenance, naming and security duties. For internal code, a private index or a git dependency avoids public exposure.
- ↔
Fewer dependencies vs reuse. Every dependency saves you time and adds risk and update work. Standard-library code or ten lines of your own is sometimes the safer choice.
- ↔
Strict hash checking vs convenience. Hashes stop tampering but make every update an explicit step. That cost is small compared with installing a poisoned file.
Done when you can
Done when you can explain wheels and sdists and read a wheel's file name.
Done when you can write a
[build-system]table and runuv build.Done when you can describe the TestPyPI-then-PyPI release flow, and why versions cannot be reused.
Done when you can explain pinning, lock files with hashes and why they protect you.
Done when you can check a package name for typosquatting and review a new dependency.