Packaging and Releasing Python Packages to PyPI

1. Overview

You’ve written a useful Python module and want others to install it with pip install. To do that, you need to package your code into a distribution and upload it to PyPI (Python Package Index).

This article walks you through the entire process. You’ll structure your project correctly, declare metadata in pyproject.toml, build a wheel and sdist, test on TestPyPI, then release for real. At the end, you’ll see how to automate releases with GitHub Actions using Trusted Publishing.

2. Project structure

Throughout this guide, we’ll use a small library called nonla-order-tools that calculates order totals. We’ll use src layout:

nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── nonla_order_tools/
│       ├── __init__.py
│       ├── pricing.py
│       └── cli.py
└── tests/
    └── test_pricing.py

Code goes in src/ rather than the project root. The main benefit is that when you run tests, Python can’t accidentally import code from your working directory. It’s forced to import the installed version, so you catch missing files early.

You need to distinguish two names:

  • Distribution name on PyPI: nonla-order-tools. Users type this with pip install.
  • Import package name: nonla_order_tools. Used in import statements; cannot contain hyphens.

2.1. Library code

# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
    product: str
    unit_price: Decimal
    quantity: int


@dataclass
class Order:
    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))


def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
    subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
    return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))

Order holds a list of OrderLine items. calculate_total sums up each line’s total, applies VAT (10% by default), then rounds to the nearest unit. The code uses Decimal instead of float to avoid rounding errors in financial calculations.

# src/nonla_order_tools/__init__.py
from importlib.metadata import version

from .pricing import Order, OrderLine, calculate_total

__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")

__version__ is read from the installed package’s metadata. That way, you declare the version in only one place: pyproject.toml.

Add a small command-line tool as well:

# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal

from .pricing import Order, calculate_total


def main() -> None:
    # Usage: order-total <price> <quantity> [<price> <quantity> ...]
    args = sys.argv[1:]
    order = Order(customer="cli")
    for price, qty in zip(args[::2], args[1::2]):
        order.add("item", price, int(qty))
    print(calculate_total(order, Decimal("0.10")))

3. Declaring metadata in pyproject.toml

pyproject.toml is the single source of truth for how to build your package and its information. You don’t need a setup.py for new projects.

[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "Calculate order totals with VAT"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
dependencies = []

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

[project.scripts]
order-total = "nonla_order_tools.cli:main"

Here’s what each part does:

  • [build-system] tells pip and build which build backend to use. Here it’s hatchling. You could also use setuptools, flit-core, or pdm-backend. The metadata in [project] stays the same.
  • name and version are required. Follow semantic versioning: MAJOR.MINOR.PATCH.
  • readme becomes the project description on PyPI.
  • requires-python prevents installation on unsupported Python versions.
  • license = "MIT" is an SPDX expression per modern standards. This requires hatchling>=1.27, which is why the minimum version is explicit above.
  • dependencies lists packages required at runtime, like ["httpx>=0.27"].
  • optional-dependencies creates “extras.” Users can install with pip install "nonla-order-tools[test]".
  • [project.scripts] creates an order-total command in PATH when the package is installed. It calls the main function from the cli module.

Hatchling automatically finds src/nonla_order_tools because the directory name matches the normalized project name (hyphens become underscores). If they differ, declare it explicitly:

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

4. Writing tests and installing in editable mode

Before releasing, make sure the core functionality works:

# tests/test_pricing.py
from decimal import Decimal

import pytest

from nonla_order_tools import Order, calculate_total


def test_total_includes_vat():
    order = Order(customer="Lan")
    order.add("Coffee", "25000", 2)
    order.add("Bread", "20000")

    assert calculate_total(order) == Decimal("77000")


def test_rejects_non_positive_quantity():
    order = Order(customer="Minh")
    with pytest.raises(ValueError, match="positive"):
        order.add("Tea", "15000", 0)

The first test calculates (25000 × 2 + 20000) × 1.1 = 77000. The second checks that zero quantity is rejected.

Install the project in editable mode and run tests:

python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
..                                                    [100%]
2 passed in 0.02s

The -e flag links the installed package to your src/ directory. Any code changes take effect immediately without reinstalling.

5. Building wheel and sdist

Install the build tool and run it in your project root:

pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl

The dist/ directory now contains two files:

  • sdist (.tar.gz): source code with metadata. Used when no suitable wheel exists.
  • wheel (.whl): pre-built distribution. The suffix py3-none-any means it runs on any OS with Python 3, since the package is pure Python.

Check that the wheel contains all files:

python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
nonla_order_tools/__init__.py                  2020-02-02 00:00:00          209
nonla_order_tools/cli.py                       2020-02-02 00:00:00          363
nonla_order_tools/pricing.py                   2020-02-02 00:00:00          786
nonla_order_tools-0.1.0.dist-info/METADATA     2020-02-02 00:00:00         1012
nonla_order_tools-0.1.0.dist-info/WHEEL        2020-02-02 00:00:00           87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00     58
...

Fixed timestamps are intentional: hatchling creates reproducible builds. Your file sizes might vary slightly.

Next, use twine to validate metadata, especially that the README renders correctly:

twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED

Before uploading, install the wheel into a clean environment and verify imports:

python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000

If this step fails with ModuleNotFoundError, you probably forgot to package a file or got the package path wrong.

6. Test on TestPyPI first

TestPyPI is an independent copy of PyPI, just for testing. Both have separate accounts and tokens.

  1. Create an account on TestPyPI and enable two-factor authentication (2FA).
  2. Go to Account settings → API tokens and create a token. It starts with pypi-.
  3. Upload:
twine upload --repository testpypi dist/*

When prompted, enter username __token__ and password as the token string. To avoid typing it every time, save it to ~/.pypirc:

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...

Set permissions to chmod 600 ~/.pypirc so other users can’t read the file. Then test installing from TestPyPI:

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  nonla-order-tools

The --extra-index-url flag lets pip fetch dependencies from the real PyPI, since TestPyPI often lacks all the packages you depend on.

7. Release to PyPI

Once everything works, the process on real PyPI is identical, just without --repository:

twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/

Now anyone can run pip install nonla-order-tools.

For future releases, follow these steps:

  1. Bump version in pyproject.toml, e.g., to 0.1.1.
  2. Clean up: rm -rf dist/.
  3. Run python -m build, twine check, then twine upload.

Note: PyPI does not allow re-uploading a file with the same version name, even if you’ve deleted the release. If you accidentally release a broken version, you can only yank it and release a new version. Always test on TestPyPI first.

8. Automate releases with Trusted Publishing

Storing long-lived tokens in CI is a security risk. Trusted Publishing eliminates tokens entirely. PyPI trusts a specific GitHub Actions workflow via OIDC and issues short-lived tokens for each run.

Set up on PyPI: go to Publishing for your project, or use “pending publisher” if it doesn’t exist yet. There, declare the owner, repository name, workflow file name publish.yml, and environment pypi.

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

on:
  release:
    types: [published]

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

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

How the workflow works:

  • It runs each time you create a GitHub Release.
  • The build job creates files in dist/ and uploads them as an artifact.
  • The publish job runs separately and only has id-token: write permission. This lets it request an OIDC token, which the pypa/gh-action-pypi-publish action uses to upload. You need no secrets.
  • Separating the jobs ensures that your code and build dependencies—which run in the build job—never get access to publishing permissions.

9. Common errors

  • Name taken: PyPI returns 403 or The name is too similar to an existing project. Check the name first and consider adding a unique prefix like nonla-.
  • File already exists: you forgot to bump the version, or old files are still in dist/. Always delete dist/ before building.
  • Missing data files (JSON, templates…): files outside the package directory don’t get packaged. Put them in src/nonla_order_tools/ and read them with importlib.resources.
  • Tests pass locally but import fails after install: src layout catches this. Always install the wheel into a clean venv as shown in section 5.
  • Pinned dependencies too strict: don’t freeze versions like requests==2.32.3 because it conflicts with users’ projects. Specify only a lower bound, like requests>=2.31. Pinning is fine for applications via lock files.
  • Token leaked: never commit ~/.pypirc or tokens to your repo. Use project-scoped tokens, or better yet, Trusted Publishing.

Note: Not all code belongs on PyPI. Internal company libraries should use private indexes (like Artifactory, GitLab Package Registry) or install directly from Git. PyPI is public, and taken names are hard to reclaim.

10. Conclusion

Modern Python packaging is straightforward. Everything centers on a single pyproject.toml file, a build backend like hatchling, the python -m build command to create wheels and sdists, and twine for validation and upload.

Build a habit of following this order: run tests, build, install into a clean venv, test on TestPyPI, then release to PyPI. Once your project stabilizes, switch to Trusted Publishing with GitHub Actions. After that, each release is just creating a Release on GitHub—no token management needed.

pythonpypipackagingpyprojectgithub-actions