Skip to content
← Back to explainers

Python explainer

Describe Your Project in pyproject.toml

About 5 min to read. Allow extra time to work through examples.

Write a complete pyproject.toml for a small package, see what each table is for, keep build and runtime dependencies apart, and move your tool settings into the same file.

This article uses Python. You can make it your practice mode to browse related content across the site.

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 in src/.

[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

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.

step 24 of 41

Modules, Packaging and Tooling

Up next Choose a Build Backend and Test the Wheel