backend / python core / packaging / 03_pyproject_and_building.md

pyproject.toml and Building Packages

6 interview angles 6 min read source

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:

  • SemVerMAJOR.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.
  • CalVerYYYY.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-upload 1.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/common editable-installed into services/api during 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. Use pyproject.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,<2 constraints 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.toml and why did it replace setup.py?” — the single standardized file for project metadata ([project], PEP 621), build config ([build-system], PEP 517/518), and tool config ([tool.*]). It replaced setup.py because setup.py was 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, so pip install . works regardless of which backend a project chose.
  • “How do you publish a package?”python -m build to produce sdist + wheel, then twine upload (or uv 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.