Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Brownfield: mit fremdem Code arbeiten

Heinrich-Heine-Universität Düsseldorf

Worum es geht. Der Normalfall im Beruf ist nicht das leere Verzeichnis, sondern ein Projekt, das jemand anderes geschrieben hat, das seit Jahren keiner angefasst hat und das jetzt eine Änderung braucht. Dieses Kapitel ist die Anleitung dafür, in der Reihenfolge, in der man sie braucht: verstehen, zum Laufen bringen, Verhalten einfrieren, modernisieren, umbauen, beitragen.

Was Sie danach können. Ein unbekanntes Projekt in einer halben Stunde so weit erschließen, dass Sie sagen können, wo es anfängt und was es braucht; entscheiden, ob Sie es überhaupt gefahrlos ausführen dürfen; sein Verhalten festhalten, bevor Sie es ändern; Syntax und Idiome mechanisch auf heutigen Stand heben, ohne Verhalten zu berühren; ein Refactoring so schneiden, dass ein Review es prüfen kann; und einen Pull Request abgeben, den jemand annehmen möchte.

Die Java-Entsprechung in einem Satz. Dasselbe Problem, dieselben Schritte — nur dass Ihnen in Python der Compiler fehlt, der beim ersten Übersetzungsversuch die halbe Inventur für Sie macht: Hier stellt sich erst zur Laufzeit heraus, was nicht mehr existiert.

1. Archäologie: Was ist das überhaupt?

Die erste Versuchung ist, eine Datei zu öffnen und zu lesen. Das ist bei 3.725 Zeilen in 53 Dateien der langsamste Weg. Schneller sind sechs Fragen, die sich mit je einem Befehl beantworten lassen.

Wo fängt es an?

Ein Python-Projekt hat seinen Einstiegspunkt an einer von vier Stellen. Fragen Sie sie in dieser Reihenfolge ab:

grep -rn "if __name__" --include="*.py" .    # klassischer Skript-Einstieg
grep -n "scripts\|entry_points" pyproject.toml setup.py setup.cfg 2>/dev/null
ls **/__main__.py                            # python -m paket
cat MANIFEST.in INSTALL README* 2>/dev/null  # was die Autoren selbst sagen

Bei Wesen liefern die ersten drei nichts Brauchbares — if __name__ findet sich nur in Testdateien, eine setup.py existiert nicht. Die vierte Zeile bringt die Antwort. In MANIFEST.in steht:

include wesen INSTALL README.md

Es gibt also eine Datei namens wesen — ohne Endung, weshalb kein *.py-Suchmuster sie findet:

cat wesen
#! /usr/bin/python3

"""Copyright 2003-2013 by Konrad Voelkel and Reimer Backhaus.
This program is distributed under the terms of the GNU General Public License.
visit https://github.com/reims/wesen for versions > 2013
or http://wesen.sourceforge.net for old versions of 2003,2004."""

from src.Wesen.loader import Loader
Loader()

Neun Zeilen, und sie beantworten mehr als die Einstiegsfrage: Das Programm startet über Loader in src/Wesen/loader.py, die Lizenz ist GPL, und es gibt eine Vorgeschichte auf SourceForge. Die Lektion: Suchen Sie nicht nur nach Python-Dateien. Einstiegspunkte sind oft Skripte ohne Endung, und die Verpackungsdateien verraten, welche.

Was verlangt es?

cat INSTALL
Installation help
=================

Dependencies

    * Python 3.3
    * numpy 1.7.1
    * PyOpenGL 3.0.2

You can fetch these via pip, and other versions may work as well.

Das ist die Selbstauskunft, und sie ist zwölf Jahre alt. Behandeln Sie sie als Hinweis, nicht als Wahrheit — welche Versionen es wirklich braucht, klären wir im nächsten Abschnitt. Wertvoll ist sie trotzdem: Sie nennt drei Namen, nach denen man sonst raten müsste.

Wer hat wann was gemacht?

Die Versionsgeschichte ist die beste Dokumentation, die ein verwaistes Projekt hat, weil sie nicht veralten kann.

git log --format="%ad" --date=format:%Y | sort | uniq -c   # Aktivität pro Jahr
git log --oneline --shortstat -5                           # was waren die letzten Änderungen?
git log --format="%h %ad %s" --date=short -- pfad/datei.py  # Geschichte einer Datei
git shortlog -sn                                           # wer hat beigetragen?

Bei Wesen ergibt der erste Befehl ein sprechendes Bild:

    129 2013
     35 2014
     57 2026

Zwei Arbeitsphasen, dazwischen zwölf Jahre Stille. Das ist die wichtigste einzelne Information über dieses Projekt, und sie kostet einen Befehl. Sie sagt Ihnen: Alles, was zwischen 2014 und heute in Python passiert ist, ist hier nicht passiert.

Zwei weitere Werkzeuge, je in einer Zeile erklärt, weil man sie selten braucht und dann dringend:

Wie hängt es zusammen, und wo ist es kompliziert?

Drei Werkzeuge, die man ohne Installation ausprobieren kann und die alle drei lohnen:

uvx radon cc -s -n C src/     # zyklomatische Komplexität, nur C und schlechter
uvx vulture src/              # Code, der nirgends benutzt wird
uvx pydeps src/Wesen          # Abhängigkeitsgraph als Bild (braucht graphviz)
python -X importtime wesen    # was kostet der Start, Modul für Modul

radon cc bewertet jede Funktion von A bis F. Nur vier Funktionen liegen über B:

src/Wesen/persistence.py
    ERROR: invalid syntax (<unknown>, line 18)
src/Wesen/objects/wesen.py
    M 121:4 Wesen.Move - C (13)
src/Wesen/sources/Dwarf/main.py
    M 27:4 WesenSource.main - C (15)
src/Wesen/sources/Rincewind/main.py
    M 68:4 WesenSource.protectFood - C (11)
src/Wesen/gui/gui.py
    M 39:4 GUI.ModifyFood - C (13)

Die durchschnittliche Komplexität liegt bei A (2,50) — das ist harmloser Code. Vier Funktionen mit C sind kein Alarm, sondern eine Leseliste.

Interessanter ist die erste Zeile.

Der Befund, der alles andere erklärt

src/Wesen/persistence.py lässt sich nicht parsen. Nicht nur von radon nicht — von Python nicht:

SyntaxError: invalid syntax | Zeile 18 | Offset 34
    getter = __getter or lambda this: this.__getattribute__(entry)

Ein lambda darf in Python nicht ohne Klammern rechts von einem or stehen. Jetzt wird die Archäologie zur Detektivarbeit, und drei Befehle klären den Fall:

grep -rn "persistence" --include="*.py" src/ wesen     # wird es benutzt?
git log --format="%h %ad %s" --date=short -- src/Wesen/persistence.py

Das grep findet nichts: Die Datei wird von keiner anderen Datei importiert. Und die Geschichte lautet:

903428b 2014-02-24 small stylistic changes after running pylint
adadcb6 2014-02-24 Coding Style Changes according to PEP8: mostly removed semicola, ...
5e153fb 2014-01-08 World also stores objects in a map of the world for fast access

Jemand hat 2014 einen Linter über eine Datei laufen lassen, die nicht übersetzt — und es nicht gemerkt, weil die Datei von niemandem importiert wird und daher nie ausgeführt wurde. Sie ist toter Code, seit über zehn Jahren.

Daraus folgt die wichtigste Warnung dieses Kapitels. Sehen Sie sich an, wie die Werkzeuge mit dieser Datei umgehen:

WerkzeugVerhalten bei persistence.py
radon ccmeldet ERROR: invalid syntax und geht weiter
vulturemeldet invalid syntax at "getter = ..." und geht weiter
banditFiles skipped (1): src/Wesen/persistence.py (syntax error while parsing AST from file)
ruffzählt zwei invalid-syntax-Befunde mit

Alle statischen Werkzeuge arbeiten auf dem AST. Eine Datei, die nicht zum AST wird, ist für sie unsichtbar — sie wird übersprungen, meist mit einer Zeile Hinweis, die im Rauschen untergeht. “Keine Befunde” heißt also nicht “keine Probleme”; es kann auch “nicht angesehen” heißen. Lesen Sie in jedem Werkzeugbericht zuerst die Zeile, die sagt, wie viele Dateien geprüft wurden, und vergleichen Sie sie mit find . -name "*.py" | wc -l.

2. Zum Laufen bringen

Jetzt die Frage, an der die meisten Wiederbelebungen scheitern: Welche Umgebung braucht das?

Die Python-Version herausfinden, statt sie zu raten

vermin liest den Code und leitet aus den benutzten Sprachmitteln und Modulen ab, welche Versionen überhaupt in Frage kommen:

uvx vermin --no-parse-comments src/ wesen
File with incompatible versions: src/Wesen/configed.py
  'configparser' module (requires !2, 3.0) vs. 'ConfigParser' module (requires 2.0, !3)
Note: Some files had incompatible versions so the results might not be correct!

Minimum required versions: 2.7, 3.2

Zwei Dinge stehen da. Erstens die Untergrenze: 2.7 oder 3.2 — das Projekt ist also unter Python 2 und 3 lauffähig gebaut. Zweitens, und wertvoller, der Widerspruch in configed.py. Sehen wir nachr:

sed -n '14,21p' src/Wesen/configed.py
# import the correct version of ConfigParser:
from sys import version_info
if version_info.major == 3 and version_info.minor < 2:  # pre version 3.2
    from configparser import SafeConfigParser
elif version_info.major >= 3:  # up to date version
    from configparser import ConfigParser as SafeConfigParser
else:  # python2
    from ConfigParser import SafeConfigParser

Das ist eine Python-2/3-Weiche, und sie ist das Fossil, an dem man das Alter des Codes ablesen kann. Bemerkenswert ist, dass sie hält: SafeConfigParser wurde in Python 3.12 entfernt, aber der mittlere Zweig fängt das ab. Der Code hat die eigene Zukunft besser vorhergesehen als die meisten.

Die Obergrenze verrät vermin nicht — dafür gibt es keinen Ersatz für den Versuch:

uv python install 3.13
uv run --python 3.13 --with numpy --with PyOpenGL python -c "
import sys; sys.path.insert(0, 'src')
from Wesen.loader import Loader; print('Import OK')"

Abhängigkeiten aus den Importen rekonstruieren

Die INSTALL-Datei nennt drei Pakete. Ob das stimmt, prüft man nicht durch Lesen, sondern durch Zählen:

grep -rhE "^\s*(import|from) " --include="*.py" src/ \
  | sed -E 's/.*(import|from) +([A-Za-z0-9_.]+).*/\2/' | cut -d. -f1 | sort | uniq -c | sort -rn

Danach trennt man die Standardbibliothek von allem anderen — was übrig bleibt, sind die echten Abhängigkeiten. Für Projekte, bei denen das von Hand zu mühsam wird, tun uvx deptry . und uvx fawltydeps genau das automatisch und melden zusätzlich das Gegenteil: Pakete, die deklariert, aber nicht benutzt werden.

Die pyproject.toml schreiben

Damit sind alle Bausteine da, und das Ergebnis ist genau die Datei, die Übungsblatt 04 verlangt:

uv init --bare              # legt eine pyproject.toml an, ohne Beispielcode
uv add numpy PyOpenGL
uv sync

Drei Dinge gehören hinein, die uv add nicht wissen kann:

[project]
requires-python = ">=3.10"

[project.scripts]
wesen = "Wesen.loader:Loader"

[tool.setuptools.packages.find]
where = ["src"]

requires-python ist Ihr Ergebnis aus dem Versuch oben, nicht die Angabe aus INSTALL. [project.scripts] ersetzt die Datei wesen durch einen richtigen Konsolenbefehl — der Nutzen ist, dass uv run wesen danach von jedem Verzeichnis aus funktioniert. Und where = ["src"] ist nötig, weil der Code unter src/ liegt und nicht im Wurzelverzeichnis.

Die Abhängigkeiten, die nicht von PyPI kommen

PyOpenGL ist ein Python-Paket, aber es bindet eine C-Bibliothek ein, die es nicht mitbringt. Auf einem frischen Linux scheitert der Start deshalb nicht bei pip, sondern erst beim Öffnen des Fensters:

sudo apt install freeglut3-dev     # Debian/Ubuntu
# pacman -S freeglut               # Arch
# brew install freeglut            # macOS

Das ist der Normalfall, nicht die Ausnahme — bei fremdem Code ist selten der Python-Teil das Problem. Und es ist der Grund, warum eine README.md mit Installationshinweisen mehr wert ist als eine vollständige pyproject.toml: Die Systemabhängigkeiten kann kein Paketmanager für Sie erraten.

Bevor Sie etwas installieren, lohnt ein Blick auf bekannte Sicherheitslücken in den Versionen, die das Projekt verlangt:

uvx pip-audit

Bei einem zwölf Jahre alten Abhängigkeitssatz ist die Antwort selten leer. Das ist kein Grund, nicht weiterzuarbeiten — aber ein Grund, die Versionen nicht einzufrieren, nur weil sie dort standen.

3. Verhalten einfrieren, bevor Sie es anfassen

Jetzt läuft das Programm, und die Versuchung ist groß, gleich etwas zu verbessern. Tun Sie es nicht. Sie wissen noch nicht, was das Programm tut — nur, dass es startet. Jede Änderung ab hier ist eine Änderung an einem Verhalten, das niemand aufgeschrieben hat.

Das Mittel dagegen heißt Charakterisierungstest: ein Test, der nicht prüft, ob das Verhalten richtig ist, sondern nur, dass es unverändert bleibt. Die Mechanik dafür steht im Kapitel Testing — hier geht es um die drei Fragen, die bei fremdem Code dazukommen.

Erstens: Wie bekomme ich überhaupt eine Ausgabe zu fassen? Eine Simulation mit OpenGL-Fenster produziert nichts, was man vergleichen kann. Suchen Sie deshalb den Modus ohne Grafik. Bei Wesen findet man ihn in der Hilfe:

uv run wesen --help
usage: wesen [-h] [--version] [-c CONFIGFILE] [-e] [--defaultconfig]
             [--printconfig] [--enablegui | --disablegui] [-s SOURCES] [-r]

--disablegui ist der Schalter — er überschreibt den Konfigurationswert [gui] enable. Für Pygame-Projekte leistet SDL_VIDEODRIVER=dummy dasselbe (siehe User Interfaces). Gibt es keinen solchen Modus, ist ihn zu schaffen die erste Änderung — und die muss dann ohne Netz geschehen, so klein wie möglich.

Zweitens: Was mache ich mit dem Zufall? Wesen ist eine Simulation; ohne festen Startwert ist jeder Lauf anders und ein Golden Master unmöglich. Der Startwert muss also von außen setzbar sein — und hier lauert eine Falle, die genau so passiert:

uv run wesen --printconfig | grep -i seed

Es gibt Treffer. In der Konfiguration stehen food.seedrate und range.seed. Wer jetzt aufhört zu lesen, hält range.seed für den gesuchten Schalter. Ist es aber nicht: Ein Blick in src/Wesen/defaults.py zeigt, dass range.seed in der Gruppe range neben look, closer_look und talk steht und den Wert 10 hat — es ist eine Entfernung im Spiel, nämlich wie weit ein Wesen säen kann. Der Zufall selbst kommt aus random.randint, und einen Startwert dafür kann man nirgends setzen.

Die Lektion: Namen sind keine Semantik. grep findet Zeichenketten; was ein Wert bedeutet, steht im Code, der ihn liest.

Damit steht man vor dem Kernproblem des Brownfield-Engineerings: Um testen zu können, muss man ändern; um sicher ändern zu können, müsste man testen. Der Ausweg ist, diesen Zirkel bewusst an der kleinstmöglichen Stelle zu durchbrechen:

# vorher, irgendwo mitten in der Logik:
#     import random
#     richtung = random.choice(richtungen)
#
# nachher: ein Parameter mit Vorbelegung, sonst nichts.
def __init__(self, zufall: random.Random | None = None):
    self._zufall = zufall or random.Random()

Diese Änderung ist verhaltensneutral für alle bestehenden Aufrufer, weil der Vorgabewert genau das alte Verhalten herstellt. Sie ist der Türöffner für alles Weitere, und sie gehört in ihren eigenen Commit mit der Nachricht, dass sie Testbarkeit herstellt und sonst nichts.

Drittens: Wie viel friere ich ein? Die Versuchung ist, alles aufzunehmen — zehntausend Zeilen Simulationsausgabe. Das ist ein Test, der beim ersten roten Lauf nicht gelesen, sondern aktualisiert wird, und damit wertlos. Nehmen Sie stattdessen etwas Kleines und Aussagekräftiges: den Zustand nach zehn Runden, die Summe der Energie aller Wesen, die Anzahl überlebender Kolonien. Drei Zahlen, die sich ändern, wenn Sie etwas kaputtmachen, und die man in einem Diff lesen kann.

mkdir -p golden
uv run wesen --disablegui -c ./golden/test.conf > golden/lauf.txt
git add golden/ && git commit -m "Charakterisierungstest: Lauf ohne GUI mit fester Konfiguration"

Die eigene Konfigurationsdatei (-c) gehört dazu: Ohne sie liest Wesen ~/.wesen/conf, und dann hängt Ihr Test davon ab, was auf Ihrem Rechner steht. uv run wesen --defaultconfig schreibt eine Vorlage zum Anpassen.

Für die Verwaltung mehrerer solcher Aufnahmen nimmt man syrupy (siehe Testing-Kapitel); von Hand geht es mit einem Dateivergleich genauso.

4. Modernisieren in kleinen Schritten

Jetzt haben Sie ein Netz und dürfen anfassen. Der erste Durchgang ist der billigste und beste: Syntax und Idiome auf heutigen Stand heben, ohne eine einzige Verhaltensentscheidung zu treffen.

Das Werkzeug dafür kennen wir aus Code-Qualität: die Regelgruppe UP von Ruff meldet nicht, dass etwas falsch ist, sondern dass es alt ist. An zwölf Jahre altem Code ist das eindrucksvoll:

uvx ruff check --select UP --statistics src/
13	UP031	[ ] printf-string-formatting
 9	UP004	[*] useless-object-inheritance
 4	UP008	[*] super-call-with-parameters
 4	UP034	[*] extraneous-parentheses
 2	     	[ ] invalid-syntax
 1	UP015	[*] redundant-open-modes
 1	UP036	[ ] outdated-version-block
Found 34 errors.
[*] 18 fixable with the `--fix` option (12 hidden fixes can be enabled with the `--unsafe-fixes` option).

Lesen Sie diese Liste wie einen Fundbericht, denn sie datiert den Code:

Zwei Regeln für diesen Schritt, und beide sind wichtiger als das Ergebnis:

Erstens: --diff vor --fix. Die Klammer [*] markiert, was Ruff automatisch ändern kann — 18 der 34 Befunde. Die restlichen wollen eine Entscheidung. Und --unsafe-fixes heißt genau das: Korrekturen, die in Randfällen das Verhalten ändern könnten. Sehen Sie sich die einzeln an.

Zweitens: Formatierung und Modernisierung sind je ein eigener Commit — und keine inhaltliche Änderung fährt mit.

uvx ruff check --select UP --fix src/
git commit -am "Modernisierung: ruff --select UP --fix, keine Verhaltensaenderung"

uvx ruff format src/
git commit -am "Formatierung: ruff format, keine Verhaltensaenderung"

Der Grund ist das Review: Ein Commit, der 3.000 Zeilen umformatiert und einen Fehler behebt, ist nicht prüfbar. Getrennt ist der eine Commit uninteressant und der andere drei Zeilen lang.

Diese großen, rein mechanischen Commits verderben allerdings git blame — danach zeigt jede Zeile auf den Formatierungs-Commit statt auf ihren Ursprung. Dafür gibt es eine Datei:

git rev-parse HEAD > .git-blame-ignore-revs     # den Formatierungs-Commit eintragen
git config blame.ignoreRevsFile .git-blame-ignore-revs

git blame überspringt diese Commits dann und zeigt wieder die inhaltliche Herkunft. GitHub und GitLab lesen die Datei ebenfalls. Sie gehört ins Repository.

Nachträgliche Type Hints sind der letzte Teil dieses Schritts und der einzige, der Geduld braucht. monkeytype beobachtet einen laufenden Prozess und schlägt aus den tatsächlich beobachteten Typen Annotationen vor (siehe Code-Qualität). Das ist bei fremdem Code Gold wert, weil es Ihnen sagt, was wirklich durch eine Funktion fließt — aber es sieht nur, was Ihr Lauf abgedeckt hat. Behandeln Sie die Vorschläge als Entwurf.

5. Refactoring-Katalog

Ab hier ändern Sie Struktur, nicht Schreibweise. Vier Muster deckten in der Praxis das meiste ab. Jedes hat im Skript ein Beispiel, an dem man es nachlesen kann.

MusterWas passiertBeispiel im Skript
Extract FunctionEin Block mit Kommentar darüber wird eine Funktion, deren Name den Kommentar ersetzt. Der erste Griff bei einer Funktion mit Komplexität C.Wesen.Move (C 13) ist der Kandidat in Wesen
Parameter ObjectVier Parameter, die immer gemeinsam auftreten, werden eine dataclass.practical-0 zu practical-1: aus money, stock, price wird Inventory und Market
Conditional zu PolymorphieEine Verzweigung über einen Typ-Code wird zu je einer Implementierung, beschrieben durch ein Protocol.Protocols; in Wesen sind die sources/ bereits so gebaut
Strangler FigDie neue Implementierung wächst neben der alten, ein Aufrufer nach dem anderen wird umgehängt, die alte stirbt zuletzt.der Schnitt Engine/UI in practical-1

Das Parameter Object und der Strangler Fig sind in practical-1 vollständig durchgeführt und begründet — lesen Sie den Abschnitt dort als Vorlage, statt hier ein zweites Beispiel zu bekommen.

Über allem steht eine Regel, die mehr Reviews rettet als jedes Muster:

Eine Refactoring-Sorte pro Commit.

Nicht “ein Refactoring pro Commit” — man darf dieselbe Sorte gern zwanzigmal anwenden. Aber nicht Umbenennen und Extrahieren und Signaturen ändern im selben Commit, denn dann kann niemand mehr sehen, welche der drei Änderungen das Verhalten gebrochen hat. Nach jedem Commit läuft der Charakterisierungstest aus Abschnitt 3. Wird er rot, war es kein Refactoring, sondern eine Änderung — dann nehmen Sie sie zurück und entscheiden bewusst.

6. Fremden Code sicher ausführen

Diesen Abschnitt hätte man auch an den Anfang stellen können, denn zeitlich kommt er vor allem anderen. Er steht hier, weil er sich erst mit dem Wissen aus Abschnitt 1 wirklich anwenden lässt.

Vor dem ersten Ausführen lesen. Nicht alles — die Stellen, an denen ein Programm die Kontrolle abgibt:

grep -rn "os.system\|subprocess\|eval(\|exec(\|pickle.load\|__import__" --include="*.py" .
uvx bandit -r src/

Bei Wesen findet das grep zwei Zeilen, und sie sind lehrreich:

src/Wesen/sources/Manual/main.py:22:        return eval(input("\n> "))
src/Wesen/sources/Manual/main.py:36:                    exec(userInput)

eval auf Nutzereingabe, und exec gleich dazu. Im Kontext ist das kein Angriff, sondern ein Feature: Die sources/ sind die Wesen-Programme der Spielenden, und Manual ist die Quelle, mit der ein Mensch sein Wesen von Hand steuert. Es soll dort beliebiger Code laufen.

Und genau das ist die Lektion: grep findet die Stelle, die Einordnung müssen Sie leisten. Ein Werkzeug kann Ihnen nicht sagen, ob ein exec ein Feature oder eine Hintertür ist. Was es Ihnen sagt, ist: Dieses Programm führt Code aus, den es von außen bekommt — also führen Sie es nicht mit den Rechten aus, die Sie sonst haben.

bandit ergänzt das systematisch und liefert bei Wesen 7 Befunde niedriger und 2 mittlerer Schwere — plus die Zeile, die wir schon kennen:

Files skipped (1):
	src/Wesen/persistence.py (syntax error while parsing AST from file)

In eine Sandbox stecken. Drei Stufen, aufsteigend nach Aufwand und Sicherheit:

uv run --isolated wesen          # eigene Umgebung, aber dasselbe Dateisystem
bwrap --ro-bind / / --bind ./sandbox /home/user --unshare-all uv run wesen
docker run --rm -it -v "$PWD:/src:ro" -w /src python:3.13 bash

Die erste Zeile schützt nur Ihre anderen Projekte vor Paketkonflikten, nicht Ihre Daten. Die zweite (bwrap, Bubblewrap) sperrt den Prozess in ein eingeschränktes Dateisystem ohne Netz. Die dritte ist der Weg, den man ohnehin kennen sollte, weil er auch die Umgebungsfrage aus Abschnitt 2 mitlöst.

Keine Zugangsdaten im Arbeitsverzeichnis. Ein .env mit einem API-Schlüssel, eine ~/.aws/credentials, ein SSH-Agent-Socket — alles davon ist für einen Prozess mit Ihren Rechten erreichbar. Klonen Sie fremde Projekte in ein Verzeichnis, in dem nichts liegt, was Sie nicht verlieren wollen.

Dasselbe gilt für KI-generierten Code. Ein Agent, der Ihnen ein Skript schreibt, und ein Repository, das Sie von GitHub laden, stehen in genau derselben Beziehung zu Ihnen: Sie haben den Code nicht geschrieben und nicht vollständig gelesen. Die Prüfliste dieses Abschnitts gilt für beides unverändert — siehe Agentic Coding.

7. Beitragen: der Pull-Request-Workflow

Sie haben etwas verbessert. Jetzt soll es zurück ins Projekt — und hier scheitern gute Änderungen an Formalien.

Erst das Issue, dann der Code. Eröffnen Sie eine Issue-Diskussion, bevor Sie zwei Tage investieren. Der Grund ist nicht Höflichkeit: Die Maintainer wissen, ob an derselben Stelle schon jemand arbeitet, ob die Änderung in eine geplante Richtung passt und ob sie überhaupt gewollt ist. Ein abgelehnter Pull Request nach zwei Tagen Arbeit ist für beide Seiten ein Verlust.

Der Ablauf:

# 1. Fork auf GitHub anlegen, dann:
git clone https://github.com/IHR-NAME/wesen.git && cd wesen
git remote add upstream https://github.com/konradvoelkel/wesen.git

# 2. Branch mit sprechendem Namen
git switch -c fix/persistence-syntaxfehler

# 3. kleine Commits, jeder für sich lauffähig
# 4. vor dem Abgeben auf den aktuellen Stand heben
git fetch upstream && git rebase upstream/master

# 5. hochschieben und PR eröffnen
git push -u origin fix/persistence-syntaxfehler

Commit-Nachrichten. Eine Zeile Betreff im Imperativ, höchstens rund 50 Zeichen, kein Punkt am Ende; bei Bedarf eine Leerzeile und dann Absätze, die das Warum erklären — das Was steht im Diff. Die verbreitete Konvention Conventional Commits (fix:, feat:, docs:, refactor:, test:) ist maschinenlesbar und erlaubt automatische Changelogs. Sehen Sie sich in jedem Fall git log --oneline -20 des Zielprojekts an und schreiben Sie, wie es dort üblich ist.

Die PR-Beschreibung beantwortet drei Fragen, in dieser Reihenfolge:

  1. Was ändert sich (ein Satz)?

  2. Warum — welches Problem, welches Issue?

  3. Wie getestet — welcher Befehl, welches Ergebnis? Bei Brownfield-Änderungen gehört hier der Charakterisierungstest hin: “Lauf mit Seed 42 über 10 Runden liefert unverändert dieselbe Ausgabe.”

Review-Checkliste, wenn Sie selbst reviewen:

8. Definition of Done für einen Brownfield-PR

Diese Liste ist absichtlich kurz und absichtlich hart. Sie taugt gleichzeitig als Bewertungsraster für die Übungsblätter.

  1. Läuft aus einem frischen Klon: git clone, uv sync, uv run <programm> ohne weitere Handgriffe — und die nötigen Systemabhängigkeiten stehen in der README.md.

  2. Charakterisierungstest grün: Das vorher aufgenommene Verhalten ist unverändert, oder die Änderung ist im PR ausdrücklich benannt und begründet.

  3. ruff und mypy nicht schlechter als vorher. Nicht “sauber” — nicht schlechter. Bei zwölf Jahre altem Code ist das der einzige erreichbare Maßstab, und er verhindert trotzdem jede Verschlechterung.

  4. Eine Refactoring-Sorte pro Commit, Formatierung getrennt, mechanische Commits in .git-blame-ignore-revs.

  5. Eine Zeile im Changelog oder in der README.md, die aus Sicht eines Nutzers beschreibt, was sich geändert hat.

  6. Die PR-Beschreibung beantwortet Was/Warum/Wie getestet.

9. Git-Grundlagen für Python-Projekte

Zum Schluss das, was in jedem Python-Repository gleich aussieht und das man einmal richtig einstellen sollte.

Was nicht ins Repository gehört. uv init legt eine passende .gitignore an; wer von Hand arbeitet, braucht mindestens:

.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
dist/
.env

Die letzte Zeile ist die wichtigste. Alles andere ist wiederherstellbar; ein versehentlich veröffentlichter API-Schlüssel ist es nicht — und ihn aus der Historie zu entfernen ist erheblich mühsamer, als ihn zu widerrufen und neu auszustellen.

Was hineingehört, obwohl es generiert ist: das Lockfile uv.lock. Es ist der Unterschied zwischen “installiert irgendwas Passendes” und “installiert genau das, was beim letzten funktionierenden Lauf da war” (siehe Packaging).

Prüfungen vor dem Commit. pre-commit verwaltet Hooks, die Git automatisch ausführt — ein .pre-commit-config.yaml mit Ruff darin bedeutet, dass unformatierter Code gar nicht erst in die Historie kommt (Beispiel im Kapitel Code-Qualität). Für fremde Projekte gilt dabei eine Einschränkung: Führen Sie pre-commit nicht beim ersten Commit über das ganze Repository aus, sonst ist Ihr erster Beitrag eine Formatierung von 3.000 Zeilen.

Versionen und Tags. Die Version steht in der pyproject.toml und soll mit einem Git-Tag übereinstimmen:

git tag -a v0.7.0 -m "Release 0.7.0"
git push --tags

Ein Tag ist der einzige verlässliche Weg, später zu sagen “so sah es aus, als es funktionierte”. Wesen hat keine Tags — deshalb mussten wir für dieses Kapitel einen Commit-Hash festnageln, und deshalb steht 88fa06f oben statt eines Versionsnamens.

Was aus Wesen geworden ist

Alles in diesem Kapitel beschreibt Arbeit, die an diesem Projekt tatsächlich gemacht wurde. Der Vergleich lohnt sich, weil er zeigt, was ein solcher Durchgang leistet — und was nicht.

git checkout master
uvx ruff check --select UP --statistics src/

Die Ausgabe ist leer: kein einziger UP-Befund mehr. Die pyproject.toml existiert seit Mai 2026 und enthält genau die Bausteine aus Abschnitt 2 — requires-python, [project.scripts] wesen = "Wesen.loader:Loader", where = ["src"]. Sie hat zusätzlich etwas, das wir dort nur als Kommentar in der README.md vorgeschlagen haben:

[tool.wesen.system-dependencies]
apt = ["freeglut3-dev"]
pacman = ["freeglut"]
homebrew = ["freeglut"]

Das ist kein Standard, den ein Werkzeug liest — es ist eine selbstgemachte Tabelle unter [tool.<projekt>], die die Systemabhängigkeiten maschinenlesbar festhält, statt sie in Prosa zu verstecken. Eine gute Idee zum Nachmachen.

Und der tote Code?

src/Wesen/persistence.py — die Datei aus Abschnitt 1, die sich nicht parsen ließ und die niemand importierte — gibt es noch. Sie parst jetzt:

git checkout master
sed -n '14,22p' src/Wesen/persistence.py
            dynamic[entry] = (
                lambda that: that.__getattribute__(entry),
                lambda that, v: that.__setattr__(entry, v),
            )
        else:
            __getter, __setter = dynamic[entry]
            getter = __getter or (
                lambda that: that.__getattribute__(entry)
            )

Die Reparatur ist ein Klammerpaar. Genau das, was der Syntaxfehler verlangt hat, und nicht mehr.

Und dann das eigentlich Erstaunliche:

grep -rl "persistence" --include="*.py" src/ tests/ | wc -l

Zehn Dateien importieren sie inzwischen — colony.py und sieben der Wesen-Quellen. Aus einer Datei, die über zehn Jahre nicht übersetzte und die kein Werkzeug ansehen konnte, ist tragender Code geworden.

Die Moral ist nicht, dass toter Code wertvoll ist. Sie ist, dass “wird nicht benutzt” und “ist nicht brauchbar” zwei verschiedene Aussagen sind, und dass man die erste mit grep beantwortet, die zweite aber nur durch Lesen. Hätte 2026 jemand vulture vertraut und die Datei gelöscht, wäre die Arbeit von 2014 verloren gewesen — die Idee darin war gut, nur die Zeile war kaputt.

Und ein Befund, der gegen die Erwartung läuft: Die durchschnittliche Komplexität ist gestiegen, von A (2,50) auf A (4,56). Das ist kein Rückschritt, sondern die Folge davon, dass in derselben Zeit Funktionalität dazugekommen ist — Turniere, mehrere Kolonien, neue Wesen-Quellen. Modernisierung macht Code nicht einfacher. Sie macht ihn wartbar, und dann baut jemand daran weiter.

Zusammenfassung und Kontrollfragen

  1. Die Reihenfolge ist nicht verhandelbar: verstehen, zum Laufen bringen, Verhalten einfrieren, modernisieren, umbauen — wer in der Mitte anfängt, ändert Verhalten, ohne es zu merken.

  2. Die Versionsgeschichte ist die einzige Dokumentation, die nicht veralten kann; git log, git blame und git bisect beantworten Fragen, für die es keine andere Quelle gibt.

  3. Eine Datei, die nicht zum AST wird, ist für alle statischen Werkzeuge unsichtbar — “keine Befunde” kann “nicht angesehen” heißen, deshalb prüft man immer, wie viele Dateien geprüft wurden.

  4. Der Zirkel “zum Testen muss ich ändern, zum Ändern müsste ich testen” wird an der kleinstmöglichen Stelle durchbrochen, verhaltensneutral und in einem eigenen Commit.

  5. Mechanische Änderungen (ruff --select UP --fix, ruff format) sind je ein eigener Commit, gehören in .git-blame-ignore-revs und tragen niemals eine inhaltliche Änderung mit.

Kontrollfragen

  1. Sie klonen ein Projekt, und uvx bandit -r src/ meldet keinen einzigen Befund. Welche zwei Möglichkeiten gibt es dafür, und mit welchem Befehl unterscheiden Sie sie?

  2. Ruff meldet in altem Code neunmal useless-object-inheritance und viermal super-call-with-parameters. Was verrät das über die Herkunft des Codes, und warum ist keiner der beiden Befunde ein Fehler?

  3. Eine Simulation liefert bei jedem Lauf ein anderes Ergebnis, und Sie brauchen einen Charakterisierungstest. Welche Änderung machen Sie zuerst, warum ist sie verhaltensneutral, und warum gehört sie in einen eigenen Commit?