Python Packaging with pyproject.toml - Wheels, pip install, CLI Entry Points Tutorial #40
Video: Python Packaging with pyproject.toml - Wheels, pip install, CLI Entry Points Tutorial #40 by Taught by Celeste AI - AI Coding Coach
Python Packaging with pyproject.toml
pyproject.tomlis 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 buildcreates 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:
[build-system]— what builds your package.setuptoolsis the default; alternatives arehatchling,flit,poetry-core.[project]— metadata. Name, version, description, dependencies, etc.[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.