To build and publish a modern Python package to PyPI, keep importable code in a src layout, define the project and build backend in pyproject.toml, produce a wheel and source distribution with python -m build, validate them with twine check, and rehearse on TestPyPI. For production releases, connect PyPI to GitHub Actions through Trusted Publishing instead of storing a long-lived token.

This guide follows a small package named clear-greeting from an empty directory to a repeatable release. If any foundation is unfamiliar, review Python modules and packages, work inside a Python virtual environment, and revisit dependency management with pip.

Import package versus distribution package

Python packaging uses “package” for two related but distinct objects. An import package is the directory found by an expression such as import clear_greeting. A distribution package is the installable archive delivered by an index, such as clear_greeting-0.1.0-py3-none-any.whl or a source .tar.gz.

The PyPI distribution name may use a hyphen while the import name must be a valid Python identifier. Users therefore run pip install clear-greeting but write from clear_greeting import greet. PyPI and pip are not synonyms either: PyPI hosts and indexes distributions; pip resolves and installs them.

A maintainable src-layout structure

clear-greeting/
├── .github/
│   └── workflows/
│       └── publish.yml
├── src/
│   └── clear_greeting/
│       ├── __init__.py
│       └── core.py
├── tests/
│   └── test_core.py
├── LICENSE
├── README.md
└── pyproject.toml

The src layout prevents a test run from silently importing code straight from the repository root. Tests must use an installed project, which catches missing packaging configuration and more closely resembles the consumer's environment. It also keeps importable code separate from documentation, CI configuration, and other project files.

__init__.py marks a regular package and is a useful place to expose the supported public API. Documentation, tests, and workflow files stay outside src. Future subpackages belong under src/clear_greeting/.

A current pyproject.toml using setuptools

A modern pyproject.toml tells build frontends both how to build the distribution and which metadata to place inside it. The official pyproject.toml specification explains the standardized tables and tool-owned configuration.

[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "clear-greeting"
version = "0.1.0"
description = "Small, predictable greetings for Python examples"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [
  { name = "Your Name", email = "[email protected]" },
]
keywords = ["greeting", "example", "tutorial"]
classifiers = [
  "Programming Language :: Python :: 3",
  "Operating System :: OS Independent",
]
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]

[project.urls]
Homepage = "https://github.com/your-user/clear-greeting"
Issues = "https://github.com/your-user/clear-greeting/issues"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[build-system] asks a frontend such as build or pip to provision setuptools in an isolated build environment. [project] uses metadata understood across compliant backends. Replace the sample identity, URLs, and description, and search PyPI before choosing the globally unique distribution name. Set requires-python to the versions your CI actually tests, not merely the interpreter on your laptop.

Libraries needed when consumers run your code belong in dependencies. Development-only tools should not be installed for every consumer, so pytest is placed in the optional test extra. Public functions also benefit from explicit contracts; our Python type hints guide covers that subject in depth.

Treat the README and license as product files

PyPI renders the declared README.md on the project page. It should state what the library does, show installation, and offer a copyable first example. Prefer absolute image URLs because a relative path that works on GitHub may fail on PyPI.

## clear-greeting

A small library for producing consistent greetings.

## Installation

`python -m pip install clear-greeting`

## Usage

```python
from clear_greeting import greet

print(greet("Ada"))
```

Choose a license deliberately and put its full text in LICENSE. The sample metadata uses the SPDX expression MIT; change it if MIT is not your intended license. Publicly visible source is not automatically permission to copy, modify, and redistribute it.

Write minimal code and test the public API

Create src/clear_greeting/core.py:

def greet(name: str) -> str:
    """Return a greeting for a non-empty name."""
    clean_name = name.strip()
    if not clean_name:
        raise ValueError("name must not be empty")
    return f"Hello, {clean_name}!"

Expose the intended API from src/clear_greeting/__init__.py:

from .core import greet

__all__ = ["greet"]

Then add tests/test_core.py:

import pytest

from clear_greeting import greet


def test_greet_strips_whitespace() -> None:
    assert greet("  Ada  ") == "Hello, Ada!"


def test_greet_rejects_empty_name() -> None:
    with pytest.raises(ValueError, match="must not be empty"):
        greet("  ")

Inside an activated virtual environment, install the repository in editable mode with its testing extra:

python -m pip install --upgrade pip
python -m pip install -e ".[test]"
python -m pytest

An editable install reflects source changes without a manual reinstall, while still making the import travel through installed project metadata. For broader test organization and fixtures, read the pytest testing guide.

Build a wheel and sdist, then inspect them

python -m pip install --upgrade build twine
python -m build
python -m twine check dist/*

The isolated build produces a universal pure-Python wheel such as clear_greeting-0.1.0-py3-none-any.whl and an sdist ending in .tar.gz. A wheel is already built and usually installs quickly. An sdist carries source and is built in the target environment. Publish both so most users receive the wheel while the source form remains available.

twine check catches malformed metadata and README rendering problems. It does not prove imports or runtime behavior, so install the wheel by file path in a fresh environment and run a smoke test. Remove stale files from dist before rebuilding a release. This sequence follows the official Python Packaging User Guide tutorial.

Rehearse the upload on TestPyPI

TestPyPI is a separate service with separate accounts, projects, and credentials. Create a TestPyPI API token for a manual rehearsal, then upload:

python -m twine upload --repository testpypi dist/*

At the prompt, the username is __token__ and the password is the entire token. Never commit it or place it directly in a recorded command. Verify the result from another clean environment:

python -m pip install --index-url https://test.pypi.org/simple/ --no-deps clear-greeting
python -c "from clear_greeting import greet; print(greet('Ada'))"

--no-deps is appropriate because this demonstration has no runtime dependencies. Real packages may depend on projects absent from TestPyPI. Install those dependencies separately rather than combining indexes casually, which can introduce dependency-confusion risk.

Token-free PyPI publishing with GitHub Actions

Trusted Publishing exchanges a GitHub OIDC identity for a short-lived PyPI credential. In PyPI, configure a publisher with the exact GitHub owner, repository, workflow filename publish.yml, and environment name pypi. You can configure it on an existing project or use a pending publisher for the first release. See the current PyPI Trusted Publishers documentation for setup details.

Create .github/workflows/publish.yml:

name: Publish package

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: python -m pip install --upgrade build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: python-package-distributions
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/clear-greeting
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: python-package-distributions
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

The narrowly scoped id-token: write permission lets the job request an OIDC identity; it is not broad repository write access. Add required reviewers to the pypi environment if releases need human approval. Your regular CI should test pushes and pull requests, while this workflow publishes only a GitHub Release. The GitHub Actions Python CI/CD guide explains that separation.

Versioning and a repeatable release routine

PyPI will not let you overwrite files for an existing release. A practical version scheme is MAJOR.MINOR.PATCH: PATCH for compatible fixes, MINOR for compatible features, and MAJOR for incompatible API changes. A candidate release may look like 0.2.0rc1. Whatever policy you choose, make the pyproject.toml version, Git tag, and release notes agree.

  1. Update code, documentation, changelog, and version.
  2. Run tests and the type checker if the project uses one.
  3. Build from a clean tree and run twine check.
  4. Install the wheel in a clean environment and smoke-test imports.
  5. Create the tag and publish a GitHub Release to trigger publishing.

Common packaging and publishing errors

  • Name already taken: the distribution name is global to the index, regardless of your import name.
  • Package missing from the wheel: verify the directory below src, __init__.py, and setuptools discovery settings.
  • Broken project description: run twine check and replace unsupported markup or local assets.
  • Unexpected files: inspect both archives; .gitignore does not define distribution contents.
  • Upload rejected: increment the version and rebuild. Never try to overwrite the previous files.
  • Untrusted workflow: owner, repository, workflow filename, and environment must exactly match PyPI's publisher record.

Pre-release checklist

  • Distribution name is available and all metadata is accurate.
  • The src package installs and its public API imports.
  • README renders, URLs resolve, and the chosen license is included.
  • Declared Python versions are covered by CI.
  • Tests pass in a clean environment.
  • Fresh wheel and sdist have been inspected and pass twine check.
  • Installation and import work through TestPyPI.
  • Trusted Publisher and the GitHub environment match, with no persistent token.
  • Version, tag, changelog, and release notes agree.

Good packaging is not merely a successful upload. It gives users a predictable installation, meaningful metadata, clear permission to use the work, and a release process you can repeat without improvisation. The same foundation can carry a teaching library today and a multi-module, multi-contributor project later.