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 withpip install. - Import package name:
nonla_order_tools. Used inimportstatements; 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]tellspipandbuildwhich build backend to use. Here it’shatchling. You could also usesetuptools,flit-core, orpdm-backend. The metadata in[project]stays the same.nameandversionare required. Follow semantic versioning:MAJOR.MINOR.PATCH.readmebecomes the project description on PyPI.requires-pythonprevents installation on unsupported Python versions.license = "MIT"is an SPDX expression per modern standards. This requireshatchling>=1.27, which is why the minimum version is explicit above.dependencieslists packages required at runtime, like["httpx>=0.27"].optional-dependenciescreates “extras.” Users can install withpip install "nonla-order-tools[test]".[project.scripts]creates anorder-totalcommand inPATHwhen the package is installed. It calls themainfunction from theclimodule.
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 suffixpy3-none-anymeans 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.
- Create an account on TestPyPI and enable two-factor authentication (2FA).
- Go to Account settings → API tokens and create a token. It starts with
pypi-. - 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:
- Bump
versioninpyproject.toml, e.g., to0.1.1. - Clean up:
rm -rf dist/. - Run
python -m build,twine check, thentwine 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
buildjob creates files indist/and uploads them as an artifact. - The
publishjob runs separately and only hasid-token: writepermission. This lets it request an OIDC token, which thepypa/gh-action-pypi-publishaction 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
403orThe name is too similar to an existing project. Check the name first and consider adding a unique prefix likenonla-. - File already exists: you forgot to bump the version, or old files are still in
dist/. Always deletedist/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 withimportlib.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.3because it conflicts with users’ projects. Specify only a lower bound, likerequests>=2.31. Pinning is fine for applications via lock files. - Token leaked: never commit
~/.pypircor 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.