# Python Package Publishing

> Builds, verifies, and publishes Python packages with hatchling, hatch-vcs dynamic versioning, twine checks, test pypi, and GitHub Actions release CI/CD.

- Skill: `paulpas/python-package-publishing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add paulpas/python-package-publishing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/paulpas/python-package-publishing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: paulpas (https://skillmd.com/u/paulpas)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/paulpas/python-package-publishing

---





# Python Package Publishing Engine

Builds, verifies, and publishes production-ready Python packages using hatchling as the build backend, hatch-vcs for git-tag-driven dynamic versioning, twine for pre-upload verification, and GitHub Actions for automated release CI/CD. Handles wheel and sdist distribution creation, CLI entry points, optional dependency groups, and native extension builds.

## TL;DR Checklist

- [ ] Set `[build-system]` to `hatchling` with `requires-python >= 3.10`
- [ ] Configure `hatch-vcs` for git-tag-driven dynamic versioning — never hardcode versions
- [ ] Run `python -m build` to produce both `.whl` and `.tar.gz` artifacts
- [ ] Verify every artifact with `twine check dist/*` before uploading
- [ ] Publish to TestPyPI first with `twine upload --repository testpypi dist/*`, then PyPI
- [ ] Define CLI entry points under `[project.scripts]` for console commands
- [ ] Group optional dependencies under `[project.optional-dependencies]`
- [ ] Configure native extension build in `hatch_build.py` when C/C++/Rust extensions are needed

---

## When to Use

- Packaging a Python library or application for distribution on PyPI
- Setting up a CI/CD release pipeline that builds and publishes on git tags
- Adding CLI entry points so users can run your package as a command-line tool
- Organizing optional dependencies (e.g., `pip install mypkg[dev,ml,gpu]`)
- Building Python packages that include native extensions (C/C++/Rust)
- Migrating from `setup.py` / `setuptools` to modern `pyproject.toml` + hatchling

---

## When NOT to Use

- Internal-only tools not meant for public or private PyPI distribution — use direct `pip install -e .` in development
- Simple one-off scripts that don't need versioning, entry points, or dependency management
- Projects already using Poetry, PDM, or uv as their primary toolchain — those have their own packaging flows. This skill is specifically for hatchling-based workflows.

---

## Core Workflow

1. **Define pyproject.toml Metadata** — Configure project name, description, authors, license, Python version constraint, dependencies, and optional dependency groups under `[project]`. Declare the build system using `[build-system]` with `hatchling`.
   **Checkpoint:** Every required field (`name`, `version` or `dynamic = ["version"]`, `description`, `readme`, `requires-python`, `dependencies`) is present. No `setup.py` file exists.

2. **Configure Dynamic Versioning with hatch-vcs** — If using git-tag-driven versions, set `dynamic = ["version"]` under `[project]` and configure `[tool.hatch.version.source = "vcs"]`. Tag releases with `v1.2.3` format; the build system derives the version from the nearest annotated tag.
   **Checkpoint:** The nearest ancestor tag follows PEP 440-compliant version format (e.g., `v1.2.3`). No hardcoded version string exists in source files.

3. **Set Up CLI Entry Points** — Define console scripts under `[project.scripts]`. Each entry maps a command name to a module path: function pair. For GUI or package-level execution, also add `[project.scripts]` and `src/package/__main__.py`.
   **Checkpoint:** Every command listed under `[project.scripts]` resolves to an existing callable at runtime. Test with `pip install -e .` and run the command.

4. **Add Optional Dependency Groups** — Declare feature-specific dependencies under `[project.optional-dependencies]`. Each group key becomes a pip extras specifier: `pip install mypackage[dev,test]`. Name groups by their use case (`dev`, `test`, `docs`, `ml`, `gpu`).
   **Checkpoint:** No dependency appears in both `dependencies` and an optional group — that creates installation ambiguity.

5. **Configure Native Extension Builds** — If your package includes C/C++/Rust extensions, create a `hatch_build.py` file in the project root. Hatchling calls functions in this file at build time to compile extensions. Use `Extension` classes and `get_extensions()` or `get_config()` hooks.
   **Checkpoint:** Extensions compile against the correct Python include paths and ABI. The built extension is present in the wheel under the expected package path.

6. **Build Distributions** — Run `python -m build` from the project root. This produces a `.whl` (wheel) for the current platform and a `.tar.gz` (source distribution) compatible with all platforms. Verify both artifacts exist in `dist/`.
   **Checkpoint:** Both `dist/*.whl` and `dist/*.tar.gz` are present. Check wheel metadata with `python -m zipfile -l dist/*.whl | head -20`.

7. **Verify with Twine** — Run `twine check dist/*` to validate long description rendering on PyPI, check for missing metadata, and ensure no insecure package contents. Fix any warnings or errors before uploading.
   **Checkpoint:** `twine check` reports zero errors and zero warnings. Review the rendered README preview at https://pypi.org/help/#rendered-readme.

8. **Publish to TestPyPI First** — Upload to TestPyPI using an API token (not a password) via `twine upload --repository testpypi dist/*`. Install from TestPyPI in a clean environment to verify: `pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ mypackage`.
   **Checkpoint:** The package installs cleanly from TestPyPI and all entry points, imports, and optional features work.

9. **Publish to Production PyPI** — After confirming the TestPyPI install works, publish to production: `twine upload dist/*` (or use API tokens with `--repository pypi`). Never skip the TestPyPI step for new packages or major version bumps.
   **Checkpoint:** The package appears on https://pypi.org/project/<your-package>/. Verify downloads and metadata are correct.

10. **Automate Release CI/CD** — Configure GitHub Actions to build, verify, and publish on git tags matching `v*`. Use the `pypa/gh-action-pypi-publish` action for secure uploads with PyPI API token stored in secrets.
    **Checkpoint:** Pushing a tag `v1.2.3` triggers the workflow, which builds artifacts, runs tests, publishes to TestPyPI, and then PyPI on merge to main.

---

## Implementation Patterns

### Pattern 1: Complete pyproject.toml with hatch-vcs Dynamic Versioning

This is the canonical `pyproject.toml` for a publishable Python package using hatchling as the build backend with git-tag-driven versioning. Replace placeholder values with your project metadata.

```toml
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "my-package"
dynamic = ["version"]
description = "A production-ready Python package with CLI entry points and optional features"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [
    { name = "Jane Developer", email = "jane@example.com" },
]
keywords = ["python", "package", "cli", "tool"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Operating System :: OS Independent",
]
dependencies = [
    "click>=8.1",
    "pydantic>=2.0,<3",
    "httpx>=0.27",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-cov>=4.1",
    "ruff>=0.4",
    "mypy>=1.10",
    "types-click>=7.1",
]
test = [
    "httpx>=0.27",
    "pytest-asyncio>=0.23",
]
docs = [
    "sphinx>=7.0",
    "sphinx-rtd-theme>=2.0",
    "myst-parser>=2.0",
]
ml = [
    "numpy>=1.26",
    "pandas>=2.2",
    "scikit-learn>=1.4",
]

[project.scripts]
mycli = "my_package.cli:main"
mytool = "my_package.commands.run:execute"

[project.gui-scripts]
mygui = "my_package.gui:main"

[tool.hatch.version]
source = "vcs"

[tool.hatch.version.raw-options]
version_scheme = "post-release"
local_scheme = "no-local-version"

[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]

[tool.hatch.build.targets.sdist]
# Include test data and docs in source distribution
include = [
    "/src",
    "/tests",
    "/README.md",
    "/LICENSE",
]

[tool.hatch.metadata]
allow-direct-references = true
```

### Pattern 2: Native Extension Build with hatch_build.py

When your package includes compiled extensions (C, C++, Rust), hatchling uses a build hook file to invoke the compiler. This pattern shows a C extension building against the Python C API with proper include paths and ABI flags.

```python
# hatch_build.py — Called by hatchling during `python -m build`

import os
import sys
from pathlib import Path

from hatchling.build import ConfigurablePlugin
from hatchling.plugin import hookimpl


class ExtensionBuilder(ConfigurablePlugin):
    """Builds native C extensions for my_package."""

    def get_sources(self, target_type, target_id, config):
        """Provide source files for the wheel build."""
        if target_type != "wheel":
            return []

        ext_dir = Path("src/my_package/_native")
        if not ext_dir.exists():
            return []

        return list(ext_dir.glob("*.c")) + [Path("src/my_package/_native/CMakeLists.txt")]

    @hookimpl
    def build_wheel(self, wheel_directory, config_settings=None, data=None):
        """Build C extensions into the wheel directory before packaging."""
        from setuptools import Extension, setup
        from setuptools.command.build_ext import build_ext

        ext = Extension(
            "my_package._native.core",
            sources=["src/my_package/_native/core.c"],
            include_dirs=[
                sys.base_prefix + "/include/python" + str(sys.version_info.major) + "." + str(sys.version_info.minor),
                "src/my_package/_native/include",
            ],
            define_macros=[("Py_LIMITED_API", "0x030A0000")],  # ABI-stable for Python 3.10+
        )

        # Compile extension in-place so it can be copied into the wheel
        setup(
            ext_modules=[ext],
            script_args=["build_ext", "--inplace"],
        )

        return super().build_wheel(wheel_directory, config_settings, data)


def build(hook_config, build_context):
    """Entry point called by hatchling at build time."""
    builder = ExtensionBuilder(hook_config or {})
    return builder.build_wheel(
        wheel_directory=build_context.context_dir / "dist",
        config_settings=None,
        data=None,
    )
```

### Pattern 3: CLI Entry Point Implementation (BAD vs. GOOD)

Proper CLI setup requires an entry point module that handles argument parsing, error handling, and a clean return code. Avoid putting all logic at the top level of `__init__.py`.

```python
# ❌ BAD — monolithic cli.py with no error handling, no exit codes
import click

@click.command()
def mycli():
    from my_package.core import run
    result = run()
    print(result)

if __name__ == "__main__":
    mycli()
```

```python
# ✅ GOOD — structured CLI with error handling, exit codes, and graceful degradation
"""Command-line interface for my-package.

Entry point registered via [project.scripts] in pyproject.toml:
    mycli = "my_package.cli:main"
"""

import sys
import logging
from typing import NoReturn

import click

logger = logging.getLogger("my_package.cli")


def _die(message: str, code: int = 1) -> NoReturn:
    """Print an error message to stderr and exit with the given code."""
    click.echo(f"Error: {message}", err=True)
    sys.exit(code)


@click.group()
@click.version_option(package_name="my-package")
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
@click.option("--quiet", "-q", is_flag=True, help="Suppress all non-error output.")
@click.pass_context
def main(ctx: click.Context, verbose: bool, quiet: bool) -> None:
    """my-package — production CLI tool."""
    ctx.ensure_object(dict)

    if verbose and quiet:
        _die("Cannot use both --verbose and --quiet simultaneously.", code=2)

    level = logging.DEBUG if verbose else (logging.WARNING if quiet else logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )


@main.command()
@click.argument("input_path", type=click.Path(exists=True, dir_okay=False))
@click.option("--output", "-o", type=click.Path(writable=True), default=None)
@click.option("--format", "fmt", type=click.Choice(["json", "csv", "yaml"]), default="json")
@click.pass_context
def process(ctx: click.Context, input_path: str, output: str | None, fmt: str) -> int:
    """Process an input file and write results."""
    from my_package.core import Engine

    try:
        engine = Engine()
        result = engine.process(input_path, format_type=fmt)
    except FileNotFoundError as exc:
        _die(f"Input file not found: {input_path}")
    except ValueError as exc:
        _die(f"Invalid input data: {exc}")

    if output:
        result.to_file(output)
        click.echo(f"Wrote results to {output}", err=bool(ctx.obj.get("verbose")))
    else:
        click.echo(result.serialize(fmt))

    return 0


if __name__ == "__main__":
    sys.exit(main())
```

### Pattern 4: GitHub Actions Release CI/CD Pipeline

This workflow builds and publishes the package on every git tag that matches `v*`. It uses a two-stage publish: first to TestPyPI for validation, then to production PyPI after manual approval.

```yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*"

permissions:
  contents: read

jobs:
  build-and-test:
    name: Build and test on Python ${{ matrix.python }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for hatch-vcs to find tags

      - name: Set up Python ${{ matrix.python }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}

      - name: Install build dependencies
        run: |
          python -m pip install --upgrade pip
          pip install build twine hatch-vcs

      - name: Build distributions
        run: python -m build

      - name: Verify with twine
        run: twine check dist/*

      - name: Run tests
        run: |
          pip install -e ".[dev,test]"
          pytest tests/ --cov=my_package --cov-report=xml -q

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml

  publish-testpypi:
    needs: build-and-test
    name: Publish to TestPyPI
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for trusted publishing
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install build tools
        run: pip install build twine

      - name: Build
        run: python -m build

      - name: Upload to TestPyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          repository-url: https://test.pypi.org/legacy/
          verbose: true
          print-hash: true

  publish-pypi:
    needs: publish-testpypi
    name: Publish to Production PyPI
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')
    permissions:
      id-token: write  # Trusted publishing via PyPI trusted publishers
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install build tools
        run: pip install build twine

      - name: Build and publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          verbose: true
          print-hash: true
```

### Pattern 5: Publishing via API Token (Manual Flow)

For manual publishing outside CI, use a `~/.pypirc` configuration file with an API token instead of a username/password. This is the recommended approach since PyPI deprecated password-based authentication.

```ini
# ~/.pypirc — store in your home directory, permissions 600
[testpypi]
    repository = https://test.pypi.org/legacy/
    # Token starts with pypi-
    password = pypi-AgEIcHlwaS5vcmc.e7Bb...

[pypi]
    repository = https://upload.pypi.org/legacy/
    # Token starts with pypi-
    password = pypi-AgEIcHlwaS5vcmc.x9Yz...
```

Publish commands using the token:

```bash
# Build both wheel and sdist
python -m build

# Verify before upload — fix any issues here
twine check dist/*

# Publish to TestPyPI first (validate install, entry points, metadata)
twine upload --repository testpypi dist/*

# After confirming TestPyPI works, publish to production PyPI
twine upload dist/*
```

---

## Constraints

### MUST DO
- Always use `dynamic = ["version"]` with `hatch-vcs` — never hardcode version numbers in `pyproject.toml` or source files
- Run `twine check dist/*` on every artifact before uploading to any registry
- Publish to TestPyPI first for new packages or major version bumps
- Tag releases with `v` prefix (e.g., `git tag -a v1.2.3 -m "Release 1.2.3" && git push origin v1.2.3`)
- Set `requires-python = ">=X.Y"` to match your minimum supported Python version
- Include both wheel and sdist in every release — users on exotic platforms may need the source distribution
- Define entry points under `[project.scripts]` for every CLI command, not inside `__init__.py`
- Use API tokens (starting with `pypi-`) instead of usernames and passwords for twine authentication
- Include a `LICENSE` file that matches the license declared in `pyproject.toml`
- Set `allow-direct-references = false` unless you have an explicit reason to allow them
- Document optional dependency groups clearly so users know `pip install mypkg[dev,test]` is valid

### MUST NOT DO
- Never publish to PyPI without running tests and twine verification first
- Never commit API tokens, credentials, or secrets to the repository
- Never use hardcoded versions that must be manually incremented on every release
- Never skip TestPyPI publishing for new package names or major version bumps (1.0.0 → 2.0.0)
- Never publish a package whose `long_description` fails twine rendering checks
- Never include test data, `.git` directories, or cache files in the source distribution
- Never use `[tool.setuptools]` alongside `[build-system] hatchling` — choose one build backend
- Never forget to run with `fetch-depth: 0` in CI when using hatch-vcs (tags will not be found)
- Never publish with `--skip-existing` without verifying the upload actually succeeded — silent skips cause corrupted releases
- Never use `setup.py` alongside `pyproject.toml` with hatchling — hatchling does not read setup.py

---

## Output Template

When implementing or auditing a Python package for publishing, produce:

1. **pyproject.toml Audit** — Verify all required `[project]` fields are present and correctly formatted. Confirm `[build-system]` declares `hatchling`. Check that `dynamic = ["version"]` is set with hatch-vcs configured under `[tool.hatch.version]`. List any missing or misconfigured fields.

2. **Distribution Verification Report** — Run `python -m build` and `twine check dist/*`. Document the list of artifacts produced, their sizes, and whether verification passed. Flag any warnings about missing classifiers, license files, or long description rendering issues.

3. **Entry Point Audit** — List all commands defined under `[project.scripts]` and verify each resolves to an existing callable. Test by installing the package in editable mode (`pip install -e .`) and executing each command. Report any broken entry points with the exact resolution path.

4. **CI/CD Pipeline Review** — Evaluate the GitHub Actions workflow (or equivalent). Confirm: checkout fetches all tags, build runs `python -m build`, twine verification passes before upload, TestPyPI publish runs first, and production PyPI uses trusted publishing via `id-token: write`.

5. **Optional Dependencies Matrix** — List each `[project.optional-dependencies]` group with its packages, note any cross-group overlaps, and verify that installing each group individually does not cause conflicts or unexpected transitive pulls.

---

## Related Skills

| Skill | Purpose |
|---|---|
| `coding/python-module-structure` | Organize your package directory layout and `__init__.py` exports before building for distribution |
| `modern-python-development` | Ensure your code follows Python 3.10+ typing, project structure, and tooling best practices that packaging builds on top of |
| `coding/software-delivery-pipelines` | Extend beyond PyPI publishing to Docker images, container registries, and multi-platform deployment workflows |

---

## Live References

> Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.

- [Hatchling Documentation](https://hatch.pypa.io/latest/) — Official build backend docs: configuration, build hooks, targets, plugins
- [hatch-vcs Documentation](https://hatch.pypa.io/latest/version/vcs/) — Git-tag-driven dynamic versioning with PEP 440 compliance
- [Python Packaging User Guide](https://packaging.python.org/en/latest/) — Canonical PyPA guidance on packaging, distribution, and publishing
- [twine Documentation](https://twine.readthedocs.io/en/stable/) — Secure PyPI uploads, verification, and repository configuration
- [PyPI Trusted Publishers (GitHub Actions)](https://docs.pypi.org/trusted-publishers/using-a-publisher/) — OIDC-based authentication for CI/CD publishing without API tokens
- [PEP 621 — Storing Project Metadata in pyproject.toml](https://peps.python.org/pep-0621/) — Specification for project metadata format used by hatchling
- [Python Packaging Authority: Building and Distributing Packages](https://packaging.python.org/en/latest/tutorials/packaging-projects/) — Step-by-step tutorial for creating distributable packages

