Zum Inhalt

Beispielarbeiten · Code & Fehler

Code-Modernisierung: veraltete Module ersetzt, Verhalten gleich

Ein altes Python-Skript mit Modulen, die in neuen Python-Versionen wegfallen, wurde modernisiert. Tests vorher und nachher, gleiche Ausgaben Byte für Byte.

Musterarbeit (Demo), kein Kundenauftrag. Gebaut als Probeaufgabe mit erfundenen Beispieldaten; die Prüfzahlen unten stammen aus dem automatischen Prüflauf und sind nicht geschönt.

Was gebaut wurde

  • Zuerst Tests, die das alte Verhalten festhalten
  • Dann veraltete Bausteine ersetzt: Kommandozeile, Zeitangaben, Typangaben, Formatierung
  • Danach dieselben Tests grün und ohne Warnung über veraltete Module

code-modernisierung-projekt.zip herunterladen

Wie geprüft wurde

Automatischer Prüflauf vom 18.10.2026, aus dem Protokoll (Testdaten erfunden, Zahlen gemessen):

  • 7/7 gruen
  • Tests gruen vorher 15/15, nachher 15/15
  • vorher: DeprecationWarning: 'cgi' is deprecated and slated for removal in Python 3.13
  • Altlasten vorher ['%-Formatierung', 'from optparse', 'import cgi', 'io.open', 'typing.Dict', 'typing.List', 'utcfromtimestamp'], nachher []
Alle Prüffälle (7 von 7 grün)
  • ✓ 1 Charakterisierungstests gegen alten Code gruen
  • ✓ 2 Alter Code wirft DeprecationWarning beim Import
  • ✓ 3 Dieselben Tests nach Modernisierung gruen (-W error::DeprecationWarning)
  • ✓ 4 Identische Testnamen vorher/nachher
  • ✓ 5 Goldene Ausgaben byte-gleich, Exitcodes gleich
  • ✓ 6 Neuer Code importiert ohne DeprecationWarning
  • ✓ 7 AST: keine Altlasten mehr (vorher gefunden)

Grenzen

Was diese Probe nicht abdeckt, steht hier, damit niemand mehr erwartet:

  • Probe: Python-Modernisierung (optparse->argparse, cgi->email, naive->UTC-Zeit, Typen/f-Strings); Sprung zwischen Frameworks (z. B. AngularJS->React, Python 2->3 mit C-Erweiterungen) ist groesser und …
  • Verhalten wird durch Charakterisierungstests + goldene Ausgaben gehalten; Fehlertexte der Kommandozeile duerfen sich im Wortlaut aendern (Exitcode bleibt)
  • requests->httpx nicht Teil der Probe (requests ist in der Werkzeug-Umgebung nicht installiert)

Die Änderung (Diff)

--- zeitbericht.py (alt)
+++ zeitbericht.py (neu)
@@ -1,86 +1,80 @@
-# -*- coding: utf-8 -*-
 """Zeitbericht: Zeiterfassungs-CSV (zeitstempel;stunden;projekt) zusammenfassen."""
-import cgi
-import datetime
-import io
+from __future__ import annotations
+
+import argparse
 import json
-import os
 import sys
-from optparse import OptionParser
-from typing import Dict, List
+from datetime import date, datetime, timezone
+from email.message import Message
+from pathlib import Path
 
 
-def kodierung_aus(content_type):
-    """'text/csv; charset=latin-1' -> 'latin-1' (Standard utf-8)."""
-    _, params = cgi.parse_header(content_type or "text/csv")
-    return params.get("charset", "utf-8")
+def kodierung_aus(content_type: str | None) -> str:
+    """'text/csv; charset=latin-1' -> 'latin-1' (Standard utf-8). Ersatz fuer cgi.parse_header."""
+    m = Message()
+    m["content-type"] = content_type or "text/csv"
+    return m.get_param("charset", "utf-8")
 
 
-def lade_eintraege(pfad, kodierung="utf-8"):
-    # type: (str, str) -> List[Dict]
+def lade_eintraege(pfad: str | Path, kodierung: str = "utf-8") -> list[dict]:
     eintraege = []
-    f = io.open(pfad, "r", encoding=kodierung)
-    try:
+    with Path(pfad).open(encoding=kodierung) as f:
         for zeile in f:
             zeile = zeile.strip()
             if not zeile or zeile.startswith("#"):
                 continue
             ts, stunden, projekt = zeile.split(";")
-            eintraege.append({"zeit": datetime.datetime.utcfromtimestamp(int(ts)),
+            eintraege.append({"zeit": datetime.fromtimestamp(int(ts), timezone.utc),
                               "stunden": float(stunden.replace(",", ".")), "projekt": projekt.strip()})
-    finally:
-        f.close()
     return eintraege
 
 
-def zusammenfassen(eintraege, projekt=None, ab=None):
-    # type: (List[Dict], str, datetime.date) -> Dict
+def _utc_text(t: datetime) -> str:
+    return t.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") + "Z"
+
+
+def zusammenfassen(eintraege: list[dict], projekt: str | None = None, ab: date | None = None) -> dict:
     auswahl = [e for e in eintraege
                if (projekt is None or e["projekt"] == projekt) and (ab is None or e["zeit"].date() >= ab)]
-    je_projekt = {}  # type: Dict[str, float]
+    je_projekt: dict[str, float] = {}
     for e in auswahl:
         je_projekt[e["projekt"]] = je_projekt.get(e["projekt"], 0.0) + e["stunden"]
     return {
         "anzahl": len(auswahl),
         "stunden": round(sum(e["stunden"] for e in auswahl), 2),
         "je_projekt": dict(sorted((k, round(v, 2)) for k, v in je_projekt.items())),
-        "erster": auswahl and min(e["zeit"] for e in auswahl).isoformat() + "Z" or None,
-        "letzter": auswahl and max(e["zeit"] for e in auswahl).isoformat() + "Z" or None,
+        "erster": _utc_text(min(e["zeit"] for e in auswahl)) if auswahl else None,
+        "letzter": _utc_text(max(e["zeit"] for e in auswahl)) if auswahl else None,
     }
 
 
-def als_text(z):
-    zeilen = ["Eintraege: %d, Stunden: %.2f" % (z["anzahl"], z["stunden"])]
-    for p, h in z["je_projekt"].items():
-        zeilen.append("  %-12s %7.2f h" % (p, h))
+def als_text(z: dict) -> str:
+    zeilen = [f"Eintraege: {z['anzahl']}, Stunden: {z['stunden']:.2f}"]
+    zeilen += [f"  {p:<12} {h:7.2f} h" for p, h in z["je_projekt"].items()]
     return "\n".join(zeilen)
 
 
-def main(argv=None):
-    parser = OptionParser(usage="%prog [optionen] DATEI")
-    parser.add_option("-p", "--projekt", dest="projekt", help="nur dieses Projekt")
-    parser.add_option("--ab", dest="ab", help="nur ab Datum JJJJ-MM-TT")
-    parser.add_option("-f", "--format", dest="format", default="text", choices=["text", "json"],
-                      help="text oder json")
-    parser.add_option("--content-type", dest="content_type", default="text/csv; charset=utf-8",
-                      help="Kodierung der Datei als Content-Type")
-    optionen, args = parser.parse_args(argv)
-    if len(args) != 1:
-        parser.error("genau eine DATEI angeben")
-    if not os.path.exists(args[0]):
-        sys.stderr.write("Datei nicht gefunden: %s\n" % args[0])
+def _datum(text: str) -> date:
+    try:
+        return datetime.strptime(text, "%Y-%m-%d").date()
+    except ValueError:
+        raise argparse.ArgumentTypeError("--ab braucht JJJJ-MM-TT") from None
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(usage="%(prog)s [optionen] DATEI")
+    parser.add_argument("datei", metavar="DATEI")
+    parser.add_argument("-p", "--projekt", help="nur dieses Projekt")
+    parser.add_argument("--ab", type=_datum, help="nur ab Datum JJJJ-MM-TT")
+    parser.add_argument("-f", "--format", default="text", choices=["text", "json"], help="text oder json")
+    parser.add_argument("--content-type", default="text/csv; charset=utf-8",
+                        help="Kodierung der Datei als Content-Type")
+    a = parser.parse_args(argv)
+    if not Path(a.datei).exists():
+        sys.stderr.write(f"Datei nicht gefunden: {a.datei}\n")
         return 3
-    ab = None
-    if optionen.ab:
-        try:
-            ab = datetime.datetime.strptime(optionen.ab, "%Y-%m-%d").date()
-        except ValueError:
-            parser.error("--ab braucht JJJJ-MM-TT")
-    z = zusammenfassen(lade_eintraege(args[0], kodierung_aus(optionen.content_type)), optionen.projekt, ab)
-    if optionen.format == "json":
-        print(json.dumps(z, sort_keys=True))
-    else:
-        print(als_text(z))
+    z = zusammenfassen(lade_eintraege(a.datei, kodierung_aus(a.content_type)), a.projekt, a.ab)
+    print(json.dumps(z, sort_keys=True) if a.format == "json" else als_text(z))
     return 0
 
 

So etwas für deinen Betrieb?

Beschreib kurz, was du brauchst. Du bekommst einen Festpreis, bevor ich anfange; ohne dein Ja passiert nichts.

Zur Werkstatt