Skip to main content

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 distribution
  • tox.ini — test configuration
  • .flake8 — linter config
  • mypy.ini — type checker config
  • pytest.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

What's Next

Lesson 5: Reproducible Installs