LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Python Packaging and Distribution: pyproject.toml, uv, Wheels, PyPI, and Vendoring

pythonpackagingpypiuvwheelsdevtools

Python packaging has a reputation for being confusing, and historically that reputation was earned. setup.py, setup.cfg, requirements.txt, Pipfile, poetry.lock, MANIFEST.in — a graveyard of standards each solving one problem while creating two more. The good news: the ecosystem has largely converged. pyproject.toml is the standard configuration file. uv has emerged as the fastest and most ergonomic tool for the full workflow. This guide covers modern Python packaging from development environment to published package.

The Modern Packaging Stack

Before getting into details, here’s the current recommended stack:

Concern Tool
Project config pyproject.toml (PEP 517/518/621)
Package manager uv (Astral)
Build backend hatchling or flit-core (for pure Python), maturin (for Rust extensions)
Publishing uv publish or twine
Environments uv venv or uv run

You don’t need setup.py anymore. You don’t need requirements.txt for managed projects (though it remains useful for deployment snapshots). You don’t need separate tools for virtual environments, dependency resolution, and building.

pyproject.toml: The Single Config File

pyproject.toml (PEP 517/518/621) is the modern replacement for setup.py, setup.cfg, and most of tox.ini. Everything about your project lives here.

Minimal Package

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mypackage"
version = "0.1.0"
description = "A short description of my package"
readme = "README.md"
license = { file = "LICENSE" }
authors = [
    { name = "Jane Smith", email = "jane@example.com" },
]
requires-python = ">=3.11"
dependencies = [
    "httpx>=0.27",
    "pydantic>=2.0",
]

[project.urls]
Homepage = "https://github.com/example/mypackage"
Documentation = "https://mypackage.readthedocs.io"
"Bug Tracker" = "https://github.com/example/mypackage/issues"

Optional Dependencies and Extras

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[project.optional-dependencies]
# Install with: pip install mypackage[dev]
dev = [
    "pytest>=8.0",
    "pytest-cov>=5.0",
    "ruff>=0.4",
    "mypy>=1.10",
]
# Install with: pip install mypackage[docs]
docs = [
    "mkdocs>=1.6",
    "mkdocs-material>=9.5",
]
# Install with: pip install mypackage[all]
all = [
    "mypackage[dev]",
    "mypackage[docs]",
]

Entry Points and Scripts

1
2
3
4
5
6
7
8
9
# Creates a `mypackage` command in the PATH when installed
[project.scripts]
mypackage = "mypackage.cli:main"
mypackage-server = "mypackage.server:run"

# Plugin entry points (other packages can discover these)
[project.entry-points."mypackage.plugins"]
json = "mypackage.plugins.json:JsonPlugin"
csv  = "mypackage.plugins.csv:CsvPlugin"

Tool Configuration in pyproject.toml

All your tool config in one place — no more scattered .cfg, .ini, and .rc files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
markers = [
    "slow: marks tests as slow",
    "integration: marks tests requiring external services",
]

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

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]   # Allow assert in tests

[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "third_party_without_stubs.*"
ignore_missing_imports = true

[tool.coverage.run]
source = ["mypackage"]
omit = ["tests/*", "**/__init__.py"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
fail_under = 85

Hatch: Environments and Versioning

If you use hatchling as the build backend, you get powerful environment management and version management for free:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[tool.hatch.version]
# Automatically read version from git tags
source = "vcs"

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

[tool.hatch.envs.default.scripts]
test = "pytest {args}"
test-cov = "pytest --cov=mypackage {args}"
lint = ["ruff check .", "mypy src/"]
fmt = "ruff format ."

[tool.hatch.envs.docs]
dependencies = ["mkdocs", "mkdocs-material"]

[tool.hatch.envs.docs.scripts]
build = "mkdocs build"
serve = "mkdocs serve"

uv: The Fastest Python Package Manager

uv (from Astral, the makers of ruff) is a Rust-based Python package manager that is 10-100x faster than pip and handles the full workflow: virtual environments, dependency resolution, locking, building, and publishing.

Installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"

# Or via pip (bootstrapping)
pip install uv

# Verify
uv --version

Project Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Create a new project
uv init mypackage
cd mypackage

# Tree:
# mypackage/
# ├── pyproject.toml
# ├── src/
# │   └── mypackage/
# │       └── __init__.py
# ├── tests/
# └── README.md

# Add dependencies
uv add httpx pydantic
uv add --dev pytest ruff mypy pytest-cov

# Remove a dependency
uv remove requests

# Sync the environment (install all deps from lockfile)
uv sync

# Run a command in the project environment
uv run pytest
uv run python -m mypackage
uv run mypackage   # If entry point is defined

# Run a script without creating a project
uv run script.py

# Show dependency tree
uv tree

The uv.lock File

uv generates a uv.lock file — a complete, reproducible snapshot of all transitive dependencies with hashes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# uv.lock (excerpt)
version = 1
requires-python = ">=3.11"

[[package]]
name = "httpx"
version = "0.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "anyio" },
    { name = "certifi" },
    { name = "httpcore" },
    { name = "idna" },
    { name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/...", hash = "sha256:..." }
wheels = [
    { url = "https://files.pythonhosted.org/...", hash = "sha256:...", filename = "httpx-0.27.0-py3-none-any.whl" },
]

Commit uv.lock to version control. It guarantees every developer and every CI run uses identical package versions, preventing “works on my machine” dependency issues.

Managing Python Versions

uv also manages Python installations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Install specific Python versions
uv python install 3.11 3.12 3.13

# List available versions
uv python list

# Pin a project to a specific version
uv python pin 3.12

# Creates .python-version file
cat .python-version
# 3.12

# Run with a specific version
uv run --python 3.11 pytest

Running Tools Without Installing

1
2
3
4
5
6
7
# Run a tool directly without installing into your project
uvx ruff check .
uvx black --check .
uvx mypy src/

# Equivalent to pipx but faster
uvx httpie GET https://httpbin.org/get

Project Structure

The src layout is strongly recommended — it prevents accidental imports of the uninstalled package:

mypackage/
├── pyproject.toml
├── uv.lock
├── README.md
├── LICENSE
├── CHANGELOG.md
├── src/
│   └── mypackage/
│       ├── __init__.py
│       ├── py.typed            ← Marks package as typed (PEP 561)
│       ├── cli.py
│       ├── core.py
│       └── _internal/         ← Private submodule (convention)
│           └── utils.py
└── tests/
    ├── conftest.py
    ├── test_core.py
    └── test_cli.py
1
2
3
4
5
6
# src/mypackage/__init__.py
# Export the public API explicitly
from mypackage.core import Client, Config
from mypackage._version import __version__

__all__ = ["Client", "Config", "__version__"]
1
2
3
4
# src/mypackage/_version.py
# Single source of truth for version
# With hatchling + vcs, this is auto-generated from git tags
__version__ = "0.1.0"

Building Wheels

A wheel (.whl) is a pre-built distribution — a zip file containing the installed package. Installing a wheel is instant; installing from source requires building. Always publish wheels.

Wheel Types

Wheel type Filename pattern When to use
Pure Python mypackage-1.0-py3-none-any.whl No compiled extensions
CPython ABI mypackage-1.0-cp311-cp311-linux_x86_64.whl C extensions, specific CPython version
ABI3 (stable) mypackage-1.0-cp311-abi3-linux_x86_64.whl C extensions, Python 3.x+
Platform mypackage-1.0-cp311-cp311-manylinux_2_28_x86_64.whl Linux binary compatibility

For pure Python packages, one wheel works everywhere. For packages with C extensions (or Rust via PyO3/maturin), you need a wheel per platform.

Building with uv

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Build both sdist (source) and wheel
uv build

# Output:
# dist/
# ├── mypackage-0.1.0.tar.gz      ← sdist
# └── mypackage-0.1.0-py3-none-any.whl  ← wheel

# Build only wheel
uv build --wheel

# Build only sdist
uv build --sdist

# Verify the wheel contents
python -m zipfile -l dist/mypackage-0.1.0-py3-none-any.whl

Building C Extensions (manylinux)

For packages with compiled extensions that need to run on Linux, you need manylinux wheels — built inside a Docker container with an old glibc to maximize compatibility:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Build manylinux wheels using cibuildwheel (industry standard)
pip install cibuildwheel

# Build for all Python versions and platforms locally
cibuildwheel --platform linux

# Configuration in pyproject.toml
[tool.cibuildwheel]
build = "cp311-* cp312-* cp313-*"   # Python versions to build for
test-command = "pytest {project}/tests"
test-requires = ["pytest"]

[tool.cibuildwheel.linux]
archs = ["x86_64", "aarch64"]
manyapi = "manylinux_2_28"

[tool.cibuildwheel.macos]
archs = ["x86_64", "arm64"]

Building Rust Extensions with Maturin

If your package wraps Rust code using PyO3:

1
2
3
4
5
6
7
8
# pyproject.toml for a mixed Python/Rust package
[build-system]
requires = ["maturin>=1.6"]
build-backend = "maturin"

[tool.maturin]
features = ["pyo3/extension-module"]
python-source = "python"   # Python code lives in python/ directory
1
2
3
4
5
6
7
8
9
# Development: build and install in editable mode
maturin develop

# Build release wheels
maturin build --release

# Build manylinux wheels via Docker
maturin build --release --target x86_64-unknown-linux-gnu \
  --manylinux 2_28

Publishing to PyPI

Set Up PyPI Credentials

1
2
3
4
5
6
7
8
9
# Create an account at pypi.org and testpypi.org
# Generate an API token at https://pypi.org/manage/account/token/

# Store the token (uv reads from ~/.netrc or PYPI_TOKEN env var)
export UV_PUBLISH_TOKEN=pypi-AgEIcHlwaS5vcmcC...

# Or configure in ~/.config/uv/uv.toml
[publish]
token = "pypi-AgEIcHlwaS5vcmcC..."

Publishing Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 1. Bump version (update pyproject.toml or git tag if using vcs versioning)
# With hatch vcs: just create a git tag
git tag v0.2.0
git push origin v0.2.0

# 2. Build
uv build

# 3. Test on TestPyPI first (always)
uv publish --publish-url https://test.pypi.org/legacy/

# 4. Verify the TestPyPI release installs correctly
uv run --with "mypackage --index-url https://test.pypi.org/simple/" \
  python -c "import mypackage; print(mypackage.__version__)"

# 5. Publish to real PyPI
uv publish

# Or with twine (the traditional tool)
twine upload dist/*

Automated Publishing with GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# .github/workflows/publish.yml
name: Publish to PyPI

on:
  push:
    tags: ["v*"]    # Trigger on version tags

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0    # Fetch full history for vcs versioning

      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true

      - name: Build
        run: uv build

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish-testpypi:
    needs: build
    runs-on: ubuntu-latest
    environment: testpypi
    permissions:
      id-token: write    # Required for OIDC trusted publishing
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - uses: astral-sh/setup-uv@v3

      - name: Publish to TestPyPI
        run: uv publish --publish-url https://test.pypi.org/legacy/
        env:
          UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }}

  publish-pypi:
    needs: publish-testpypi
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/mypackage
    permissions:
      id-token: write    # Trusted publishing — no token needed!
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - uses: astral-sh/setup-uv@v3

      - name: Publish to PyPI
        run: uv publish
        # Trusted publishing: PyPI verifies this is github.com/yourorg/mypackage
        # No PYPI_TOKEN needed — OIDC auth via id-token permission

Trusted Publishing (OIDC) is the recommended approach — PyPI verifies the GitHub Actions workflow identity directly via OIDC, so no tokens are stored in GitHub Secrets. Set it up at pypi.org/manage/project/mypackage/settings/publishing/.

Vendoring Dependencies

Vendoring means bundling dependency source code directly in your repository. This trades disk space for reproducibility — no network, no registry, no “package disappeared” surprises.

When to Vendor

  • CLI tools distributed as single files: tools like pip, aws-cli, and black vendor their dependencies so they work without installing anything
  • Offline/air-gapped environments: no PyPI access
  • Security-critical deployments: you want to audit every line of code that runs
  • Preventing supply chain attacks: vendored deps can’t be silently updated

Vendoring with uv

1
2
3
4
5
6
7
8
# Export pinned requirements
uv export --no-dev --format requirements-txt > requirements.txt

# Download all packages into a vendor directory
pip download -r requirements.txt -d vendor/wheels/ --no-deps

# Install from vendor directory (no network)
pip install --no-index --find-links vendor/wheels/ -r requirements.txt

Vendoring Source Code Directly

For stricter vendoring where you want the source in your repo:

1
2
3
4
5
6
7
# Use pip's vendor technique (used by pip itself)
pip install --target vendor/ httpx pydantic --no-deps

# Then adjust sys.path in your entry point
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'vendor'))

Single-File Distribution with zipapp

For CLI tools that should be a single file with no installation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Create a self-contained .pyz file
mkdir -p build/myapp
cp -r src/mypackage build/myapp/

# Install dependencies into the archive
pip install httpx pydantic --target build/myapp/ --no-deps

# Create the zipapp (single executable file)
python -m zipapp build/myapp \
  --main "mypackage.cli:main" \
  --output myapp.pyz \
  --python "/usr/bin/env python3"

# Run it — no installation needed
chmod +x myapp.pyz
./myapp.pyz --help

# Distribute as a single file
scp myapp.pyz user@server:/usr/local/bin/myapp

Dependency Pinning Strategies

Application vs Library

The strategy differs depending on what you’re building:

Library (published to PyPI):

  • Keep dependencies in pyproject.toml as loose constraints: "httpx>=0.27,<1"
  • Let users pick compatible versions
  • Commit uv.lock for testing, but don’t require users to use it

Application (deployed service, CLI tool):

  • Lock all deps to exact versions
  • Commit uv.lock — it’s your deployment contract
  • Generate requirements.txt from the lockfile for deployment systems that need it
1
2
3
4
5
6
7
8
# Generate requirements.txt from uv.lock for Docker builds
uv export --frozen --no-dev > requirements.txt

# Dockerfile using the frozen requirements
FROM python:3.12-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/

Updating Dependencies

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Update all dependencies to latest compatible versions
uv lock --upgrade

# Update a specific package
uv lock --upgrade-package httpx

# Check for outdated packages
uv tree --outdated

# See what changed
git diff uv.lock

Common Packaging Patterns

Namespace Packages

For large organizations splitting a project across multiple packages:

# acme-core package
acme/
  core/
    __init__.py   ← namespace package (no __init__ in acme/)

# acme-auth package
acme/
  auth/
    __init__.py   ← namespace package
1
2
3
4
5
6
# acme-core/pyproject.toml
[project]
name = "acme-core"

[tool.hatch.build.targets.wheel]
packages = ["src/acme/core"]
1
2
3
# Users install both and import from the same namespace
from acme.core import Client
from acme.auth import OAuth2

Conditional Dependencies

1
2
3
4
5
6
7
8
9
[project]
dependencies = [
    # Use ujson on CPython for speed, json on PyPy (ujson doesn't support PyPy)
    "ujson>=5; implementation_name=='cpython'",
    # Windows-specific
    "pywin32>=305; sys_platform=='win32'",
    # Python version specific
    "tomllib>=1.0; python_version<'3.11'",   # Built into 3.11+
]

Data Files and Package Resources

1
2
3
4
5
6
[tool.hatch.build.targets.wheel]
# Include non-Python files in the wheel
artifacts = [
    "src/mypackage/data/*.json",
    "src/mypackage/templates/**/*.html",
]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Access bundled data files correctly (works after installation too)
from importlib import resources

# Python 3.9+
data = resources.files("mypackage.data").joinpath("config.json").read_text()

# Or with pathlib
import importlib.resources as pkg_resources
with pkg_resources.path("mypackage", "data") as data_dir:
    config_path = data_dir / "config.json"

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Project setup
uv init mypackage              # New project
uv add httpx pydantic          # Add runtime dep
uv add --dev pytest ruff       # Add dev dep
uv sync                        # Install all deps from lockfile
uv run pytest                  # Run in project env

# Python management
uv python install 3.12         # Install Python
uv python pin 3.12             # Pin project to version

# Build and publish
uv build                       # Build sdist + wheel → dist/
uv publish --publish-url https://test.pypi.org/legacy/   # TestPyPI
uv publish                     # PyPI

# Lockfile
uv lock                        # Regenerate lockfile
uv lock --upgrade              # Update all to latest
uv export --no-dev > requirements.txt   # Export for pip

# Run tools without installing
uvx ruff check .
uvx mypy src/
uvx pytest

# Show dependency tree
uv tree
uv tree --outdated

# pyproject.toml minimal required sections
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mypackage"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []

Python packaging in 2026 is in the best shape it has ever been. pyproject.toml gives you one config file. uv gives you a single tool that’s faster than any predecessor. The src layout prevents a whole category of import bugs. Trusted publishing eliminates token management for PyPI releases. If you’re still using setup.py, pip-tools, and virtualenv separately — the migration to this stack is straightforward and worth every minute.

Comments