Skip to main content Brad's PyNotes

PEP 621: Storing Project Metadata in Pyproject.toml

TL;DR

PEP 621 established a standardized [project] table in pyproject.toml for declaring Python project metadata. It provided a tool-agnostic, static format that replaced executable setup.py code and tool-specific setup.cfg files with declarative TOML that could be parsed without running code.

Interesting!

Creating executable scripts uses a simple table syntax:

toml code snippet start

[project.scripts]
my-tool = "my_package.cli:main"

toml code snippet end

This creates a console command my-tool that calls the main() function from my_package.cli when executed.

Key Motivations

PEP 621 had three primary goals:

Static metadata specification: Encourage users to specify core metadata statically for speed, ease of specification, unambiguity, and deterministic consumption by build backends. Static metadata could be read and validated without executing code. This addressed issues with setup.py, which required executing arbitrary Python code to extract metadata - code that could have side effects, be slow, or behave non-deterministically.

Tool-agnostic specification: Provide a tool-agnostic way of specifying metadata for ease of learning and transitioning between build backends. Developers wouldn’t need to relearn metadata formats when switching tools. While setup.cfg was declarative, it was specific to setuptools. The [project] table works across all modern build backends.

Code sharing: Allow for more code sharing between build backends for the “boring parts” of a project’s metadata. Instead of each tool implementing its own metadata parser, they could share a common foundation.

PEP 518 had introduced pyproject.toml as the standard configuration file, but only specified build system requirements. PEP 621 extended this by standardizing the metadata itself.

The [project] Table

All standardized metadata lives in a single [project] table at the root of pyproject.toml:

toml code snippet start

[project]
name = "my-package"
version = "1.0.0"
description = "A short summary of the project"
requires-python = ">=3.8"

toml code snippet end

The name field is the only truly required field. Everything else can be omitted or marked as dynamic if computed by build tools.

Core Metadata Fields

PEP 621 defined fields covering all aspects of project metadata:

Identity and versioning:

toml code snippet start

[project]
name = "example-pkg"
version = "2.1.0"
description = "Does something useful"

toml code snippet end

Dependencies:

toml code snippet start

[project]
dependencies = [
    "requests>=2.28.0",
    "click>=8.0",
]

[project.optional-dependencies]
dev = ["pytest>=7.0", "black>=22.0"]
docs = ["sphinx>=4.0"]

toml code snippet end

Author information:

toml code snippet start

[project]
authors = [
    {name = "Jane Developer", email = "jane@example.com"},
]
maintainers = [
    {name = "Bob Maintainer", email = "bob@example.com"},
]

toml code snippet end

Project URLs:

toml code snippet start

[project.urls]
Homepage = "https://example.com"
Repository = "https://github.com/example/pkg"
Documentation = "https://docs.example.com"
"Bug Tracker" = "https://github.com/example/pkg/issues"

toml code snippet end

Dynamic vs Static Metadata

The dynamic field explicitly declares which metadata will be provided by build tools:

toml code snippet start

[project]
name = "my-package"
dynamic = ["version", "readme"]

toml code snippet end

This configuration tells tools that version and readme will be computed during the build process (perhaps from git tags or README files), while all other metadata is static. Build backends can then provide these values programmatically.

Without the dynamic field, all metadata is assumed to be static and present in the file.

Entry Points and Scripts

Creating executable console commands was a common need, but the syntax varied across tools. PEP 621 standardized it with intuitive table structures.

Before (setup.py):

python code snippet start

setup(
    entry_points={
        'console_scripts': [
            'my-command=my_package.cli:main',
            'another-tool=my_package.tools:run',
        ],
        'pytest11': [
            'my-plugin=my_package.pytest_plugin',
        ],
    }
)

python code snippet end

After (PEP 621):

toml code snippet start

[project.scripts]
my-command = "my_package.cli:main"
another-tool = "my_package.tools:run"

[project.gui-scripts]
my-gui-app = "my_package.gui:main"

[project.entry-points.pytest11]
my-plugin = "my_package.pytest_plugin"

toml code snippet end

The [project.scripts] table creates console commands, [project.gui-scripts] creates GUI application entry points, and [project.entry-points] handles plugin systems like pytest plugins.

Real-World Example

Here’s a complete pyproject.toml for a typical Python package:

toml code snippet start

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "useful-tool"
version = "1.2.3"
description = "A tool that does useful things"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
    {name = "Developer Name", email = "dev@example.com"},
]
keywords = ["utility", "tools", "helpful"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "License :: OSI Approved :: MIT License",
]
dependencies = [
    "click>=8.0",
    "rich>=10.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "black>=22.0",
    "mypy>=0.991",
]

[project.urls]
Homepage = "https://github.com/example/useful-tool"
Documentation = "https://useful-tool.readthedocs.io"
Repository = "https://github.com/example/useful-tool"
Changelog = "https://github.com/example/useful-tool/blob/main/CHANGELOG.md"

[project.scripts]
useful = "useful_tool.cli:main"

toml code snippet end

Tool Support

Python build tools added PEP 621 support following its acceptance:

  • Hatch/Hatchling: Full support with extensions
  • Setuptools (v61.0.0+): Added support via setup.py-free workflows
  • PDM: Built around PEP 621 from the start
  • Flit (v3.2+): Migrated to use PEP 621 format

Some tools like Poetry maintained their own metadata sections but could read PEP 621 metadata for interoperability.

Adoption and Current Status

PEP 621 became the recommended approach in the official Python Packaging User Guide. The packaging flow documentation now centers around pyproject.toml as the primary configuration file, and the packaging overview describes the [project] table as the standard metadata location.

The older approach using setuptools with setup.py is now documented as outdated, though still functional for existing projects.

Benefits

PEP 621 provided several practical advantages:

  • Tool independence: Projects could switch build backends without rewriting metadata
  • Declarative format: TOML configuration replaced executable Python code
  • Standardized parsing: All tools interpreted the same fields consistently
  • Clear documentation: A single specification covered all metadata fields

virtual environments work seamlessly with PEP 621-based projects, and modern dependency management tools parse the standardized format for consistent behavior across the ecosystem.

Reference: PEP 621 – Storing project metadata in pyproject.toml