pyproject.toml and Building Packages
pyproject.toml is the modern, standardized home for project metadata, dependencies, and tool config. Building and publishing a package are downstream of getting it right.
pyproject.toml — the single config file
Before, a Python project sprawled across setup.py, setup.cfg, requirements.txt, MANIFEST.in, plus per-tool config files. PEP 518/621 consolidated metadata and build config into one declarative pyproject.toml.
# PEP 621 — standardized metadata
[project]
name = "myservice"
version = "1.2.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110",
"sqlalchemy>=2.0",
"celery>=5.3",
]
# extras: pip install myservice[dev]
[project.optional-dependencies]
dev = ["pytest>=8.0", "mypy>=1.11", "ruff>=0.6"]
[project.scripts] # console entry points
myservice = "myservice.cli:main" # creates a `myservice` command
# PEP 517/518 — how to build this
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# tool config lives here too
[tool.ruff]
line-length = 100
[tool.mypy]
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]What one file buys
The win: one file, declarative, standardized. Metadata under [project] is tool-agnostic (pip, uv, Poetry all read it). Each tool’s config sits under its own [tool.*] table.
Note: Poetry historically used its own [tool.poetry] tables instead of the standard [project] table; newer Poetry supports the standard. uv uses the standard [project] table.
setup.py is legacy
setup.py was executable config — running it ran arbitrary Python, which made builds non-deterministic and a security concern. PEP 517/518 replaced it with the declarative [build-system] + [project] model. New projects: no setup.py. You’ll still see it in older codebases.
The build system — PEP 517/518
[build-system] declares how to turn your source into installable artifacts:
requires— the build dependencies (the build backend itself).build-backend— which backend does the building.
Build backends (interchangeable, all PEP 517-compliant):
| Backend | Notes |
|---|---|
| hatchling | modern, simple, popular default |
| setuptools | the classic; still fully supported via pyproject.toml |
| flit | minimal, for pure-Python packages |
| pdm-backend / poetry-core | the backends behind PDM / Poetry |
| maturin | for packages with Rust extensions |
The point of PEP 517: the frontend (pip, uv, build) and the backend (hatchling, setuptools) are decoupled. pip install . works regardless of which backend the project uses.
sdist vs wheel — the two artifact types
sdist (.tar.gz) |
wheel (.whl) |
|
|---|---|---|
| What | source distribution — the source + metadata | built distribution — ready to install, no build step |
| Install | pip must build it on the target machine | pip just unpacks it — fast |
| C extensions | built on install (needs a compiler on the target) | pre-built per platform (...-cp312-manylinux...) |
| Always producible | yes | yes for pure Python; per-platform for compiled |
# produces both dist/myservice-1.2.0.tar.gz and dist/myservice-1.2.0-py3-none-any.whl
python -m buildWhen you pip install requests, pip prefers the wheel (no build, fast). The sdist is the fallback and the canonical source. For a pure-Python package, one wheel works everywhere (py3-none-any). For a package with C/Rust extensions, you publish a wheel per platform/Python-version (Linux, macOS, Windows × 3.11, 3.12, …) — that’s the manylinux / cibuildwheel machinery.
Versioning
version under [project]. Conventions:
- SemVer —
MAJOR.MINOR.PATCH; bump major on breaking changes, minor on features, patch on fixes. The norm for libraries — consumers’ version constraints (>=1.2,<2) depend on you honoring it. - CalVer —
YYYY.MM.x— date-based; some projects (Ubuntu, pip itself) use it. - Dynamic version — derive the version from a git tag (
hatch-vcs,setuptools-scm) so you don’t hand-edit it. Common in CI-driven release flows.
Publishing to PyPI
python -m build # build sdist + wheel into dist/
python -m twine upload dist/* # upload to PyPI
# or: uv publish / poetry publish- Test on TestPyPI first (
twine upload --repository testpypi) — a real PyPI upload of a given version is immutable; you can’t re-upload1.2.0. - Authenticate with a scoped API token, not your password. Better: PyPI Trusted Publishing — OIDC from GitHub Actions, no token stored at all (same idea as AWS OIDC; see the CI docs).
- For private packages: a private index (AWS CodeArtifact, GCP Artifact Registry, Azure Artifacts, or a self-hosted devpi).
Editable installs — for development
pip install -e . # or: uv pip install -e .An editable install links the package into the environment by path rather than copying it. You edit the source and the change is live immediately — no reinstall. Essential for developing a library or working in a monorepo where one package depends on another local package. (PEP 660 standardized editable installs for the pyproject.toml world.)
src/ layout vs flat layout
Where the package directory sits decides whether your tests import the installed package or the working copy.
flat layout src layout
myservice/ src/myservice/
__init__.py __init__.py
tests/ tests/
pyproject.toml pyproject.tomlWith a flat layout, the repository root is on sys.path when you run pytest
from it, so import myservice finds the source directory whether or not the
package is installed. That hides a whole class of bug: a module missing from
the wheel, a missing __init__.py, a data file not declared in the build
config. Everything passes locally and fails the moment someone installs it.
A src/ layout makes the source directory unimportable from the root, so
tests can only reach the package through the install. Combined with an
editable install, that is the same ergonomics with the packaging bug surfaced
at test time instead of at release time. It is the default for new projects
for exactly this reason.
The setting that matters alongside it:
[tool.pytest.ini_options]
# Do not put the rootdir on sys.path - the point of src/ is defeated by it.
addopts = "--import-mode=importlib"Monorepo / workspace dependencies
When one package in a repo depends on another local package, you don’t want to publish-and-reinstall on every change:
- uv workspaces / Poetry path dependencies — declare the dependency as a local path; the tool links it editable.
- This is how a monorepo keeps
libs/commoneditable-installed intoservices/apiduring development, while still resolving to a published version in production.
requires-python
requires-python = ">=3.12"Declares the Python versions the package supports. pip/uv won’t install it into an incompatible interpreter, and for a published library it tells consumers the floor. Set it deliberately — too loose and you claim support you don’t test; too tight and you needlessly exclude users.
Common gotchas
- Still using
setup.py— legacy, executable, non-declarative. Usepyproject.toml+ a PEP 517 backend. - Confusing sdist and wheel — sdist needs a build on the target (and a compiler for C extensions); wheel is pre-built. pip prefers the wheel.
- Re-uploading a version to PyPI — immutable; you can’t overwrite
1.2.0. Bump the version; test on TestPyPI first. - Password auth to PyPI — use a scoped API token, or Trusted Publishing (OIDC) with no stored secret.
- Not honoring SemVer in a library — consumers’
>=1.2,<2constraints break when you ship a breaking change in a minor bump. - Forgetting
pip install -e .for local dev — editing source without an editable install means reinstalling on every change. - Pinning exact versions in a published library’s
dependencies— libraries declare loose constraints so consumers can resolve compatibly; exact pins belong in an application’s lockfile, not a library’s metadata.
Interview angle 6
- “What is
pyproject.tomland why did it replacesetup.py?” — the single standardized file for project metadata ([project], PEP 621), build config ([build-system], PEP 517/518), and tool config ([tool.*]). It replacedsetup.pybecausesetup.pywas executable config — non-deterministic and a security concern; the new model is declarative. - “sdist vs wheel?” — sdist is the source distribution; pip must build it on the target machine (and needs a compiler for C extensions). A wheel is pre-built — pip just unpacks it, much faster. pip prefers the wheel; pure-Python projects ship one universal wheel, compiled ones ship a wheel per platform.
- “What does
[build-system]do?” — PEP 517: it declares how to build the project — the build backend (hatchling, setuptools, etc.) and its requirements. It decouples the frontend (pip/uv) from the backend, sopip install .works regardless of which backend a project chose. - “How do you publish a package?” —
python -m buildto produce sdist + wheel, thentwine upload(oruv publish). Authenticate with a scoped API token or, better, PyPI Trusted Publishing via OIDC — no stored secret. Test on TestPyPI first because a published version is immutable. - “What’s an editable install and when do you need one?” —
pip install -e .links the package by path instead of copying it, so source edits are live with no reinstall. Essential for developing a library, and for monorepos where one local package depends on another. - “Should a library pin its dependency versions?” — no — a library declares loose constraints (
fastapi>=0.110) so consumers can resolve compatibly with their other dependencies. Exact pins belong in an application’s lockfile, not a library’s published metadata.