Backend / Python core / Packaging / 03_pyproject_and_building.md

pyproject.toml and Building Packages

Updated 6 interview angles 7 min read source
On this page12
  1. pyproject.toml — the single config file
  2. setup.py is legacy
  3. The build system — PEP 517/
  4. sdist vs wheel — the two artifact types
  5. Versioning
  6. Publishing to PyPI
  7. Editable installs — for development
  8. src/ layout vs flat layout
  9. Monorepo / workspace dependencies
  10. requires-python
  11. Common gotchas
  12. Interview angle

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.

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
bash
# produces both dist/myservice-1.2.0.tar.gz and dist/myservice-1.2.0-py3-none-any.whl
python -m build

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

bash
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

bash
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.

text
flat layout                 src layout
  myservice/                  src/myservice/
      __init__.py                 __init__.py
  tests/                      tests/
  pyproject.toml              pyproject.toml

With 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:

toml
[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/common editable-installed into services/api during development, while still resolving to a published version in production.

requires-python

toml
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 6

  • “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.