pyproject.toml, Lockfiles und das src/-Layout — moderne Projektstruktur
Von setup.py zu pyproject.toml, reproduzierbare Builds mit uv.lock, __init__.py und das src/-Layout — und ein flacher Skriptordner wird zu einem richtigen Paket mit Befehl.
Wie möchtest du lernen?
Das Problem, das wir lösen
Die Ticket-Triage-Arbeit begann als ein paar Skripte in einem einzigen Ordner: clean.py, score.py, run_triage.py. Man steht im Ordner, führt python run_triage.py aus, und alles funktioniert. Dann, über drei Monate hinweg:
- Ein Teammitglied hat ein Report-Skript in einen Unterordner
scripts/gelegt, und es ist mitModuleNotFoundError: No module named 'score'gescheitert — obwohlscore.pydirekt nebenan im Ordner liegt. - Jemand hat im Ordner eine Datei
secrets.pyangelegt, um einen API-Schlüssel abzulegen, und plötzlich ist das eingebaute Modulsecretsin einem anderen Skript „kaputt“. - Eine neue Person ist dazugekommen. Die alte
setup.pyund dierequirements.txtführten Pakete auf zwei unterschiedliche Arten auf, und niemand wusste, welche Angabe stimmt.
Im Code steckt kein Bug; der Fehler liegt in der Struktur. In diesem Kapitel machen wir aus diesem Ordner ein richtiges Python-Paket — mit einer pyproject.toml, einer Lockfile und einem src/-Layout. So sieht fast jedes moderne Python-Repository in einer Firma aus.
Am Ende dieses Kapitels kannst du
- Erklären, warum Python von
setup.pyzupyproject.tomlgewechselt ist, und die wichtigsten Abschnitte einerpyproject.tomllesen - Erklären, was
uv.lockfesthält, unduv lock,uv syncunduv sync --lockedan den richtigen Stellen einsetzen - Ein Projekt mit
src/-Layout überuv init --packageund--libanlegen und erklären, was__init__.pymacht - Die zwei Import-Fallen eines flachen Ordners erkennen und zeigen, warum das
src/-Layout sie früh abfängt - Einen flachen Skriptordner in ein Paket mit einem Kommandozeilen-Einstiegspunkt umwandeln
Was du vorher brauchst: Git, Branches und Pull Requests — dazu uv init, uv add und uv run aus dem Kapitel davor.
Von setup.py zu pyproject.toml
In älteren Python-Projekten findest du oft eine Datei wie diese:
from setuptools import find_packages, setup
setup(
name="ticket-triage",
version="0.1.0",
packages=find_packages(),
install_requires=["httpx"],
entry_points={"console_scripts": ["triage=ticket_triage.cli:main"]},
)Daneben liegt eine requirements.txt, oft mit einer anderen Liste. Das Grundproblem von setup.py ist, dass es ein Programm ist: Selbst um den Namen oder die Abhängigkeiten des Projekts zu erfahren, musst du es ausführen. Und weil es Python ist, kann dort alles stehen — Dateien lesen, Bedingungen, sogar Netzwerkaufrufe. Werkzeuge können darüber nie mit Sicherheit etwas sagen.
pyproject.toml enthält dieselben Informationen als Deklaration — eine einfache TOML-Datei, die gelesen, aber nie ausgeführt wird. Sie ist inzwischen der Python-Standard, und uv, Editoren, Testwerkzeuge und Linter lesen sie. So sieht das aus, was uv init --package ticket-triage erzeugt:
[project]
name = "ticket-triage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nadia Karim", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ticket-triage = "ticket_triage:main"
[build-system]
requires = ["uv_build>=0.9.1,<0.10.0"]
build-backend = "uv_build"(uv übernimmt Name und E-Mail-Adresse aus deinen Git-Einstellungen.) Drei Abschnitte:
[project]— die Identität des Projekts und seine Abhängigkeiten.uv addverwaltet die Listedependenciesfür dich.[project.scripts]— Kommandozeilenbefehle.ticket-triage = "ticket_triage:main"heißt: Lege einen Befehl namensticket-triagean, der die Funktionmainim Paketticket_triageaufruft.[build-system]— das Werkzeug, das das Projekt in ein installierbares Paket verwandelt (das „Build-Backend“).uvnutzt sein eigenesuv_build; in anderen Projekten siehst du vielleichthatchlingodersetuptools— mit derselben Aufgabe.
Achte darauf, dass das Projekt ticket-triage (mit Bindestrich) heißt, das Python-Paket aber ticket_triage (mit Unterstrich). Bindestriche sind in Python-Namen nicht erlaubt — import ticket-triage wäre eine Subtraktion —, deshalb wandelt uv den Namen für dich um.
Lockfiles: von „hat funktioniert“ zu „funktioniert immer gleich“
In pyproject.toml werden Abhängigkeiten als Bereiche geschrieben: "httpx>=0.28.1" bedeutet Version 0.28.1 oder jede neuere. Das ist Absicht — du beschreibst, was funktionieren wird. Aber wenn du heute und in drei Monaten installierst, kann dabei eine andere Version herauskommen, von den Abhängigkeiten darunter ganz zu schweigen.
uv.lock schließt diese Lücke. Es hält die genaue Version und die Datei-Hashes jedes Pakets fest — auch die weiter unten in der Kette. Liegt es im Repository, installieren jeder Rechner, die CI und der Produktivserver genau dasselbe. Das nennt man einen „reproduzierbaren Build“.
Vier Befehle, vier Aufgaben:
uv lock— liestpyproject.tomlund legt die Lockfile an oder aktualisiert sie. Installiert nichts.uv sync— bringt.venvgenau in den Stand der Lockfile; aktualisiert die Lockfile vorher, falls nötig.uv sync --locked— dasselbe, aber wenn die Lockfile veraltet ist, bricht es ab, statt sie zu aktualisieren.uv lock --check— ändert nichts, meldet nur, ob die Lockfile zupyproject.tomlpasst.
uv add und uv remove aktualisieren beide Dateien gemeinsam, deshalb rufst du diese Befehle im normalen Arbeitsalltag nicht einzeln auf. Probleme beginnen, wenn jemand pyproject.toml von Hand bearbeitet. Angenommen, jemand ändert dependencies = [] von Hand in dependencies = ["httpx>=0.28"] und führt kein uv lock aus:
uv sync --locked
Resolved 8 packages in 57ms
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.Deshalb führt die CI immer uv sync --locked aus: Dort kann sich keine ungeprüfte Version unbemerkt einschleichen.
Einfachesuv syncmeldet hier keinen Fehler. Ohne--lockedlöstuvin derselben Situation die Abhängigkeiten still neu auf, schreibtuv.lockselbst um und macht weiter. Auf deinem eigenen Rechner ist das bequem; in der CI heißt es, dass etwas anderes getestet wird als das, was im Repository liegt.
Pakete, __init__.py und das src/-Layout
Ein Modul ist eine einzelne .py-Datei. Ein Paket ist ein Ordner mit Modulen und einer __init__.py darin. __init__.py hat zwei Aufgaben: Sie markiert den Ordner als importierbares Paket, und sie ist die Datei, die zuerst läuft, wenn du import ticket_triage schreibst — deshalb kann dort alles stehen, was du als „Eingangstür“ des Pakets zeigen willst.
Drei Formen von uv init:
uv init— ein Skript-Projekt:main.pyundpyproject.toml, kein Build-System. Für Aufgaben mit ein oder zwei Dateien und für Experimente.uv init --package— ein Anwendungspaket:src/-Layout, ein Build-System und ein Befehl in[project.scripts].uv init --lib— eine Bibliothek, die andere importieren werden:src/-Layout und ein Build-System, aber kein Befehl, dafür eine leere Dateipy.typed, die Type-Checkern sagt, dass das Paket Typinformationen mitliefert.
Die Struktur aus uv init --package ticket-triage:
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyDas Paket liegt nicht direkt im Projektstamm, sondern in einem Ordner src/. Um zu verstehen, warum es diese zusätzliche Ebene gibt, schauen wir zuerst, was in einem flachen Ordner schiefgeht.
Zwei Fallen in einem flachen Ordner
Falle eins: wo Python sucht. Wenn du ein Skript ausführst, sucht Python Importe zuerst im Ordner des Skripts — nicht dort, wo du im Terminal stehst. Im flachen Ordner funktioniert from score import priority nur, weil score.py und das Skript im selben Ordner liegen. Das scripts/report.py des Teammitglieds:
from score import priority
print(priority("Database breach reported"))Du stehst im Ordner triage-scripts und führst python scripts/report.py aus:
Traceback (most recent call last):
File "scripts\report.py", line 1, in <module>
from score import priority
ModuleNotFoundError: No module named 'score'(Auf deinem Rechner zeigt der Traceback den vollständigen Pfad; hier ist er gekürzt.) Python hat in scripts/ gesucht, und dort gibt es kein score.py. Ob ein Import funktioniert, hängt davon ab, wo die Datei gerade liegt — für ein Projekt gibt es kaum fragilere Regeln.
Falle zwei: Namenskollisionen. Aus demselben Grund werden Dateien im Ordner des Skripts vor Pythons eigenen Modulen durchsucht. Jemand legt secrets.py im Ordner an, um einen Schlüssel abzulegen, und das daneben liegende new_id.py will das Standardbibliotheks-Modul secrets:
import secrets
print(len(secrets.token_hex(8)))Traceback (most recent call last):
File "new_id.py", line 3, in <module>
print(len(secrets.token_hex(8)))
^^^^^^^^^^^^^^^^^
AttributeError: module 'secrets' has no attribute 'token_hex'import secrets hat keinen Fehler ausgelöst — es hat die secrets.py des Projekts gefunden und diese importiert. Der Fehler kommt erst später, wenn der Code etwas anfordert, das diese Datei nicht hat. In KI-Projekten passiert das oft mit experimentellen Dateien namens openai.py, anthropic.py oder httpx.py, die genau die Bibliothek verdecken, die du gerade verwenden willst.
Wie das src/-Layout diese Fehler früh abfängt
In einem src/-Layout ändern sich zwei Dinge.
Erstens liegt alles unter einem eindeutigen Namen. Die Module heißen nicht mehr clean, score, secrets — sondern ticket_triage.clean, ticket_triage.score. Keine Datei im Projekt kann die Standardbibliothek oder ein anderes Paket verdecken, und die Importe selbst zeigen, welcher Code dir gehört.
Zweitens lässt sich das Paket nicht importieren, ohne dass es installiert ist. Versuch es aus dem Projektstamm mit einfachem python:
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'Der Fehler ist das Feature. Läge das Paket direkt im Projektstamm, würde dieser Befehl funktionieren — Python würde den Ordner direkt finden —, und du würdest nie erfahren, ob die Konfiguration in pyproject.toml stimmt. In src/ gibt es nur einen Weg zum Paket: die Installation. Und uv run erledigt das jedes Mal — es installiert das Projekt als „editierbar“ in .venv, das heißt, das installierte Paket zeigt zurück auf deinen src/-Ordner, sodass du nach jeder Code-Änderung nicht neu installieren musst:
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highSo zeigt sich jeder Fehler in der Paketierung auf deinem eigenen Rechner, beim ersten uv run — nicht am Tag des Deployments. Wenn zum Beispiel der Paketordner falsch benannt ist oder __init__.py fehlt:
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(Der Pfad ist gekürzt.) Das Build-Backend leitet den Paketnamen aus dem Projektnamen ab — ticket-triage wird zu src/ticket_triage/ — und bricht ab, wenn es dort nichts findet. Wenn du bewusst einen anderen Namen willst, sag es mit module-name unter [tool.uv.build-backend] in pyproject.toml.
Ein vollständiges Beispiel
Jetzt wandeln wir den flachen Ordner vom Anfang des Kapitels um. Nachdem du uv init --package ticket-triage --python 3.12 in einem Nachbarordner ausgeführt und die alten Dateien ins Paket verschoben hast, sieht die Struktur so aus:
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pyEine Änderung in pyproject.toml — ein kürzerer Befehlsname, der auf das neue Modul cli zeigt:
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — das alte clean.py, jetzt mit Typhinweisen und einem Docstring:
import re
def clean_text(text: str) -> str:
"""Lowercase the text and collapse every run of whitespace to one space."""
return re.sub(r"\s+", " ", text).strip().lower()src/ticket_triage/score.py — die einzige echte Änderung ist die Importzeile: Aus from clean import ... wird from ticket_triage.clean import ...:
from ticket_triage.clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"src/ticket_triage/__init__.py — die Eingangstür des Pakets:
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — das Skript, das vorher kaputtging, importiert jetzt über den Paketnamen:
from ticket_triage import priority
print(priority("Database breach reported"))Und src/ticket_triage/cli.py — der Einstiegspunkt der Kommandozeile, der das alte run_triage.py ersetzt:
import sys
from ticket_triage.score import priority
DEMO_TICKETS = ["Login page is slow", "Payments DOWN for all users"]
def main() -> None:
tickets = sys.argv[1:] or DEMO_TICKETS
for ticket in tickets:
print(f"{priority(ticket):<6} {ticket}")
if __name__ == "__main__":
main()Aus dem Projektstamm ausführen:
uv run triage
uv run triage "Service down since morning" "Typo on the pricing page"
uv run scripts/report.py
uv lock --checknormal Login page is slow
high Payments DOWN for all users
high Service down since morning
normal Typo on the pricing page
high
Resolved 1 package in 2msDer allererste uv run triage gibt davor noch ein paar Zeilen aus — das Anlegen von .venv, dann Building ticket-triage und Built ticket-triage —, weil uv das Projekt zum ersten Mal installiert; danach erscheinen sie nicht mehr. Die letzte Zeile stammt von uv lock --check, und dessen Exit-Code ist 0: Die Lockfile passt zu pyproject.toml.
Die Entscheidungen bei dieser Umwandlung:
Absolute Importe (from ticket_triage.clean import ...). Relative Importe funktionieren innerhalb eines Pakets auch — from .clean import clean_text —, aber absolute Importe sehen in jeder Datei gleich aus und lassen sich leichter suchen. Egal, wofür du dich entscheidest: Verwende pro Projekt einen einheitlichen Stil.
Nur priority in __init__.py. Äußerer Code (scripts/report.py, künftige Tests, andere Dienste) kennt nur from ticket_triage import priority. Ändert sich später die Aufteilung zwischen score.py und clean.py, bricht äußerer Code nicht. __all__ hält diese öffentliche Liste ausdrücklich fest.
Eine eigene cli.py. Wenn Logik (score.py) und Ein- und Ausgabe (cli.py) getrennt sind, kann priority von überall aufgerufen werden — in einem Test, einer API —, ohne sys.argv oder print. Ist sys.argv[1:] leer, laufen die Demo-Tickets, sodass auch ein Aufruf ohne Argumente etwas zeigt.
f"{priority(ticket):<6}". :<6 richtet den Text linksbündig auf sechs Zeichen aus, damit die Tickets nach high und nach normal in derselben Spalte beginnen.
Eine Einschränkung solltest du kennen: clean_text trennt Wörter nur an Leerzeichen. Im Satz "The site is down." ist das Wort also down. — mit Punkt — und passt nicht zu URGENT_WORDS. Bessere Methoden, Wörter aus Text zu extrahieren, kommen später im Kurs; dieses Kapitel geht um die Struktur, und weil die Struktur stimmt, passiert diese Verbesserung in clean.py und nirgendwo anders.
Wenn es schiefgeht
ModuleNotFoundError: No module named 'clean' Die Dateien wurden ins Paket verschoben, aber irgendeine Datei hat noch den alten Import from clean import clean_text. Innerhalb eines Pakets gibt es kein oberstes Modul namens clean; schreib from ticket_triage.clean import clean_text. Nach der Umwandlung durchsuchst du das ganze Projekt nach from clean und from score.
ModuleNotFoundError: No module named 'ticket_triage' Es wurde mit einfachem python statt mit uv run gestartet, also wurde die Umgebung mit dem installierten Paket nicht verwendet. Starte es mit uv run .... Das ist das src/-Layout, wie es gedacht ist, und kein Fehler.
Expected a Python module at: src\ticket_triage\__init__.py Das Paket liegt nicht in dem Ordner, den das Build-Backend aus dem Projektnamen erwartet — der Ordner ist falsch benannt (src/triage/, src/ticket-triage/) oder __init__.py fehlt. Korrigiere den Ordnernamen, lege __init__.py an oder gib den richtigen Namen mit module-name an.
The lockfile at uv.lock needs to be updated, but --locked was provided. pyproject.toml wurde von Hand bearbeitet und uv lock wurde nicht ausgeführt, oder die Lockfile wurde nicht committet. Führe uv lock aus und committe beide Dateien zusammen. Ändere Abhängigkeiten beim nächsten Mal mit uv add und uv remove — diese Befehle halten beide Dateien synchron.
AttributeError: module 'secrets' has no attribute 'token_hex' Das Projekt enthält eine Datei, die nach einem Standardbibliotheks-Modul benannt ist (secrets.py, random.py, logging.py) und das echte Modul verdeckt. Benenne die Datei um — und bewahre Geheimnisse in .env auf, nicht in einer .py-Datei.
error: Failed to spawn: triag gefolgt von Caused by: program not found Der Name, den du uv run übergibst, unterscheidet sich von dem in [project.scripts] — hier ein Tippfehler. Prüfe den Namen in pyproject.toml; nach dem Umbenennen installiert uv run das Projekt von selbst neu und legt den neuen Befehl an.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Die clean_text-Funktion des Kapitels wird auf ein unordentliches Ticket angewendet. Was wird ausgegeben?
import re
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
print(repr(clean_text(" Payments DOWN\tsince 9 ")))- A`' payments down since 9 '`
- B`'payments down since 9'`
- C`'payments down\tsince 9'`
- D`'Payments DOWN since 9'`
Nach dem Wechsel vom flachen Ordner zum src/-Layout gibt uv run triage ModuleNotFoundError: No module named 'clean' aus. clean.py liegt direkt daneben, unter src/ticket_triage/clean.py. Was ist der Fehler?
# src/ticket_triage/score.py
from clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"- A`clean.py` muss zurück in den Projektstamm, denn dort sucht Python
- BDer Import ist noch im Stil des flachen Ordners; im Paket muss er `from ticket_triage.clean import clean_text` lauten
- CMit einer `__init__.py` in `src/ticket_triage/` können Nachbardateien nicht importiert werden; sie muss gelöscht werden
- D`uv run triage` schaut nur in `cli.py`; Importe in `score.py` werden nie ausgeführt
In einem PR hat jemand pyproject.toml von Hand bearbeitet, um ein Paket hinzuzufügen. Das uv sync --locked in der CI schlägt fehl: The lockfile at uv.lock needs to be updated. Was ist die richtige Lösung?
- AEntferne `--locked` aus der CI, damit die CI die Lockfile selbst aktualisiert
- BTrage `uv.lock` in `.gitignore` ein, damit jeder seine eigene Lockfile erzeugt
- CFühre `uv lock` lokal aus (oder verwende von Anfang an `uv add`) und committe die aktualisierte `uv.lock` im selben PR
- DLösche `uv.lock`, denn ohne Lockfile kann dieser Fehler nicht auftreten
Spielen ist Teil von Pro
+30 XP warten
Spiele dieses Kapitel mit Pro
Lesen ist kostenlos, und das ganze Kapitel ist offen. Die ersten 5 Kapitel jedes kostenlosen Kurses kannst du kostenlos spielen. Ab Kapitel 6 gehören Fragen beantworten, XP sammeln und deine Serie ausbauen zu Pro.
Spielen mit Pro freischaltenJetzt bist du dran
Baue den flachen Ordner vom Anfang des Kapitels selbst nach und wandle ihn um. Lege zuerst drei Dateien in einen Ordner triage-scripts/: clean.py (das clean_text von oben, ohne Typhinweise), score.py (beginnend mit from clean import clean_text) und run_triage.py, das die Priorität von zwei Demo-Tickets ausgibt. Dann:
- Lege daneben ein neues Projekt mit
uv init --package ticket-triage --python 3.12an, verschiebe die Dateien nachsrc/ticket_triage/und schreibe jeden Import mitticket_triage. - Mach aus
run_triage.pyeine Funktionmain()incli.py, wie in diesem Kapitel (mit denselben zwei Demo-Tickets), und tragetriage = "ticket_triage.cli:main"in[project.scripts]ein - Stelle in
__init__.pynurpriorityzur Verfügung und verwende es inscripts/report.pymitfrom ticket_triage import priority - Führe
uv lock --checkaus, um zu bestätigen, dass die Lockfile passt, und committepyproject.toml,uv.lock,src/undscripts/in einem Commit — ohne.venv
Aus dem Projektstamm sollten uv run triage und danach uv run scripts/report.py genau das ausgeben:
normal Login page is slow
high Payments DOWN for all users
highDann bau es zweimal absichtlich kaputt. Einmal: Ändere den Import in score.py zurück auf from clean import clean_text und schau, welcher Fehler in welcher Zeile erscheint. Ein anderes Mal: Lege eine secrets.py in src/ticket_triage/ an, füge import secrets und print(len(secrets.token_hex(8))) in cli.py ein und führe uv run triage aus — funktioniert die Falle aus dem flachen Ordner auch hier, und warum nicht?
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Mit Pro spielen