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.
[project] # PEP 621 — standardized metadata
name = "myservice"
version = "1.2.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110",
"sqlalchemy>=2.0",
"celery>=5.3",
]
[project.optional-dependencies] # extras: pip install myservice[dev]
dev = ["pytest>=8.0", "mypy>=1.11", "ruff>=0.6"]
[project.scripts] # console entry points
myservice = "myservice.cli:main" # creates a `myservice` command
[build-system] # PEP 517/518 — how to build this
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff] # tool config lives here too
line-length = 100
[tool.mypy]
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
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 |
python -m build # produces both dist/myservice-1.2.0.tar.gz and dist/myservice-1.2.0-py3-none-any.whl
When 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.)
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
- “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.