Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~30 min· topic 9 of 9

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.

pyproject.tomlwhole filetoml
[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.

terminalwhole filebash
uv build
ls dist/
# tinyhello-0.1.0-py3-none-any.whl
# tinyhello-0.1.0.tar.gz

03What 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.

What is inside a wheeldiagram
Rendering diagram…

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.

terminalwhole filebash
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.

terminalwhole filebash
uv build
uv publish

06Versioning 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.

requirements.txtwhole filebash
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. 1

    Open a real wheel

    On your computer run python -m pip download requests --no-deps -d wheels, then open the downloaded .whl file with any zip tool (or python -m zipfile -l wheels/<file>.whl). Find the METADATA file inside and read it. A wheel has no magic in it.

  2. 2

    Tamper with a hash

    In the second example, change b"package contents v1!" to b"package contents v1" and run it. The second line now says True, because the bytes are identical again. Change it back, and then change one letter inside the expected bytes instead. Now both lines say False, 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. 3

    Check your own dependencies

    Open a pyproject.toml or requirements.txt of 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

A wheel is just a zip file New tab

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.

Sign in to run this example in your browser.

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
Spot look-alike names and check a hash New tab

`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.

Sign in to run this example in your browser.

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: False
From source to PyPIdiagram
Rendering diagram…
A release checklistbash

Option 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 --tags

Break 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.

terminal
$ python -m pip install --require-hashes -r requirements.txt
── what you'll see ──
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.

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-url does 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-audit or uv-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/*.whl and tar -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 cibuildwheel project 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. 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 of pyproject.toml: requires = ["hatchling"] and build-backend = "hatchling.build" for Hatchling, or requires = ["setuptools>=61"] and build-backend = "setuptools.build_meta" for Setuptools. Tools like uv build or python -m build read that table, install the backend in a temporary environment, and ask it to build.

  2. 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 into site-packages, plus a .dist-info folder with metadata. Installing a wheel is just unpacking, which is why it is fast and why pip prefers it.

  3. 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-any means 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 as cp313 for CPython 3.13 and manylinux, macosx or win tags. If no wheel matches, pip falls back to the sdist and tries to compile, which needs a compiler.

  4. 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. 5

    The flow is: uv build creates dist/*.whl and dist/*.tar.gz; uv publish uploads 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. 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 as pip-audit in CI. Review new dependencies the way you review new code.

Explain it without notes

01

What is the difference between a wheel and an sdist?

02

What does the [build-system] table do?

03

Why publish to TestPyPI first, and why can't you re-upload the same version?

04

List practical defences against malicious or compromised packages.

Practice

01

Write the [build-system] table of a pyproject.toml that uses Hatchling as the build backend.

02

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.

03

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 run uv 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.