cheat sheet

pyproject.toml

The canonical config file for modern Python projects — [project] metadata, [build-system] backends (setuptools, hatchling, pdm-backend, poetry-core, flit-core), [tool.*] sections, dynamic versioning, scripts, building, and publishing.

pyproject.toml — Modern Python Project Config

What it is

pyproject.toml is the canonical configuration file for a modern Python project — replacing setup.py, setup.cfg, MANIFEST.in, and per-tool config files like .flake8 or mypy.ini. It is a TOML file that lives at the project root and declares three things: project metadata ([project], per PEP 621), the build system used to package the project ([build-system], per PEP 517/518), and tool-specific configuration ([tool.<name>], per PEP 518).

Maintained by the Python Packaging Authority, pyproject.toml is what pip install ., pip, uv, poetry, python -m build, ruff, mypy, and pytest all read. For new projects in 2026 it is the only config file you should be writing — the older setup.py and setup.cfg files are still supported but no longer recommended for new code.

Install

pyproject.toml is a file, not a tool — there is nothing to install. What you may need is a build frontend (build) and a build backend (chosen via [build-system]).

bash
# Build frontend — invokes the backend declared in pyproject.toml
pip install build

# Twine — for uploading dists to PyPI
pip install twine

# A modern all-in-one (preferred)
pip install uv

Output: (none — exits 0 on success)

Minimal example

The smallest viable pyproject.toml declares a name, a version, and a build system. With just this, pip install . will package and install the project.

toml
[project]
name = "my-tool"
version = "0.1.0"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

Output: (none — exits 0 on success)

File anatomy

A full-featured pyproject.toml has three top-level table groups. Everything else ([tool.*]) is owned by individual tools.

toml
[project]              # PEP 621 — project metadata (name, version, deps, etc.)
[build-system]         # PEP 517/518 — how to build a distribution
[tool.<name>]          # tool-specific (ruff, mypy, pytest, hatch, poetry, …)

Output: (none — exits 0 on success)

[project] — core metadata

The [project] table is the PEP 621 declaration of who your project is. Most fields are optional; only name is strictly required, but in practice you should always set version (or mark it dynamic), description, readme, requires-python, and dependencies.

toml
[project]
name            = "my-tool"
version         = "0.4.2"
description     = "A delightful CLI for managing widgets"
readme          = "README.md"
requires-python = ">=3.10"
license         = { text = "MIT" }
keywords        = ["cli", "widgets", "automation"]
authors         = [{ name = "Alice Dev", email = "alice@example.com" }]
maintainers     = [{ name = "Alice Dev", email = "alice@example.com" }]
classifiers = [
    "Development Status :: 4 - Beta",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Topic :: Utilities",
]
dependencies = [
    "click>=8.1",
    "rich>=13",
    "httpx>=0.27",
]

Output: (none — exits 0 on success)

Field reference

FieldTypePurpose
namestr (required)distribution name on PyPI
versionstrsemver-style version (or list it under dynamic)
descriptionstrone-line summary shown by pip show and PyPI
readmestr or tablepath to README, or { file = "README.md", content-type = "text/markdown" }
requires-pythonstrPEP 440 version specifier (e.g. ">=3.10,<4")
licensestr or tableSPDX expression or { file = "LICENSE" }
authors / maintainerslist of tables{ name = "...", email = "..." }
keywordslist of strPyPI search tags
classifierslist of strPyPI classifier strings
urlstableHomepage, Documentation, Repository, Issues, etc.
dependencieslist of strPEP 508 version specs
optional-dependenciestable of listsextras (pip install pkg[extra])
scriptstablename = "module:function" console entry points
gui-scriptstablesame, but for GUI apps (no console window on Windows)
entry-pointstableother entry-point groups (e.g. plugins)
dynamiclist of strwhich fields are computed at build time

Dependencies

Dependencies use PEP 508 syntax — the same string format pip install accepts. The most useful operators:

SpecifierMeaning
pkg==1.2.3exact
pkg>=1.2,<2range
pkg~=1.2compatible release (>=1.2,<2)
pkg>=1.2; python_version<"3.12"environment marker
pkg[extra1,extra2]>=1.0with extras
pkg @ git+https://github.com/o/r.git@v1direct URL
toml
[project]
dependencies = [
    "click>=8.1",
    "rich>=13",
    "httpx[http2]>=0.27",
    "tomli>=2.0; python_version<'3.11'",
    "mylib @ git+https://github.com/alicedev/mylib.git@v1.2",
]

Output: (none — exits 0 on success)

Optional dependencies (extras)

Use [project.optional-dependencies] to define named groups of optional deps. Users opt in with pip install my-tool[group]. This is how packages like fastapi[all] and uvicorn[standard] work.

toml
[project.optional-dependencies]
dev  = ["pytest>=8", "ruff>=0.4", "mypy>=1.10"]
docs = ["sphinx>=7", "furo"]
all  = ["my-tool[dev,docs]"]

Output: (none — exits 0 on success)

PEP 735 (Python 3.13 standard) introduces a [dependency-groups] table that is not shipped to PyPI — use it for development-only groups like lint or test. Tooling support (uv, pip 25+) is still maturing in 2026.

Scripts (console entry points)

[project.scripts] declares command-line entry points. Each name = "module:function" entry produces a launcher binary on pip install that calls the named function — no shebang scripts required.

toml
[project.scripts]
my-tool      = "my_tool.cli:main"
my-tool-dump = "my_tool.cli:dump"

Output: (none — exits 0 on success)

After pip install ., my-tool and my-tool-dump become available on $PATH.

bash
my-tool --help

Output:

text
Usage: my-tool [OPTIONS] COMMAND [ARGS]...

  A delightful CLI for managing widgets.

Options:
  --help  Show this message and exit.

URLs

[project.urls] is a free-form table of named URLs. PyPI renders these on the project page sidebar; common keys are shown below but any string label works.

toml
[project.urls]
Homepage      = "https://github.com/alicedev/my-tool"
Documentation = "https://my-tool.readthedocs.io"
Repository    = "https://github.com/alicedev/my-tool"
Issues        = "https://github.com/alicedev/my-tool/issues"
Changelog     = "https://github.com/alicedev/my-tool/blob/main/CHANGELOG.md"

Output: (none — exits 0 on success)

Dynamic fields

dynamic = ["..."] declares fields that are computed by the build backend rather than written literally in pyproject.toml. The most common use is version — read from __init__.py, a git tag, or a separate VERSION file.

toml
[project]
name    = "my-tool"
dynamic = ["version"]              # version comes from elsewhere

[tool.setuptools.dynamic]
version = { attr = "my_tool.__version__" }

Output: (none — exits 0 on success)

[build-system] — backend selection

[build-system] tells pip, build, and uv how to package your project. The two keys are requires (build-time dependencies) and build-backend (the dotted import path of the backend's build_meta). Every modern Python project should set this — without it, tools fall back to a legacy setup.py mode.

toml
[build-system]
requires      = ["setuptools>=68", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"

Output: (none — exits 0 on success)

Build backend comparison

The five most common backends, in decreasing order of "batteries included":

BackendInstallbuild-backendNotes
setuptoolsbundled with pipsetuptools.build_metathe legacy default; still the most compatible; supports C extensions out of the box
hatchlingpip install hatchlinghatchling.buildmaintained by PyPA; fast, modern, no setup.py; powers hatch
flit-corepip install flit-coreflit_core.buildapiminimalist — pure-Python projects only, no C extensions
poetry-corepip install poetry-corepoetry.core.masonry.apifor projects managed by poetry; also usable standalone
pdm-backendpip install pdm-backendpdm.backendfor projects managed by pdm; PEP 621-native
maturinpip install maturinmaturinRust-extension Python packages
scikit-build-corepip install scikit-build-corescikit_build_core.buildCMake-based C/C++ extensions

setuptools

The historical default. Use it for projects with C extensions, legacy setup.py history, or maximum tool compatibility.

toml
[build-system]
requires      = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
"my_tool" = ["py.typed", "*.json"]

Output: (none — exits 0 on success)

hatchling

PyPA's modern recommendation for new pure-Python projects. Faster than setuptools, no setup.py, sensible defaults. Powers hatch but is usable standalone with any build frontend.

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

[tool.hatch.build.targets.wheel]
packages = ["src/my_tool"]

[tool.hatch.version]
path = "src/my_tool/__init__.py"

Output: (none — exits 0 on success)

flit-core

Pure-Python projects with no C code, no plugins, no fuss. Reads __version__ from your top-level package automatically.

toml
[build-system]
requires      = ["flit_core>=3.9"]
build-backend = "flit_core.buildapi"

[project]
name    = "my-tool"
dynamic = ["version", "description"]

Output: (none — exits 0 on success)

poetry-core

For projects managed by poetry. Older poetry projects also use [tool.poetry] for metadata; the modern (poetry 2.0+) layout is fully PEP 621 with [project].

toml
[build-system]
requires      = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"

[project]
name    = "my-tool"
version = "0.1.0"
dependencies = ["click>=8"]

Output: (none — exits 0 on success)

pdm-backend

For projects managed by pdm. Like hatchling, it's modern and PEP 621-first; pick it if your team is on PDM.

toml
[build-system]
requires      = ["pdm-backend"]
build-backend = "pdm.backend"

Output: (none — exits 0 on success)

Common [tool.*] tables

Most Python tools read their config from [tool.<name>] so you can consolidate everything into one file. The most common settings below.

[tool.ruff]

ruff (and ruff format) replaces flake8, isort, pyupgrade, black, and a dozen others. The settings nest under [tool.ruff] with sub-tables for lint and format.

toml
[tool.ruff]
line-length    = 100
target-version = "py310"
src            = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
ignore = ["E501"]                     # line-length handled by formatter

[tool.ruff.format]
quote-style = "double"

Output: (none — exits 0 on success)

[tool.mypy]

mypy reads [tool.mypy] and per-module overrides under [[tool.mypy.overrides]]. The strict = true shortcut enables every recommended check.

toml
[tool.mypy]
python_version   = "3.12"
strict           = true
warn_unused_configs = true
files            = ["src", "tests"]

[[tool.mypy.overrides]]
module                = ["legacy.*"]
ignore_errors         = true

[[tool.mypy.overrides]]
module                = ["yaml.*", "requests.*"]
ignore_missing_imports = true

Output: (none — exits 0 on success)

[tool.pytest.ini_options]

The pytest table is awkwardly named ini_options for historical reasons — when pytest reads pyproject.toml it expects everything under that key.

toml
[tool.pytest.ini_options]
minversion  = "8.0"
testpaths   = ["tests"]
addopts     = "-ra -q --strict-markers --cov=src"
markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "integration: marks integration tests",
]
filterwarnings = [
    "error",
    "ignore::DeprecationWarning:pkg_resources",
]

Output: (none — exits 0 on success)

[tool.coverage]

coverage.py reads [tool.coverage.run] and [tool.coverage.report]. Pair with pytest-cov's --cov flag.

toml
[tool.coverage.run]
branch = true
source = ["src"]

[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under   = 80
exclude_lines = [
    "pragma: no cover",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
]

Output: (none — exits 0 on success)

[tool.uv]

uv reads [project] for dependencies (PEP 621) and its own [tool.uv] for workspace, sources, and dev groups.

toml
[tool.uv]
dev-dependencies = ["pytest>=8", "ruff>=0.4", "mypy>=1.10"]

[tool.uv.sources]
mylib = { git = "https://github.com/alicedev/mylib.git", branch = "main" }

Output: (none — exits 0 on success)

[tool.hatch]

When using hatchling as a backend with the hatch CLI, [tool.hatch.envs.*] defines reproducible scripted environments.

toml
[tool.hatch.envs.default]
dependencies = ["pytest", "pytest-cov"]

[tool.hatch.envs.default.scripts]
test     = "pytest {args:tests}"
test-cov = "pytest --cov=src {args:tests}"
lint     = "ruff check src tests"

Output: (none — exits 0 on success)

[tool.black] (if still used)

Most teams have migrated to ruff format, but black is still common. Note line-length defaults to 88.

toml
[tool.black]
line-length    = 100
target-version = ["py310", "py311", "py312"]

Output: (none — exits 0 on success)

A complete example

A real-world pyproject for a small CLI tool published to PyPI, with optional dev/test groups, linting, type-checking, and test config.

toml
[project]
name            = "widgets-cli"
version         = "1.0.0"
description     = "Manage widgets from the command line"
readme          = "README.md"
requires-python = ">=3.10"
license         = { text = "MIT" }
authors         = [{ name = "Alice Dev", email = "alice@example.com" }]
keywords        = ["cli", "widgets"]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Programming Language :: Python :: 3 :: Only",
    "License :: OSI Approved :: MIT License",
]
dependencies = [
    "click>=8.1",
    "rich>=13",
    "httpx>=0.27",
    "pydantic>=2.7",
    "tomli>=2.0; python_version<'3.11'",
]

[project.optional-dependencies]
dev  = ["pytest>=8", "pytest-cov>=5", "ruff>=0.4", "mypy>=1.10"]
docs = ["sphinx>=7", "furo"]

[project.scripts]
widgets = "widgets_cli.__main__:main"

[project.urls]
Homepage      = "https://github.com/alicedev/widgets-cli"
Documentation = "https://widgets-cli.readthedocs.io"
Repository    = "https://github.com/alicedev/widgets-cli"
Issues        = "https://github.com/alicedev/widgets-cli/issues"

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

[tool.hatch.build.targets.wheel]
packages = ["src/widgets_cli"]

[tool.ruff]
line-length    = 100
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]

[tool.mypy]
python_version = "3.12"
strict         = true
files          = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts   = "-ra -q --cov=src"

[tool.coverage.report]
fail_under = 80

Output: (none — exits 0 on success)

Building a distribution

python -m build reads [build-system], installs the listed requirements into an isolated venv, then invokes the backend to produce a source distribution (sdist) and a built distribution (wheel).

bash
# Install the build frontend once
pip install build

# Produce dist/widgets_cli-1.0.0.tar.gz and dist/widgets_cli-1.0.0-py3-none-any.whl
python -m build

ls dist/

Output:

text
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Successfully built widgets_cli-1.0.0.tar.gz and widgets_cli-1.0.0-py3-none-any.whl

widgets_cli-1.0.0-py3-none-any.whl
widgets_cli-1.0.0.tar.gz

uv build and hatch build are drop-in replacements for python -m build with the same output structure but faster isolated-env setup.

Publishing to PyPI

twine upload uploads the built artifacts to PyPI (or TestPyPI). Authenticate with an API token stored in ~/.pypirc or the TWINE_PASSWORD environment variable.

bash
# Test against TestPyPI first
twine upload --repository testpypi dist/*

# Real upload
twine upload dist/*

Output:

text
Uploading distributions to https://upload.pypi.org/legacy/
Uploading widgets_cli-1.0.0-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 24.3/24.3 kB • 00:01 • ?
Uploading widgets_cli-1.0.0.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 18.7/18.7 kB • 00:01 • ?
View at: https://pypi.org/project/widgets-cli/1.0.0/

Once a version is uploaded to PyPI you cannot replace it — only yank it. Always test on TestPyPI first, and bump the version on every retry.

setup.py → pyproject.toml migration

The most common migration is from the classic setup.py (or setup.cfg) to a pyproject.toml-only project. Map fields as follows:

setup.pypyproject.toml
name=[project] name
version=[project] version or dynamic = ["version"]
description=[project] description
long_description=open("README.md").read()[project] readme = "README.md"
long_description_content_type=...infer from readme extension
author=, author_email=[project] authors = [{name, email}]
python_requires=">=3.10"[project] requires-python = ">=3.10"
install_requires=[...][project] dependencies = [...]
extras_require={...}[project.optional-dependencies]
entry_points={"console_scripts": [...]}[project.scripts]
packages=find_packages(...)[tool.setuptools.packages.find]
package_data={...}[tool.setuptools.package-data]
include_package_data=True[tool.setuptools] include-package-data = true
classifiers=[...][project] classifiers = [...]
keywords=[...][project] keywords = [...]
license=...[project] license = { text = "..." }

Before — setup.py

python
from setuptools import setup, find_packages

setup(
    name="my-tool",
    version="0.4.2",
    description="A delightful CLI for managing widgets",
    long_description=open("README.md").read(),
    long_description_content_type="text/markdown",
    author="Alice Dev",
    author_email="alice@example.com",
    python_requires=">=3.10",
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    install_requires=["click>=8.1", "rich>=13"],
    extras_require={"dev": ["pytest", "ruff"]},
    entry_points={"console_scripts": ["my-tool=my_tool.cli:main"]},
)

Output: (none — exits 0 on success)

After — pyproject.toml

toml
[project]
name            = "my-tool"
version         = "0.4.2"
description     = "A delightful CLI for managing widgets"
readme          = "README.md"
requires-python = ">=3.10"
authors         = [{ name = "Alice Dev", email = "alice@example.com" }]
dependencies    = ["click>=8.1", "rich>=13"]

[project.optional-dependencies]
dev = ["pytest", "ruff"]

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

[build-system]
requires      = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

Output: (none — exits 0 on success)

Reading pyproject.toml from Python

Python 3.11+ ships a tomllib module in the standard library — no third-party dep needed.

python
import sys
if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib

with open('pyproject.toml', 'rb') as f:
    data = tomllib.load(f)

print(data['project']['name'])
print(data['project']['dependencies'])

Output:

text
widgets-cli
['click>=8.1', 'rich>=13', 'httpx>=0.27', 'pydantic>=2.7', "tomli>=2.0; python_version<'3.11'"]

tomllib.load requires the file be opened in binary mode ('rb'), not text mode. Use tomllib.loads(string) if you already have the contents as a string.

Common pitfalls

  1. Missing [build-system] — without it, pip install . falls back to a legacy mode that may not respect your declared metadata. Always include it, even for trivial projects.
  2. Mixing [tool.poetry] and [project] — older poetry projects used [tool.poetry] exclusively. Poetry 2.0+ supports PEP 621 [project]. Don't define the same field in both — only one wins, depending on the version.
  3. Forgetting requires-python — without it, pip will let users install your package on incompatible interpreters and fail at runtime.
  4. Wrong build-backend import pathsetuptools.build_meta (with underscore), not setuptools.build-meta. The backend name follows Python module syntax.
  5. dynamic field also defined statically — if you list version in dynamic, you must NOT also write version = "..." in [project]. The build will fail.
  6. TOML escaping in strings\n inside a "..." string is a newline; use single-quoted '...' literal strings to disable escaping (e.g. Windows paths).
  7. Tool config in the wrong section[ruff] does nothing; it must be [tool.ruff]. Same for every other tool table.
  8. Comma in TOML arrays after the last item — trailing commas are allowed in arrays and inline tables. Missing commas between array elements are not — TOML errors are surfaced by python -m build long before pip touches it.
  9. license = "MIT" is not valid (license must be a table or use the SPDX-expression form license = "MIT" only after PEP 639 became standard in Python 3.13). For maximum compatibility use license = { text = "MIT" } or license = { file = "LICENSE" }.
  10. Editable installs with src-layoutpip install -e . requires either setuptools>=64 with [tool.setuptools.packages.find] configured, or hatchling/pdm-backend which handle src-layout natively.

Real-world recipes

Library with C extension

A package shipping a C extension module, built via setuptools.

toml
[project]
name            = "myfast"
version         = "0.1.0"
requires-python = ">=3.10"

[build-system]
requires      = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ["myfast"]

[tool.setuptools.package-data]
myfast = ["*.pyi", "py.typed"]

Output: (none — exits 0 on success)

Pair with a minimal setup.py for the extension itself:

python
from setuptools import Extension, setup

setup(
    ext_modules=[
        Extension(
            "myfast._core",
            sources=["src/myfast/_core.c"],
            extra_compile_args=["-O2"],
        ),
    ],
)

Output: (none — exits 0 on success)

Pure-Python library with flit-core

For a one-file or single-package pure-Python library, flit-core is the simplest backend on the market.

toml
[project]
name    = "tiny-tool"
authors = [{ name = "Alice Dev", email = "alice@example.com" }]
dynamic = ["version", "description"]

[build-system]
requires      = ["flit_core>=3.9"]
build-backend = "flit_core.buildapi"

Output: (none — exits 0 on success)

Add a one-line docstring and __version__ to your package and flit infers the metadata:

python
"""A tiny tool that does one thing well."""
__version__ = "0.1.0"

Output: (none — exits 0 on success)

Monorepo with workspaces (uv)

uv supports a workspace layout — one top-level pyproject.toml plus per-package pyproject.toml files sharing one lockfile.

toml
[project]
name    = "myapp"
version = "0.1.0"

[tool.uv.workspace]
members = ["packages/*"]

[tool.uv.sources]
shared = { workspace = true }

[project.dependencies]
shared = "*"

Output: (none — exits 0 on success)

Dynamic version from git tags

Read the version directly from your most recent git tag with setuptools-scm. The version field is left out of [project] and added to dynamic.

toml
[project]
name    = "my-tool"
dynamic = ["version"]

[build-system]
requires      = ["setuptools>=68", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"

[tool.setuptools_scm]
write_to = "src/my_tool/_version.py"

Output: (none — exits 0 on success)

After git tag v1.2.3, python -m build produces my_tool-1.2.3-py3-none-any.whl.

CLI plugin via entry-points

Expose a plugin discovery hook for third-party packages to register.

toml
[project.entry-points."widgets_cli.plugins"]
widget-stats = "my_plugin.stats:register"
widget-export = "my_plugin.export:register"

Output: (none — exits 0 on success)

Plugin discovery from Python (3.10+):

python
from importlib.metadata import entry_points

for ep in entry_points(group='widgets_cli.plugins'):
    handler = ep.load()
    handler()

Output:

text
registered: widget-stats
registered: widget-export

Single source of truth for tool configs

Consolidate ruff, mypy, pytest, coverage, and uv into one file — replacing .flake8, mypy.ini, pytest.ini, .coveragerc, and setup.cfg simultaneously.

toml
[tool.ruff]
line-length = 100

[tool.mypy]
strict = true

[tool.pytest.ini_options]
addopts = "-ra -q"

[tool.coverage.run]
source = ["src"]

[tool.uv]
dev-dependencies = ["pytest", "ruff", "mypy"]

Output: (none — exits 0 on success)

CI-ready build + publish workflow

A GitHub Actions snippet that builds on every tag push and publishes to PyPI via trusted publishing (no token in CI).

yaml
name: publish
on:
  push:
    tags: ['v*']

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write          # for PyPI trusted publishing
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install build
      - run: python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1

Output: (none — exits 0 on success)

See also

  • pip — installs from pyproject.toml via pip install .
  • uv — fastest tool to manage pyproject.toml projects in 2026
  • poetry — alternative project manager that owns its own pyproject.toml flavor
  • ruff, mypy, pytest — common [tool.*] consumers
  • PEP 621[project] metadata spec
  • PEP 517, PEP 518[build-system] spec