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.

# 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/voelkel/sciebo/hhu/eipy-26/eipy-skript/part-tools/code_quality.py:before	2026-07-01 11:53:03.657897
+++ /home/voelkel/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/voelkel/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 --quiet -r code_quality_better.py
                                                                               

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]

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).

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:

!rm -f 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