Worum es geht. Was ein Paket ist, wie Abhängigkeiten deklariert und installiert werden, und warum das in Python länger unaufgeräumt war als in Rust, Node oder Java. Praktisch läuft heute alles auf ein Werkzeug zu: uv.
Was Sie danach können. Eine pyproject.toml von Hand schreiben, [project.scripts] eingeschlossen; eine virtuelle Umgebung und ein Lockfile anlegen und sagen, was ein Lockfile garantiert; fremden Code mit uv sync zum Laufen bringen; und ein eigenes Paket bauen und veröffentlichen.
Die Java-Entsprechung in einem Satz. Die pyproject.toml ist die pom.xml oder build.gradle, ein Wheel ist ein JAR — und uv übernimmt die Rollen von Maven und einem Versionsmanager gleichzeitig, denn es installiert den Interpreter mit.
Im Gegensatz zu Rust’s Cargo oder Node.js npm oder auch der Java Maven/Gradle Welt wirkt das Packaging-Ökosystem in Python etwas weniger aufgeräumt. Glücklicherweise hat sich auf dem Sektor sehr viel getan in den letzten Jahren, sodass man nun mit dem Python Package- und Projektmanager uv sehr gut arbeiten kann.
Eine solide Übersicht bietet inzwischen der Python Packaging User Guide der Python Package Authority, sodass wir hier nur die wesentlichen Begrifflichkeiten klären müssen. Alles weitere lässt sich bei Bedarf dann im Packaging User Guide nachlesen, etwa wenn man selbst ein Paket anderen zur Verfügung stellen möchte.
Ebenfalls gutes Ergänzungsmaterial zu diesem Kapitel ist Peter Wang’s PyBay 2024 Vortrag, “The Five Demons of Python Packaging”, darüber, wieso Packaging in der Python-Welt besonders schwer ist.
__init__.py oder was ist ein Paket?¶
Ein Paket (package) in Python ist nichts anderes als ein Ordner, der mindestens eine (meist leere) Datei namens __init__.py enthält, und typischerweise weitere .py-Dateien. Das Zen of Python enthält die Weisheit “Namespaces are one honking great idea—let’s do more of those!” und so wird jedes Paket zu einem Namespace.
import thisDie Ordnerstruktur database/utils/misc/compare.py erlaubt in Python import database.utils.misc.compare. Es gibt auch eine Nischenanwendung, die __init__.py wegzulassen, wenn man Namespaces anlegen möchte, die über mehrere Pakete hinweg verwendet werden (namespace packages). Die Datei __init__.py darf auch Code enthalten, der ausgeführt wird, wenn das Paket importiert wird. Darin lassen sich z.B. Aliase für Unterpakete definieren um ein API bereitszustellen. Früher wurden auch Metadaten wie Version, Autoren dort hinterlegt, aber das ist abgelöst durch pyproject.toml.
Pip installs packages¶
Pip ist ein Python-Modul, welches Pakete vom Python Package Index (PyPI) herunterladen kann.
Es gibt zwar auf den meisten System ein Kommando pip aber es ist üblich, mit python -m pip zu arbeiten, damit sichergestellt ist, dass das verwendete Pip in der gleichen Umgebung arbeitet wie python (denn es können z.B. mehrere Python-Installationen vorhanden sein). Wir wollen in der Regel die aktuellste Version von Pip verwenden:
python3 -m pip install --upgrade pipMit python -m pip install mypy wird der Type Checker Mypy installiert (siehe den Abschnitt über Type Hints), und auch alle Abhängigkeiten (Pakete, die von Mypy benötigt werden).
Ein typischer Weg, die Abhängigkeiten zu dokumentieren (und insbesondere die Versionen der Pakete, die benötigt werden), ist eine requirements.txt, in der jede Zeile eine Paketversionsbeschreibung ist, wie sie auch direkt pip übergeben werden kann:
python -m pip install django==4.2.1 # Exakt diese Version
python -m pip install "django>=4.0" # Minimalversion
python -m pip install "django~=4.2.0" # Kompatibel (>= 4.2.0, == 4.2.*)Manche Pakete haben optionale Features, die man beim Installieren auswählen kann, hier etwa das Feature security des Pakets requests:
python -m pip install "requests[security]"Diese Zusatzfeatures haben in der Regel eigene Abhängigkeiten, was also z.B. auch zu Konflikten und Sicherheitsrisiken führen kann.
Insgesamt regelt PEP 508 wie Abhängigkeiten ausgedrückt werden können. Das Beispiel aus der Dokumentation, was alle Features vereint ist auch requests:
requests [security,tests] >= 2.8.1, == 2.8.* ; python_version < "2.7"Ein üblicher Ablauf ist, wenn man in Quelltexte-Ordner eines Python-Projektes python projekt.py ausführen möchte, zuvor mit
python -m pip install -r requirements.txtdie Abhängigkeiten zu installieren.
Man kann auch Pakete installieren, die nicht im PyPI gelistet sind, etwa ein Git Repository via
python -m pip install git+https://github.com/user/reponame.git@branchAuch einen lokalen Ordner oder ein Archiv, welches ein Python-Paket enthält kann man installieren, indem man den Pfad explizit angibt.
Man entfernt Pakete mit pip uninstall.
Es gibt zu jeder Python-Installation mindestens zwei Orte, an denen Pakete liegen können: der systemweite Ordner site-packages und ein entsprechender Ordner pro Nutzer. Dabei wird import das Paket immer zuerst beim Benutzer suchen, bevor es die systemweiten site-packages absucht.
Mit der inzwischen überarbeiteten PEP 668 angefangen sollte der Konflikt zwischen Pip und Systemeigenen Paketverwaltungen aufgelöst werden. So passiert es in jüngeren Debian-Distributionen, dass z.B. pip install sphinx mit error: externally-managed-environment begegnet wird, und nur --break-system-packages erlaubt, die Python-Installation zu modifizieren, die von der systemeigenen Paketverwaltung installiert wurde. Was lange erlaubt blieb, Paketinstallationen im Userspace, ist in Debian 12 nun auch ‘verboten’.
venv¶
Eine Lösung für die Dependency Hell ist Containern (Isolation), d.h. eine Anwendung zusammen mit allen Abhängigkeiten in einen Ordner ablegen, und dafür zu sorgen, dass Python nur in diesem Ordner die Pakete verwendet (und nicht etwa andere Versionen davon an anderen Orten auf dem selben Rechner). Eine ‘starke’ Containerisierung wäre ein VM Image (eine maßgeschneiderte Debian-Installation mit genau der richtigen Python-Version und den passenden Paketen im System) oder ein entsprechendes Docker Image. Leichtgewichtiger ist, die Python-Installation eines Systems als gegeben anzunehmen und nur die Pakete, die man benötigt, in einem Container zu isolieren.
Der Befehl venv erstellt einen lokalen Ordner (oft .venv genannt, wobei der Punkt in Unix die Semantik eines ‘versteckten’ Ordners impliziert).
Dieser enthält ein eigenes leeres site-packages-Verzeichnis und Symlinks auf einen Python-Interpreter (der im System installiert ist).
Damit wird in einem venv (=virtual environment) ein Interpreter ausgewählt (wir kontrollieren explizit, welche Python-Version) und es gibt einen dritten Ort neben System und Userspace, in den pip Pakete installieren darf - und das auch in Debian 12.
Ein venv wird benutzt, indem es ‘aktiviert’ wird, was nur bedeutet, dass der System-PATH um den Ordner ergänzt wird, sodass Python-Pakete darin gesucht/installiert werden.
python -m venv .venv # venv anlegen
source .venv/bin/activate # venv aktivieren
python -m pip install requests # ins venv installierenZwei verschiedene Softwareprojekte / Python-Pakete können somit jedes ein eigenes venv erhalten und für sich individuelle Versionen von Abhängigkeiten verwenden.
Etwas ärgerlich ist nun, wenn man 10 Projekte hat, die alle eine Abhängigkeit auf ein großes Paket haben (z.B. wenn man PyTorch einsetzt), dann liegt die große Abhängigkeit 10 Mal auf der Festplatte. Das verschwendet Platz und auch Zeit, wenn die Abhängigkeit heruntergeladen werden muss. Hierzu gibt es offensichtlich Caching-Möglichkeiten, die venv allerdings nicht von Haus aus mitbringt (aber uv).
setup.py, distutils, setuptools, hatchling¶
In den frühen Versionen von Python hat man ein Paket kompiliert / verteilt, indem man eine setup.py Datei geschrieben hat, die mit dem distutils-Paket aus der Standardbibliothek den Installationsprozess durchgeführt hat. In Python 3.12 wurde distutils endgültig abgeschafft und durch setuptools ersetzt. Das macht sich manchmal bemerkbar, wenn man ältere Python-Projekte auf Python >= 3.12 zum Laufen bekommen möchte.
Inzwischen versucht man, die Metadaten eines Pakets und die Installtionsdetails vom Code zu trennen, und verwaltet die Daten daher nicht mehr in setup.py sondern in der pyproject.toml. Diesem Wechsel vom imperativem Ansatz zum deklarativen Ansatz begleitet der Wechsel von setuptools zu hatch/hatchling, welches auch von der Python Package Authority (PyPA) verwaltet wird.
pyproject.toml¶
In PEP 621 ist beschrieben, wie ein Python-Projekt seine Metadaten so formuliert, dass Packaging-Werkzeuge diese verarbeiten können. Dabei wurde das TOML-Format gewählt, weil es menschenlesbar ist, im Gegensatz zu JSON auch Kommentare erlaubt und nicht so strikte Formatierungsregeln wie YAML einfordert und leichter zu parsen ist.
Ein Beispiel einer (kleinen) Projektkonfiguration:
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "my_minimal_package"
version = "0.1.0"
description = "A minimal example Python package"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
dependencies = [
"requests>=2.30.0"
]Eggs und Wheels¶
Ein .egg war der erste Anlauf, in Python Pakete (als Zip-Archive) zu verteilen; Eggs werden heute nicht mehr verwendet.
Wheels .whl sind das aktuelle Distributionsformat für Python. Ein Wheel ist ein Zip-Archiv, welches vorkompilierten Python-Code (Bytecode, .pyc-Dateien) zusammen mit vorkompiliertem C-Code oder Rust-Code enthält. Somit kann pip install direkt ein lauffähiges Paket bereitstellen, auch wenn im System keine passenden C- oder Rust-Compiler vorliegen.
Poetry und Pipx¶
Anstatt manuell ein venv anzulegen und mit pip die Abhängigkeiten in requirements.txt händisch zu verwalten, kann man die Metadaten (Abhängigkeiten) in der pyproject.toml beschreiben und mit einem High-Level Projektverwaltungswerkzeug wie Poetry bearbeiten. Poetry automatisiert nicht nur die Erstellung der virtuellen Umgebung und die strikte Auflösung von Abhängigkeitskonflikten (via Lockfile), sondern kümmert sich auch ganzheitlich um das Bauen und Veröffentlichen (Publishing) von Paketen auf PyPI. Damit kapselt man den venv+pip-Workflow für reproduzierbare Builds. Eine ältere Alternative war pipenv.
Ein weiteres Werkzeug ist pipx. Während pip (oder Poetry) primär Bibliotheken in der Entwicklungsumgebung installiert, ist pipx speziell dafür gedacht, ausführbare Endnutzer-Kommandozeilenanwendungen (CLI-Tools wie black, mypy oder ruff) global im System bereitzustellen. Der Clou: pipx verpackt jedes Tool automatisch in ein eigenes, unsichtbares venv. So können diese global aufgerufen werden, ohne jemals die systemweiten Pakete oder andere Tools durch inkompatible Abhängigkeiten zu stören.
Diese Werkzeuge (und ihre spezifischen Workflows) werden heutzutage im Wesentlichen durch das allumfassende uv abgelöst.
uv¶
Uv ist ein schneller Python Paket- und Projektmanager, der in Rust geschrieben ist. Es ist das Aushängeschild für Oxidization in der Python-Welt, das ist der Trend, gewisse Komponenten in Rust zu implementieren, für mehr Sicherheit und Geschwindigkeit. Uv ersetzt u.a. pip, pipx, poetry, virtualenv und ist dabei um Faktor 10 bis 100 mal schneller als pip.
Unix:
curl -LsSf https://astral.sh/uv/install.sh | shWindows:
powershell -c "irm https://astral.sh/uv/install.ps1 | more"Der Workflow ist, die ‘alten’ Befehle mit ‘uv’-Varianten zu ersetzen:
uv pip show requests
uv pip install requests
uv pip list --outdated
uvx mypy # like pipxBeispiel¶
Um ein Programm mit seinen Abhängigkeiten zu installieren / zu starten ist ein typischer Workflow:
sudo apt install freeglut3-dev # install external dependencies!
git clone https://github.com/konradvoelkel/wesen.git
cd wesen
uv venv
uv sync
uv run wesenDie wesentliche Arbeit macht uv sync, denn dabei wird die pyproject.toml eingelesen, ein Lockfile uv.lock erstellt mit präzisen Versionsangaben aller Abhängigkeiten sowie Quellen für die Pakete. Der Befehl uv run wesen ruft ein zuvor in der pyproject.toml konfiguriertes Skript auf.
Das ist kein konstruiertes Beispiel: Genau diese Schritte verlangen die Übungsblätter 4 und 5 für Wesen — ein Projekt von 2003/2013, für das eine pyproject.toml erst noch zu schreiben ist. Wer dort auf eine Systemabhängigkeit wie freeglut stößt, hat den Normalfall beim Übernehmen fremden Codes erlebt: Der Python-Teil ist selten das Problem.
Ein Beispiel einer pyproject.toml mit uv-spezifischen Anweisungen (siehe auch die Folgekapitel):
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "wesen"
version = "0.7.0"
description = "A game where players program Wesen behavior."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"numpy",
"PyOpenGL>=3.1.0",
"Pillow>=12.2.0",
]
[project.scripts]
# Creates a terminal command 'wesen' that runs Loader()
wesen = "Wesen.loader:Loader"
[dependency-groups]
dev = [
"mypy>=2.1.0",
"ruff>=0.15.13",
]
Inline-Metadaten:
Besonders elegant für einzelne, in sich geschlossene Skripte (Self-contained scripts) ist die durch PEP 723 standardisierte Möglichkeit, Metadaten wie Paketabhängigkeiten direkt als speziellen Kommentarblock oben in der .py-Datei abzulegen.
Ruft man ein solches Skript beispielsweise mit uv run mein_skript.py auf, liest uv diese Metadaten, baut on-the-fly eine temporäre, isolierte Umgebung mit exakt diesen Paketen, führt das Skript aus und räumt danach wieder auf – völlig ohne dass dafür ein Ordner, eine pyproject.toml oder ein manuelles venv angelegt werden muss.
Dazu sieht der Anfang von mein_skript.py so aus:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests>=2.32.0",
# ]
# ///
print("Hier kommt normaler Python-Code")
Ein Projekt pflegen und ausliefern¶
Die Befehle oben richten eine Umgebung ein. Fünf weitere braucht man, sobald ein Projekt länger lebt als eine Sitzung.
Die Python-Version festnageln. Ohne Festlegung nimmt uv irgendeinen passenden Interpreter, und “läuft bei mir” wird zu einer Aussage über Ihren Rechner. uv python pin schreibt die Version in eine .python-version-Datei, die mit ins Repository gehört:
uv python install 3.13
uv python pin 3.13 # schreibt .python-version
uv python list # was ist verfügbarDas ergänzt requires-python in der pyproject.toml, ersetzt es aber nicht: requires-python ist eine Zusage an alle, die das Paket benutzen (“läuft ab 3.13”), .python-version eine Anweisung an das Werkzeug, mit welcher Version hier entwickelt wird. Wer beide setzt, bekommt in der CI denselben Interpreter wie auf dem Laptop — und Mypy prüft gegen dieselbe Version (siehe Code-Qualität).
Sehen, wo eine Abhängigkeit herkommt. uv tree zeigt den Abhängigkeitsbaum. Die interessante Frage ist meist nicht “was ist installiert”, sondern “warum ist das installiert”:
uv tree --depth 1 # nur die direkten Abhängigkeiten
uv tree --invert --package certifi # wer zieht certifi herein?Abhängigkeiten bewusst aktualisieren. uv sync hält sich an das Lockfile und ändert Versionen nicht von selbst — das ist der Sinn eines Lockfiles. Neue Versionen holt man ausdrücklich:
uv lock --upgrade # alles, was geht
uv lock --upgrade-package requests # nur dieses eine Paket
uv lock --check # ist das Lockfile zur pyproject.toml aktuell?uv lock --check ist der Befehl für die CI: Er schlägt fehl, wenn jemand eine Abhängigkeit in die pyproject.toml geschrieben, aber das Lockfile nicht mitgeliefert hat. Und ja — uv.lock wird eingecheckt. Bei einer Anwendung will man reproduzierbare Installationen; bei einer Bibliothek, die andere als Abhängigkeit einbinden, ist das Lockfile für deren Auflösung ohnehin unerheblich, im eigenen Repository aber weiter nützlich.
Ein Paket bauen und veröffentlichen. Für den Fall, dass das Abschlussprojekt tatsächlich auf PyPI landen soll:
uv build # erzeugt dist/*.tar.gz (sdist) und dist/*.whl (wheel)
uv publish --index testpypi # erst auf TestPyPI ausprobieren
uv publish # dann auf PyPIuv build erzeugt beides, was der Abschnitt über Eggs und Wheels beschreibt: das Source Distribution als Fallback und das Wheel als fertiges Paket. Für uv publish braucht man einen API-Token des Index; der gehört in eine Umgebungsvariable (UV_PUBLISH_TOKEN) oder einen Credential-Store, nicht in eine Datei im Projekt.
Das Build-Backend wählen. Das Beispiel oben benutzt hatchling. Inzwischen bringt uv ein eigenes, minimales Backend mit, das für reine Python-Pakete genügt und schneller ist:
[build-system]
requires = ["uv_build>=0.9,<0.13"]
build-backend = "uv_build"uv init --build-backend uv legt ein neues Projekt direkt so an (hatch, flit, pdm, poetry, setuptools, maturin und scikit stehen ebenfalls zur Wahl). Die Entscheidung ist weniger folgenreich, als sie aussieht: Solange kein C- oder Rust-Code kompiliert werden muss, tun alle dasselbe, und ein Wechsel kostet drei Zeilen. Wer Erweiterungen in Rust einbindet, nimmt maturin; für C-Erweiterungen bleibt setuptools die sichere Wahl.
Zusammenfassung und Kontrollfragen¶
Ein Paket ist ein Verzeichnis mit Modulen;
__init__.pymacht es explizit und regelt, was beim Import sichtbar wird.Die
pyproject.tomlist die eine standardisierte Stelle für Metadaten, Abhängigkeiten und Werkzeugkonfiguration — das Build-Backend dahinter ist austauschbar und für reine Python-Pakete zweitrangig.Eine virtuelle Umgebung trennt die Abhängigkeiten eines Projekts von allen anderen;
uv runbenutzt sie, ohne dass man sie von Hand aktiviert.Das Lockfile
uv.lockhält exakte Versionen und Bezugsquellen fest und wird eingecheckt:uv syncstellt daraus dieselbe Umgebung wieder her,uv lock --upgradeist der bewusste Schritt zu neueren Versionen.Das eigentliche Problem beim Übernehmen fremden Codes sind selten die Python-Pakete, sondern die Systemabhängigkeiten —
freeglutbei Wesen ist der Normalfall, nicht die Ausnahme.
Kontrollfragen
Sie klonen ein fremdes Projekt, das eine
pyproject.tomlund einuv.lockenthält. Welcher einzelne Befehl stellt die Umgebung her, und was genau leistet dabei das Lockfile?Warum genügt
requires-python = ">=3.13"nicht, damit alle im Team mit derselben Python-Version arbeiten? Was ergänzt man, und worin unterscheiden sich die beiden Angaben in ihrer Aussage?Ein einzelnes Skript braucht genau eine externe Bibliothek und soll ohne Projektordner laufen. Welcher Mechanismus erlaubt das, und wo stehen die Abhängigkeiten dann?