Python Packaging #3 pyproject.toml: The Standard for Project Configuration

4 min read

The previous post concluded that the list of intent and the reproduction snapshot must be separated. This post is about where the intent lives. Open almost any recent Python repository and there is a pyproject.toml at the root. Dependency declarations, project metadata, formatter, linter, and test settings all gather in this one file. Knowing why it became the standard and what each section holds changes how fast you can read someone else’s project.

From scattered files to one #

Before pyproject.toml, a Python project’s configuration was scattered: package information lived in a setup.py script whose contents you could only learn by executing it, dependencies in requirements.txt, and tool settings in their own files — setup.cfg, .flake8, pytest.ini. PEP 518 introduced this file and PEP 621 defined the standard format for project metadata, and the ecosystem converged on “declarations about a project go in pyproject.toml.” Today both installers (pip, uv, poetry) and development tools (ruff, pytest, mypy) read this file.

[project]: declaring what the project is #

The central section first:

pyproject.toml
[project]
name = "invoice-tool"
version = "0.1.0"
description = "Internal tool that generates invoice PDFs"
requires-python = ">=3.12"
dependencies = [
    "django>=5.2,<6",
    "requests~=2.32.0",
    "celery>=5.5",
]
  • name, version: the package’s identity. When we publish to PyPI in #6, this name is used as-is.
  • requires-python: the range of Python versions the project runs on. Tools read this declaration and match the interpreter, so always write it.
  • dependencies: this is exactly the “list of intent” from #2. Direct dependencies only, each with its intended range. No transitive dependencies sneak in the way they do in freeze output.

Writing versions as ranges like >=5.2,<6 is the technique. Exact pinning is not this file’s job — it belongs to the lock file in #5. Here you declare only the intent: “the range our code is compatible with.”

Optional dependencies: extras and dependency-groups #

Not all dependencies carry the same weight. Two kinds of add-on dependencies stay separated:

pyproject.toml
[project.optional-dependencies]
pdf = ["weasyprint>=63"]        # features the installer chooses (extras)

[dependency-groups]
dev = [                          # tools only developers use (PEP 735)
    "pytest>=8",
    "ruff>=0.8",
]
  • optional-dependencies (extras): dependencies the package’s user opts into by feature: pip install invoice-tool[pdf]. Only people who need PDF export download the heavy library.
  • dependency-groups: tool lists needed only by the people developing the project, regardless of its users (the PEP 735 standard). The record that uv add --dev pytest from the testing series leaves in pyproject.toml is exactly this section.

Separating extras (which can ship with the distribution) from dependency-groups (development-only) is how the modern standard organizes it.

[build-system]: how this project becomes a package #

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

This section declares which tool builds the project into an installable distribution. It matters when you build and publish a library; an application that never gets published can omit it. What a build backend actually does, we verify hands-on in #6. For now it’s enough to read it as a signal: “this section present — this is a publishable package.”

[tool.*]: the gathering place for tool settings #

The other half of pyproject.toml is development tool configuration, each tool under its own namespace:

pyproject.toml
[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
strict = true

When settings stop being scattered across .flake8, pytest.ini, and mypy.ini, you get more than a cleaner repository root. The project’s conventions become visible in one file: a new teammate has one file to read, and config-file conflicts disappear.

The relationship to requirements.txt #

The division of roles: pyproject.toml’s dependencies are the declaration (direct dependencies plus compatible ranges), while requirements.txt was the file that doubled as snapshot in the traditional workflow. With pyproject.toml taking the declaration, the remaining question is who owns the snapshot. The answer is the lock file — and the tool that builds the lock from the declaration and syncs the environment to match is uv, next post’s subject.

Summary #

What we covered in this post:

  • Project information scattered across setup.py, setup.cfg, and per-tool config files converged into pyproject.toml via PEP 518/621
  • [project] declares the name, version, requires-python, and direct dependencies with their intended ranges. Exact pinning is the lock file’s job
  • User-selected features go in extras (optional-dependencies); developer-only tools go in dependency-groups (PEP 735). The latter is where uv add --dev writes
  • [build-system] declares how a publishable package gets built; projects that never publish can omit it
  • Gathering ruff, pytest, and mypy settings under [tool.*] makes project conventions readable from one file

In the next post (#4 uv) we cover uv — the tool that reads this declaration and handles the virtual environment, installation, locking, and even Python versions in one stroke. Everything we did by hand in #1–#3 folds into a few commands.

X