Open an older Python repository and count the configuration files:
setup.py, setup.cfg, MANIFEST.in, .flake8, mypy.ini, pytest.ini,
tox.ini, .isort.cfg. Eight files, four syntaxes, and at least two places
where the same setting can be written with different precedence.
The concrete cost is not tidiness. It is that a setting can be shadowed — local reads one file, CI reads another, and the resulting “works on my machine” takes an afternoon to diagnose because nobody knows which file wins.
pyproject.toml is one file, one syntax, and one place to look.
[build-system]
[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"
This is what PEP 517/518 bought: your project declares how it is built rather than being an executable build script. Any frontend — pip, uv, build — reads these two keys, installs the requirements into an isolated environment, and calls the backend.
Pin requires with a range, not an exact version and not nothing. Exact
pinning means you get no bug fixes and your build breaks when the pin becomes
unavailable; unbounded means a backend major release can break your build
with no change on your side.
[project], field by field
[project]
name = "pricing"
version = "1.4.2"
description = "Order pricing rules."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "A Dev", email = "dev@example.com" }]
keywords = ["pricing", "money"]
classifiers = ["Development Status :: 4 - Beta", "Programming Language :: Python :: 3.12"]
dependencies = ["httpx>=0.27", "attrs>=24.1"]
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2"]
[project.urls]
Homepage = "https://example.com/pricing"
Source = "https://github.com/example/pricing"
[project.scripts]
pricing = "pricing.cli:main"
Three fields deserve more than a line.
requires-python is a hard gate at install time: an installer will
refuse to install this distribution on an interpreter outside the range, and
will pick an older release if one is compatible. It is not documentation —
getting it wrong strands users on a version that cannot work, or lets them
install one that crashes on syntax.
license is now an SPDX expression (PEP 639), not a table and not a free
string: license = "MIT", license = "Apache-2.0 OR MIT". The accompanying
license-files key lists the files to include in the distribution. The old
license = { file = "LICENSE" } table form and the License :: classifiers
are the legacy spelling.
dynamic lists the [project] keys the backend will fill in — most
commonly version, when it is read from __init__.py or from a git tag:
[project]
name = "pricing"
dynamic = ["version"]
The rule that makes this safe is that a backend must error rather than
guess for anything not listed in dynamic. There is no silent fallback: a
key is either statically present, or declared dynamic, or the build fails.
💡A colleague sets version statically *and* lists it in dynamic. What happens, and why is the spec's choice the right one?
click to reveal
It is an error. A key may be static or dynamic, never both, and the build frontend refuses rather than picking one.
The alternative designs are all worse. “Static wins” means a project reading its version from a git tag silently ships whatever stale literal is in the file. “Dynamic wins” means the value you can read in the repository is not the value that ships. Either way the metadata lies, and it lies in the field people trust most.
This is the general principle behind dynamic and it is worth internalising:
the manifest is authoritative about where each value comes from, even when
it does not contain the value itself. A tool reading pyproject.toml can
always tell whether a field is knowable statically, which is what lets an
installer resolve dependencies without executing your build.
What it legitimately absorbs
Tool configuration, under [tool.<name>]:
[tool.ruff]
src = ["src"]
line-length = 100
[tool.mypy]
python_version = "3.12"
strict = true
[tool.pytest.ini_options]
addopts = "-ra --strict-markers"
testpaths = ["tests"]
[tool.coverage.run]
source = ["src"]
branch = true
Ruff, mypy, pytest, coverage, hatch, uv and import-linter all read their settings from here. That is seven files gone and one place to look.
What must not go in it
Secrets. pyproject.toml is committed, shipped inside your sdist, and
read by every tool that touches your project. An index token in it is a token
in the artefact you upload to that index.
Environment-specific values. A database URL, a hostname, a log level, a feature flag. This file describes the distribution, and the distribution is the same object in staging and production. Configuration that differs between deployments belongs in the environment, parsed at startup into a typed object.
The dividing line is a useful test: if the value could differ between two
running copies of the same version of your code, it does not belong in
pyproject.toml.
💡setup.py is still in your repository and CI runs python setup.py sdist. Which part of that is deprecated?
click to reveal
Only the second part. setup.py the file is not deprecated — setuptools
still reads it, and it remains the way to configure a C extension build,
which is genuinely imperative work that a static table cannot express.
What is deprecated is invoking it as a command-line interface:
python setup.py sdist, python setup.py bdist_wheel, python setup.py install, python setup.py test. Those run your build script directly, with
no isolated environment, no [build-system].requires, and no guarantee that
the build dependencies are present.
The replacement is a frontend: python -m build, uv build, or
pip install .. Each reads [build-system], sets up an isolated
environment, and calls the backend through the PEP 517 hooks. If your project
is pure Python you can usually delete setup.py entirely and move the
metadata into [project]; if it builds an extension, keep the file and stop
invoking it.