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.

Code-Qualität mit Lintern und Formattern

Heinrich-Heine-Universität Düsseldorf

In diesem Notebook schauen wir uns an, wie wir die Qualität unseres Codes automatisch sicherstellen können. Wir analysieren dazu das Skript code_quality.py, welches absichtlich verschiedene Fehler und Stil-Mängel enthält.

Worum es geht. Werkzeuge, die den Code lesen, ohne ihn auszuführen: Formatter, Linter und Type Checker. Vorgeführt werden sie an einer absichtlich mangelhaften Datei; am Ende steht die Konfiguration, die man in eigene Projekte übernimmt.

Was Sie danach können. Formatter, Linter und Type Checker auseinanderhalten und sagen, welche Art von Fehler jeder findet; ruff an die Stelle von Black, isort und Flake8 setzen; eine Mypy-Meldung einordnen, auch wenn sie von einer untypisierten Bibliothek stammt; und das Ganze per pre-commit und CI verbindlich machen, statt es der Disziplin zu überlassen.

Die Java-Entsprechung in einem Satz. Ruff übernimmt gleichzeitig die Rollen von Checkstyle, SpotBugs und dem Formatter — und Mypy ist der Teil des Compilers, den es in Python nicht gibt, weshalb er optional, nachträglich und unvollständig bleibt.

# Vorbereitung
!uv venv --quiet
!uv sync --quiet
!uv pip install --quiet autoflake bandit beartype black flake8 interrogate isort jaxtyping monkeytype mypy ruff typeguard
# Reset Startbedingungen:
!cp code-quality-backup.py code_quality.py
!cp code-quality-better.py code_quality_better.py
!cat code_quality.py
import sys
import os
import random
import numpy as gabelstapler
from dataclasses import dataclass, field
from enum import Enum, auto


class GameException(Exception):
    pass


class NotEnoughMoneyException(GameException):
    pass


class ProductType(Enum):
    FOOD = auto()

    def __str__(self):
        return self.name.title()


@dataclass
class Inventory:
    money: float
    stock: dict = {} 

    def execute_trade(self, product, quantity: int, unit_price):
        cost = quantity*unit_price

        if quantity > 0:
            if self.money < cost:
                raise NotEnoughMoneyException(f'Need {cost}€, have {self.money}€')
        elif quantity < 0:
            current_stock = self.stock.get(product, 0)
            if current_stock < abs(quantity):
                raise NotEnoughStockException("Not enough stock") 

        self.money -= cost
        self.stock[product] = self.stock.get(product, 0) + quantity

@dataclass
class Market:
    product: ProductType
    current_price: float
    volatility: float

    def adjust_price(self, direction: int) -> None:
        change = direction * self.volatility 
        new_price = self.current_price + change
        self.current_price = max(1.0, round(new_price, 2))

def run_simulation(starting_money, market_price):
    player = Inventory(money=starting_money)
    market = Market(product=ProductType.FOOD, current_price=market_price, volatility=0.5)
    
    try:
        player.execute_trade(ProductType.FOOD, 5, market.current_price)
    except:
        print("Etwas ist schiefgelaufen")
        
    return player

if __name__ == "__main__":
    final_state = run_simulation(100.0, "10.0") 
    print(final_state)

Formatter

Black

Formatter kümmern sich um das Aussehen des Codes (PEP 8). Black ist der bekannteste “uncompromising” Formatter. Wir schauen uns mit --diff an, was Black ändern würde, ohne die Datei direkt zu überschreiben.

!uvx black --diff --color code_quality.py --target-version=py313
Installed 7 packages in 6ms                                      
--- code_quality.py	2026-07-01 09:53:02.341896+00:00
+++ code_quality.py	2026-07-01 09:53:03.075483+00:00
@@ -22,46 +22,51 @@
 
 
 @dataclass
 class Inventory:
     money: float
-    stock: dict = {} 
+    stock: dict = {}
 
     def execute_trade(self, product, quantity: int, unit_price):
-        cost = quantity*unit_price
+        cost = quantity * unit_price
 
         if quantity > 0:
             if self.money < cost:
-                raise NotEnoughMoneyException(f'Need {cost}€, have {self.money}€')
+                raise NotEnoughMoneyException(f"Need {cost}€, have {self.money}€")
         elif quantity < 0:
             current_stock = self.stock.get(product, 0)
             if current_stock < abs(quantity):
-                raise NotEnoughStockException("Not enough stock") 
+                raise NotEnoughStockException("Not enough stock")
 
         self.money -= cost
         self.stock[product] = self.stock.get(product, 0) + quantity
+
 
 @dataclass
 class Market:
     product: ProductType
     current_price: float
     volatility: float
 
     def adjust_price(self, direction: int) -> None:
-        change = direction * self.volatility 
+        change = direction * self.volatility
         new_price = self.current_price + change
         self.current_price = max(1.0, round(new_price, 2))
 
+
 def run_simulation(starting_money, market_price):
     player = Inventory(money=starting_money)
-    market = Market(product=ProductType.FOOD, current_price=market_price, volatility=0.5)
-    
+    market = Market(
+        product=ProductType.FOOD, current_price=market_price, volatility=0.5
+    )
+
     try:
         player.execute_trade(ProductType.FOOD, 5, market.current_price)
     except:
         print("Etwas ist schiefgelaufen")
-        
+
     return player
 
+
 if __name__ == "__main__":
-    final_state = run_simulation(100.0, "10.0") 
+    final_state = run_simulation(100.0, "10.0")
     print(final_state)
would reformat code_quality.py

All done! ✨ 🍰 ✨
1 file would be reformatted.

Wir wollen den Output von Black zur Weiterverarbeitung sichern:

!uvx black code_quality.py --line-length=80 --target-version=py313
!mv code_quality.py code_quality_black.py
!cp code-quality-backup.py code_quality.py
reformatted code_quality.py                                                          

All done! ✨ 🍰 ✨
1 file reformatted.

Wenn sich nichts ändert, kann man das auch in Continuous Integration einbauen, indem man mit --check Änderungen vermeidet:

!echo "\nIdempotent:"
!uvx black code_quality_black.py --target-version=py313
!echo "\nCheck nach Black:"
!uvx black --check code_quality_black.py --target-version=py313
!echo "\nCheck, der fehlschlägt:"
!uvx black --check code_quality.py --target-version=py313

Idempotent:
reformatted code_quality_black.py                                                    

All done! ✨ 🍰 ✨
1 file reformatted.

Check nach Black:
All done! ✨ 🍰 ✨                                                                     
1 file would be left unchanged.

Check, der fehlschlägt:
would reformat code_quality.py                                                       

Oh no! 💥 💔 💥
1 file would be reformatted.

Man kann mit Black auch Jupyter Notebooks reformatieren:

!uvx --from "black[jupyter]" black --diff --color ../part-practical/practical-1.ipynb --target-version=py313
Installed 25 packages in 258ms                                       
--- ../part-practical/practical-1.ipynb	2026-06-10 09:12:20.539137+00:00:cell_1
+++ ../part-practical/practical-1.ipynb	2026-07-01 09:53:05.919385+00:00:cell_1
@@ -6,17 +6,21 @@
 from typing import Optional
 
 
 class GameException(Exception):
     """Base class for all game-related errors."""
+
     pass
+
 
 class NotEnoughMoneyException(GameException):
     pass
 
+
 class NotEnoughStockException(GameException):
     pass
+
 
 class ProductType(Enum):
     FOOD = auto()
     # Später auch andere Produkte
 
would reformat ../part-practical/practical-1.ipynb

All done! ✨ 🍰 ✨
1 file would be reformatted.

isort

Aus Java-IDEs kennt man vielleicht noch ‘Organize Imports’, was die import-Statements in eine kanonische Reihenfolge bringt (und ungenutzte Imports herauswirft - das passiert hier nicht).

!uvx --from "isort[colors]"  isort --diff --color code_quality.py
--- /home/user/sciebo/hhu/eipy-26/eipy-skript/part-tools/code_quality.py:before	2026-07-01 11:53:03.657897
+++ /home/user/sciebo/hhu/eipy-26/eipy-skript/part-tools/code_quality.py:after	2026-07-01 11:53:06.257103
@@ -1,9 +1,10 @@
-import sys
 import os
 import random
-import numpy as gabelstapler
+import sys
 from dataclasses import dataclass, field
 from enum import Enum, auto
+
+import numpy as gabelstapler
 
 
 class GameException(Exception):

Konfigurations-Formatter und Linter

Wenn man die Projektdaten in einer pyproject.toml verwaltet (wie sich das heutzutage gehört), möchte man diese auch versionieren. Dabei wäre es ungünstig, wenn Permutationen der Zeilenreihenfolge oder Whitespace-Änderungen die Commit History zukleistern und damit die tatsächliche History schwierig nachvollziehbar machen. Um das zu vermeiden, normalisiert man die Reihenfolge und die Whitespace-Platzierungen, entweder per Konvention die von Allen eingehalten werden muss, oder durch ein Programm: einen Konfigurations-Linter bzw. Konfigurations-Formatter (siehe den folgenden Abschnitt für Python-Code-Linter).

Geeignet sind taplo (für Syntax und Formatierung) und toml-sort (für eine alphabetisch konsistente Ordnung), das sind generisch TOML-Werkzeuge. Spezifisch ist pyproject-fmt für diese Aufgabe. Der Allrounder ruff (s.u.) kann die pyproject.toml nur lintern (Fehler aufdecken), nicht formatieren.

!cp ../pyproject.toml ./
!uvx pyproject-fmt pyproject.toml
!uvx toml-sort --in-place pyproject.toml
!uvx taplo format pyproject.toml
!rm -f pyproject.toml
Installed 1 package in 6ms.1                                         
no change for pyproject.toml
Installed 2 packages in 8ms                                          
Installed 1 package in 5ms                                           
 INFO taplo:format_files:collect_files: found files total=1 excluded=0 cwd="/home/user/sciebo/hhu/eipy-26/eipy-skript/part-tools"

Linter

Linter suchen nach logischen Fehlern, ungenutzten Imports oder gefährlichen Patterns (wie dem bare except:).

Flake8

Wir betrachten erstmal den Klassiker flake8, der PyFlakes, pycodestyle und McCabe-Komplexitätsanalyse bündelt.

!uvx flake8 code_quality_black.py
code_quality_black.py:1:1: F401 'sys' imported but unused
code_quality_black.py:2:1: F401 'os' imported but unused
code_quality_black.py:3:1: F401 'random' imported but unused
code_quality_black.py:4:1: F401 'numpy as gabelstapler' imported but unused
code_quality_black.py:5:1: F401 'dataclasses.field' imported but unused
code_quality_black.py:34:80: E501 line too long (82 > 79 characters)
code_quality_black.py:38:23: F821 undefined name 'NotEnoughStockException'
code_quality_black.py:64:5: E722 do not use bare 'except'

Die Fehlercodes F401 und E501 kommen von pycodestyle, wobei E für einen Style Error steht und F für einen Fehler, den PyFlakes eingeführt hat. Es gibt zudem noch z.B. I für Probleme mit imports (isort), D für Docstring-Probleme (pydocstyle), UP für Upgrade-Empfehlungen (pyugrade).

Wir können auch schauen, welche Warnungen wir bereits durch die Formatierung mit Black erledigt haben:

%%bash
run_linter() {
    uvx flake8 --format="%(row)d:%(col)d: %(code)s %(text)s" "$1"
}
diff \
  <(run_linter code_quality_black.py) \
  <(run_linter code_quality.py) || true
5a6
> 27:21: W291 trailing whitespace
8c9,18
< 64:5: E722 do not use bare 'except'
---
> 38:66: W291 trailing whitespace
> 43:1: E302 expected 2 blank lines, found 1
> 50:45: W291 trailing whitespace
> 54:1: E302 expected 2 blank lines, found 1
> 56:80: E501 line too long (89 > 79 characters)
> 57:1: W293 blank line contains whitespace
> 60:5: E722 do not use bare 'except'
> 62:1: W293 blank line contains whitespace
> 65:1: E305 expected 2 blank lines after class or function definition, found 1
> 66:48: W291 trailing whitespace

Autoflake

Das Werkzeug Autoflake soll manche Probleme automatisch beheben:

!uvx autoflake --remove-all-unused-imports code_quality.py
--- original/code_quality.py                                                                 
+++ fixed/code_quality.py
@@ -1,8 +1,4 @@
-import sys
-import os
-import random
-import numpy as gabelstapler
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from enum import Enum, auto
 
 
!uvx autoflake --in-place --remove-all-unused-imports code_quality_black.py
!uvx autoflake --verbose --verbose --remove-all-unused-imports code_quality_black.py
Clean code_quality_black.py: nothing to fix                                                  

Pylint

Ein eher pedantisches Werkzeug, um Konventionen zu prüfen. Vergibt eine Punktzahl, sodass man vergleichen kann, ob Änderungen am Code die so gemessene Code-Qualität verbessern oder verschlechtern.

!uvx pylint code_quality_black.py --output-format colorized --enable-all-extensions
Installed 7 packages in 8ms                                          
************* Module code_quality_black
code_quality_black.py:30:0: C0301: Line too long (82/80) (line-too-long)
code_quality_black.py:31:8: R5601: Consecutive elif with differing indentation level, consider creating a function to separate the inner elif (confusing-consecutive-elif)
code_quality_black.py:33:15: R6103: Use 'if (current_stock := self.stock.get(product, 0)) < abs(quantity):' instead (consider-using-assignment-expr)
code_quality_black.py:34:22: E0602: Undefined variable 'NotEnoughStockException' (undefined-variable)
code_quality_black.py:60:4: W0702: No exception type(s) specified (bare-except)

------------------------------------------------------------------
Your code has been rated at 7.91/10 (previous run: 7.91/10, +0.00)

Dabei haben wir sogar einige sehr nervige Meldungen unterdrückt, indem in der pyproject.toml dieser Abschnitt steht:

[tool.pylint.messages_control]
disable = [
    "missing-module-docstring",   # C0114
    "missing-class-docstring",    # C0115
    "missing-function-docstring", # C0116
    "too-few-public-methods",     # R0903
    "invalid-name",               # C0103
]

Und wir können auch Inline im Code Pylint-Warnungen unterdrücken, wie in diesem Beispiel:

def alte_funktion(a, b, c, d, e, f, g): # pylint: disable=too-many-arguments
    # pylint: disable=invalid-name
    x = a + b
    return x

Das ist dann sinnvoll, wenn wir z.B. die Methodensignatur nicht ändern können, weil eine alte API das so vorgibt.

Die Fehlercodes von Pylint sind nicht genau die selben wie bei Flake8, so steht z.B. C für Code Convention (also Stil), was bei Pylint bzw. pycodestyle E und F waren. Refactoring-Empfehlungen beginnen mit R.

FawltyDeps

FawltyDeps ist ein Dependency Linter. Es wird systematisch in .py und .ipynb nach Import-Deklarationen gesucht und mit Projektdateien wie requirements.txt oder pyproject.toml abgeglichen. Damit findet man Pakete, die man noch nicht explizit als Abhängigkeit deklariert hatte, sowie Pakete, deren Abhängigkeit im Code gar nicht gegeben ist, aber fälschlicherweise für das Paket deklariert wurde.

uvx fawltydeps

Bandit

Es gibt auch ‘Linter’ mit dem Ziel, Sicherheitslücken bzw. problematische Muster wie hartkodierte Passwörter zu finden. Wir stellen fest, dass der bisher besprochene Code nicht problematisch ist:

!uvx bandit -r code_quality_better.py
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: None
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	running on Python 3.13.3
Run started:2026-09-24 10:29:18.014926+00:00

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 50
	Total lines skipped (#nosec): 0
	Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0

Run metrics:
	Total issues (by severity):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
	Total issues (by confidence):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
Files skipped (0):

Das folgende ist ein Beispiel für Probleme, die Bandit aufdecken kann:

%%writefile bad_security.py
def calculate_dynamic_price(base_price, user_formula):
    # GEFÄHRLICH: Code-Injection möglich
    return base_price * eval(user_formula)

API_SECRET = "super_secret_token_12345"
Writing bad_security.py
!uvx bandit -r bad_security.py
[main]	INFO	profile include tests: None                                                      
[main]	INFO	profile exclude tests: None
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	running on Python 3.13.2
Run started:2026-07-01 09:53:11.509096+00:00

Test results:
>> Issue: [B307:blacklist] Use of possibly insecure function - consider using safer ast.literal_eval.
   Severity: Medium   Confidence: High
   CWE: CWE-78 (https://cwe.mitre.org/data/definitions/78.html)
   More Info: https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b307-eval
   Location: ./bad_security.py:3:24
2	    # GEFÄHRLICH: Code-Injection möglich
3	    return base_price * eval(user_formula)
4	

--------------------------------------------------
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'super_secret_token_12345'
   Severity: Low   Confidence: Medium
   CWE: CWE-259 (https://cwe.mitre.org/data/definitions/259.html)
   More Info: https://bandit.readthedocs.io/en/1.9.4/plugins/b105_hardcoded_password_string.html
   Location: ./bad_security.py:5:13
4	
5	API_SECRET = "super_secret_token_12345"

--------------------------------------------------

Code scanned:
	Total lines of code: 3
	Total lines skipped (#nosec): 0

Run metrics:
	Total issues (by severity):
		Undefined: 0
		Low: 1
		Medium: 1
		High: 0
	Total issues (by confidence):
		Undefined: 0
		Low: 0
		Medium: 1
		High: 1
Files skipped (0):

Interrogate

Wir hatten zuvor die pedantischen Meldungen über Docstrings abgeschaltet, würden aber evtl. doch eine Übersicht haben wollen, wie viel des Projekts dokumentiert ist:

!uvx interrogate code_quality_better.py --color
Installed 6 packages in 5ms                                      
RESULT: FAILED (minimum: 80.0%, actual: 9.1%)

Type Checker

Während Linters die Struktur prüfen, sichern Type-Checker die Typ-Konsistenz. Neben dem De-facto-Standard Mypy hat sich das von Microsoft entwickelte pyright durch extrem schnelle Analysezeiten etabliert. Jüngere Entwicklungen wie ty (astral-sh/ty) von den Machern von uv/ruff oder das darauf aufbauende pyrefly (Vergleich) experimentieren mit noch besserer Performance und tieferer Integration in moderne Toolchains (ähnlich wie Ruff es für Flake8/Black getan hat). Dabei geht der Trend zu eher strengeren Typ-Überprüfungen.

Wenn man die grundlegende Logik von mypy verstanden hat, lässt sich ein Projekt später leicht auf pyrefly umstellen. Generell kann man als Faustregel nutzen: sollen andere Entwickler den Code nutzen (als import), so sollte man eher strenge Typenchecks verwenden, ansonsten kann man es auch etwas lockerer handhaben.

Mypy

!uvx mypy code_quality_black.py
Installed 6 packages in 28ms                                     
code_quality_black.py:34: error: Name "NotEnoughStockException" is not defined; did you mean "NotEnoughMoneyException"?  [name-defined]
Found 1 error in 1 file (checked 1 source file)

Damit wir uns nicht von der Fehlermeldung verwirren lassen, betrachten wir eine Version ohne den Fehler (wo die fehlende Exception deklariert ist):

!echo "Geänderter Code:"
!diff code_quality_black.py code_quality_better.py
!echo "Output von mypy:"
!uvx mypy code_quality_better.py
Geänderter Code:
1c1
< from dataclasses import dataclass
---
> from dataclasses import dataclass, field
12a13,17
> class NotEnoughStockException(GameException):
>     """If stock runs low..."""
>     pass
> 
> 
23c28,29
<     stock: dict = {}
---
>     # dataclasses throw ValueError on using the same {}
>     stock: dict = field(default_factory=dict)
Output von mypy:
Success: no issues found in 1 source file                                        

Wir erinnern uns aber daran, dass der Code z.B. diese Zeile enthält:

def execute_trade(self, product, quantity: int, unit_price):

Da fehlen offensichtlich Type Hints bei product und unit_price, aber Mypy hat sich nicht beschwert (und bisher auch sonst kein Linter). Das lässt sich explizit einfordern:

!uvx mypy code_quality_better.py --strict
code_quality_better.py:21: error: Function is missing a type annotation  [no-untyped-def]
code_quality_better.py:29: error: Missing type arguments for generic type "dict"  [type-arg]
code_quality_better.py:31: error: Function is missing a return type annotation  [no-untyped-def]
code_quality_better.py:31: error: Function is missing a type annotation for one or more parameters  [no-untyped-def]
code_quality_better.py:58: error: Function is missing a type annotation  [no-untyped-def]
code_quality_better.py:73: error: Call to untyped function "run_simulation" in typed context  [no-untyped-call]
Found 6 errors in 1 file (checked 1 source file)

Wir können auch Codeblöcke explizit von den Typechecker Warnungen ausschließen, wie in diesem Beispiel:

final_state = run_simulation(100.0, "10.0") # type: ignore[arg-type]

Vier Fallen, über die jeder einmal stolpert

Die Fehlermeldungen oben waren echte Funde. In der Praxis erzeugt Mypy daneben eine zweite Sorte Meldungen, die keine Fehler im eigenen Code sind. Wer die nicht einordnen kann, schaltet entnervt alles ab — und verliert damit auch die echten Funde.

1. Untypisierte Bibliotheken. error: Skipping analyzing "irgendwas": module is installed, but missing library stubs or py.typed marker. Das heißt nicht, dass Sie etwas falsch gemacht haben, sondern dass das Paket keine Typinformationen mitliefert (siehe den Abschnitt über py.typed). Drei Auswege, in dieser Reihenfolge:

# In pyproject.toml — pauschal, aber ehrlich:
[tool.mypy]
ignore_missing_imports = true

# Besser: nur für die Pakete, die es wirklich betrifft
[[tool.mypy.overrides]]
module = ["pygame.*", "joblib.*"]
ignore_missing_imports = true

Am besten wäre, Stubs zu installieren, falls es sie gibt (types-requests, pandas-stubs und Verwandte). Dann prüft Mypy die Aufrufe wirklich, statt sie als Any durchzuwinken.

2. # type: ignore zu grob. Ein nacktes # type: ignore schaltet in dieser Zeile alle Prüfungen ab — auch die, die später einmal einen echten Fehler melden würden. Schreiben Sie den Code dazu: # type: ignore[arg-type]. Welchen Code Sie brauchen, steht in der Meldung in eckigen Klammern. Mit warn_unused_ignores = true meldet Mypy Ihnen außerdem, wenn eine Unterdrückung überflüssig geworden ist — das passiert häufiger, als man denkt, etwa nachdem eine Bibliothek Typen nachgeliefert hat.

3. Die Python-Version nicht festgelegt. Mypy prüft gegen die Version des Interpreters, unter dem es selbst läuft. Das heißt: Auf Ihrem Rechner grün, in der CI rot, oder umgekehrt — und str | None ist unter der einen Version gültig und unter der anderen ein Syntaxfehler. Legen Sie die Version an drei Stellen konsistent fest:

# pyproject.toml
[project]
requires-python = ">=3.13"

[tool.mypy]
python_version = "3.13"

plus eine .python-version-Datei für uv (siehe Packaging).

4. Tests bleiben untypisiert. Typische Konfigurationen schließen tests/ aus — und genau dort steckt die Hälfte der Aufrufe der eigenen API. Wenn eine Funktionssignatur sich ändert, merkt es der Type Checker dann überall außer im Test. Nehmen Sie die Tests mit hinein; der Aufwand ist gering, weil Testfunktionen meist -> None zurückgeben.

Monkeytype

Da Python dynamisch ist, ist es schwierig über statische Analyse die Typen abzuleiten. Also führen wir das Programm doch einmal aus!

!python code_quality_better.py
Etwas ist schiefgelaufen
Inventory(money=100.0, stock={})

Das Modul Monkeytype versucht, während der Ausführung die Typinformation zu raten:

!uvx monkeytype run code_quality_better.py
!uvx monkeytype stub code_quality_better
Etwas ist schiefgelaufen                                                                     
Inventory(money=100.0, stock={})
No traces found for module code_quality_better                                               

Da Monkeytype Code aus __main__ ignoriert, muss man einen Workaround nutzen, ein separates Runner Script:

%%writefile tmp_run.py
from code_quality_better import run_simulation
if __name__ == "__main__":
    final_state = run_simulation(100.0, 10.0)
Writing tmp_run.py
!uvx monkeytype run tmp_run.py
!uvx monkeytype stub code_quality_better
!uvx monkeytype apply code_quality_better
def run_simulation(starting_money: float, market_price: float) -> Inventory: ...             


class Inventory:
    def execute_trade(self, product: ProductType, quantity: int, unit_price: float): ...
from dataclasses import dataclass, field                                                     
from enum import Enum, auto


class GameException(Exception):
    pass


class NotEnoughMoneyException(GameException):
    pass


class NotEnoughStockException(GameException):
    """If stock runs low..."""
    pass


class ProductType(Enum):
    FOOD = auto()

    def __str__(self):
        return self.name.title()


@dataclass
class Inventory:
    money: float
    # dataclasses throw ValueError on using the same {}
    stock: dict = field(default_factory=dict)

    def execute_trade(self, product: ProductType, quantity: int, unit_price: float):
        cost = quantity * unit_price

        if quantity > 0:
            if self.money < cost:
                raise NotEnoughMoneyException(f"Need {cost}€, have {self.money}€")
        elif quantity < 0:
            current_stock = self.stock.get(product, 0)
            if current_stock < abs(quantity):
                raise NotEnoughStockException("Not enough stock")

        self.money -= cost
        self.stock[product] = self.stock.get(product, 0) + quantity


@dataclass
class Market:
    product: ProductType
    current_price: float
    volatility: float

    def adjust_price(self, direction: int) -> None:
        change = direction * self.volatility
        new_price = self.current_price + change
        self.current_price = max(1.0, round(new_price, 2))


def run_simulation(starting_money: float, market_price: float) -> Inventory:
    player = Inventory(money=starting_money)
    market = Market(
        product=ProductType.FOOD, current_price=market_price, volatility=0.5
    )

    try:
        player.execute_trade(ProductType.FOOD, 5, market.current_price)
    except:
        print("Etwas ist schiefgelaufen")

    return player


if __name__ == "__main__":
    final_state = run_simulation(100.0, "10.0")
    print(final_state)

Das fügt immer noch nicht alle Type Hints hinzu, die wir uns wünschen könnten, weil es Code gibt, der von unserem Runner nicht erreicht wird.

Laufzeit-Typüberprüfung

Mypy prüft Typen statisch, d.h. ohne Ausführung des Programms. In der Praxis stehen Dimensionen von Matrizen oder Tensoren manchmal erst zur Laufzeit fest. Die Bilbiothek typeguard erzwingt diese Checks während der Ausführung. Das Anwendungsgebiet von typeguard, Überprüfung, ist übrigens zu unterscheiden von der Konvertierung von Daten, wie es Pydantic macht (siehe späteres Kapitel).

%%writefile simple_typeguard.py
from typeguard import typechecked

@typechecked
def process_scores(scores: list[int]) -> int:
    """Erwartet eine Liste, die ausschließlich Integer enthält."""
    return sum(scores)

if __name__ == "__main__":
    print("Korrekt:", process_scores([10, 20, 30]))
    
    try:
        # Provoziert einen Fehler: Float statt Int in der Liste
        process_scores([10, 20.5, 30])
    except Exception as e:
        print("\nTyp-Fehler abgefangen:\n", type(e).__name__)
        print(e)
Writing simple_typeguard.py
!uv run python simple_typeguard.py
Korrekt: 60

Typ-Fehler abgefangen:
 TypeCheckError
the return value (float) is not an instance of int

Wenn man wirklich Array-Dimensionen spezifizieren möchte, kann jaxtyping hilfreich sein. Da die Typenprüfung von typeguard einer Liste der Länge nn in O(n)O(n) läuft, verwendet man für große Daten eher den drop-in Ersatz beartype, in dem Listen nur stichprobenartig geprüft werden. Das ist zur Laufzeit oft hinreichend, während in einer kontrollierten Test-Umgebung dann wiederum typeguard aufgrund der Reproduzierbarkeit von Vorteil ist.

%%writefile runtime_types.py
import numpy as np
from jaxtyping import Float, jaxtyped, TypeCheckError
from beartype import beartype

@jaxtyped(typechecker=beartype)
def calculate_batch_cost(prices: Float[np.ndarray, "N"], quantities: Float[np.ndarray, "N"]) -> Float[np.ndarray, "N"]:
    return prices * quantities

if __name__ == "__main__":
    p = np.array([10.5, 20.0])
    q_correct = np.array([2.0, 3.5])
    q_wrong = np.array([[1.0], [2.0]])
    
    a, b = calculate_batch_cost(p, q_correct)
    assert a == p[0]*q_correct[0] and b == p[1]*q_correct[1]

    a, b = p * q_wrong # ohne Prüfung!
    try:
        a_new, b_new = calculate_batch_cost(p, q_wrong)
    except TypeCheckError:
        print(f"Shape-Fehler abgefangen: {q_wrong.shape} passt nicht zum erwarteten {p.shape}.")

    print(a, "!=", p[0]*q_wrong[0])
Overwriting runtime_types.py
!uv run python runtime_types.py
Shape-Fehler abgefangen: (2, 1) passt nicht zum erwarteten (2,).
[10.5 20. ] != [10.5]

Ein (2, 1)-Array ist eine 2×12\times 1-Matrix und kein flaches 1D-Array; die strikte Typüberprüfung verhindert hier ein fehlerhaftes, stillschweigendes numpy-Broadcasting. Der Output wäre ein (2, 2)-Array, also eine 2×22\times 2-Matrix, kein 1D-Array. Ohne die Typüberprüfung kann das Broadcasting solche Fehler also weit weg transportieren.

Ruff

Ruff ist in Rust geschrieben und extrem schnell. Es ersetzt Flake8, isort, Black und viele mehr. In Projekten mit und ohne uv lässt es sich direkt via uvx ruff ausführen. Dabei ist ruff format als Drop-in Ersatz für den Formatter Black gedacht und ruff check ist der eigentliche Linter.

print("--- Ruff Check (Linting) ---")
!uvx ruff check code_quality_better.py --color never --no-cache

print("\n--- Ruff Format (Diff) ---")
!uvx ruff format --diff code_quality_better.py
--- Ruff Check (Linting) ---
Installed 1 package in 3ms                                       
E722 Do not use bare `except`
  --> code_quality_better.py:66:5
   |
64 |     try:
65 |         player.execute_trade(ProductType.FOOD, 5, market.current_price)
66 |     except:
   |     ^^^^^^
67 |         print("Etwas ist schiefgelaufen")
   |

Found 1 error.

--- Ruff Format (Diff) ---
--- code_quality_better.py                                                          
+++ code_quality_better.py
@@ -12,6 +12,7 @@
 
 class NotEnoughStockException(GameException):
     """If stock runs low..."""
+
     pass
 
 

1 file would be reformatted

Man kann bei jedem brauchbaren Linter gezielt Warnungen deaktivieren, bei Ruff mit noqa: CODE, hier E722 für den Bare-Except-Fehler (noqa = No Quality Assurance):

try:
    player.execute_trade(ProductType.FOOD, 5, market.current_price)
except:  # noqa: E722
    print("Etwas ist schiefgelaufen")
Etwas ist schiefgelaufen

Wie beim graduellen Hinzufügen von Typeninformationen kann man auch das Lintern des Codes nach und nach machen, indem man z.B. für eine ganze Datei Fehlercodes ausschließt mit einem Kommentar # ruff: noqa: CODE am Anfang der Datei.

Die Fehlercode-Systematik übernimmt Ruff von Flake8, wobei Pylint-Fehler auch mit dem Prefix PL verwendet werden können (etwa in noqa-Anweisungen).

Konfiguration zum Kopieren

Ruff wird über die pyproject.toml konfiguriert, mit der Sie ohnehin schon arbeiten. Der folgende Block ist ein sinnvoller Ausgangspunkt für ein Projekt in diesem Kurs — inklusive Mypy, damit beide Werkzeuge dieselbe Python-Version annehmen:

[tool.ruff]
line-length = 80
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"]       # Zeilenlaenge meldet der Formatter, nicht der Linter

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["B011"]    # assert False ist in Tests in Ordnung

[tool.ruff.format]
quote-style = "double"

[tool.mypy]
python_version = "3.13"
ignore_missing_imports = true
warn_redundant_casts = true
warn_unused_ignores = true

Die fünf Regelgruppen hinter select sind bewusst knapp gehalten — sie decken den größten Teil des Nutzens ab, ohne dass man vor einer Wand aus Meldungen steht:

GruppeHerkunftwas sie findet
EpycodestyleFormalien: Einrückung, Leerzeichen, Zeilenlänge
FPyflakesechte Fehler: unbenutzte Importe, undefinierte Namen, doppelte Argumente
IisortImport-Reihenfolge — erspart die Diskussion im Review
UPpyupgradeKonstrukte, die für Ihre Python-Version veraltet sind
Bflake8-bugbeartypische Denkfehler, etwa ein veränderliches Default-Argument

Wer mehr will, schaltet gruppenweise zu ("SIM" für Vereinfachungen, "RUF" für Ruff-eigene Regeln, "ANN" für fehlende Annotationen) — aber erst, wenn das Projekt mit den fünf oben sauber ist.

Dieses Skript selbst benutzt genau diesen Block. In seiner pyproject.toml stehen zusätzlich ein paar exclude-Einträge, jeweils mit einer Zeile Begründung daneben: Die absichtlich fehlerhaften Lehrbeispiele in diesem Kapitel und der KI-generierte Code aus Generierte Wirtschaftssimulation sollen nicht sauber sein — das ist ihr Zweck. exclude mit Begründung ist legitim, exclude ohne Begründung ist Aufschieben.

Bemerkenswert ist, was dort nicht steht: strict = true für Mypy. Der Abschnitt oben lebt davon, den Unterschied zwischen einem normalen Lauf und --strict zu zeigen; eine projektweite Voreinstellung würde genau diesen Kontrast unsichtbar machen. Für Ihr eigenes Projekt ist strict = true trotzdem die Empfehlung — am besten von Anfang an, weil das nachträgliche Einschalten in einem gewachsenen Projekt hunderte Meldungen auf einmal produziert.

UP als Modernisierer

Die Regelgruppe UP verdient eine eigene Erwähnung, weil sie etwas anderes tut als der Rest: Sie meldet nicht, dass etwas falsch ist, sondern dass es alt ist. Damit wird Ruff zu einem Werkzeug für fremden, gewachsenen Code — das Verfahren, mit dem man ein Projekt von 2013 in wenigen Minuten auf heutige Syntax hebt.

Hier eine Datei, wie man sie in älteren Projekten findet:

%%writefile alter_stil.py
from typing import Dict, List, Optional


def lade_preise(pfad: str, markt: Optional[str] = None) -> Dict[str, List[float]]:
    ergebnis = {}
    f = open(pfad, "r")
    for zeile in f.readlines():
        name, wert = zeile.split(":")
        ergebnis["%s/%s" % (markt, name)] = [float(wert)]
    f.close()
    return ergebnis
Writing alter_stil.py
!uvx ruff check --select UP --diff alter_stil.py --no-cache
--- alter_stil.py
+++ alter_stil.py
@@ -1,9 +1,9 @@
 from typing import Dict, List, Optional
 
 
-def lade_preise(pfad: str, markt: Optional[str] = None) -> Dict[str, List[float]]:
+def lade_preise(pfad: str, markt: str | None = None) -> dict[str, list[float]]:
     ergebnis = {}
-    f = open(pfad, "r")
+    f = open(pfad)
     for zeile in f.readlines():
         name, wert = zeile.split(":")
         ergebnis["%s/%s" % (markt, name)] = [float(wert)]

Would fix 4 errors (1 additional fix available with `--unsafe-fixes`).

--diff zeigt nur, was passieren würde; --fix schreibt es in die Datei. Die Empfehlung für fremden Code lautet: erst --diff lesen, dann --fix, und das Ergebnis als eigenen Commit — getrennt von jeder inhaltlichen Änderung. Sonst versteckt sich die eine Zeile, die wirklich etwas ändert, zwischen zweihundert Zeilen Modernisierung.

Zwei Details, die das Beispiel zeigt:

  • Dict/List/Optional aus typing sind seit Python 3.9 bzw. 3.10 durch dict, list und | ersetzt. Ruff weiß das nur, weil target-version gesetzt ist — ohne diese Angabe bleibt es vorsichtig und meldet nichts.

  • Die Meldung “1 additional fix available with --unsafe-fixes” betrifft das "%s/%s" % (...). Ruff nennt eine Korrektur unsafe, wenn sie das Verhalten in Randfällen ändern könnte. Solche Korrekturen sieht man sich einzeln an, statt sie pauschal durchzuwinken — und genau dafür gibt es die Trennung.

Was UP nicht kann: Es modernisiert Syntax, nicht Architektur. Ein Projekt, das nach ruff check --select UP --fix durchläuft, benutzt aktuelle Schreibweisen — ob es sinnvoll geschnitten ist, steht auf einem anderen Blatt.

Automatisierung

Wir wollen Code-Qualitätstests nicht bei jeder Änderung manuell im Terminal anstoßen. In der Praxis greifen Automatisierungen auf verschiedenen Ebenen ineinander:

  • IDE-Integration: Moderne Entwicklungsumgebungen formatieren den Code oft bereits beim Speichern (Format on Save), wenn man das möchte. Dadurch werden Stil-Konflikte (unnötige Diffs) in der Versionskontrolle minimiert.

  • Lokale Automatisierung (Pre-Commit-Hooks): Um zu verhindern, dass Linter-Fehler überhaupt erst in die Versionskontrolle gelangen, verankert man die Checks im Git-Workflow.

  • Serverseitige Automatisierung (CI/CD): CI-Systeme wie GitLab CI oder GitHub Actions führen Linter und Tests bei jedem Push in sauberen Containern aus. Das garantiert Reproduzierbarkeit unabhängig vom Rechner der Entwickler*innen.

pre-commit

Das Tool pre-commit verwaltet Skripte, die Git automatisch vor jedem git commit ausführt. Schlägt ein Test fehl, wird der Commit abgebrochen. Die Konfiguration erfolgt über eine Datei namens .pre-commit-config.yaml im Hauptverzeichnis des Projekts.

Ein typisches Setup, das Standard-Fehler behebt und Ruff für Linting und Formatierung nutzt, sieht so aus:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-added-large-files

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      # Linter ausführen (und beheben, falls möglich)
      - id: ruff
        args: [ --fix ]
      # Formatter ausführen
      - id: ruff-format

und uvx pre-commit install installiert den pre-commit-hook so, dass git commit ihn auslöst.

Continuous Integration

Serverseitig wird z.B. die .gitlab-ci.yml so konfiguriert:

stages:
  - lint

lint_code:
  stage: lint
  image: python:3.13-slim
  script:
    - pip install uv
    - uv pip install --system ruff mypy
    
    # Checks durchführen (Fehler führen zum Abbruch der Pipeline)
    - ruff check .
    - ruff format --check .
    - mypy .

Dabei ist python:3.13-slim ein Docker-Image, also reproduzierbar und sandboxed.

Zum Ende des Notebooks räumen wir die temporären Dateien wieder auf:

Zusammenfassung und Kontrollfragen

  1. Die drei Werkzeugklassen beantworten verschiedene Fragen: Ein Formatter entscheidet über das Aussehen, ein Linter erkennt verdächtige Muster, ein Type Checker prüft die Verträglichkeit von Typen.

  2. Formatierung hört auf, ein Review-Thema zu sein, sobald sie automatisiert ist — deshalb hat ein Formatter das beste Verhältnis von Aufwand zu vermiedenem Streit.

  3. ruff ersetzt Black, isort und Flake8 in einem Programm und ist dabei um Größenordnungen schneller; die Regelgruppen schaltet man einzeln zu, statt alles auf einmal.

  4. Mypy prüft nur, was annotiert ist: Fehlende Type Hints sind im Standardlauf unsichtbar und werden erst mit --strict zum Fehler.

  5. Verbindlich wird all das erst durch Automatisierung — pre-commit lokal vor dem Commit, die CI als letzte Instanz, die niemand vergessen kann.

Kontrollfragen

  1. Mypy meldet für eine Funktion ohne Type Hints keinen Fehler. Ist die Funktion deshalb typsicher? Welcher Schalter ändert die Antwort, und warum ist er nicht die Voreinstellung dieses Skripts?

  2. In welcher Reihenfolge wenden Sie ruff format, ruff check --fix und mypy auf ein fremdes Projekt an? Warum gehört das Ergebnis in einen eigenen Commit?

  3. Eine Bibliothek liefert keine Typinformationen mit, und Mypy meldet das bei jedem Import. Welche Auswege gibt es, und welcher ist der bessere — mit Begründung?

!rm -f alter_stil.py bad_security.py code_quality.py code_quality.py.bak code_quality_better.py code_quality_black.py monkeytype.sqlite3 runtime_types.py simple_typeguard.py tmp_run.py