Packaging Python Projects
Packaging turns a directory of Python source into an installable, distributable unit — described by pyproject.toml and built into wheels or source distributions.
Packaging is the process of describing a Python project's metadata and structure so that tools can build it into standard distribution formats — installable by pip, indexable on PyPI, and importable exactly like any other third-party library. The modern, standardized entry point for all of this is a single file: pyproject.toml.
What Packaging Solves
A folder of .py files is directly runnable, but it isn't yet a *package* other tools and people can reliably install, version, and depend on. Packaging adds a formal description — name, version, dependencies, entry points — and a defined build step that turns source into an installable artifact.
pyproject.toml
pyproject.toml, standardized by PEP 517/518/621, is the single configuration file modern Python projects use to declare both how to build the project ([build-system]) and what the project is ([project]). It replaced the older pattern of a setup.py script that had to be executed to learn even basic metadata.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "weatherkit"
version = "0.1.0"
description = "A small toolkit for fetching and parsing weather data."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Ada Example", email = "ada@example.com" }]
dependencies = [
"requests>=2.28,<3",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "black"]
[project.scripts]
weatherkit = "weatherkit.cli:main"Package Metadata
The [project] table is defined by PEP 621 and is build-backend-agnostic — any compliant backend reads the same fields. Key fields: name and version (required), description, readme, requires-python (the interpreter version range supported), dependencies (runtime requirements), optional-dependencies (extras like dev or test), authors, license, and classifiers (structured PyPI metadata like supported Python versions and license type).
version can instead be marked dynamic = ["version"] and computed by the build backend (e.g. from a git tag), a common pattern for avoiding manual version bumps.
Source Layout
There are two common ways to lay out a package's source relative to the project root: flat layout (the importable package sits directly in the project root) and src layout (the importable package sits inside a src/ subdirectory).
src layout is widely recommended for anything beyond a quick script: it prevents tests or a stray shell session from accidentally importing the package straight from the source tree (bypassing the installed version), forcing you to test against what would actually be installed. Full tradeoffs are covered in the Project Structure topic.
# Flat layout
weatherkit/
├── pyproject.toml
├── weatherkit/
│ ├── __init__.py
│ └── client.py
└── tests/
# src layout
weatherkit/
├── pyproject.toml
├── src/
│ └── weatherkit/
│ ├── __init__.py
│ └── client.py
└── tests/Build Backends
[build-system] names which tool actually turns your source tree into a wheel/sdist. Python standardized the *interface* (PEP 517) so any compliant backend can be swapped in without changing how pip or build invoke it. Common third-party backends: setuptools (the long-standing default, now pyproject.toml-native), hatchling (Hatch's backend, minimal config for typical projects), flit-core (very simple, pure-Python projects), and poetry-core (used by the Poetry tool).
setuptools, hatchling, flit-core, and poetry-core are third-party/ecosystem build backends — none of them are part of the Python standard library. pip and the build frontend are what invoke a chosen backend uniformly.
Wheels
A wheel (.whl) is a pre-built, ready-to-install binary distribution format — essentially a zip archive with a defined internal layout. Installing from a wheel is fast because it's just unpacking files into site-packages; no build step runs at install time. Wheel filenames encode compatibility, e.g. weatherkit-0.1.0-py3-none-any.whl means: version 0.1.0, works on any Python 3 implementation (py3), no ABI constraint (none), pure-Python / any platform (any).
Packages with compiled extensions produce platform-specific wheels instead, e.g. numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.whl (built for CPython 3.12 on a specific Linux ABI/architecture).
Source Distributions (sdist)
A source distribution (.tar.gz, e.g. weatherkit-0.1.0.tar.gz) contains the project's source code plus pyproject.toml, but no pre-built binary — installing from an sdist requires running the build backend on the installer's machine. sdists are the fallback pip uses when no compatible wheel is available, and they're what a package index typically also carries alongside wheels so source is always retrievable.
Building a Package
The build package (third-party, PEP 517 frontend) invokes whichever backend pyproject.toml names and produces both a wheel and an sdist.
python -m pip install build
python -m build
# Produces:
dist/
├── weatherkit-0.1.0-py3-none-any.whl
└── weatherkit-0.1.0.tar.gzPractical Usage
A minimal, real-world workflow for turning a project into an installable package:
During development, an editable install (pip install -e .) links the environment directly to your source tree instead of copying files, so edits take effect immediately without rebuilding.
1. Write pyproject.toml with [build-system] and [project].
2. Lay out source (src/ layout recommended).
3. python -m build # produces dist/*.whl and dist/*.tar.gz
4. pip install dist/weatherkit-0.1.0-py3-none-any.whl # install locally to verify
5. (optionally) upload dist/* to an index — see Build & DistributionEdge Cases
Forgetting to include non-code files (data files, templates) means they won't ship in the built distribution unless explicitly configured via the backend's package-data mechanism. A name that collides with an existing PyPI project prevents publishing under that name (though local/private builds are unaffected). Mixing an old setup.py-only project with a new pyproject.toml can confuse which one a tool reads — modern practice is to drop setup.py entirely once pyproject.toml covers everything.
Best Practices
Use pyproject.toml exclusively — avoid legacy setup.py/setup.cfg for new projects. Prefer src layout for anything meant to be installed and imported by others. Pin requires-python to the versions you actually test. Use optional-dependencies for extras (dev, test, docs) rather than a separate requirements file per purpose. Build with python -m build so the process is backend-agnostic and reproducible on any machine.
Complete Example
weatherkit/
├── pyproject.toml
├── README.md
├── src/
│ └── weatherkit/
│ ├── __init__.py
│ └── client.py
└── tests/
└── test_client.py[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "weatherkit"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.28,<3"]
[project.optional-dependencies]
dev = ["pytest>=8.0"]python -m venv .venv && source .venv/bin/activate
pip install build
python -m build
pip install dist/weatherkit-0.1.0-py3-none-any.whl
python -c "import weatherkit; print('installed ok')"Mental Model and Quick Reference
Think of pyproject.toml as the project's birth certificate and the build backend as the factory that reads it to manufacture two shippable products: a ready-to-run wheel and a from-source sdist.
pyproject.toml what the project IS and how to build it
[build-system] which backend builds it (setuptools/hatchling/flit-core/poetry-core)
[project] name, version, dependencies, entry points (PEP 621)
python -m build invokes the backend -> dist/*.whl + dist/*.tar.gz
wheel (.whl) pre-built, fast to install, no build step at install time
sdist (.tar.gz) source only, built at install time by the recipientKey Takeaways
pyproject.toml is the standardized single source of truth for a project's build configuration and metadata (PEP 517/518/621).
A build backend (setuptools, hatchling, flit-core, poetry-core — all third-party) does the actual work of turning source into distributions.
Wheels are pre-built and fast to install; sdists contain source and are built at install time.
src layout guards against accidentally testing an uninstalled copy of your package.
python -m build is the standard, backend-agnostic way to produce distributable artifacts.
Without pyproject.toml, standard tools (pip, build) have no defined way to build or install the project as a real package.
weatherkit/ ├── weatherkit/ # flat layout, no src/ │ └── __init__.py └── tests/ └── test_client.py # can accidentally import the uninstalled source tree weatherkit/ ├── src/ │ └── weatherkit/ │ └── __init__.py └── tests/ └── test_client.py # forced to import the INSTALLED package
Flat layout lets tests import the package directly from the working directory even if it was never installed correctly, hiding packaging bugs that src layout would catch.
dependencies = ["requests"] # no version bound at all, shipped to PyPI dependencies = ["requests>=2.28,<3"]
An unbounded dependency in a published package can pull in a future breaking release for every downstream user; declare a sensible compatible range instead.
What is the role of pyproject.toml's [build-system] table versus its [project] table?
[build-system] declares which build backend and its requirements are needed to build the project; [project] (PEP 621) declares the project's own metadata — name, version, dependencies, entry points — independent of which backend is used.
What is the practical difference between a wheel and a source distribution?
A wheel is pre-built and ready to install (just unpacked into site-packages); a source distribution contains source code and pyproject.toml and requires the build backend to run on the installer's machine before it can be installed.
Why does src layout help catch packaging mistakes that flat layout can hide?
In flat layout, the package directory sits next to your working directory, so Python can import it directly from source even if it was never actually installed; src layout removes that accidental path, forcing imports to go through the real installed package.
Name two third-party build backends and state that they are not part of the standard library.
setuptools and hatchling (also flit-core and poetry-core) are third-party/ecosystem tools; none of them ship as part of Python's standard library.
What command produces both a wheel and an sdist from a properly configured project?
python -m build (after installing the third-party build package), which reads pyproject.toml and invokes the declared backend.
What is an editable install and when is it used?
pip install -e . links the environment to the project's source tree instead of copying files, so code edits take effect immediately without reinstalling — used during active development.
Write a minimal pyproject.toml for a package named textkit, version 0.2.0, requiring Python 3.10+, depending on click>=8.0, and built with hatchling.
[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "textkit" version = "0.2.0" requires-python = ">=3.10" dependencies = ["click>=8.0"]
The [build-system] table names hatchling as the backend; [project] declares the required PEP 621 metadata (name, version) plus requires-python and dependencies.
Decode this wheel filename: what does each dash-separated segment after the version mean?
py3 = compatible with any Python 3 implementation; none = no specific ABI requirement; any = works on any platform — together indicating a pure-Python wheel with no compiled extensions.
Write the two shell commands to build a project with the build package and then install the resulting wheel with pip (assume it lands in dist/mypkg-1.0.0-py3-none-any.whl).
python -m build pip install dist/mypkg-1.0.0-py3-none-any.whl python -m build invokes the declared backend to produce dist/*.whl and dist/*.tar.gz; pip install then installs directly from the built wheel.
weatherkit-0.1.0-py3-none-any.whl