pyproject.toml - The Modern Python Project Standard
Reading time: ~40 minutes | Level: Intermediate → Engineering
Before reading further, look at this real project root from 2018 and count the configuration files:
my_project/
├── setup.py # package metadata and build instructions
├── setup.cfg # same, but in INI format (confusing duplication)
├── MANIFEST.in # what to include in the source distribution
├── requirements.txt # dependencies? or just for developers?
├── tox.ini # test environment configuration
├── .flake8 # linting configuration
├── mypy.ini # type checker configuration
└── pytest.ini # test runner configuration
Eight files. Each tool has its own format. requirements.txt duplicates the install_requires in setup.py. setup.py is executable Python - it runs arbitrary code at install time, which breaks build tools that need to read metadata without executing code.
How many of these files does a modern project need?
Show Answer
One: pyproject.toml.
my_project/
├── pyproject.toml # everything: metadata, dependencies, build system, tool config
├── src/
│ └── my_project/
│ ├── __init__.py
│ └── core.py
└── tests/
└── test_core.py
pyproject.toml is a TOML file (not executable Python) that carries:
- Build system specification (which tool builds your package)
- Project metadata (name, version, description, license, authors)
- Runtime dependencies and optional dependency sets
- Console entry points (CLI commands)
- Configuration for pytest, coverage, ruff, mypy, black, and any other tool that supports it
The consolidation happened through a series of PEPs: 517, 518, and 621. Understanding the history explains why the file is structured the way it is.
Eight config files for one project is not a Python quirk - it was a symptom of an ecosystem without a standard. pyproject.toml is that standard. Every serious Python project written after 2022 uses it.
What You Will Learn
- The history: from
setup.pytopyproject.toml(PEP 517, 518, 621) - The
[build-system]table: what it specifies and why it exists - Build backends:
hatchling,setuptools,flit,pdm-backend- differences and trade-offs - The
[project]table: every field you need for a real package - Optional dependencies and extras (
pip install mypackage[dev]) - Entry points: turning your package into a CLI command
- Tool configuration sections: pytest, coverage, ruff, mypy, black
- Dynamic versioning: reading version from source
- The
src/layout and why it prevents a class of bugs - Building distribution artifacts with
python -m build
Prerequisites
- Lesson 01 - venv and virtualenv (build tools run in virtual environments)
- Lesson 02 - pip and requirements (understanding how packages are installed)
- Module 01 - OOP (package structure mirrors class/module design)
Part 1 - The History: How We Got Here
Understanding why pyproject.toml exists requires knowing what came before it.
The Three PEPs That Matter
PEP 518 (2016) - Specifying Minimum Build System Requirements
Before PEP 518, there was no way for a project to declare what tools were needed to build it. python setup.py install assumed setuptools was available. If your build process needed something else, users had to know to install it manually.
PEP 518 introduced pyproject.toml with a single section:
[build-system]
requires = ["setuptools", "wheel"]
This told pip: "Before building this package, install these tools into a temporary isolated environment." Pip could now safely build any package without contaminating the user's environment.
PEP 517 (2017) - A Build-Backend Interface
PEP 517 standardized the interface between build frontends (pip, build) and build backends (setuptools, flit, hatchling). Any tool implementing the PEP 517 interface could be specified as the build-backend. This decoupled the ecosystem from setuptools.
PEP 621 (2021) - Storing Project Metadata in pyproject.toml
PEP 621 standardized the [project] table - the format for declaring name, version, dependencies, authors, and all other project metadata. Before PEP 621, each backend used its own metadata format. Now there is one standard.
Part 2 - The [build-system] Table
Every pyproject.toml starts with a [build-system] table:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
| Key | Meaning |
|---|---|
requires | Packages pip must install (into a temporary build environment) before it can build your package |
build-backend | The Python object that implements the PEP 517 backend interface |
When pip builds your package, it:
- Creates a temporary, isolated virtual environment
- Installs the packages listed in
requiresinto that environment - Calls the
build-backendobject's PEP 517 methods to build sdist and wheel artifacts - Discards the temporary environment
Your project's runtime dependencies are never in this temporary environment. Build tools and runtime dependencies are completely separated.
Build Backends Compared
| Backend | build-backend value | Philosophy | Best for |
|---|---|---|---|
| hatchling | hatchling.build | Modern, opinionated, fast | New projects; pure Python |
| setuptools | setuptools.build_meta | Maximum compatibility, most flexible | Legacy code; C extensions |
| flit | flit_core.buildapi | Minimal, pure-Python only | Simple libraries without C |
| pdm-backend | pdm.backend | Feature-rich, PDM ecosystem | Projects using PDM |
| maturin | maturin | Rust extensions | Python + Rust mixed projects |
For new pure-Python projects in 2025, hatchling is the recommended default. For projects with C extensions (NumPy-style), use setuptools.
setup.py still works and setuptools still supports it. Migrating existing projects is not urgent - setup.py is deprecated for new projects but not removed. If you maintain an older project, migrate when the opportunity arises (a major version bump, a CI overhaul) rather than as a standalone task.
Part 3 - The [project] Table
The [project] table carries all standard metadata. Here is a complete, annotated example:
[project]
# Package name as it appears on PyPI. Use hyphens (not underscores).
name = "my-data-validator"
# Version: either static here, or declared dynamic (see Part 5)
version = "1.2.0"
# One-line description shown on PyPI
description = "A data validation library for production Python services"
# README file shown on the PyPI project page
readme = "README.md"
# SPDX license identifier (PEP 639 style, preferred)
license = { text = "MIT" }
# Minimum Python version
requires-python = ">=3.10"
# Authors and maintainers
authors = [
{ name = "Jane Smith", email = "[email protected]" },
]
maintainers = [
{ name = "Engineering Team", email = "[email protected]" },
]
# Keywords shown on PyPI
keywords = ["validation", "data", "schema"]
# PyPI classifiers: https://pypi.org/classifiers/
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
]
# Runtime dependencies - installed when users install your package
# Use ranges here (not exact pins) so they compose with user environments
dependencies = [
"pydantic>=2.0,<3",
"httpx>=0.25",
"typing-extensions>=4.8; python_version < '3.12'",
]
Environment Markers
The last dependency shows an environment marker: ; python_version < '3.12'. This installs typing-extensions only on Python < 3.12 (where some features are not yet in stdlib). Markers are evaluated at install time based on the target Python environment.
Common markers:
dependencies = [
# Only on Python < 3.11
"tomli>=2.0; python_version < '3.11'",
# Only on Windows
"pywin32>=305; sys_platform == 'win32'",
# Only on Linux
"uvloop>=0.18; sys_platform == 'linux'",
]
Part 4 - Optional Dependencies and Extras
Optional dependencies let users install additional functionality on demand:
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"ruff>=0.1",
"mypy>=1.6",
]
docs = [
"sphinx>=7.2",
"sphinx-rtd-theme>=1.3",
"myst-parser>=2.0",
]
redis = [
"redis>=5.0",
"hiredis>=2.2",
]
Users install extras with bracket syntax:
pip install my-data-validator # runtime only
pip install "my-data-validator[dev]" # runtime + dev tools
pip install "my-data-validator[dev,docs]" # multiple extras
pip install "my-data-validator[redis]" # runtime + redis support
In your own development environment:
# Install the package in editable mode with dev extras:
pip install -e ".[dev]"
Use the src/ layout with editable installs. The package must be installed (even in editable mode) before it can be imported, which means your tests always import the installed package - not a stale .py file from the project root. This catches packaging bugs early.
Part 5 - Entry Points: CLI Commands
When users install your package, they can also get CLI commands automatically added to their PATH:
[project.scripts]
# "command-name" = "module.path:function_name"
validate = "my_data_validator.cli:main"
validator-server = "my_data_validator.server:run"
[project.gui-scripts]
# Same as scripts but for GUI applications (no terminal window on Windows)
validator-gui = "my_data_validator.gui:launch"
After pip install my-data-validator, users can run validate from any terminal. pip creates a small wrapper script in venv/bin/validate that calls my_data_validator.cli.main().
Your CLI function:
# src/my_data_validator/cli.py
def main() -> None:
"""Entry point for the 'validate' command."""
import argparse
parser = argparse.ArgumentParser(description="Validate data files")
parser.add_argument("file", help="File to validate")
args = parser.parse_args()
# ... validation logic
Plugin Entry Points
[project.entry-points] declares plugin registration points - the mechanism used by pytest plugins, Sphinx extensions, and similar ecosystems:
[project.entry-points."pytest11"]
# pytest discovers plugins via this entry point group
my-pytest-plugin = "my_package.pytest_plugin"
[project.entry-points."sphinx.builders"]
my-builder = "my_package.sphinx_builder:MyBuilder"
Part 6 - Project URLs
[project.urls]
Homepage = "https://example.com/my-data-validator"
Documentation = "https://my-data-validator.readthedocs.io"
Repository = "https://github.com/example/my-data-validator"
"Bug Tracker" = "https://github.com/example/my-data-validator/issues"
Changelog = "https://github.com/example/my-data-validator/blob/main/CHANGELOG.md"
These appear in the sidebar on the PyPI project page.
Part 7 - Tool Configuration Sections
The [tool.*] namespace in pyproject.toml is where every development tool stores its configuration. Tools opt into this convention - not all do, but the major ones do.
pytest
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--strict-markers", # fail if a marker is not registered
"--strict-config", # fail on any config warning
"-ra", # show short test summary for all except passed
]
markers = [
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
"slow: marks tests as slow",
]
coverage
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/tests/*", "*/conftest.py"]
[tool.coverage.report]
show_missing = true
fail_under = 85
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
ruff (linter and formatter)
[tool.ruff]
target-version = "py310"
line-length = 88
src = ["src"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = ["E501"] # line too long (handled by formatter)
[tool.ruff.lint.isort]
known-first-party = ["my_data_validator"]
mypy
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = false
[[tool.mypy.overrides]]
module = ["httpx.*", "pydantic.*"]
ignore_missing_imports = true
black (formatter)
[tool.black]
line-length = 88
target-version = ["py310", "py311", "py312"]
Part 8 - Dynamic Versioning
Hardcoding the version in pyproject.toml means it must be kept in sync with __version__ in your source if you define both. The dynamic field solves this:
[project]
name = "my-data-validator"
dynamic = ["version"]
[tool.hatch.version]
path = "src/my_data_validator/__init__.py"
# src/my_data_validator/__init__.py
__version__ = "1.2.0"
hatchling reads __version__ from the source file at build time. There is one source of truth for the version. No sync required.
For setuptools:
[build-system]
requires = ["setuptools>=61", "setuptools-scm"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
# Derives version from git tags: git tag v1.2.0; python -m build
setuptools-scm derives the version from git tags. No __version__ needed anywhere. The version is always the latest git tag.
If you define version = "1.0.0" in [project] AND __version__ = "1.0.0" in __init__.py, they will drift apart as you release new versions. Developers will update one and forget the other. Use dynamic = ["version"] with a single source of truth. This is not a hypothetical bug - it happens on every project that uses the two-field approach.
Part 9 - The src/ Layout
One of the most impactful decisions when structuring a Python package is where to put the source code.
Flat Layout (old default)
my_project/
├── my_data_validator/ # package: importable from project root
│ ├── __init__.py
│ └── core.py
├── tests/
│ └── test_core.py
└── pyproject.toml
src/ Layout (modern standard)
my_project/
├── src/
│ └── my_data_validator/ # package: only importable after install
│ ├── __init__.py
│ └── core.py
├── tests/
│ └── test_core.py
└── pyproject.toml
The difference is subtle but consequential:
With flat layout: When you run pytest from the project root, Python adds my_project/ to sys.path. Your test imports from my_data_validator.core import .... This imports directly from the my_data_validator/ directory - not from the installed package. If your pyproject.toml excludes a file from the distribution (a common mistake), tests still pass because they import the file directly from the source tree.
With src/ layout: my_data_validator/ is inside src/, which is NOT automatically added to sys.path. Running pytest tries to import my_data_validator - and fails, unless the package is installed (pip install -e .). This forces you to install the package before testing, which means you always test the installed package, not the source tree.
The src/ layout catches packaging bugs that the flat layout hides. If a file is missing from your distribution, you find out when you pip install -e ., not after you publish to PyPI.
Use src/ layout for all new packages. Configure hatchling to find it:
[tool.hatch.build.targets.wheel]
packages = ["src/my_data_validator"]
Or setuptools:
[tool.setuptools.packages.find]
where = ["src"]
Part 10 - A Complete Production pyproject.toml
This is what a real library's pyproject.toml looks like:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-data-validator"
dynamic = ["version"]
description = "A data validation library for production Python services"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
authors = [
{ name = "Jane Smith", email = "[email protected]" },
]
keywords = ["validation", "data", "schema", "pydantic"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
dependencies = [
"pydantic>=2.0,<3",
"httpx>=0.25",
"typing-extensions>=4.8; python_version < '3.12'",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"ruff>=0.1",
"mypy>=1.6",
]
docs = [
"sphinx>=7.2",
"myst-parser>=2.0",
]
[project.scripts]
validate = "my_data_validator.cli:main"
[project.urls]
Homepage = "https://example.com/my-data-validator"
Documentation = "https://my-data-validator.readthedocs.io"
Repository = "https://github.com/example/my-data-validator"
"Bug Tracker" = "https://github.com/example/my-data-validator/issues"
[tool.hatch.version]
path = "src/my_data_validator/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/my_data_validator"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = ["--strict-markers", "-ra"]
markers = [
"integration: integration tests",
"slow: slow tests",
]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 85
[tool.ruff]
target-version = "py310"
line-length = 88
src = ["src"]
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP"]
[tool.mypy]
python_version = "3.10"
strict = true
Never put secrets - API keys, database passwords, private registry tokens - in pyproject.toml. When you publish to PyPI, pyproject.toml is included in the distribution package and becomes publicly readable by anyone who downloads your package. Use environment variables for secrets, and .env files (gitignored) for local development.
Part 11 - Building Distribution Artifacts
pip install build
# Build both sdist (source distribution) and wheel:
python -m build
# Build only wheel:
python -m build --wheel
# Build only sdist:
python -m build --sdist
The dist/ directory will contain:
dist/
├── my_data_validator-1.2.0-py3-none-any.whl # wheel (binary distribution)
└── my_data_validator-1.2.0.tar.gz # sdist (source distribution)
Wheel filename format: {name}-{version}-{python_tag}-{abi_tag}-{platform_tag}.whl
py3: works with any Python 3none: no ABI constraints (pure Python)any: works on any platform
For packages with C extensions, the wheel would look like: mypackage-1.0.0-cp311-cp311-manylinux_2_17_x86_64.whl - compiled for CPython 3.11 on manylinux on x86_64.
# Verify the wheel contents before publishing:
unzip -l dist/my_data_validator-1.2.0-py3-none-any.whl
# Check package metadata:
python -m twine check dist/*
Part 12 - Graded Practice
Level 1 - Predict and Identify
Question 1: What is the difference between requires in [build-system] and dependencies in [project]?
Show Answer
[build-system] requires lists packages that must be installed to build your package - tools like hatchling, setuptools, Cython. pip installs these into a temporary, isolated build environment before building. Users who install your package never see these.
[project] dependencies lists packages that must be installed to run your package at runtime - libraries your code imports. pip installs these into the user's environment alongside your package.
The separation is critical: build tools should never leak into the user's runtime environment. Before PEP 518, there was no clean way to declare build dependencies, which is why python setup.py install often required users to manually pre-install setuptools and wheel.
Question 2: You have this in your pyproject.toml:
[project]
dependencies = [
"requests>=2.28",
"tomli>=2.0; python_version < '3.11'",
]
A user installs your package on Python 3.12. Which packages does pip install?
Show Answer
Only requests>=2.28. The tomli dependency has the marker python_version < '3.11'. On Python 3.12, this condition is false, so tomli is not installed.
tomli provides a TOML parser. Python 3.11 added tomllib to the stdlib. The marker pattern tomli>=2.0; python_version < '3.11' is the standard way to use the stdlib on newer Python while falling back to the backport on older versions.
Question 3: What is the key advantage of the src/ layout over the flat layout?
Show Answer
With the src/ layout, your package is not importable from the project root without first installing it (even in editable mode with pip install -e .). This means your tests always import the installed package, not the raw source directory.
This catches packaging bugs: if a file is accidentally excluded from the wheel by a misconfigured [tool.hatch.build] section, tests with a flat layout would still pass (they import the source file directly). With src/ layout, tests would fail immediately after pip install -e . because the installed package is missing the file.
Level 2 - Debug and Fix
Scenario: A team member's pyproject.toml has the following, and pip install -e . followed by pytest fails with ModuleNotFoundError: No module named 'my_validator':
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "my-validator"
version = "0.1.0"
requires-python = ">=3.10"
[tool.setuptools.packages.find]
where = ["src"]
The source code is at src/my_validator/__init__.py. What is wrong, and how do you diagnose and fix it?
Show Answer
Diagnosing:
# Check what setuptools discovered:
python -c "import setuptools; print(setuptools.find_packages('src'))"
# If this prints [] or doesn't include 'my_validator', packages are not found.
# Check the installed package location:
pip show my-validator
# Check if Location points to src/
# List what was actually installed:
pip show -f my-validator
Likely cause: setuptools find_packages with where = ["src"] looks for directories with __init__.py inside src/. The package my_validator should be found. But if the __init__.py is missing, or if the path is src/my_validator/ but there is no __init__.py inside it, setuptools will find nothing.
Check:
ls src/my_validator/
# __init__.py should be here
If __init__.py is missing, create it:
touch src/my_validator/__init__.py
pip install -e .
python -c "import my_validator"
Alternative cause: The package name contains a hyphen (my-validator) but the Python module uses an underscore (my_validator). This is correct - hyphens are for the distribution package name, underscores for the Python module. Make sure imports use the underscore form: import my_validator.
Prevention: After pip install -e ., always verify:
python -c "import my_validator; print(my_validator.__file__)"
# Should print a path inside src/my_validator/
Level 3 - Design
Challenge: You are building a Python library called datastream with the following requirements:
- Pure Python, supports Python 3.10, 3.11, 3.12
- Runtime deps:
pydantic>=2.0,httpx>=0.25 - Optional
kafkaextra:confluent-kafka>=2.3,avro-python3>=1.11 - Optional
redisextra:redis>=5.0 - Dev tools: pytest, ruff, mypy, coverage
- A CLI command
datastream-ingestthat callsdatastream.cli:ingest_main - Version managed from
src/datastream/__init__.py - Uses
src/layout with hatchling
Write the complete pyproject.toml for this project. Include all tool configuration sections needed for a production-ready project.
Show Answer
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "datastream"
dynamic = ["version"]
description = "A streaming data ingestion library for production Python services"
readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.10"
authors = [
{ name = "Your Name", email = "[email protected]" },
]
keywords = ["streaming", "kafka", "redis", "data", "ingestion"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
dependencies = [
"pydantic>=2.0,<3",
"httpx>=0.25",
"typing-extensions>=4.8; python_version < '3.12'",
]
[project.optional-dependencies]
kafka = [
"confluent-kafka>=2.3",
"avro-python3>=1.11",
]
redis = [
"redis>=5.0",
"hiredis>=2.2", # C extension for performance
]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"pytest-asyncio>=0.21",
"ruff>=0.1",
"mypy>=1.6",
]
[project.scripts]
datastream-ingest = "datastream.cli:ingest_main"
[project.urls]
Repository = "https://github.com/example/datastream"
Documentation = "https://datastream.readthedocs.io"
"Bug Tracker" = "https://github.com/example/datastream/issues"
# --- hatchling configuration ---
[tool.hatch.version]
path = "src/datastream/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/datastream"]
# --- pytest ---
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = [
"--strict-markers",
"--strict-config",
"-ra",
"--cov=src/datastream",
"--cov-report=term-missing",
]
markers = [
"integration: marks tests requiring external services",
"slow: marks slow tests",
"kafka: marks tests requiring a Kafka broker",
"redis: marks tests requiring a Redis instance",
]
# --- coverage ---
[tool.coverage.run]
source = ["src"]
branch = true
omit = [
"*/tests/*",
"*/conftest.py",
"src/datastream/__main__.py",
]
[tool.coverage.report]
show_missing = true
fail_under = 85
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"def __repr__",
"raise NotImplementedError",
"@(abc\\.)?abstractmethod",
]
# --- ruff ---
[tool.ruff]
target-version = "py310"
line-length = 88
src = ["src"]
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.isort]
known-first-party = ["datastream"]
# --- mypy ---
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
show_error_codes = true
[[tool.mypy.overrides]]
module = [
"confluent_kafka.*",
"avro.*",
]
ignore_missing_imports = true
Key decisions:
dynamic = ["version"]with[tool.hatch.version]for single version sourcesrc/layout enforced via[tool.hatch.build.targets.wheel]- Kafka and Redis as separate extras - users install only what they need
hiredisincluded in theredisextra for production performanceasyncio_mode = "auto"for pytest-asyncio since streaming code is often asyncfail_under = 85for coverage - strict but achievable- mypy overrides for packages without type stubs
Key Takeaways
pyproject.tomlreplacessetup.py,setup.cfg,MANIFEST.in, and all tool-specific config files - one file, one format, one source of truth[build-system]declares build tools (pip installs these into a temporary environment at build time, not the user's environment)[project]carries standardized metadata;dependenciesuses version ranges (not exact pins) so your library composes with users' other packages- Optional dependencies (
[project.optional-dependencies]) let users install extras on demand withpip install mypkg[extra] [project.scripts]creates CLI commands that land in the user'sPATHafter installation- Dynamic versioning with
dynamic = ["version"]eliminates version drift betweenpyproject.tomland__init__.py - The
src/layout forces a proper install before tests can import your package, catching packaging bugs before they reach PyPI - Tool sections (
[tool.pytest.*],[tool.ruff],[tool.mypy]) centralize all configuration in one file - Never put secrets in
pyproject.toml- it is distributed to every user who installs your package
