Contents
Part 5: Operations, Packaging, Testing
  1. 30.Type checker setup and CI integration
  2. 31.Logging and observability
  3. 32.Publishing a library with uv — pyproject.toml and shipping to PyPI
  4. 33.Building CLI tools (Typer)
32 Chapter

Publishing a library with uv — pyproject.toml and shipping to PyPI

Pin down what pyproject.toml means in one pass, then publish your first library to PyPI with uv build · uv publish.

In Chapter 7 modules and pyproject.toml you saw pyproject.toml from the angle of “config for running my project.” This chapter looks at the same file from the angle of “config for shipping my library on PyPI so other people can use it.” We run that whole flow end-to-end with the single tool uv.

The CLI you’ll build in Chapter 33 building CLI tools can be shipped to PyPI through this chapter’s flow — the two chapters form one set.

Why ship one yourself #

Even if you don’t expect to publish a library, going through it once makes it visible why other PyPI packages are structured the way they are. How you declare dependencies, how the README renders on the PyPI page, what wheel and sdist are and why you upload both — all of that only settles in after you’ve done it yourself.

The pyproject.toml standard — what goes where #

pyproject.toml is the accumulated result of several PEPs.

SectionPEPRole
[build-system]PEP 518Which tools are needed to build the package
[project]PEP 621Package metadata (name, version, authors, dependencies)
[project.scripts]PEP 621CLI entry points
[project.optional-dependencies]PEP 621Optional dependency groups
[dependency-groups]PEP 735Development dependency groups (supported by uv / pip)
[tool.<name>]tool-definedEach tool’s config (ruff, pyright, pytest, etc.)

Minimal example #

pyproject.toml — minimal form for a library
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "swcli"
version = "0.1.0"
description = "Schoolofweb sample CLI"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [
  { name = "Your Name", email = "you@example.com" },
]
keywords = ["cli", "example"]
classifiers = [
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3 :: Only",
  "License :: OSI Approved :: MIT License",
  "Operating System :: OS Independent",
]
dependencies = [
  "typer>=0.12",
  "rich>=13",
]

[project.optional-dependencies]
yaml = ["pyyaml>=6"]

[project.urls]
Homepage = "https://github.com/you/swcli"
Issues = "https://github.com/you/swcli/issues"

[project.scripts]
swcli = "swcli.cli:app"

[dependency-groups]
dev = [
  "pytest>=8",
  "pytest-cov",
  "ruff",
  "pyright",
]

Key fields, in plain terms #

  • name — the unique name on PyPI. Once registered, similar names are blocked too (typosquatting protection).
  • version — SemVer recommended (MAJOR.MINOR.PATCH).
  • requires-python — which Python version it runs on. PyPI auto-filters incompatible wheels.
  • dependencies — required at runtime. Range specification is the key (next section).
  • optional-dependencies — extras the user can opt into, like pip install swcli[yaml].
  • dependency-groups.dev — dependencies used only by developers. Not included in the published wheel.
  • project.scripts — installing puts a swcli command on PATH. The right side is <module>:<callable>.

src layout vs flat layout #

There are two conventions for a library’s directory layout.

flat layout

myproject/
  pyproject.toml
  swcli/
    __init__.py
    cli.py
  tests/

src layout

myproject/
  pyproject.toml
  src/
    swcli/
      __init__.py
      cli.py
  tests/

src layout is recommended. Why:

  • cd myproject; python -c "import swcli" doesn’t accidentally work — without installation, the import fails. So the “it accidentally works without being installed” class of bug doesn’t happen.
  • Tests always import the “installed package,” which matches the user’s experience.

With src layout, the build backend (hatchling, etc.) finds src/ automatically.

Declaring dependencies — the principle of range specification #

If dependencies just says requests, you’re saying “any requests version is fine.” A year later a user might install an incompatible new version and your package breaks.

The policy differs between libraries and applications.

  • Library (published on PyPI) — declare lower and upper bounds. requests>=2.31,<3. Upper bound at the major version.
  • Application (deployed with a uv.lock pin) — uv.lock pins exact versions, so ranges can be looser.

This chapter is from the library angle, so the recommended form is:

dependencies = [
  "typer>=0.12,<1",
  "rich>=13,<15",
]

Without an upper bound, a major update of a dependency can slip in silently and break user environments.

uv build — making wheel and sdist #

uv build

Two files land in dist/.

dist/
  swcli-0.1.0-py3-none-any.whl
  swcli-0.1.0.tar.gz
FormatWhat it is
wheel (.whl)Built binary form. Fast to install. Compatibility tags (py3-none-any) in the name
sdist (.tar.gz)Source distribution. Pre-built. Can be built in the user’s environment

Uploading both is standard for a pure-Python library. Packages with C extensions also upload multiple platform-specific wheels (manylinux_2_28_x86_64, macosx_11_0_arm64, etc.).

Rehearse with TestPyPI #

For a first release, upload to TestPyPI first — mistakes don’t pollute real PyPI.

Account and token #

  1. Sign up at https://test.pypi.org/account/register/.
  2. Issue an API token at https://test.pypi.org/manage/account/token/. Scope the token to a single project if possible (don’t leave a full-permission token in env vars).

Pass via environment variables #

export UV_PUBLISH_TOKEN="pypi-AgENd..."
export UV_PUBLISH_URL="https://test.pypi.org/legacy/"

uv build
uv publish

On success, check https://test.pypi.org/project/swcli/.

Install test #

uv pip install --index-url https://test.pypi.org/simple/ \
               --extra-index-url https://pypi.org/simple/ \
               swcli

--extra-index-url is added because TestPyPI may not have your dependencies, so they’re pulled from real PyPI.

uv publish — the real PyPI release cycle #

Once the rehearsal is done, on to real PyPI.

unset UV_PUBLISH_URL    # real PyPI is the default
export UV_PUBLISH_TOKEN="pypi-AgEN..."   # pypi.org token

uv build
uv publish

Check https://pypi.org/project/swcli/.

Install:

uv pip install swcli
swcli --help

Congratulations — the library is out in the world.

A version, once uploaded, cannot be re-uploaded #

This is PyPI’s most important rule. Uploading the same version again is rejected, and a mistaken upload can be deleted but the same version can’t be re-uploaded in its place. This is so users’ dependency resolvers stay consistent.

If you messed up, bump the version (0.1.00.1.1) and release again. That’s why the TestPyPI rehearsal matters.

SemVer and CHANGELOG #

Semantic Versioning #

The meaning of MAJOR.MINOR.PATCH:

Kind of changePosition bumped
Breaking change (signature change, removal)MAJOR
Backward-compatible new featureMINOR
Bug fix (behavior unchanged)PATCH

0.x is the convention “API isn’t stable yet.” During this phase, MINOR can effectively include breaking changes like MAJOR would. Once you hit 1.0.0, you start following the rules strictly.

CHANGELOG.md #

The Keep a Changelog format is standard.

CHANGELOG.md
# Changelog

## [Unreleased]

## [0.2.0] - 2026-06-01
### Added
- `--json` output option.

### Fixed
- Color codes showing literally on Windows.

## [0.1.0] - 2026-05-17
- First release.

The flow is: add one line under [Unreleased] on every merged PR, then move it under a version section right before release.

README · LICENSE · metadata #

The PyPI package page shows:

  • README.md — the page body. It’s the first screen, so “what is it” + “why use it” + “how to start” should be visible in 5 seconds.
  • License[project] license = "MIT" (SPDX identifier). Without it, people can’t use your code (default is no permission).
  • classifiers — PyPI’s classification tags. Category / topic / Python version here affect search.
  • Homepage / Repository / Issues URL — sidebar links.

Adding badges to the README is also a trust signal.

![CI](https://github.com/you/swcli/actions/workflows/ci.yml/badge.svg)
![PyPI](https://img.shields.io/pypi/v/swcli)
![Python](https://img.shields.io/pypi/pyversions/swcli)

Automating publish on tag push with GitHub Actions #

Running uv build && uv publish by hand every time is a source of mistakes. Auto-publish on git tag push.

Trusted Publishing — token-free #

The new recommended approach from PyPI. Authenticate to PyPI with GitHub Actions’ OIDC token. No API token in secrets, which is the safest option.

PyPI-side setup:

  1. Go to https://pypi.org/manage/project/<name>/settings/publishing/.
  2. “Add a new publisher” → pick GitHub.
  3. Enter owner, repository, workflow filename, environment name.

Workflow file:

.github/workflows/publish.yml
name: Publish to PyPI

on:
  push:
    tags:
      - "v*"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv python install 3.14
      - run: uv build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi             # must match PyPI-side setup
    permissions:
      id-token: write             # needed to issue the OIDC token
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

Push a tag:

git tag v0.2.0
git push origin v0.2.0

GitHub Actions handles build → OIDC auth to PyPI → publish automatically.

Common pitfalls #

Trying to upload the same version again #

Covered above. PyPI rejects it. Bump the version and re-release.

Missing dependency upper bound #

dependencies = ["pydantic"]   # not recommended
dependencies = ["pydantic>=2,<3"]   # recommended

Without an upper bound, a dependency’s major update can silently break user environments.

Missing LICENSE #

If license is empty or has the wrong identifier, PyPI may warn and internal-corporate policies may block use. Use an SPDX identifier (MIT, Apache-2.0, BSD-3-Clause, etc.) whenever possible.

Large files inside the sdist #

.gitignore and tool.hatch.build.targets.sdist.exclude are different. Crack the build output open and look (unzip dist/*.whl -d /tmp/inspect; ls -lh /tmp/inspect) to be safe.

Wrong path on the CLI entry point #

The right side of project.scripts is a module path.

swcli = "swcli.cli:app"   # OK — app in src/swcli/cli.py
swcli = "src.swcli.cli:app"   # Wrong — src is not a package

If swcli --help fails after install with “module not found,” suspect this path.

Mixing up TestPyPI and real PyPI tokens #

The domains differ and the tokens differ. In automation, separate secrets per environment (PYPI_TOKEN, TEST_PYPI_TOKEN).

Pairing with Chapter 33 — shipping the Typer CLI #

The Typer-based CLI you’ll build in Chapter 33 can be shipped directly through this chapter’s flow.

swcli/
  pyproject.toml         ← this chapter
  src/swcli/
    __init__.py
    cli.py               ← Chapter 33
  tests/
  README.md
  CHANGELOG.md
  .github/workflows/
    ci.yml               ← Chapter 30
    publish.yml          ← this chapter

Only code that passes Chapter 30’s CI enters main, and once a tag is pushed to main, it is automatically published to PyPI.

Exercises #

  1. Look inside the wheel — Take the dist/<name>-0.1.0-py3-none-any.whl produced by this chapter’s flow and unzip it. What’s inside? Open METADATA and check that what you wrote in pyproject.toml made it through unchanged, and look for any missing items (a typo in a classifier, say).
  2. Effect of dependency ranges — Change one of pyproject.toml’s dependencies to pydantic>=2 (no upper bound), build the wheel, install it, and then try grabbing a prerelease like uv add pydantic==3.0.0a1 (hypothetical new version). How does the resolver behave? How does the result change once you add <3 back?
  3. Setting up Trusted Publishing — On your own GitHub repo and PyPI account, set up Trusted Publishing yourself and publish v0.0.1 once. What error do you get if permissions: id-token: write is missing? What error do you get if environment: pypi doesn’t match the PyPI setting?
Note
In one line — Declare metadata, dependencies, and the CLI entry point in one pyproject.toml, build wheel/sdist with uv build, rehearse on TestPyPI, then publish to real PyPI. Automate with GitHub Actions + Trusted Publishing — keeping no token around is the safest option.

The next chapter is building CLI tools (Typer). It’s the step where you actually build the package that this chapter’s flow ships.

X