Part of Python for Beginners

Python Packaging with pyproject.toml - Wheels, pip install, CLI Entry Points Tutorial #40

Sandy LaneSandy Lane

Video: Python Packaging with pyproject.toml - Wheels, pip install, CLI Entry Points Tutorial #40 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python Packaging with pyproject.toml

pyproject.toml is the modern Python project metadata file (PEP 621). One config for build system, dependencies, scripts, tooling. pip install -e . installs your project in editable mode. pip install build && python -m build creates wheels for distribution.

A "package" in Python distribution terms is what you pip install. Turning your code into one is mostly configuration.

The minimal pyproject.toml

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

[project]
name = "mathtools"
version = "0.1.0"
description = "Simple calculator toolkit"
requires-python = ">=3.8"
authors = [{name = "Your Name"}]

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

[project.scripts]
mathtools = "mathtools.cli:main"

Three sections that matter:

  1. [build-system] — what builds your package. setuptools is the default; alternatives are hatchling, flit, poetry-core.
  2. [project] — metadata. Name, version, description, dependencies, etc.
  3. [project.scripts] — command-line entry points.

The standard "src" layout

mathtools/
  pyproject.toml
  README.md
  src/
    mathtools/
      __init__.py
      calculator.py
      cli.py
  tests/
    test_calculator.py

Why src/? It prevents accidentally importing from the source tree before the package is installed. Tests run against the installed copy, mirroring real usage.

Installing in editable mode

pip install -e .

-e (editable) makes a "development install" — the package is linked, not copied. Edit a file, see the change immediately.

After install:

mathtools add 3 5
# Result: 8

python -c "from mathtools.calculator import add; print(add(3, 5))"
# 8

The [project.scripts] entry created the mathtools command. The package is also importable like any pip-installed library.

Dependencies

[project]
dependencies = [
  "requests>=2.30",
  "click>=8.0",
  "rich",
]

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]
docs = ["mkdocs", "mkdocs-material"]

Direct dependencies under dependencies. Install:

pip install -e .            # only base deps
pip install -e ".[dev]"     # base + dev
pip install -e ".[dev,docs]"   # multiple groups

Version constraints

"requests>=2.30"          # at least 2.30
"requests>=2.30,<3.0"     # 2.x.x where x >= 30
"requests~=2.30.0"        # 2.30.x
"requests==2.32.5"        # exact (rarely a good idea)

For libraries, prefer ranges. Pinning exact versions makes your library hard to combine with others.

For applications (where you want reproducibility), pin everything via a separate requirements.txt or a lockfile.

Building distributions

pip install build
python -m build

Output:

dist/
  mathtools-0.1.0-py3-none-any.whl
  mathtools-0.1.0.tar.gz
  • .whl (wheel) — the modern format. Pre-built; pip installs it directly. Fast.
  • .tar.gz (sdist) — source distribution. Pip builds it on the user's machine.

Always upload both. Pip prefers wheels.

Publishing to PyPI

pip install twine
twine upload dist/*

You'll need a PyPI account. For testing, use TestPyPI:

twine upload --repository testpypi dist/*
pip install -i https://test.pypi.org/simple/ mathtools

For private packages: use a private PyPI server (Devpi, Cloudsmith, Artifactory) or distribute as a Git URL.

More project metadata

[project]
name = "mathtools"
version = "0.1.0"
description = "Simple calculator toolkit"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.8"
authors = [
  {name = "Alice", email = "alice@example.com"}
]
keywords = ["math", "calculator"]
classifiers = [
  "Development Status :: 4 - Beta",
  "Programming Language :: Python :: 3",
  "License :: OSI Approved :: MIT License",
]

[project.urls]
Homepage = "https://github.com/you/mathtools"
Documentation = "https://mathtools.readthedocs.io"
Issues = "https://github.com/you/mathtools/issues"

These show up on PyPI. Worth filling in before publishing.

Multiple entry points

[project.scripts]
mathtools = "mathtools.cli:main"
calc = "mathtools.cli:calc"

[project.gui-scripts]
mathtools-gui = "mathtools.gui:main"

Each maps a CLI command to a Python function (module:function). After install, all of these become commands in your $PATH.

Including non-Python files

[tool.setuptools.package-data]
mathtools = ["data/*.json", "templates/*.html"]

Or via a MANIFEST.in file (older style).

For files outside the package (e.g., LICENSE), they're handled automatically.

Tool configuration

pyproject.toml is also where many Python tools store their config:

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"

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

[tool.mypy]
strict = true

[tool.black]
line-length = 100

One file for all tooling — replaces setup.cfg, pytest.ini, .pylintrc, .flake8, etc.

Build backends

setuptools is the default, but you have choices:

# setuptools (most flexible, most common)
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

# Hatchling (modern, simpler)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# Flit (very simple, single-package)
[build-system]
requires = ["flit_core"]
build-backend = "flit_core.buildapi"

# Poetry
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

For new projects, try hatchling — clean, modern, no surprises. Stick with setuptools if you have complex build needs (C extensions, custom hooks).

Versioning

Three common approaches:

  • Hardcoded in pyproject.toml. Edit by hand. Simple.
  • Single source of truth in __init__.py. Read by the build system.
  • Setuptools-scm (or hatch-vcs). Derive version from git tags. No manual editing.
[tool.setuptools.dynamic]
version = {attr = "mathtools.__version__"}
# src/mathtools/__init__.py
__version__ = "0.1.0"

Or for git-driven:

[build-system]
requires = ["setuptools>=64", "setuptools-scm>=8"]

[tool.setuptools_scm]
version_file = "src/mathtools/_version.py"

The version comes from git describe. Tag a release (git tag v1.0.0), version is automatic.

Testing your install

# Build
python -m build

# Install in a fresh venv
python -m venv test-venv
source test-venv/bin/activate
pip install dist/mathtools-0.1.0-py3-none-any.whl

# Test
mathtools add 3 5
python -c "from mathtools import add; print(add(2, 2))"

A clean venv catches dependency issues that an editable install in your dev venv hides.

Dependency groups (PEP 735, Python 3.13+)

[dependency-groups]
test = ["pytest", "pytest-cov"]
lint = ["ruff", "mypy"]
pip install --group test

Cleaner than [project.optional-dependencies] for things that aren't real "extras." New as of 2024 — wider tooling support coming.

Modern alternatives

  • Poetry — handles deps + build + publish + lockfile. Strong opinions.
  • Hatch — similar; cleaner with PEP 621.
  • uv — Rust-based, very fast. Builds, installs, runs.
  • Rye — workflow tool around the modern stack.

For most beginner projects, plain pyproject.toml + setuptools (or hatchling) is enough.

A complete release flow

# 1. Bump version (edit pyproject.toml or tag)
git tag v0.2.0

# 2. Run tests
pytest

# 3. Build
python -m build

# 4. Test install in clean venv
python -m venv .test-venv
.test-venv/bin/pip install dist/*.whl
.test-venv/bin/mathtools --help

# 5. Upload
twine upload dist/*

# 6. Push tag
git push origin v0.2.0

For real projects, automate this in CI (GitHub Actions, etc.).

Common stumbles

Old setup.py. Pre-2021 projects used setup.py and setup.cfg. Both still work but pyproject.toml is the modern path. New projects should skip setup.py entirely.

pip install not finding package. Your pyproject.toml is missing [tool.setuptools.packages.find] or your code isn't where setuptools expects.

Editable install fails for src layout. Need pip>=21.3 and modern setuptools (>=61). Upgrade.

Wheel for one Python version on another. Pure Python wheels are py3-none-any — work everywhere. C extensions need per-platform wheels.

Forgetting to bump version. Uploading to PyPI with the same version fails. Always bump.

Including secrets in build artifacts. MANIFEST.in and src layout help — but check dist/*.whl (it's just a zip) before publishing.

Publishing private code to PyPI. PyPI is public. For private code: GitHub Packages, AWS CodeArtifact, or simply pip install git+https://....

What's next

Lesson 41: PEP 8, Black, Ruff, mypy. Code style and quality tooling.

Recap

pyproject.toml is the single config file: build system, project metadata, dependencies, scripts, tool configs. pip install -e . for editable install. python -m build creates wheels and source dists. twine upload publishes to PyPI. Use src/ layout. Pin direct dependencies with ranges; let pip resolve transitive ones. Try hatchling for new projects; setuptools for compatibility.

Next lesson: PEP 8, Black, Ruff, mypy.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.