pyproject.toml Basics
What You'll Learn
What pyproject.toml is, how to write one for a real project, and how it replaces older packaging files.
Why pyproject.toml?
Before pyproject.toml, a Python project might have:
setup.py— package metadata (executable Python)setup.cfg— more metadata (config file)MANIFEST.in— what to include in distributiontox.ini— test configuration.flake8— linter configmypy.ini— type checker configpytest.ini— test runner config
pyproject.toml replaces all of these with one file.
Minimal pyproject.toml
For a simple script project:
[project]
name = "my-project"
version = "0.1.0"
requires-python = ">=3.11"
That's enough for pip to understand the project.
Complete pyproject.toml
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "my-tools"
version = "1.2.0"
description = "Internal data processing and reporting tools"
readme = "README.md"
license = { text = "MIT" }
authors = [
{ name = "Alice Smith", email = "alice@example.com" }
]
requires-python = ">=3.11"
keywords = ["automation", "data", "tools"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
# Runtime dependencies (installed with pip install my-tools)
dependencies = [
"requests>=2.28,<3.0",
"python-dotenv>=1.0",
"click>=8.1",
]
[project.optional-dependencies]
# pip install my-tools[dev]
dev = [
"pytest>=8.0",
"ruff>=0.3",
"mypy>=1.9",
"pytest-cov",
"pytest-mock",
]
# pip install my-tools[docs]
docs = [
"mkdocs>=1.5",
"mkdocs-material",
]
[project.urls]
Homepage = "https://github.com/myorg/my-tools"
Repository = "https://github.com/myorg/my-tools"
[project.scripts]
process-data = "mytools.processor:main"
run-report = "mytools.reporter:main"
Tool Configuration
Consolidate all tool configs under [tool.*] sections:
# pytest
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --strict-markers"
markers = [
"slow: marks tests as slow",
"integration: marks tests requiring external services",
]
# ruff
[tool.ruff]
line-length = 99
target-version = "py311"
src = ["src"]
[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "UP", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # allow assert in tests
# mypy
[tool.mypy]
python_version = "3.11"
strict = false
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
# coverage
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/conftest.py"]
[tool.coverage.report]
show_missing = true
fail_under = 80
setuptools Package Discovery
Tell setuptools where your code lives:
[tool.setuptools.packages.find]
where = ["src"] # look for packages in src/
[tool.setuptools.package-data]
"mytools" = ["*.json", "templates/*.html"] # include non-Python files
Accessing Project Metadata in Code
from importlib.metadata import version, metadata
# Get your package version
__version__ = version("my-tools")
print(__version__) # "1.2.0"
# Get full metadata
meta = metadata("my-tools")
print(meta["Author"])
print(meta["Summary"])
Dynamic Versioning
Keep the version in one place — the source code:
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "mytools.__version__"}
# src/mytools/__init__.py
__version__ = "1.2.0"
Or use setuptools-scm to derive version from git tags:
pip install setuptools-scm
[build-system]
requires = ["setuptools>=68", "setuptools-scm"]
[tool.setuptools_scm]
# version is read from git tags: v1.2.0 → "1.2.0"
git tag v1.2.0
python3 -m build # version is "1.2.0"
The Build and Distribution Workflow
# Install build tool
pip install build twine
# Build source distribution + wheel
python3 -m build
# Creates:
# dist/my_tools-1.2.0.tar.gz
# dist/my_tools-1.2.0-py3-none-any.whl
# Check the package
twine check dist/*
# Upload to PyPI (public)
twine upload dist/*
# Upload to private PyPI
twine upload --repository-url https://pypi.internal.myorg.com dist/*
Quick Reference
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "my-pkg"
version = "1.0.0"
description = "Short description"
requires-python = ">=3.11"
dependencies = ["requests>=2.28"]
[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]
[project.scripts]
my-cmd = "mypkg.module:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v"
[tool.ruff]
line-length = 99
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.mypy]
ignore_missing_imports = true
pip install -e . # editable install
pip install -e ".[dev]" # install with dev extras
python3 -m build # build wheel + sdist