Python-Pakete paketieren und auf PyPI veröffentlichen

1. Übersicht

Du hast ein nützliches Python-Modul geschrieben und möchtest, dass andere es mit pip install installieren können. Dafür musst du deinen Code als Distribution paketieren und auf PyPI (Python Package Index) hochladen.

Dieser Artikel führt dich durch den gesamten Prozess. Du wirst die Standard-Projektstruktur aufbauen, Metadaten in pyproject.toml deklarieren, Wheels und Sdists bauen, auf TestPyPI testen und dann wirklich veröffentlichen. Am Ende zeige ich dir, wie du die Veröffentlichung mit GitHub Actions und Trusted Publishing automatisierst.

2. Projektstruktur

Das durchgehende Beispiel ist die kleine Bibliothek nonla-order-tools, die der Berechnung von Gesamtrechnungsbeträgen dient. Wir verwenden das 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 befindet sich in src/ statt im Root-Verzeichnis. Der Hauptvorteil ist, dass Python beim Ausführen von Tests versehentlich keinen Code aus dem Arbeitsverzeichnis importieren kann. Es zwingt dich, die installierte Version zu importieren, sodass du früh Fehler von vergessenen Dateien beim Paketieren erkennst.

Es gibt zwei Namen zu unterscheiden:

  • Der Name der Distribution auf PyPI: nonla-order-tools. Benutzer geben diesen Namen mit pip install ein.
  • Der Import-Name des Pakets: nonla_order_tools. Wird in import verwendet, darf keine Bindestriche enthalten.

2.1. Code der Bibliothek

# 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 enthält eine Liste von OrderLine. calculate_total addiert das Geld jeder Zeile, multipliziert mit der Mehrwertsteuer (Standard 10%) und rundet auf Einheiten. Der Code verwendet Decimal statt float, um Fehler bei Geldberechnungen zu vermeiden.

# 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__ wird aus den Metadaten des installierten Pakets gelesen. Dadurch wird die Version nur an einer Stelle in pyproject.toml deklariert.

Hier ist ein kleines Kommandozeilentool:

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

from .pricing import Order, calculate_total


def main() -> None:
    # Verwendung: order-total <preis> <menge> [<preis> <menge> ...]
    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. Metadaten in pyproject.toml deklarieren

pyproject.toml ist der einzige Ort, an dem der Build-Prozess und die Paketinformationen beschrieben werden. Du brauchst setup.py nicht für neue Projekte.

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

[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "Berechnet Gesamtrechnungsbeträge mit Mehrwertsteuer"
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"

Erklärung der einzelnen Teile:

  • [build-system] teilt pip und build mit, welches Build-Backend verwendet werden soll. Hier ist es hatchling. Du kannst auch setuptools, flit-core oder pdm-backend verwenden. Metadaten in [project] bleiben gleich.
  • name und version sind erforderlich. Die Version sollte das Format MAJOR.MINOR.PATCH folgen.
  • readme wird zur Beschreibungsseite auf PyPI.
  • requires-python verhindert die Installation auf nicht unterstützten Python-Versionen.
  • license = "MIT" ist ein SPDX-Ausdruck nach dem neuen Standard. Dieser Standard benötigt hatchling>=1.27, daher ist die Mindestversion oben angegeben.
  • dependencies listet die erforderlichen Bibliotheken zur Laufzeit auf, z.B. ["httpx>=0.27"].
  • optional-dependencies erstellt „Extras”. Benutzer können mit pip install "nonla-order-tools[test]" installieren.
  • [project.scripts] erstellt den Befehl order-total im PATH, wenn das Paket installiert wird. Dieser Befehl ruft die main-Funktion im Modul cli auf.

Hatchling findet src/nonla_order_tools automatisch, weil der Verzeichnisname dem normalisierten Projektnamen entspricht (Bindestriche werden zu Unterstrichen). Wenn die Namen unterschiedlich sind, musst du es manuell deklarieren:

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

4. Tests schreiben und im Bearbeitungsmodus installieren

Bevor du veröffentlichst, stelle sicher, dass sich die Hauptfunktion korrekt verhält:

# 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("Cà phê", "25000", 2)
    order.add("Bánh mì", "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("Trà", "15000", 0)

Der erste Test berechnet (25000 × 2 + 20000) × 1.1 = 77000. Der zweite Test überprüft, dass eine Menge von 0 abgelehnt wird.

Installiere das Projekt im Bearbeitungsmodus und führe die Tests aus:

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

Die Flagge -e verknüpft das installierte Paket mit dem Verzeichnis src/. Änderungen am Code sind sofort wirksam, ohne Neuinstallation.

5. Wheels und Sdists bauen

Installiere das build-Tool und führe es im Root-Verzeichnis aus:

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

Das Verzeichnis dist/ enthält jetzt zwei Dateien:

  • sdist (.tar.gz): Quellcode mit Metadaten. Wird verwendet, wenn kein passendes Wheel vorhanden ist.
  • wheel (.whl): vorgefertigte Build-Version. Das Suffix py3-none-any bedeutet, dass es auf allen Betriebssystemen mit Python 3 läuft, weil das Paket pure Python ist.

Überprüfe, ob das Wheel alle Dateien enthält:

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
...

Das festgelegte Datum/Zeit ist beabsichtigt: Hatchling erstellt reproduzierbare Builds. Die Dateigröße auf deinem Rechner kann sich leicht unterscheiden.

Verwende als Nächstes twine, um die Metadaten zu überprüfen, besonders ob die README richtig angezeigt wird:

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

Der letzte Schritt vor dem Hochladen ist die Installation des Wheels in einer sauberen Umgebung und zum Testen:

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

Wenn dieser Schritt den Fehler ModuleNotFoundError ergibt, hast du wahrscheinlich vergessen, eine Datei zu paketieren, oder einen falschen Paketpfad deklariert.

6. Zuerst auf TestPyPI testen

TestPyPI ist eine unabhängige Kopie von PyPI, speziell für Tests. Beide haben separate Konten und Token.

  1. Erstelle ein Konto auf TestPyPI und aktiviere die Zwei-Faktor-Authentifizierung (2FA).
  2. Gehe zu Account settings → API tokens und erstelle einen Token. Der Token beginnt mit pypi-.
  3. Hochladen:
twine upload --repository testpypi dist/*

Wenn du gefragt wirst, gib __token__ als Benutzername ein und die Token-Zeichenfolge als Passwort. Um nicht jedes Mal eingeben zu müssen, kannst du es in ~/.pypirc speichern:

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

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

Setze die Berechtigungen mit chmod 600 ~/.pypirc, damit andere Benutzer auf dem Rechner die Datei nicht lesen können. Versuche dann die Installation von TestPyPI:

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

Die Flagge --extra-index-url erlaubt pip, Abhängigkeiten vom echten PyPI zu holen, da TestPyPI normalerweise nicht über alle Abhängigkeitspakete verfügt.

7. Auf PyPI veröffentlichen

Wenn alles in Ordnung ist, ist der Prozess auf dem echten PyPI identisch, nur ohne das --repository-Flag:

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/

Von jetzt an kann jeder pip install nonla-order-tools ausführen.

Für zukünftige Veröffentlichungen folgst du diesen Schritten:

  1. Erhöhe version in pyproject.toml, z.B. auf 0.1.1.
  2. Lösche das alte Verzeichnis dist/: rm -rf dist.
  3. Führe python -m build, twine check, dann twine upload erneut aus.

Hinweis: PyPI erlaubt keine erneute Hochladung einer Datei mit einer bereits existierenden Version, auch wenn du diese Version bereits gelöscht hast. Wenn du versehentlich eine fehlerhafte Version veröffentlichst, kannst du sie nur yanken und eine neue Version veröffentlichen. Deshalb solltest du immer zuerst auf TestPyPI testen.

8. Automatische Veröffentlichung mit Trusted Publishing

Das Speichern von langfristigen Token in CI birgt das Risiko, dass sie offengelegt werden. Trusted Publishing beseitigt Token ganz. PyPI vertraut einem bestimmten GitHub Actions-Workflow über OIDC und erteilt für jede Ausführung Token mit kurzer Gültigkeit.

Einrichtung auf PyPI: Gehe zu Publishing des Projekts, oder verwende „pending publisher”, wenn das Projekt noch nicht existiert. Hier deklarierst du den Owner und den Repository-Namen, den Namen der Workflow-Datei publish.yml und die Umgebung 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

So funktioniert der Workflow:

  • Der Workflow läuft jedes Mal, wenn du eine GitHub Release erstellst.
  • Der Job build erstellt Dateien in dist/ und speichert sie als Artefakt.
  • Der Job publish ist getrennt und hat nur die Berechtigung id-token: write. Diese Berechtigung erlaubt die Anforderung eines OIDC-Token, und die Action pypa/gh-action-pypi-publish verwendet diesen Token zum Hochladen. Du brauchst kein Secret.
  • Die Trennung der beiden Jobs hilft dabei, dass der Build-Schritt, der deinen Code und die Build-Abhängigkeiten ausführt, nie auf die Veröffentlichungsberechtigungen zugreifen kann.

9. Häufige Fehler

  • Name bereits vergeben: PyPI meldet 403 oder The name is too similar to an existing project. Überprüfe den Namen, bevor du ihn wählst, und füge ein eigenes Präfix wie nonla- hinzu.
  • File already exists: du hast vergessen, die Version zu erhöhen, oder das Verzeichnis dist/ enthält noch alte Dateien. Lösche immer dist/ vor dem Build.
  • Fehlende Datendateien (JSON, Template…): Die Datei liegt nicht im Paketverzeichnis, dann wird sie nicht gepackt. Lege sie in src/nonla_order_tools/ ab und lies sie mit importlib.resources.
  • Tests bestehen auf dem Rechner, aber Importe fehlen nach der Installation: das ist genau der Fehler, den das src-Layout dir hilft zu vermeiden. Installiere immer das Wheel in einem sauberen venv wie in Abschnitt 5.
  • Abhängigkeiten zu streng deklariert: Bibliotheken sollten die Version nicht wie requests==2.32.3 festlegen, weil das zu Konflikten mit Projekten des Benutzers führt. Gib nur die untere Grenze an, z.B. requests>=2.31. Das Festlegen ist nur für Anwendungen angemessen und sollte über eine Lock-Datei erfolgen.
  • Token preisgegeben: Committe nie ~/.pypirc oder Token ins Repository. Verwende Token, die auf jedes Projekt beschränkt sind, oder noch besser Trusted Publishing.

Hinweis: Nicht jeder Code sollte auf PyPI veröffentlicht werden. Interne Unternehmenslibrotheken sollten einen privaten Index verwenden (wie Artifactory, GitLab Package Registry) oder direkt von Git aus installiert werden. PyPI ist ein öffentlicher Raum, und bereits vergebene Namen sind sehr schwer zurückzubekommen.

10. Fazit

Der moderne Python-Paketierungsprozess ist ziemlich übersichtlich. Alles dreht sich um eine einzige pyproject.toml-Datei, ein Build-Backend wie hatchling, den Befehl python -m build zum Erstellen von Wheels und Sdists, sowie twine zum Überprüfen und Hochladen.

Du solltest dich an diese Reihenfolge gewöhnen: Tests ausführen, bauen, in ein sauberes venv installieren, auf TestPyPI testen, dann auf PyPI veröffentlichen. Wenn das Projekt stabil ist, wechsle zu Trusted Publishing mit GitHub Actions. Jede Veröffentlichung besteht dann nur noch darin, ein Release zu erstellen, und du musst Token nicht mehr verwalten.

pythonpypipackagingpyprojectgithub-actions