Python packaging has evolved away from legacy setup.py scripts toward a standardized, declarative model governed by PEP 517 and PEP 621. If you're building reusable libraries or command-line tools, configuring your project correctly with a pyproject.toml file ensures seamless distribution on the Python Package Index (PyPI).
This guide breaks down the modern workflow for packaging Python projects cleanly, avoiding deprecated patterns, pinning dependencies accurately, and publishing your code without friction.
Understanding the Modern Packaging Stack
In the past, packaging relied on direct script executions that bundled custom build logic into every repository. Today, the ecosystem splits responsibilities into distinct layers:
- Build Backends: Libraries like Hatchling, Flit-core, or Setuptools parse your configuration and compile your code into distribution files.
- Build Frontends: Tools like build read your backend choice and generate the actual distribution files.
- Distribution Formats: Source distributions (sdists) and built wheels (wheels) replace arbitrary archive formats with standardized zip-based structures.
Writing a clean configuration means keeping metadata declarative inside pyproject.toml rather than writing imperative setup scripts.
Structuring Your Project Layout
A clean repository layout separates source code from tests, build configurations, and documentation. Here is a standard directory structure for a modern pure-Python project:
my_project/
├── .gitignore
├── README.md
├── LICENSE
├── pyproject.toml
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── core.py
└── tests/
├── __init__.py
└── test_core.py
Placing your package inside a src/ directory prevents accidental imports of local code during testing, forcing you to install your package in editable mode or rely on your test runner's path configuration.
Writing the pyproject.toml File
The core of modern packaging lives in pyproject.toml. It defines your build system requirements and metadata according to standard specifications. Here is a production-ready configuration using Hatchling as the build backend:
[build-system]
requires = ["hatchling>=1.18.0"]
build-backend = "hatchling.build"
[project]
name = "my-package-name"
version = "0.1.0"
description = "A concise, clear summary of what your package does."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [
{ name = "Jane Doe", email = "jane@example.com" }
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"requests>=2.31.0",
]
[project.urls]
Homepage = "https://github.com/example/my-package-name"
Repository = "https://github.com/example/my-package-name.git"
Make sure your package name is unique on PyPI before publishing, as name collisions will reject your upload.
Building and Verifying Artifacts
Never run raw setup scripts. Instead, use the isolated build frontend to generate your distribution packages. First, install your build tools inside an active virtual environment:
python -m pip install --upgrade build twine
Run the build command from the root directory where your pyproject.toml resides:
python -m wheel
python -m build
This command generates two files inside a new dist/ directory:
- An sdist (
.tar.gz), which contains your source code and build instructions. - A wheel (
.whl), which is a pre-built distribution ready for fast installation.
Check your distribution files for metadata issues before uploading by running:
python -m twine check dist/*
Publishing to PyPI Securely
Manually typing username and password combinations for PyPI is deprecated. Modern publishing relies on API tokens or Trusted Publishing via continuous integration pipelines like GitHub Actions.
To upload manually using a token:
- Generate an API token from your PyPI account settings.
- Run Twine, passing
__token__as your username:
python -m twine upload dist/*
When prompted for your password, paste your full token string (including the pypi- prefix). For testing your packaging workflow without polluting the main index, repeat the process using TestPyPI.
Common Mistakes to Avoid
- Invoking setup.py directly: Never run
python setup.py installorpython setup.py sdist. These commands invoke deprecated pathways and fail on modern PEP 517 backends. - Hardcoding dependency versions too strictly: Avoid pinning exact patch versions in library dependencies unless debugging a specific conflict. Use lower bounds (e.g.,
requests>=2.31.0) to prevent installation blocks for downstream users. - Skipping the README formatting check: Ensure your
README.mduses standard Markdown and renders correctly, because PyPI displays this file directly on your project page.
Frequently Asked Questions
Why should I use pyproject.toml instead of setup.py?
The pyproject.toml file standardizes project configuration across different build backends. It decouples the build system requirements from the build execution tool, making builds reproducible and secure.
How do I handle command-line scripts in my package?
You can expose executable scripts by adding a entry point block inside your pyproject.toml file under [project.scripts]:
[project.scripts]
my-cli = "my_package.cli:main"
When users install your package, the installer automatically generates an executable wrapper pointing to your function.
What is the difference between an sdist and a wheel?
A source distribution (.tar.gz) contains raw source files and requires local compilation if the package includes C extensions. A wheel (.whl) is a built archive that installs instantly without running a setup compilation step on the end user's machine.
Can I use dynamic versioning from Git tags?
Yes. Modern backends like Hatchling support dynamic versioning plugins that read your version straight from Git tags or package initialization files, removing the need to update version strings manually in multiple places.