In the last article you built a wheel and installed it. The file that told
pip how to build it was pyproject.toml. Here is a complete one for a small
project with this layout:
pricing-demo/
pyproject.toml
README.md
src/
pricing/
__init__.py
cli.py
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "pricing-lesson-demo"
version = "1.4.2"
description = "A small packaging example."
readme = "README.md"
requires-python = ">=3.14"
[project.scripts]
pricing-demo = "pricing.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
Leave __init__.py empty, write one line about the project in README.md,
and put this in src/pricing/cli.py:
def main() -> None:
print("Pricing demo")
if __name__ == "__main__":
main()
The file has three kinds of table:
[build-system]says how to build the project.[project]describes the package. It holds the name, the version and the Python versions the package supports, and installers read it.[tool.<name>]tables hold the settings of one tool each. Here[tool.setuptools.packages.find]tells setuptools to look for packages insrc/.
[project.scripts] belongs to the project table. It asks the installer to
create a pricing-demo command that calls main in pricing.cli.
Build it and run the command
Install the build package, then run python -m build in the project
folder. It writes an sdist and a wheel to dist/. It builds the sdist first
and then builds the wheel from the sdist. So the build fails if the sdist
lacks a file the build needs. Install the wheel into a fresh virtual
environment, as in the last article, and run pricing-demo. It prints
Pricing demo. That one run checks the package contents and the command.
Keep build and runtime dependencies apart
The requires list in [build-system] names what the build needs, here
setuptools. The build frontend is the command you run, such as pip or
python -m build. It installs those packages in a separate, temporary
environment, then calls the build backend named in build-backend. The
backend, here setuptools, turns your folder into a wheel. The next article
covers this split.
The packages your code imports when it runs go somewhere else:
[project]
dependencies = ["httpx>=0.27"]
[project.optional-dependencies]
postgres = ["psycopg>=3.2"]
dependencies lists what every install needs. optional-dependencies lists
extras, which are optional features a user asks for by name, as in
pip install pricing-lesson-demo[postgres]. Tools you use only while you
work on the project, such as pytest, go in a dependency group. A later
article covers groups and extras.
Know what installers do with the metadata
requires-python lists the Python versions your package supports. When pip
chooses a release of your package, it skips any release whose
requires-python excludes the running Python. It may install an older
release instead. The field does not install Python for you, and it does not
test your code on those versions.
To declare a license, give an SPDX license expression and list the license files:
# Inside [project]:
license = "MIT"
license-files = ["LICENSE"]
The LICENSE file must exist. The older table form, such as
license = {text = "MIT"}, is deprecated, so check that your backend
supports the new form before you switch.
Let the backend fill in the version
Some projects keep the version in the code, as __version__, and want the
wheel to match it. List the field in dynamic and tell the backend where to
find the value:
# Inside [project], instead of version = "1.4.2":
dynamic = ["version"]
# A separate table:
[tool.setuptools.dynamic]
version = {attr = "pricing.__version__"}
Then put __version__ = "1.4.2" in src/pricing/__init__.py. The dynamic
list only says that the backend supplies the field. Each backend has its own
table for saying where the value comes from.
Check your understanding
What happens if version has a value in [project] and is also listed in dynamic?
Show answer
Hide answer
The build fails. A field can have a value in [project] or appear in
dynamic, and the backend rejects a file that does both. Remove the value
if the backend should supply the version. Remove "version" from dynamic if
pyproject.toml should hold it.
Move tool settings in one at a time
Most tools read their settings from their own [tool.<name>] table. Add
these to the same file:
[tool.mypy]
python_version = "3.14"
strict = true
[tool.pytest.ini_options]
addopts = "-ra --strict-markers"
testpaths = ["tests"]
Run python -m mypy src and python -m pytest from the project folder, and
both tools read these tables. Move one tool at a time, check that it still
reports the same results, then delete its old file. An old mypy.ini or
pytest.ini in the same folder wins over pyproject.toml, so a forgotten
one silently keeps the old settings. Some files stay. Setuptools still reads
MANIFEST.in to choose the extra files that go into the sdist.
Keep passwords and API keys out of pyproject.toml. You commit it, and the
sdist includes it. Read secrets from environment variables when the program
runs.
Going further
Older projects describe their build in a setup.py file. Setuptools still
supports it, and a build that needs custom Python code may still use one.
Running it directly, as python setup.py sdist or python setup.py install,
is deprecated. Use python -m build to build and python -m pip install .
to install. See the
setup.py migration guidance.
The project metadata specification
lists every [project] field.