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 mitpip installein. - Der Import-Name des Pakets:
nonla_order_tools. Wird inimportverwendet, 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]teiltpipundbuildmit, welches Build-Backend verwendet werden soll. Hier ist eshatchling. Du kannst auchsetuptools,flit-coreoderpdm-backendverwenden. Metadaten in[project]bleiben gleich.nameundversionsind erforderlich. Die Version sollte das FormatMAJOR.MINOR.PATCHfolgen.readmewird zur Beschreibungsseite auf PyPI.requires-pythonverhindert die Installation auf nicht unterstützten Python-Versionen.license = "MIT"ist ein SPDX-Ausdruck nach dem neuen Standard. Dieser Standard benötigthatchling>=1.27, daher ist die Mindestversion oben angegeben.dependencieslistet die erforderlichen Bibliotheken zur Laufzeit auf, z.B.["httpx>=0.27"].optional-dependencieserstellt „Extras”. Benutzer können mitpip install "nonla-order-tools[test]"installieren.[project.scripts]erstellt den Befehlorder-totalimPATH, wenn das Paket installiert wird. Dieser Befehl ruft diemain-Funktion im Modulcliauf.
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 Suffixpy3-none-anybedeutet, 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.
- Erstelle ein Konto auf TestPyPI und aktiviere die Zwei-Faktor-Authentifizierung (2FA).
- Gehe zu Account settings → API tokens und erstelle einen Token. Der Token beginnt mit
pypi-. - 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:
- Erhöhe
versioninpyproject.toml, z.B. auf0.1.1. - Lösche das alte Verzeichnis
dist/:rm -rf dist. - Führe
python -m build,twine check, danntwine uploaderneut 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
builderstellt Dateien indist/und speichert sie als Artefakt. - Der Job
publishist getrennt und hat nur die Berechtigungid-token: write. Diese Berechtigung erlaubt die Anforderung eines OIDC-Token, und die Actionpypa/gh-action-pypi-publishverwendet 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
403oderThe name is too similar to an existing project. Überprüfe den Namen, bevor du ihn wählst, und füge ein eigenes Präfix wienonla-hinzu. File already exists: du hast vergessen, die Version zu erhöhen, oder das Verzeichnisdist/enthält noch alte Dateien. Lösche immerdist/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 mitimportlib.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.3festlegen, 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
~/.pypircoder 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.