Packaging & Publishing
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
Your project is not "ship-ready" just because it runs on your laptop. Shipping means someone else can install the right files, import the right package, and see the right version.
Packaging is how Python turns your shaped src/ project into files other environments can install. Publishing is the extra step of putting those files on a package index such as PyPI.
The project file
pyproject.toml is the standard Python project settings file that packaging tools read from the project root. TOML is a configuration format with section headers like [project] and key/value lines like name = "snack-tracker".
Think of pyproject.toml as the shipping label on the project bench. It tells packaging tools what the project is called, which Python versions it supports, where the code lives, and which builder should make the package.
A build backend is the tool packaging uses behind the scenes to turn your source files into installable distribution files. This example uses Hatchling, a popular build backend. Setuptools is another common build backend; do not mix backends in one project unless you know why.
For the src/ layout from the last lesson, a small library-shaped project might start here:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "snack-tracker"
version = "0.1.0"
description = "A tiny snack report tool for team lunches."
readme = "README.md"
requires-python = ">=3.14"
authors = [{ name = "Mina Patel" }]
dependencies = [
"rich>=13.9",
]
[tool.hatch.build.targets.wheel]
packages = ["src/snack_tracker"]The [project] table holds project metadata, the facts package indexes and installers need: name, version, description, readme, supported Python, authors, and dependencies. The name uses a hyphen for the public package, while the import folder uses an underscore: people install snack-tracker, but code imports snack_tracker.
Libraries declare ranges, apps lock exact sets
A library is a package other projects import. An application is a program you run as one product. That difference changes how you think about dependencies.
For an application, the lockfile from lesson 18.1 is the drawer inventory. You want the exact known-good set.
For a library, the public metadata usually declares a compatible range:
[project]
dependencies = [
"rich>=13.9",
]That line means "this package needs rich version 13.9 or newer." You are giving the application that installs your library room to choose a compatible set with its own lockfile. Many library projects still keep a lockfile for their own test and release workflow, but the package metadata should not force every user into your exact private environment.
This is the lockfile nuance from the first lesson in this section: applications usually commit and install from a lock; libraries publish dependency promises that fit into someone else's lock.
Build distribution files
A distribution file is a file created for installation or publication. Python commonly builds two kinds.
A wheel is a built distribution file, ending in .whl, that installers can place into an environment quickly. An sdist, short for source distribution, is a source archive, usually .tar.gz, that contains the project source and metadata so a package can be rebuilt.
When a project has its packaging tools installed, the command shape is:
$ python -m buildThat command reads pyproject.toml, asks the build backend to work, and writes build output to dist/. A small package usually ends up with files shaped like this:
dist/
|-- snack_tracker-0.1.0-py3-none-any.whl
`-- snack_tracker-0.1.0.tar.gzDo not save dist/ output in project history. You rebuild it from source when you release. The source files, tests, pyproject.toml, and lockfile are the record; dist/ is the packed box.
Before publishing, install the wheel into a fresh environment and run your tests or a tiny smoke check. A smoke check is a small check that proves the package basically imports and starts, like opening a packed lunch before taking it on the road.
$ python -m venv /tmp/snack-check
$ source /tmp/snack-check/bin/activate
(snack-check) $ python -m pip install dist/snack_tracker-0.1.0-py3-none-any.whl
(snack-check) $ python -m snack_trackerOn Windows, use the PowerShell activation path you saw in the virtual environment lesson. The habit matters more than the exact temporary folder: test the packaged thing, not only the source tree.
Publish safely
PyPI is the Python Package Index, the main public tool shelf for Python packages. TestPyPI is a separate rehearsal index for testing uploads without claiming the real public name on PyPI.
Publishing usually uses an API token, a secret text key that lets an upload tool publish to your account without using your password. Treat it like the secrets from lesson 18.2: put it in your environment or your publishing tool's secure config, not in source code, README files, screenshots, or .env.example.
A common upload workflow uses Twine, a packaging upload tool that sends distribution files to a package index:
$ python -m build
$ python -m twine upload --repository testpypi dist/*
$ python -m twine upload dist/*TestPyPI comes first because it catches boring mistakes: missing metadata, broken README rendering, wrong package contents, or a version you already used.
Once a version is published to PyPI, treat it as permanent. You can release a new version, but you should not rely on replacing the old one. People and build systems may already have downloaded it.
Name versions clearly
Semantic versioning is a version pattern written as MAJOR.MINOR.PATCH, where each number signals the kind of change you made.
For snack-tracker, 0.1.0 means early work. Once you are making real promises to users, read the numbers like this:
1.0.0 -> first stable public promise
1.0.1 -> PATCH: bug fix, no intended behavior break
1.1.0 -> MINOR: new feature, old code should still work
2.0.0 -> MAJOR: breaking change, users may need to edit codeThe version in pyproject.toml should change before each new release. PyPI will not let you replace an existing distribution file for the same version. That is good pressure: a release is a named snapshot, not a moving target.
A practical release checklist looks like this:
- Update the version in
pyproject.toml. - Run tests from the project environment.
- Build wheel and sdist files.
- Smoke-check the wheel in a fresh environment.
- Upload to TestPyPI first.
- Upload to PyPI when the rehearsal is clean.
Checkpoint
Answer all three to mark this lesson complete
You now have the full Section 18 arc: isolate the environment, shape the project, then package and publish with care. Next, Section 19 gives that work a history with Git, so every release has a clear trail behind it.