pyproject.toml is where a CLI stops being code on your machine and becomes something other people install. The [project] table decides what the tool is called on PyPI, which Python versions can install it, what gets pulled in alongside it, what command appears on the user's PATH, and what people see when they find it. Most fields are simple, but several have consequences that are easy to miss — an upper bound on Python that locks out the next release, an exact pin that conflicts with everything else in the user's environment, a development tool listed as a runtime dependency. This guide walks through a complete pyproject.toml for a CLI, field by field, explaining what each decision does for users. It belongs to the packaging Python CLIs for distribution topic.
Prerequisites
- A CLI in a
src/layout with a function or Typer/Click app to expose as a command. - A build backend — the examples use
hatchling; everything in[project]is standard (PEP 621) and works with any backend.
The anatomy of the [project] table
Every field falls into one of three groups: identity (what the package is), installability (who can install it and what comes with it), and discoverability (how people find it and decide to trust it). For a CLI, two fields decide whether it works at all — requires-python and [project.scripts] — and the rest decide whether people choose it and whether it coexists with everything else they have installed.
The recipe
[project]
name = "deploy-helper"
version = "2.4.0"
description = "Deploy static sites to object storage with previews and rollbacks"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Platform Team", email = "platform@example.com" }]
requires-python = ">=3.10"
keywords = ["deploy", "static-site", "s3", "cli"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Build Tools",
]
dependencies = [
"typer>=0.12",
"httpx>=0.27",
"rich>=13.7",
]
[project.optional-dependencies]
s3 = ["boto3>=1.34"]
gcs = ["google-cloud-storage>=2.16"]
[project.scripts]
deploy-helper = "deploy_helper.cli:app"
dh = "deploy_helper.cli:app"
[project.entry-points."deploy_helper.backends"]
local = "deploy_helper.backends.local:LocalBackend"
[project.urls]
Homepage = "https://github.com/acme/deploy-helper"
Documentation = "https://acme.github.io/deploy-helper/"
Changelog = "https://github.com/acme/deploy-helper/blob/main/CHANGELOG.md"
Issues = "https://github.com/acme/deploy-helper/issues"
[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10"]
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
Identity
name is the distribution name on PyPI and what users type in pipx install deploy-helper. It does not have to match the importable package (deploy_helper) or the command (deploy-helper, dh), but keeping them aligned saves confusion. Check PyPI for collisions before you commit to one; renaming later is painful.
description is the one line people see in search results and pip show. Lead with the verb — what the tool does — rather than "A CLI for...". readme becomes the PyPI project page; make sure it shows the command being run in its first screen.
version can be static, as here, or derived from git tags; see deriving versions from git tags with hatch-vcs. license uses an SPDX expression (PEP 639), and license-files includes the text in the distribution.
Installability
requires-python states the oldest supported Python. Installers use it to choose a compatible release: a user on Python 3.9 asking for deploy-helper gets the newest release that still supports 3.9, not a broken install. Keep it in sync with your CI matrix. Do not add an upper bound such as <3.14: it does not make your tool work on 3.14, it only stops people trying — including when your tool would have worked perfectly — and resolvers treat it in surprising ways.
dependencies should be ranges with lower bounds, not exact pins. typer>=0.12 means "we need features from 0.12"; typer==0.12.3 means "we conflict with every other tool that wants a different patch release". Your lockfile pins exact versions for development and CI; the published metadata should be as permissive as is true. Add an upper bound only for a known incompatibility, and document why.
[project.optional-dependencies] defines extras that users opt into: pipx install "deploy-helper[s3]". Use them for heavy, backend-specific dependencies most users do not need, and import those dependencies lazily so the base tool stays small, as in reducing CLI dependency weight. Development tools never belong here — they go in [dependency-groups], which are not published at all.
The command itself
[project.scripts] is what makes this a CLI. Each entry creates an executable on the user's PATH at install time — a small launcher that imports the named object and calls it. Two entries pointing at the same app give users a long name and a short alias. The object can be a Typer app, a Click group, or any callable; the conventions are covered in best practices for Python CLI entry points.
[project.entry-points."group"] installs no command; it publishes metadata that your CLI can discover at runtime. This is how plugins announce themselves, as described in discovering plugins with entry points. Declaring your own built-in backends through the same mechanism means built-ins and third-party plugins are loaded the same way.
Discoverability
Classifiers feed PyPI's filters. Environment :: Console marks a command-line tool. The Programming Language :: Python :: 3.x entries should match your tested versions. keywords help search, and [project.urls] populate the sidebar of the PyPI page — Changelog and Issues in particular are what users look for before upgrading.
UX considerations
Metadata is user experience that happens before the first run:
- Name the command for typing. Short, lowercase, hyphenated, unlikely to collide with system tools. Offer a short alias if the canonical name is long.
- Keep base installs light. Every runtime dependency is installed for every user and imported on every run you do not make lazy. Push optional backends into extras.
- Make the PyPI page useful. The README's first screen should show installation (
pipx install deploy-helper) and one real command with its output. People decide in seconds. - Tell installers the truth about Python support. A correct
requires-pythonmeans users on old interpreters get an older working release instead of a confusing failure.
Testing the behaviour
Metadata mistakes are cheap to catch before release:
uvx validate-pyproject pyproject.toml # schema check for [project] and friends
uv build # does it build?
uvx twine check dist/* # will PyPI render the README?
unzip -p dist/*.whl '*.dist-info/entry_points.txt'
And a test that the declared command actually exists and runs, using the metadata of the installed package rather than the source tree:
# tests/test_metadata.py
from importlib.metadata import distribution
def test_console_scripts_are_declared():
eps = distribution("deploy-helper").entry_points
scripts = {ep.name: ep.value for ep in eps if ep.group == "console_scripts"}
assert scripts == {"deploy-helper": "deploy_helper.cli:app", "dh": "deploy_helper.cli:app"}
for ep in eps:
if ep.group == "console_scripts":
assert callable(ep.load())
def test_requires_python_matches_classifiers():
meta = distribution("deploy-helper").metadata
assert meta["Requires-Python"] == ">=3.10"
assert "Programming Language :: Python :: 3.10" in meta.get_all("Classifier")
Loading each entry point catches typos in module paths — deploy_helper.cli:ap instead of :app — that otherwise surface only when a user types the command. The full artefact-level check is in smoke-testing the built wheel in CI.
Conclusion
A CLI's pyproject.toml decides who can install it, what comes with it, what command appears and how people find it. Use lower-bounded ranges rather than pins, set requires-python to your oldest tested version without an upper bound, put optional backends in extras and tooling in dependency groups, declare commands and plugin entry points under [project.scripts] and [project.entry-points], and fill in the description, classifiers and URLs that make the PyPI page useful. Validate it, build it, and load the entry points in a test before every release.
Frequently asked questions
Should the command name match the package name?
It is convenient but not required. Choose the command for typing (dh) and the distribution name for searching (deploy-helper); many tools have both, via two script entries.
Do I need setup.py or setup.cfg?
No. Every modern backend reads [project] from pyproject.toml. A setup.py is only needed for unusual custom build steps, which most CLIs do not have.
How do I include non-Python files like templates?
Put them inside the package directory and read them with importlib.resources; most backends include package files by default. Details and backend differences are in bundling data files with importlib.resources.
Can I read the metadata from inside the CLI?
Yes, and it is the right way to avoid duplicating information. importlib.metadata.metadata("deploy-helper") returns the installed metadata, so --version can read Version, an about command can print the Summary and Project-URL entries, and an error message can point users at the Issues URL — all from the single source of truth in pyproject.toml. The runtime side is covered in exposing version info and build metadata.
What is license-files for, and do I need it?
It lists license text files to include in the distribution's metadata directory, alongside the SPDX license expression. Include it: many organisations' compliance tooling reads license files from installed packages.