diff --git a/HANDBUCH.md b/HANDBUCH.md index aea57ce..fecf19f 100644 --- a/HANDBUCH.md +++ b/HANDBUCH.md @@ -445,7 +445,7 @@ Alles andere (Langoptionen wie `--all`, `sudo`, andere Pfade, Reihenfolge von Ar Laufzeitumgebung (Browser und `pnpm verify` identisch, siehe `src/python/harness.py`): -- Pyodide mit **Python 3.14** und der Standardbibliothek (inklusive `sqlite3`, `json`, `csv`, `re`, `pathlib`, `shutil`, `zipfile`, `hashlib`, `email`, `html.parser`, `xml.etree`, `tomllib`, `unittest.mock`). Zusätzlich verfügbar: `bs4` (BeautifulSoup), `yaml` (PyYAML), `zoneinfo` mit Zeitzonendaten, **pandas 3.0 mit numpy 2.4** sowie **pytest 9.0 und Hypothesis 6.168** (siehe unten). Beim ersten `import pandas` lädt der Browser einmalig etwa 8 MB; das zählt nicht zum Zeitlimit. pandas 3 hat Copy-on-Write und einen eigenen `str`-Datentyp für Text – Beispiele und erwartete Ausgaben müssen dazu passen. `pnpm verify` führt pandas-Code in einer virtuellen Umgebung mit denselben Versionen aus (angelegt mit `uv`). Nicht verfügbar: matplotlib, openpyxl, pyarrow – Code dafür als `python` ohne `run` oder als `practice`. +- Pyodide mit **Python 3.14** und der Standardbibliothek (inklusive `sqlite3`, `json`, `csv`, `re`, `pathlib`, `shutil`, `zipfile`, `hashlib`, `hmac`, `email`, `html.parser`, `xml.etree`, `tomllib`, `logging`, `contextvars`, `unittest.mock`). Zusätzlich verfügbar: `bs4` (BeautifulSoup), `yaml` (PyYAML), `zoneinfo` mit Zeitzonendaten, **pandas 3.0 mit numpy 2.4**, **pytest 9.0 und Hypothesis 6.168** (siehe unten) sowie **openpyxl 3.1** für Excel-Dateien. Beim ersten `import pandas` lädt der Browser einmalig etwa 8 MB, bei openpyxl rund 270 KB; das zählt nicht zum Zeitlimit. pandas 3 hat Copy-on-Write und einen eigenen `str`-Datentyp für Text – Beispiele und erwartete Ausgaben müssen dazu passen. openpyxl rechnet Formeln nicht aus: Eine Formel bleibt Text, `data_only=True` liefert nur Werte, die eine Tabellenkalkulation gespeichert hat. `pnpm verify` führt pandas-, pytest- und openpyxl-Code in einer virtuellen Umgebung mit denselben Versionen aus (angelegt mit `uv`). Nicht verfügbar: matplotlib, pyarrow – Code dafür als `python` ohne `run` oder als `practice`. - **Nicht verfügbar:** Netzwerk, `subprocess`, Threads (`threading`, `ThreadPoolExecutor`), `multiprocessing`-Prozesse, `input()`. Für diese Themen `explain`, `output` (nur deterministisch), `bug` oder `practice` verwenden – oder die Aufgabe so schneiden, dass die Logik als reine Funktion testbar ist (zum Beispiel eine Funktion, die die Befehlsliste für `subprocess.run` *baut*, oder ein Abrufer, der als Parameter übergeben wird). - **Async:** Tests dürfen `await` auf oberster Ebene verwenden (`assert await hole(1) == …`). `asyncio.gather`, `TaskGroup`, `asyncio.timeout` und `asyncio.sleep` funktionieren. Niemals `asyncio.run()` im Lernenden-Code oder in Tests aufrufen – im Browser läuft bereits eine Event-Loop. - Jeder Lauf startet in einem frischen, leeren Arbeitsverzeichnis. `setup` kann dort Dateien anlegen, auch Module: Das Verzeichnis ist importierbar, `from tarif import mietpreis` findet also eine Datei `tarif.py` aus `setup`. @@ -782,7 +782,7 @@ pnpm test:e2e # Browser-Tests (Playwright) pnpm build # TypeScript + Produktionsbuild ``` -`pnpm verify` braucht lokal `python3` und `rustc` (Edition 2024), für pandas- und pytest-Code zusätzlich `uv`: Beim ersten Lauf entsteht unter `node_modules/.cache/verify-python/` eine virtuelle Umgebung mit genau den pandas- und numpy-Versionen des Browsers (aus `pyodide-lock.json`) und den pytest- und Hypothesis-Versionen aus `src/python/packages.ts`. TypeScript- und JavaScript-`output` laufen mit dem Node, das auch `pnpm verify` ausführt. SQL prüft `verify` in Pyodide unter Node, also mit derselben SQLite wie im Browser, PostgreSQL mit derselben PGlite-Version wie im Browser. Kotlin wird nicht kompiliert; Kotlin-Aufgaben daher besonders sorgfältig gegen die offizielle Dokumentation prüfen. Excel-Formeln und Power-Query-Code laufen nirgends; sie brauchen besonders sorgfältige Prüfung gegen die Microsoft-Dokumentation. +`pnpm verify` braucht lokal `python3` und `rustc` (Edition 2024), für pandas-, pytest- und openpyxl-Code zusätzlich `uv`: Beim ersten Lauf entsteht unter `node_modules/.cache/verify-python/` eine virtuelle Umgebung mit genau den pandas- und numpy-Versionen des Browsers (aus `pyodide-lock.json`) und den pytest-, Hypothesis- und openpyxl-Versionen aus `src/python/packages.ts`. TypeScript- und JavaScript-`output` laufen mit dem Node, das auch `pnpm verify` ausführt. SQL prüft `verify` in Pyodide unter Node, also mit derselben SQLite wie im Browser, PostgreSQL mit derselben PGlite-Version wie im Browser. Kotlin wird nicht kompiliert; Kotlin-Aufgaben daher besonders sorgfältig gegen die offizielle Dokumentation prüfen. Excel-Formeln und Power-Query-Code laufen nirgends; sie brauchen besonders sorgfältige Prüfung gegen die Microsoft-Dokumentation. `content:check` und `verify` nehmen auch einzelne Module: `pnpm verify data/sql-joins`. Dann zählen nur Fehler dieses Moduls und der `area.yaml` – praktisch, solange andere Module desselben Bereichs noch entstehen. diff --git a/README.md b/README.md index 04f272a..c61fe80 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Die Plattform ist kein Nachschlagewerk. Jedes Modul besteht aus einer Lektion, d ## Start -Node.js 24, pnpm 11. Für `pnpm verify` zusätzlich `python3` (3.14), `rustc` (Edition 2024) und `uv` (legt für pandas- und pytest-Übungen eine Umgebung mit den pandas-, numpy-, pytest- und Hypothesis-Versionen des Browsers an), für den Bear-Generator Python 3. +Node.js 24, pnpm 11. Für `pnpm verify` zusätzlich `python3` (3.14), `rustc` (Edition 2024) und `uv` (legt für pandas-, pytest- und Excel-Übungen eine Umgebung mit den pandas-, numpy-, pytest-, Hypothesis- und openpyxl-Versionen des Browsers an), für den Bear-Generator Python 3. ```sh pnpm install @@ -20,7 +20,7 @@ pnpm dev # http://127.0.0.1:5180 | Befehl | Zweck | | --- | --- | | `pnpm content:check [bereich[/modul]]` | Inhalte validieren (Pflichtfelder, IDs, Lücken, Richtwerte) | -| `pnpm verify [bereich[/modul]]` | Python-Übungen und -Beispiele ausführen (auch pandas, pytest und Hypothesis), SQL mit der SQLite und dem PostgreSQL des Browsers prüfen, Rust kompilieren, TypeScript-Ausgaben mit Node prüfen, Ausgaben vergleichen, Git-Labore durchspielen | +| `pnpm verify [bereich[/modul]]` | Python-Übungen und -Beispiele ausführen (auch pandas, pytest, Hypothesis und openpyxl), SQL mit der SQLite und dem PostgreSQL des Browsers prüfen, Rust kompilieren, TypeScript-Ausgaben mit Node prüfen, Ausgaben vergleichen, Git-Labore durchspielen | | `pnpm links [bereich]` | Alle externen Links und Anker prüfen | | `pnpm test` | Unit-Tests (Prüflogik, Speicher, Bear-Rekonstruktion, Inhalte) | | `pnpm lint`, `pnpm typecheck` | ESLint und strikte TypeScript-Prüfung einschließlich Backend | @@ -75,7 +75,7 @@ src/pages/ Startseite, Bereich, Lektion, Übung, Karten, Wiederh src/python/ Pyodide-Worker und Harness für Python, pytest und SQLite (dieselbe Harness nutzt pnpm verify), Liste der Test-Pakete src/pg/ PostgreSQL 18 (PGlite) im Worker: frische Datenbank je Lauf, Ausgabe wie psql (dieselbe Engine nutzen pnpm verify und der Build) src/vm/ Linux-VM im Browser (v86): Laden, Snapshot, Steuerkanal, Prüfungen, Terminal in Übungen und Lektionen -vendor/pyodide/ Zusätzliche Pyodide-Pakete (tzdata, beautifulsoup4, PyYAML, pandas mit numpy, pytest, Hypothesis) +vendor/pyodide/ Zusätzliche Pyodide-Pakete (tzdata, beautifulsoup4, PyYAML, pandas mit numpy, pytest, Hypothesis, openpyxl mit et_xmlfile) vendor/vm/ Gebautes Debian-Image samt Snapshot für die Linux-VM ``` diff --git a/content/automation/area.yaml b/content/automation/area.yaml index a5334a1..defe684 100644 --- a/content/automation/area.yaml +++ b/content/automation/area.yaml @@ -3,17 +3,21 @@ title: Automation mit Python short: Automation tagline: Wiederkehrende Arbeit einmal lösen – und dann nie wieder. description: > - Dateien und Ordner, Kommandozeilen-Werkzeuge, Prozesse, Datenformate, - reguläre Ausdrücke, Web-APIs, Scraping und Browser-Automatisierung, - zeitgesteuerte Jobs, Benachrichtigungen, robuste Fehlerbehandlung, - Deployment mit CI und KI-gestützte Automatisierung. + Dateien und Ordner, Kommandozeilen-Werkzeuge, Prozesse, Datenformate mit Excel, + reguläre Ausdrücke, Web-APIs mit Authentifizierung, Scraping und + Browser-Automatisierung, Postfach und Kalender, zeitgesteuerte Jobs, + Benachrichtigungen, robuste Fehlerbehandlung mit ausführbaren Fehlerfällen, + Logs und Geheimnisse, Deployment mit CI und KI-gestützte Automatisierung. + Module „Am PC“ brauchen einen Rechner, etwa für Playwright; ihre Lektionen + lassen sich auch unterwegs lesen. color: "#2BD97C" order: 5 outcomes: - Du erkennst, was sich zu automatisieren lohnt, und baust Skripte sicher, idempotent und mit Trockenlauf. - Du automatisierst Dateien, Ordner, Tabellen, JSON, YAML und Excel. - Du baust saubere Kommandozeilen-Werkzeuge mit argparse oder Typer, Logging und Konfiguration. - - Du sprichst Web-APIs robust an – mit Authentifizierung, Paginierung, Wiederholungen und Rate Limits. + - Du sprichst Web-APIs robust an – mit API-Keys, OAuth 2.0 und Webhook-Signaturen, Paginierung, Wiederholungen und Rate Limits. + - Du liest Postfächer per IMAP, verarbeitest Anhänge sicher und erzeugst Termine als ICS. - Du planst Jobs mit cron oder systemd-Timern und lässt sie mit GitHub Actions laufen. - Du setzt LLM-APIs verantwortungsvoll in Automatisierungen ein. tracks: @@ -21,8 +25,8 @@ tracks: description: Denken wie ein Automatisierer, Dateien, Kommandozeile und externe Programme. modules: [automatisieren-denken, dateien-ordner, cli-werkzeuge, prozesse-shell] - title: Daten & Web - description: Formate, Text, APIs, Scraping und Browser-Steuerung. - modules: [datenformate, regex-text, web-apis, scraping-browser] + description: Formate mit Excel, Text, APIs mit Authentifizierung, Scraping und Browser-Steuerung. + modules: [datenformate, regex-text, web-apis, api-authentifizierung, scraping-browser] - title: Betrieb - description: Zeitsteuerung, Benachrichtigungen, Robustheit, Deployment und KI. - modules: [zeitsteuerung, benachrichtigungen, robustheit, deployment-ci, ki-automatisierung] + description: Zeitsteuerung, Benachrichtigungen, Postfach und Kalender, Robustheit mit ausführbaren Fehlerfällen, Logs und Geheimnisse, Deployment und KI. + modules: [zeitsteuerung, benachrichtigungen, postfach-kalender, robustheit, fehlerfaelle, logs-geheimnisse, deployment-ci, ki-automatisierung] diff --git a/content/automation/cheatsheet.md b/content/automation/cheatsheet.md index 175be34..47f4f1d 100644 --- a/content/automation/cheatsheet.md +++ b/content/automation/cheatsheet.md @@ -98,9 +98,40 @@ if not isinstance(config, dict) or not isinstance(config.get("limit"), int): | CSV | Encoding, Trennzeichen, Quotes, Kopfzeile, Typkonvertierung | | JSON | Syntax, danach Wurzeltyp, Pflichtfelder, Wertebereiche | | YAML | Safe Loader, danach Schema und Größenlimits | -| Excel | Zelltypen, leere Zellen, führende Nullen; separater lokaler Adapter | +| Excel | Blatt über Namen, Spalten über Kopfzeile, Formeln werden nicht berechnet, fremden Text als Text | | HTML-Bericht | Fremden Text escapen, keine ungeprüften HTML-Fragmente übernehmen | +## Excel mit openpyxl + +```python +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font + +buch = Workbook() +ws = buch.active +ws.title = "Bericht" +ws.append(["Posten", "Betrag"]) +ws["A1"].font = Font(bold=True) +ws.append(["Miete", 950]) +ws["B2"].number_format = "#,##0.00" +ws["A3"], ws["B3"] = "Summe", "=SUM(B2:B2)" +ws.freeze_panes = "A2" +buch.create_sheet("Fehler") +buch.save("bericht.xlsx") # überschreibt ohne Rückfrage + +ws = load_workbook("ein.xlsx")["Eingang"] # über den Namen, nicht active +kopf, *zeilen = ws.values +``` + +| Falle | Abhilfe | +| --- | --- | +| `buch.active` ist eine Position | Blätter über `buch["Name"]` ansprechen | +| Formel wird nicht berechnet | `data_only=True` liest nur gespeicherte Ergebnisse; Summen in Python rechnen | +| Formeln in der Datei | englische Namen, Kommas: `=SUM(B2:B9)` (deutsches Excel zeigt `SUMME`) | +| Text beginnt mit `=` | nach Zuweisung `zelle.data_type = "s"` | +| Beträge als `"120,50 €"` | Zahl schreiben, `number_format` setzen | +| Original überschrieben | neue Datei schreiben; openpyxl verliert Formen beim Speichern | + ## Reguläre Ausdrücke | Muster | Bedeutung | @@ -153,6 +184,57 @@ def retry_delays(attempts: int, cap: float = 30.0) -> list[float]: for attempt in range(attempts)] ``` +## Authentifizierung + +| Verfahren | Header | Merke | +| --- | --- | --- | +| API-Key | z. B. `X-API-Key: …` | nie als Query-Parameter | +| Basic | `Authorization: Basic base64(user:pass)` | nur mit TLS, kein `:` im Nutzer | +| Bearer | `Authorization: Bearer ` | Token = Passwort mit Ablaufdatum | +| OAuth `client_credentials` | Token per POST an Token-Endpunkt | kein Refresh-Token, neu anfragen | +| OAuth Refresh | `grant_type=refresh_token` | rotiertes Token sofort atomar speichern | + +```python +def rufe(anfrage, token): + r = anfrage(token.aktuell()) + if r.status != 401: + return r # 403: neues Token mit gleichem Scope hilft nicht + token.verwerfen() + r = anfrage(token.aktuell()) # genau einmal wiederholen + if r.status == 401: + raise AuthFehler("401") + return r +``` + +```python +mac = hmac.new(geheim, roh, hashlib.sha256) # Rohdaten, nicht json.dumps(...) +soll = "sha256=" + mac.hexdigest() +ok = hmac.compare_digest(soll.encode(), kopf.encode()) # Bytes, konstante Zeit +``` + +`invalid_grant` → neu anmelden, kein Retry. Token-Cache: erneuern, wenn `uhr() + puffer >= ablauf`. + +## Fehlerfälle im Test + +```python +class Uhr: # Fake: schlafe() stellt die Uhr vor + def __init__(self): self.t = 0.0 + def __call__(self): return self.t + def schlafe(self, s): self.t += s + +pause = zufall() * min(8, 2 ** versuch) # Full Jitter +if uhr() + pause > ende: raise TimeoutError() # Deadline vor dem Schlafen prüfen +``` + +| Muster | Kern | +| --- | --- | +| Retry nach Status | 429, 502, 503, 504 ja; 400, 401, 403, 404, 422 nein | +| Retry-After | Ziffern = Sekunden, sonst `email.utils.parsedate_to_datetime`, min. 0 | +| Lockfile | `os.open(p, O_CREAT \| O_EXCL \| O_WRONLY)`; nur eigene Sperre löschen | +| flock | `fcntl.flock(f, LOCK_EX \| LOCK_NB)` → `BlockingIOError`; verschwindet mit dem Prozess | +| Checkpoint | nach der Wirkung, atomar mit `os.replace`; kaputte Datei → anhalten | +| Deduplizierung | `UNIQUE` + `INSERT OR IGNORE`, `cursor.rowcount == 1` heißt neu; committen | + ## Zeitplanung | cron-Feld | Bedeutung | @@ -209,6 +291,82 @@ jobs: Das ist ein Zeitplan-Gerüst, noch kein lauffähiger Digest. Für den eigenen Workflow Actions-Referenzen prüfen und festlegen, Lockfile verwenden, Zustand zwischen Runnern bewusst speichern und fehlende Erfolge überwachen. Ohne zusätzliche Zeitzonen-Konfiguration ist UTC der Ausgangspunkt; Details anhand der aktuellen [Schedule-Dokumentation](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows) prüfen. +## Postfach und Kalender + +```python +ctx = ssl.create_default_context() # ohne: Zertifikat ungeprüft +with imaplib.IMAP4_SSL(HOST, ssl_context=ctx, timeout=30) as imap: + imap.login(USER, PASSWORT) + imap.select("INBOX") # UIDVALIDITY merken + typ, daten = imap.uid("SEARCH", None, "UNKEYWORD", "$Verarbeitet") + for uid in daten[0].split(): + typ, teile = imap.uid("FETCH", uid, "(BODY.PEEK[])") # kein \Seen + roh = next(t[1] for t in teile if isinstance(t, tuple)) + verarbeite(roh) + imap.uid("STORE", uid, "+FLAGS", "($Verarbeitet)") # erst nach Erfolg +``` + +| Thema | Regel | +| --- | --- | +| Anhangname | `Path(name).name`, nur `[A-Za-z0-9._-]`, führende Punkte weg, nie überschreiben | +| Anhang senden | `mail.add_attachment(daten, maintype="application", subtype="pdf", filename=…)` | +| ICS-Datei | CRLF, `VERSION:2.0`, `PRODID`; `VEVENT` mit `UID`, `DTSTAMP`, `DTSTART` | +| ICS-Text | `\` → `\\`, `;` → `\;`, `,` → `\,`, Umbruch → `\n` (Backslash zuerst) | +| ICS-Faltung | höchstens 75 Bytes je Zeile, Fortsetzung mit Leerzeichen; beim Lesen zuerst entfalten | +| Google Calendar | `syncToken`, gelöschte Termine `cancelled`, 410 → alles neu laden | +| Microsoft Graph | `calendarView/delta`, `@odata.nextLink` / `@odata.deltaLink` | + +## Logs und Geheimnisse + +```python +lauf_id = contextvars.ContextVar("lauf_id", default="-") + +class LaufFilter(logging.Filter): + def filter(self, r): + r.lauf = lauf_id.get() + return True + +h = RotatingFileHandler("job.log", maxBytes=1_000_000, backupCount=5) # 0 = nie rotieren +h.addFilter(LaufFilter()) # am Handler, sonst fehlen Kind-Logger +lauf_id.set(os.environ.get("INVOCATION_ID", uuid.uuid4().hex[:12])) +``` + +| Quelle | Wofür | +| --- | --- | +| keyring | Skripte am Desktop: `keyring.get_password(dienst, name)` → `None` | +| `.env` + python-dotenv | Entwicklung; `load_dotenv()` überschreibt nichts; Datei in `.gitignore` | +| `LoadCredential=api_token` | systemd-Dienst, Datei aus `/etc/credstore/`, lesen unter `$CREDENTIALS_DIRECTORY` | +| `Environment=` | keine Geheimnisse: über D-Bus sichtbar, an Kindprozesse vererbt | +| `EnvironmentFile=` | Einstellungen; Werte landen in der Umgebung und werden vererbt | +| `${{ secrets.NAME }}` | Actions, als `env:` an einzelne Schritte; nicht in Forks | + +Leak: widerrufen → ersetzen → Audit-Log prüfen → entfernen → Ursache abstellen. + +## Playwright im Betrieb + +```python +context.storage_state(path="playwright/.auth/state.json") # wie ein Passwort; indexed_db=True bei Bedarf +context = browser.new_context(storage_state="playwright/.auth/state.json") + +with page.expect_response("**/api/kurse") as info: # warten mit Ziel + page.get_by_role("button", name="Laden").click() +expect(page.get_by_role("row")).to_have_count(12, timeout=10_000) + +context.tracing.start(screenshots=True, snapshots=True) +try: + lauf(page) +except Exception: + page.screenshot(path="fehler.png", full_page=True) + context.tracing.stop(path="trace.zip") # playwright show-trace trace.zip + raise +else: + context.tracing.stop() + +page.route("**/api/kurse", lambda r: r.fulfill(json=KURSE)) # Glob trifft die ganze URL +``` + +Timeouts: Aktionen 30 s (`set_default_timeout`), `expect` 5 s. `click` wirft `TimeoutError`, `expect` wirft `AssertionError`. Nie `wait_for_timeout` im Betrieb. + ## Werkzeuge und Betriebsvertrag ```bash @@ -234,4 +392,4 @@ pipx run --spec package-name command-name ## Offizielle Referenzen -[pathlib](https://docs.python.org/3/library/pathlib.html) · [subprocess](https://docs.python.org/3/library/subprocess.html) · [logging](https://docs.python.org/3/library/logging.html) · [csv](https://docs.python.org/3/library/csv.html) · [Regex](https://docs.python.org/3/library/re.html) · [HTTPX](https://www.python-httpx.org/advanced/timeouts/) · [systemd.timer](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html) · [zoneinfo](https://docs.python.org/3/library/zoneinfo.html) · [uv](https://docs.astral.sh/uv/) +[pathlib](https://docs.python.org/3/library/pathlib.html) · [subprocess](https://docs.python.org/3/library/subprocess.html) · [logging](https://docs.python.org/3/library/logging.html) · [csv](https://docs.python.org/3/library/csv.html) · [Regex](https://docs.python.org/3/library/re.html) · [HTTPX](https://www.python-httpx.org/advanced/timeouts/) · [systemd.timer](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html) · [zoneinfo](https://docs.python.org/3/library/zoneinfo.html) · [uv](https://docs.astral.sh/uv/) · [openpyxl](https://openpyxl.readthedocs.io/en/stable/tutorial.html) · [RFC 6749 OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749.html) · [hmac](https://docs.python.org/3/library/hmac.html) · [imaplib](https://docs.python.org/3/library/imaplib.html) · [RFC 5545 iCalendar](https://www.rfc-editor.org/rfc/rfc5545.html) · [contextvars](https://docs.python.org/3/library/contextvars.html) · [systemd Credentials](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Credentials) · [Playwright Trace Viewer](https://playwright.dev/python/docs/trace-viewer) diff --git a/content/automation/glossary.yaml b/content/automation/glossary.yaml index be1a821..d6aeb35 100644 --- a/content/automation/glossary.yaml +++ b/content/automation/glossary.yaml @@ -102,3 +102,40 @@ terms: definition: Deterministische Prüfung einer Ausgabe gegen Format und fachliche Grenzen. - term: Fallback definition: Definierter Ersatzpfad bei Ausfall oder ungültigem Ergebnis einer Abhängigkeit. +- term: OAuth 2.0 + definition: Protokoll, mit dem ein Programm von einem Authorization Server kurzlebige Tokens für eine API bekommt, + statt Passwörter zu speichern. Dienstkonten nutzen den Grant `client_credentials`, Programme im Namen einer Person + den Authorization Code mit Refresh-Token. +- term: Access Token + definition: Kurzlebiger Nachweis, den eine API im Header `Authorization` erwartet, meist als Bearer-Token. Wer es + besitzt, kann es benutzen; `expires_in` nennt die Lebensdauer in Sekunden. +- term: Refresh Token + definition: Langlebiger Schlüssel, mit dem ein Client ohne erneute Anmeldung neue Access-Tokens holt. Bei Rotation + ersetzt jede Erneuerung ihn; das neue muss sofort gespeichert werden. +- term: HMAC-Signatur + definition: Prüfsumme über Nachricht und gemeinsames Geheimnis, etwa im Header `X-Hub-Signature-256`. Sie wird über + die Rohdaten berechnet und mit `hmac.compare_digest` verglichen. +- term: UID und UIDVALIDITY + definition: Die UID ist die stabile Kennung einer Nachricht in einem IMAP-Postfach. Sie gilt, solange der Wert + UIDVALIDITY des Postfachs gleich bleibt; ändert er sich, sind gemerkte UIDs ungültig. +- term: IMAP-Flag + definition: Markierung einer Nachricht wie `\Seen` oder ein eigenes Schlüsselwort wie `$Verarbeitet`. Eigene + Schlüsselwörter sind erlaubt, wenn `PERMANENTFLAGS` des Postfachs `\*` enthält. +- term: iCalendar + definition: Textformat für Termine nach RFC 5545, Dateiendung `.ics`. Zeilen enden mit CRLF, werden nach 75 Oktetts + gefaltet, und jeder Termin braucht `UID` und `DTSTAMP`. +- term: storage_state + definition: Playwright-Funktion, die Cookies und Local Storage eines Browser-Kontexts als JSON speichert und wieder + lädt, mit `indexed_db=True` auch IndexedDB. Die Datei ist so vertraulich wie ein Passwort. +- term: Trace + definition: Aufzeichnung eines Playwright-Laufs mit Aktionen, DOM-Schnappschüssen, Screenshots und Netzwerk. + `playwright show-trace` zeigt sie Schritt für Schritt. +- term: ContextVar + definition: Variable aus `contextvars`, deren Wert pro Kontext gilt, etwa pro asyncio-Task. Geeignet, um eine + Lauf-ID ohne Parameter durch den Code und in jeden Logeintrag zu tragen. +- term: systemd-Credential + definition: Geheimnis, das systemd per `LoadCredential=` als schreibgeschützte Datei unter `$CREDENTIALS_DIRECTORY` + an genau einen Dienst gibt, statt es in die Umgebung zu legen. +- term: Deadline + definition: Zeitbudget für einen ganzen Vorgang samt Wiederholungen und Pausen. Passt die nächste Pause nicht mehr + hinein, gibt der Job sofort auf. diff --git a/content/automation/interview.yaml b/content/automation/interview.yaml index dfe50e7..7969c90 100644 --- a/content/automation/interview.yaml +++ b/content/automation/interview.yaml @@ -442,3 +442,150 @@ cards: tags: - praxis level: 3 +- id: auth-401-einmal + module: api-authentifizierung + q: Eine API liefert mitten im Job 401. Was tust du? + a: Ich verwerfe das Token, hole genau ein neues und wiederhole die Anfrage einmal. Kommt wieder 401, breche ich mit + einer klaren Meldung ab, denn dann ist das Secret falsch oder das Konto gesperrt, und weitere Versuche belasten + nur den Dienst. Ein 403 behandle ich nicht mit einem neuen Token, weil es mit demselben Scope dieselben Rechte hätte. Zusätzlich + erneuere ich vorab, kurz bevor `expires_in` abläuft, damit der 401 die Ausnahme bleibt. + tags: + - http + level: 2 +- id: auth-grant-waehlen + module: api-authentifizierung + q: Wann nimmst du client_credentials, wann einen Refresh-Token? + a: client_credentials, wenn mein Job als Dienstkonto in eigenem Namen handelt; dort gibt es laut RFC 6749 normalerweise + kein Refresh-Token, ich frage nach Ablauf ein neues Access-Token an. Handelt der Job im Namen einer Person, + etwa für ihren Kalender, meldet sie sich einmal im Browser an, und der Job erneuert danach mit dem Refresh-Token. + Rotiert der Server es, speichere ich das neue sofort atomar; `invalid_grant` heißt neu anmelden, nicht wiederholen. + tags: + - http + level: 2 +- id: webhook-signatur + module: api-authentifizierung + q: Wie prüfst du, ob ein Webhook wirklich vom Anbieter kommt? + a: Ich berechne den HMAC-SHA256 der empfangenen Rohdaten mit dem gemeinsamen Geheimnis und vergleiche ihn mit + `hmac.compare_digest` mit dem Header, bei GitHub `X-Hub-Signature-256`. Rohdaten, weil neu serialisiertes JSON + andere Bytes hat. `compare_digest`, weil `==` über die Antwortzeit Zeichen verrät. Gegen wiederholte Lieferungen + hilft eine Zustell-ID oder ein signierter Zeitstempel. + tags: + - web + level: 2 +- id: postfach-zustand + module: postfach-kalender + q: Wie merkt sich ein Mail-Job, welche Nachrichten er schon verarbeitet hat? + a: Im Postfach selbst mit einem eigenen Schlüsselwort wie `$Verarbeitet`, gesucht mit `UNKEYWORD` und gesetzt erst + nach erfolgreicher Verarbeitung. Ich hole mit `BODY.PEEK[]`, damit Mails für Menschen ungelesen bleiben, und arbeite + mit UIDs statt Sequenznummern. Erlaubt der Server keine eigenen Schlüsselwörter, verschiebe ich in einen Ordner + oder speichere UIDVALIDITY und UID in einer Datenbank. + tags: + - betrieb + level: 2 +- id: ics-stabile-uid + module: postfach-kalender + q: Dein Job exportiert Fälligkeiten als ICS. Warum ist die UID wichtig? + a: Die UID identifiziert den Termin dauerhaft. Bekommt derselbe Termin bei jedem Lauf dieselbe UID, etwa aus der + Rechnungsnummer, aktualisiert der Kalender ihn, statt ihn doppelt anzulegen; der Export wird idempotent. Ändert sich + der Termin, erhöhe ich `SEQUENCE`, damit Kalender die neue Fassung übernehmen. Dazu + gehören CRLF, Faltung nach 75 Bytes, Escaping und Zeiten in UTC mit `Z`, damit strenge Kalender die Datei + annehmen. + tags: + - daten + level: 2 +- id: anhang-dateiname + module: postfach-kalender + q: Was ist am Dateinamen eines Mail-Anhangs gefährlich? + a: Er kommt vom Absender. Ein Name wie `../../.bashrc` schreibt außerhalb des Zielordners, gleiche Namen überschreiben + einander. Ich nehme nur den letzten Pfadteil, erlaube eine kleine Zeichenmenge, entferne führende Punkte, vergebe + bei Kollisionen neue Namen und prüfe Größe und Inhalt, etwa `%PDF-` am Anfang, statt der Endung zu vertrauen. + tags: + - dateien + level: 2 +- id: retry-ohne-warten + module: fehlerfaelle + q: Wie testest du Backoff und Deadline, ohne dass der Test eine Minute dauert? + a: Uhr, Wartefunktion und Zufall sind Parameter. Im Test stellt die Wartefunktion eine Fake-Uhr vor, statt zu schlafen, + und der Zufall ist fest. Dann prüfe ich die Liste der Pausen, dass 400 nicht wiederholt wird und dass die Funktion + vor der Deadline aufgibt, statt über sie hinaus zu schlafen. + tags: + - tests + level: 3 +- id: lockfile-oder-flock + module: fehlerfaelle + q: Wie verhinderst du, dass sich zwei Läufe überlappen? + a: Mit einem Lock, der atomar entsteht. Ein Lockfile lege ich mit `os.open` und `O_CREAT | O_EXCL` an, weil Prüfen und + Anlegen sonst zwei Schritte mit einer Lücke sind. Auf Linux nehme ich lieber `fcntl.flock`, weil der Kernel den Lock + beim Prozessende freigibt, auch nach `kill -9`; ein Lockfile bliebe dann liegen. Ein systemd-Timer startet einen + laufenden Dienst nicht doppelt, schützt aber nicht, wenn jemand das Skript direkt oder per cron startet. + tags: + - betrieb + level: 3 +- id: checkpoint-reihenfolge + module: fehlerfaelle + q: Schreibst du den Checkpoint vor oder nach der Wirkung? + a: Danach. Stürzt der Prozess dazwischen ab, wiederholt der nächste Lauf genau einen Auftrag (at least once); die + Wirkung muss dafür idempotent sein. Davor hieße, dass dieser Auftrag still verloren geht (at most once). Den + Checkpoint schreibe ich atomar mit `os.replace`, und eine kaputte Checkpoint-Datei hält den Job an, statt bei null + neu zu beginnen. + tags: + - robustheit + level: 3 +- id: dedup-sqlite + module: fehlerfaelle + q: Wie meldet ein Poller nur neue Ereignisse, auch nach einem Neustart? + a: Mit einer Tabelle, die `UNIQUE (quelle, quell_id)` erzwingt, und `INSERT OR IGNORE`. `cursor.rowcount` ist danach 1 + für neu und 0 für bekannt; nur bei 1 wird gemeldet. Die Datenbank entscheidet atomar, auch wenn zwei Prozesse + gleichzeitig schreiben. Ohne Commit wäre nach einem Neustart alles wieder neu. + tags: + - robustheit + level: 3 +- id: secret-fuer-dienst + module: logs-geheimnisse + q: Wie gibst du einem systemd-Dienst ein API-Token? + a: Nicht über `Environment=`, denn laut systemd-Dokumentation sind Umgebungsvariablen über D-Bus sichtbar und werden + vererbt. Ich lege die Datei in `/etc/credstore/`, am besten mit `systemd-creds` verschlüsselt, und gebe sie mit + `LoadCredential=` an den Dienst. Das Programm liest `$CREDENTIALS_DIRECTORY/api_token`; für die Entwicklung gibt es + einen bewussten Rückfall auf die Umgebung. + tags: + - betrieb + level: 3 +- id: token-geleakt + module: logs-geheimnisse + q: Ein Token ist in einem öffentlichen Commit gelandet. Was tust du? + a: Zuerst beim Anbieter widerrufen, sofort. Dann ein neues Token erzeugen und überall eintragen, im Audit-Log prüfen, + ob das alte benutzt wurde, und es aus Code und Logs entfernen. Die Historie umzuschreiben ersetzt den Widerruf nie, + weil Klone und Forks den alten Stand behalten. Zum Schluss die Ursache abstellen, etwa mit Push Protection. + tags: + - praxis + level: 2 +- id: lauf-id-logs + module: logs-geheimnisse + q: Drei Läufe haben sich überlappt, das Log ist ein Durcheinander. Wie verhinderst du das künftig? + a: Jeder Eintrag bekommt eine Lauf-ID. Ich setze sie zu Beginn in eine `ContextVar` und lasse einen Filter am Handler + sie in jeden Eintrag schreiben, auch von Kind-Loggern; ein Filter am Logger sähe die nicht. Unter systemd nehme ich + `INVOCATION_ID`, dann finde ich die Zeilen auch mit `journalctl _SYSTEMD_INVOCATION_ID=…`. Als JSON-Zeilen lassen + sich die Einträge danach nach Lauf filtern. + tags: + - betrieb + level: 3 +- id: excel-formeln + module: datenformate + q: Du liest eine Excel-Datei mit openpyxl und bekommst bei einer Summe `None`. Warum? + a: openpyxl rechnet Formeln nicht aus. Mit `data_only=True` liest es nur das Ergebnis, das eine Tabellenkalkulation + beim letzten Speichern abgelegt hat; hat nie Excel die Datei gespeichert, gibt es keins. Braucht mein Programm die + Zahl, rechne ich sie in Python aus. In Ausgabedateien schreibe ich Zahlen als Zahlen mit Zahlenformat und fremden + Text ausdrücklich als Text, damit er nicht als Formel endet. + tags: + - daten + level: 2 +- id: browser-job-flaky + module: scraping-browser + q: Ein nächtlicher Playwright-Job scheitert gelegentlich mit Timeout. Wie gehst du vor? + a: Zuerst sammle ich Belege. Trace und Screenshot, aufgezeichnet nur im Fehlerfall, zeigen, was die Seite wirklich anzeigte. + Dann prüfe ich die Wartelogik. Feste Pausen ersetze ich durch Warten auf ein Ziel, etwa `expect_response` oder eine + Assertion mit bewusster Grenze. Läuft eine gespeicherte Sitzung ab, erkennt der Job die Login-Seite und meldet + das, statt dort nach Daten zu suchen. + tags: + - web + level: 3 diff --git a/content/automation/modules/api-authentifizierung.yaml b/content/automation/modules/api-authentifizierung.yaml new file mode 100644 index 0000000..51c740c --- /dev/null +++ b/content/automation/modules/api-authentifizierung.yaml @@ -0,0 +1,918 @@ +id: api-authentifizierung +title: API-Authentifizierung – Schlüssel, Tokens und Signaturen +level: 2 +minutes: 210 +summary: API-Keys, Basic, Bearer und OAuth 2.0 richtig einsetzen, abgelaufene Tokens genau einmal erneuern und + Webhooks über ihre HMAC-Signatur prüfen. +goals: +- Du unterscheidest API-Key, Basic und Bearer und schickst Zugangsdaten im Header statt in der URL. +- Du holst Tokens per client_credentials und erneuerst Nutzer-Tokens per Refresh-Token mit Rotation. +- Du erkennst ein ablaufendes Token vorab mit einer injizierten Uhr und reagierst auf 401 mit genau einer Erneuerung. +- Du prüfst Webhook-Signaturen mit HMAC über den Rohdaten und vergleichst in konstanter Zeit. +- Du testest Authentifizierung mit Fakes, die 401 liefern, statt mit echten Zugangsdaten. +resources: +- &oauth + title: RFC 6749 · OAuth 2.0 + url: https://www.rfc-editor.org/rfc/rfc6749.html +- &bearer + title: RFC 6750 · Bearer Token Usage + url: https://www.rfc-editor.org/rfc/rfc6750.html +- &basic + title: RFC 7617 · Basic Authentication + url: https://www.rfc-editor.org/rfc/rfc7617.html +- &hmac + title: Python · hmac + url: https://docs.python.org/3/library/hmac.html +- &webhook + title: GitHub · Validating webhook deliveries + url: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries +place: unterwegs +lesson: | + Ein nächtlicher Export läuft seit Wochen. Eines Morgens steht im Log: `401 Unauthorized`, danach tausend weitere Zeilen derselben Art. Das Access-Token war nach einer Stunde abgelaufen. Die schnelle Reparatur „bei 401 einfach neu versuchen“ hat eine Schleife gebaut, die den Anbieter mit ungültigen Anfragen überschüttet und am Ende das Konto sperren lässt. Authentifizierung ist kein Header, den man einmal setzt. Sie ist ein kleines Protokoll mit Zuständen: gültig, bald abgelaufen, abgelaufen, widerrufen. + + Dieses Modul baut dieses Protokoll Schritt für Schritt auf. Alle ausführbaren Beispiele arbeiten mit Fakes und offensichtlichen Platzhaltern wie `geheim-123`. Kein Beispiel spricht einen echten Dienst an, und keine Übung braucht echte Zugangsdaten. + + ## 401 und 403: nicht angemeldet oder nicht berechtigt + + Authentifizierung beantwortet „Wer bist du?“, Autorisierung „Was darfst du?“. HTTP trennt die beiden Fälle. [401 Unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.2) heißt: Die Anfrage hat keine gültigen Zugangsdaten. Der Server schickt dazu einen `WWW-Authenticate`-Header, der sagt, welches Verfahren er erwartet. [403 Forbidden](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.4) heißt: Der Server hat dich erkannt, erlaubt die Aktion aber nicht. + + Für Bearer-Tokens präzisiert [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1) die Fehler: `invalid_token` mit 401 für abgelaufene, widerrufene oder kaputte Tokens, `insufficient_scope` mit 403 für fehlende Rechte. Daraus folgt die wichtigste Regel dieses Moduls: Ein neues Token kann ein 401 beheben. Ein 403 behebt ein neu geholtes Token mit demselben Scope nicht, denn es hätte dieselben Rechte. Fehlt ein Scope, kann der Server ihn im Fehler nennen; dann braucht es eine neue, weiter reichende Berechtigung, und die vergibt meist ein Mensch. + + ```text + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer + error="invalid_token", + error_description= + "The access token expired" + ``` + + ## API-Key, Basic und Bearer: drei Arten von Header + + Ein **API-Key** ist ein langer, zufälliger String, den du im Portal des Anbieters erzeugst. Wie er übertragen wird, legt der Anbieter fest, oft in einem eigenen Header wie `X-API-Key`. Manche Dienste erlauben ihn auch als Query-Parameter. Nimm trotzdem den Header: URLs landen in Server- und Proxy-Logs, in der Shell-History und im Browserverlauf. RFC 6750 rät aus demselben Grund davon ab, [Bearer-Tokens in URLs](https://www.rfc-editor.org/rfc/rfc6750.html#section-2.3) zu schicken. + + **Basic** nach [RFC 7617](https://www.rfc-editor.org/rfc/rfc7617.html) verbindet Nutzer und Passwort mit einem Doppelpunkt und kodiert das Ergebnis mit Base64. Base64 ist eine Kodierung, keine Verschlüsselung: Jeder, der den Header sieht, liest das Passwort zurück. Basic gehört deshalb ausschließlich in TLS-Verbindungen. Ein Nutzername darf keinen Doppelpunkt enthalten, sonst wäre die Trennung mehrdeutig. + + ```python run + import base64 + + paar = "api-nutzer:geheim-123" + roh = paar.encode("utf-8") + kodiert = base64.b64encode(roh) + print("Basic", kodiert.decode()) + print(base64.b64decode(kodiert)) + ``` + + **Bearer** heißt „Inhaber“: Wer das Token besitzt, darf es benutzen. Das Token selbst sagt dem Server, wer du bist und was du darfst. Es gehört in den Header `Authorization: Bearer `. Ein Bearer-Token ist so viel wert wie ein Passwort, nur meist kürzer gültig. + + ```python run + def kopf(art, wert): + if art == "key": + return {"X-API-Key": wert} + if art == "bearer": + return {"Authorization": + f"Bearer {wert}"} + raise ValueError(art) + + print(kopf("bearer", "tok-1")) + ``` + + Halte diese Funktion klein und an einer Stelle. Dann gibt es genau einen Ort, an dem Zugangsdaten in eine Anfrage gelangen, und genau einen Ort, an dem du sicherstellst, dass sie nicht ins Log fallen. + + ## OAuth 2.0: Tokens statt Passwörter + + [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749.html) trennt vier Rollen: die Person, der die Daten gehören, dein Programm als **Client**, den **Authorization Server**, der Tokens ausgibt, und den **Resource Server**, also die eigentliche API. Dein Programm bekommt vom Authorization Server ein kurzlebiges **Access-Token** für die API. Das Passwort der Person sieht dein Programm nie. + + Für Automatisierung zählen zwei Wege. Handelt dein Job **als er selbst**, etwa ein Dienstkonto, das Berichte abholt, nutzt du den Grant [client_credentials](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.4). Dein Programm schickt seine Client-ID und sein Client-Secret an den Token-Endpunkt und bekommt ein Access-Token zurück. + + ```python + import httpx + + antwort = httpx.post( + TOKEN_URL, + data={"grant_type": + "client_credentials"}, + auth=(CLIENT_ID, SECRET), + timeout=10, + ) + antwort.raise_for_status() + daten = antwort.json() + ``` + + `data=` schickt das Formular als `application/x-www-form-urlencoded`, `auth=` die Client-Daten als Basic-Header. Die Antwort enthält `access_token`, `token_type` und meist `expires_in`, die Lebensdauer in Sekunden. Bei client_credentials gibt es laut [RFC 6749 §4.4.3](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.4.3) normalerweise **kein** Refresh-Token: Ist das Access-Token abgelaufen, fragst du mit denselben Client-Daten ein neues an. + + > [!DEEP] Sonderzeichen im Client-Secret + > RFC 6749 §2.3.1 verlangt, Client-ID und Secret vor der Base64-Kodierung mit dem Formularverfahren zu kodieren. Viele Bibliotheken tun das nicht. Bei IDs aus Buchstaben und Ziffern spielt das keine Rolle; enthält ein Secret `+`, `%` oder `:`, prüfe die Dokumentation deines Anbieters. + + Handelt dein Job **im Namen einer Person**, etwa für deren Kalender, meldet sich die Person einmal im Browser an (Authorization Code mit PKCE). Danach hat dein Programm ein Access-Token und ein **Refresh-Token**. Mit dem Refresh-Token holt es später ohne Browser neue Access-Tokens. Die Erstanmeldung passiert am Rechner; alles danach kann unbeaufsichtigt laufen. + + ## Refresh-Token: erneuern, rotieren, sicher speichern + + Die Erneuerung ist eine eigene Anfrage an den Token-Endpunkt mit `grant_type=refresh_token` ([RFC 6749 §6](https://www.rfc-editor.org/rfc/rfc6749.html#section-6)). Der Server darf dabei ein neues Refresh-Token ausgeben. Dann gilt: Der Client muss das alte verwerfen und das neue speichern. Diese **Rotation** empfiehlt die [OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2): Taucht ein bereits ersetztes Refresh-Token wieder auf, erkennt der Server einen möglichen Diebstahl und kann auch das gültige widerrufen. + + ```python run + def erneuere(sende, ablage): + alt = ablage["refresh"] + form = {"grant_type": + "refresh_token", + "refresh_token": alt} + daten = sende(form) + neu = daten.get( + "refresh_token", alt) + ablage["refresh"] = neu + return daten["access_token"] + + def sende(form): + neu = {"access_token": "a-2"} + neu["refresh_token"] = "r-2" + return neu + + ablage = {"refresh": "r-1"} + print(erneuere(sende, ablage)) + print(ablage) + ``` + + Im Betrieb ist `ablage` eine Datei oder ein Schlüsselbund, kein Dictionary. Schreibe das neue Refresh-Token atomar, bevor dein Programm weiterarbeitet. Stürzt es nach der Erneuerung ab, ohne gespeichert zu haben, ist das alte Token beim nächsten Lauf schon entwertet. Der Server antwortet dann mit `invalid_grant`. Das ist kein vorübergehender Fehler: Hier hilft keine Wiederholung, nur eine neue Anmeldung durch einen Menschen. Dein Job meldet das deutlich und hört auf. + + ## Ablauf vorab erkennen: Token-Cache mit injizierter Uhr + + Ein Token pro Anfrage zu holen ist verschwenderisch, und manche Anbieter begrenzen Token-Anfragen streng. Ein Cache hält das Token und seinen Ablaufzeitpunkt. `expires_in` ist eine Dauer, also rechnest du mit einer monotonen Uhr wie [`time.monotonic`](https://docs.python.org/3/library/time.html#time.monotonic): `bis = jetzt + expires_in`. Erneuere ein wenig **vor** dem Ablauf, damit ein Token nicht zwischen Prüfung und Ankunft beim Server verfällt. + + ```python run + class TokenCache: + def __init__(self, hole, uhr): + self.hole = hole + self.uhr = uhr + self.wert = None + self.bis = 0 + + def aktuell(self, puffer=60): + jetzt = self.uhr() + rest = self.bis - jetzt + ok = rest > puffer + if self.wert and ok: + return self.wert + wert, dauer = self.hole() + self.wert = wert + self.bis = jetzt + dauer + return wert + + def verwerfen(self): + self.wert = None + + zeiten = iter([0, 3000, 3540]) + nr = 0 + def hole(): + global nr + nr += 1 + return f"tok-{nr}", 3600 + + cache = TokenCache( + hole, lambda: next(zeiten)) + for _ in range(3): + print(cache.aktuell()) + ``` + + Die Uhr ist ein Parameter. Im Betrieb übergibst du `time.monotonic`, im Test eine Funktion, die vorgegebene Zeiten liefert, hier 0, 3000 und 3540. So prüfst du in Millisekunden, was nach einer knappen Stunde passiert. Bei 3540 Sekunden fehlen noch 60 bis zum Ablauf; der Puffer greift, der Cache holt `tok-2`. + + ## Genau einmal erneuern, wenn trotzdem 401 kommt + + Auch ein frisches Token kann ungültig sein: Jemand hat es widerrufen, der Server wurde neu gestartet, die Uhren gehen auseinander. Deshalb gibt es neben der Prüfung vorab eine Reaktion hinterher. Die Regel ist eng: Bei 401 verwirfst du das Token, holst **ein** neues und wiederholst die Anfrage **einmal**. Kommt wieder 401, ist etwas grundsätzlich falsch, etwa ein gesperrtes Konto oder ein falsches Secret. Dann bricht der Job mit einer klaren Meldung ab. + + ```python run + class AuthFehler(Exception): + pass + + def rufe(anfrage, token): + r = anfrage(token.aktuell()) + if r.status != 401: + return r + token.verwerfen() + r = anfrage(token.aktuell()) + if r.status == 401: + raise AuthFehler("401") + return r + ``` + + Diese Funktion hat keine Schleife, und genau das ist der Punkt. Die Zahl der Versuche steht im Code, nicht in einer Bedingung, die vielleicht nie falsch wird. 403 geht unverändert zurück, denn ein neues Token mit demselben Scope ändert keine Rechte. Fehler wie 429 und 503 gehören zur Wiederholungslogik aus den Modulen [Web-APIs](/automation/web-apis) und [Fehlerfälle ausführbar machen](/automation/fehlerfaelle), nicht hierher. + + > [!DEEP] Mehrere gleichzeitige Anfragen + > Laufen Anfragen parallel, bekommen oft mehrere gleichzeitig ein 401. Erneuert jede für sich, schickt bei Rotation die zweite ein schon ersetztes Refresh-Token. Abhilfe ist eine Sperre um die Erneuerung: Wer sie bekommt, prüft zuerst, ob ein anderer das Token inzwischen erneuert hat. + + ## Webhooks: Signaturen prüfen + + Bei einem Webhook ruft der Anbieter dich auf. Jeder, der deine URL kennt, kann dorthin POST-Anfragen schicken. Deshalb signiert der Anbieter jede Lieferung mit einem gemeinsamen Geheimnis. GitHub schickt im Header `X-Hub-Signature-256` den Wert `sha256=` plus den HMAC-SHA256 des Körpers als Hexstring. Die [GitHub-Dokumentation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) nennt einen Testfall, den du nachrechnen kannst. + + ```python run + import hashlib + import hmac + + geheim = (b"It's a Secret " + b"to Everybody") + koerper = b"Hello, World!" + mac = hmac.new(geheim, koerper, + hashlib.sha256) + hexwert = mac.hexdigest() + print(hexwert[:32]) + print(hexwert[32:]) + ``` + + Drei Regeln machen die Prüfung sicher. Erstens rechnest du über die **Rohdaten**, genau die Bytes, die angekommen sind. Wer erst JSON parst und wieder serialisiert, bekommt andere Leerzeichen oder eine andere Schlüsselreihenfolge und damit eine andere Signatur. Zweitens vergleichst du mit [`hmac.compare_digest`](https://docs.python.org/3/library/hmac.html#hmac.compare_digest). `==` bricht beim ersten abweichenden Zeichen ab; aus der Antwortzeit lässt sich so Zeichen für Zeichen auf die richtige Signatur schließen. Drittens vergleichst du Bytes: `compare_digest` wirft bei Strings mit Nicht-ASCII-Zeichen einen `TypeError`, und ein Angreifer bestimmt, was im Header steht. + + Eine gültige Signatur beweist die Herkunft, nicht die Frische. Eine aufgezeichnete Lieferung bleibt gültig signiert. Anbieter wie Stripe nehmen deshalb einen Zeitstempel in die signierten Daten auf, und du lehnst alte Lieferungen ab. GitHub schickt eine eindeutige ID im Header `X-GitHub-Delivery`, mit der du doppelte Lieferungen erkennst. + + ## Testen mit Fakes, die 401 liefern + + Authentifizierungscode testest du nicht gegen den echten Dienst. Ein Fake-Server ist eine Funktion, die je nach Token mit 200, 401 oder 403 antwortet und jede Anfrage protokolliert. Damit prüfst du die Fälle, die im Betrieb selten und teuer sind: widerrufenes Token, zweimal 401, fehlende Rechte. + + ```python run + class Antwort: + def __init__(self, status): + self.status = status + + gesehen = [] + def fake_api(token): + gesehen.append(token) + ok = token == "tok-2" + return Antwort(200 if ok + else 401) + + print(fake_api("tok-1").status) + print(fake_api("tok-2").status) + print(gesehen) + ``` + + Ein guter Fake schützt sich selbst: Nach fünf Anfragen wirft er einen Fehler „Endlosschleife?“. So endet ein fehlerhafter Test sofort mit einer verständlichen Meldung, statt am Zeitlimit zu scheitern. + + > [!PRAXIS] + > Lege Zugangsdaten an genau einer Stelle ab und gib sie als Parameter weiter. Dann kann ein Test einen Platzhalter übergeben, und ein Review sieht auf einen Blick, wo Geheimnisse fließen. Im Log stehen Ereignisse wie „Token erneuert“, nie das Token selbst. + + ## Häufige Fehler + + - **Token in der URL.** Es landet in Logs und Verlauf. Header benutzen. + - **Schleife bei 401.** `while status == 401` endet nie, wenn das Secret falsch ist. Genau eine Erneuerung, dann Abbruch. + - **Erneuern bei 403.** Neue Tokens bringen keine neuen Rechte. + - **Rotation vergessen.** Das neue Refresh-Token wird nicht gespeichert; der nächste Lauf scheitert mit `invalid_grant`. + - **Signatur über geparstes JSON.** Gültige Lieferungen werden abgelehnt, oder die Prüfung wird „repariert“, indem man sie abschaltet. + - **`==` statt `compare_digest`.** Funktioniert im Test und ist trotzdem angreifbar. + + > [!INTERVIEW] + > „Was machst du, wenn eine API mitten im Job 401 liefert?“ Ich verwerfe das Token, hole genau ein neues und wiederhole die Anfrage einmal. Kommt wieder 401, breche ich mit einer klaren Meldung ab, statt den Dienst mit Wiederholungen zu belasten. 403 behandle ich nicht mit einem neuen Token, weil ein Token mit demselben Scope dieselben Rechte hat. + + > [!MERKE] + > Ein 401 rechtfertigt eine Erneuerung, nicht zwei. Die Zahl der Versuche steht im Code. + + ## Zusammenfassung + + - 401 heißt „nicht gültig angemeldet“, 403 „nicht berechtigt“. Ein 401 behebt ein neues Token, ein 403 braucht mehr Rechte. + - API-Keys und Tokens gehören in Header, nie in URLs. Basic ist nur Base64 und braucht TLS. + - client_credentials liefert Tokens für Dienstkonten ohne Refresh-Token; Nutzer-Tokens erneuerst du per Refresh-Token. + - Rotierte Refresh-Tokens speicherst du sofort und atomar; `invalid_grant` verlangt eine neue Anmeldung. + - Ein Token-Cache erneuert vor dem Ablauf; die Uhr ist ein Parameter. + - Bei 401 genau einmal erneuern und einmal wiederholen, sonst abbrechen. + - Webhook-Signaturen prüfst du über die Rohdaten mit `hmac.compare_digest` auf Bytes. +exercises: +- id: key-im-header + type: choice + title: Header oder URL? + prompt: Ein Dienst akzeptiert den API-Key als Header `X-API-Key` oder als Query-Parameter `?api_key=…`. Was spricht + für den Header? + options: + - text: URLs landen in Server- und Proxy-Logs, in der Shell-History und im Browserverlauf. + correct: true + why: Der Header wird zwar genauso übertragen, aber nicht als Teil der Adresse gespeichert und weitergereicht. + - text: Header sind verschlüsselt, Query-Parameter nicht. + correct: false + why: Mit TLS sind beide verschlüsselt, ohne TLS keines von beiden. Das Problem ist die Speicherung, nicht der + Transport. + - text: Query-Parameter haben eine Längenbegrenzung, die lange Keys abschneidet. + correct: false + why: Ein API-Key ist weit unter üblichen URL-Grenzen. Das ist nicht der Grund. + explanation: Zugangsdaten in URLs verbreiten sich über Logs, Verlauf und Weiterleitungen. RFC 6750 rät aus demselben + Grund davon ab, Bearer-Tokens in URLs zu übertragen. + resources: + - *bearer +- id: status-401-403 + type: choice + title: Was hilft gegen welchen Status? + prompt: Dein Job bekommt eine Fehlerantwort. Welche Reaktion ist richtig? + options: + - text: 401 mit `error="invalid_token"`, erstes Mal in dieser Anfrage – Token verwerfen, ein neues holen, die Anfrage + einmal wiederholen. + correct: true + why: Ein abgelaufenes oder widerrufenes Token lässt sich durch ein neues ersetzen. + - text: 403 mit `error="insufficient_scope"` – Token erneuern, dann klappt es. + correct: false + why: Ein erneuertes Token hat denselben Scope und damit dieselben Rechte. Hier fehlt eine Berechtigung, die meist ein Mensch vergeben muss. + - text: 401 direkt nach einer Erneuerung – weiter erneuern, bis es klappt. + correct: false + why: Ein zweites 401 deutet auf ein grundsätzliches Problem wie ein falsches Secret oder ein gesperrtes Konto. + Weitere Versuche belasten den Dienst und können das Konto sperren. + - text: 429 – Token erneuern, weil der Server die Anfragen ablehnt. + correct: false + why: 429 heißt „zu viele Anfragen“. Das ist ein Fall für Warten und Retry-After, nicht für ein neues Token. + explanation: Ein 401 lässt sich durch ein neues Token beheben, und das genau einmal pro Anfrage. 403 ist eine Frage + der Rechte, 429 eine Frage des Tempos. + resources: + - *bearer +- id: ablauf-bei-401 + type: order + title: Ablauf bei einem abgelaufenen Token + prompt: Bring die Schritte einer Anfrage mit Token-Erneuerung in die richtige Reihenfolge. + lang: prose + lines: |- + Gültiges Token aus dem Cache holen oder neu anfordern + Anfrage mit Bearer-Header senden + Bei 401 das Token verwerfen + Genau ein neues Token holen + Die Anfrage genau einmal wiederholen + Bei erneutem 401 mit klarer Meldung abbrechen + explanation: Der Ablauf hat eine feste Zahl von Schritten und keine Schleife. Erst das Verwerfen sorgt dafür, dass + der Cache wirklich ein neues Token holt und nicht das alte zurückgibt. + resources: + - *bearer +- id: puffer-vorhersagen + type: output + title: Wann holt der Cache ein neues Token? + prompt: | + Welche Ausgabe erzeugt dieses Programm? Jedes Token gilt 3600 Sekunden, der Puffer ist 60 Sekunden. + lang: python + code: |- + PUFFER = 60 + bis = 0 + wert = None + nr = 0 + + def aktuell(jetzt): + global bis, wert, nr + if jetzt + PUFFER < bis: + return wert + nr += 1 + wert = f"t{nr}" + bis = jetzt + 3600 + return wert + + for t in [0, 3000, 3540, 3600]: + print(t, aktuell(t)) + expected: |- + 0 t1 + 3000 t1 + 3540 t2 + 3600 t2 + explanation: | + Bei 0 gibt es noch kein Token. Bei 3000 ist `3060 < 3600`, `t1` bleibt. Bei 3540 ist `3600 < 3600` falsch: Der Puffer greift, `t2` gilt bis 7140. Bei 3600 ist `3660 < 7140`, also bleibt `t2`. Die Grenze liegt genau 60 Sekunden vor dem Ablauf. + resources: + - title: Python · time.monotonic + url: https://docs.python.org/3/library/time.html#time.monotonic +- id: json-neu-serialisiert + type: output + title: Dasselbe JSON, andere Bytes + prompt: Welche Ausgabe erzeugt dieses Programm? + lang: python + code: |- + import json + roh = b'{"b":1,"a":2}' + neu = json.dumps(json.loads(roh)) + print(neu) + print(neu.encode() == roh) + expected: |- + {"b": 1, "a": 2} + False + explanation: | + `json.dumps` setzt standardmäßig Leerzeichen nach `:` und `,`. Inhaltlich ist es dasselbe Objekt, als Bytes aber verschieden. Eine HMAC-Signatur über `neu` passt deshalb nicht zur Signatur des Absenders über `roh`. Signaturen prüfst du immer über die empfangenen Rohdaten. + resources: + - *webhook +- id: oauth-anfrage + type: gap + title: Token holen und benutzen + prompt: Ergänze die Token-Anfrage eines Dienstkontos und den Header für die API. + lang: python + code: |- + antwort = httpx.post( + TOKEN_URL, + data={"grant_type": + "⟦client_credentials⟧"}, + auth=(CLIENT_ID, SECRET), + timeout=10, + ) + antwort.raise_for_status() + daten = antwort.json() + token = daten["⟦access_token⟧"] + headers = {"Authorization": + f"⟦Bearer⟧ {token}"} + gaps: + - label: Grant für ein Dienstkonto + hint: Der Job handelt als er selbst, mit Client-ID und Secret. + - label: Feld mit dem Token + hint: So heißt das Feld in der Token-Antwort nach RFC 6749. + - label: Verfahren im Header + hint: Das Wort bedeutet „Inhaber“. + accept: ["bearer"] + explanation: client_credentials ist der Grant für Maschinen ohne beteiligte Person. Die Antwort trägt das Token + im Feld `access_token`, und die API erwartet es im Header `Authorization` nach dem Wort `Bearer`. + resources: + - *oauth + - *bearer +- id: basic-header + type: code + title: Einen Basic-Header bauen + prompt: | + Schreibe `basic_header(nutzer, passwort)`. Sie verbindet beide mit `:`, kodiert das Ergebnis als UTF-8 und dann mit Base64 und gibt `"Basic "` plus den Base64-Text zurück. Enthält `nutzer` einen Doppelpunkt, wirf `ValueError`. Die Fehlermeldung darf das Passwort nicht enthalten. + lang: python + starter: |- + import base64 + + def basic_header(nutzer, passwort): + ... + solution: |- + import base64 + + def basic_header(nutzer, passwort): + if ":" in nutzer: + raise ValueError( + "Doppelpunkt im Namen") + paar = f"{nutzer}:{passwort}" + roh = paar.encode("utf-8") + text = base64.b64encode(roh) + return "Basic " + text.decode() + tests: + - name: Beispiel aus RFC 7617 + code: |- + h = basic_header("Aladdin", + "open sesame") + assert h == ("Basic QWxhZGRpbjp" + "vcGVuIHNlc2FtZQ=="), h + - name: Umlaute als UTF-8 + code: |- + h = basic_header("jörg", "pw") + assert h == "Basic asO2cmc6cHc=", h + - name: Doppelpunkt im Passwort ist erlaubt + code: |- + h = basic_header("bot", "a:b") + assert h == "Basic Ym90OmE6Yg==", h + - name: Doppelpunkt im Nutzer wird abgelehnt + code: |- + try: + basic_header("a:b", "geheim-123") + except ValueError as e: + assert "geheim-123" not in str(e), ( + "Das Passwort steht in der Meldung.") + else: + assert False, "ValueError erwartet" + explanation: | + Base64 macht aus beliebigen Bytes ASCII-Text, mehr nicht. Deshalb ist Basic nur über TLS vertretbar. Der Doppelpunkt trennt Nutzer und Passwort; im Nutzernamen wäre er mehrdeutig, im Passwort ist er erlaubt, weil der Server am ersten Doppelpunkt trennt. Fehlermeldungen landen in Logs und Tickets, deshalb gehört das Passwort nie hinein. + resources: + - *basic + - title: Python · base64 + url: https://docs.python.org/3/library/base64.html +- id: token-cache + type: code + title: Ein Token-Cache mit Puffer + prompt: | + Schreibe die Klasse `TokenCache(hole, uhr, puffer=60, standard=300)`. + + - `hole()` liefert die Token-Antwort als Dictionary mit `access_token` und meist `expires_in` (Sekunden). Fehlt `expires_in`, gilt das Token `standard` Sekunden. `uhr()` liefert die aktuelle Zeit in Sekunden. + - `aktuell()` gibt das gespeicherte Token zurück, solange `uhr() + puffer < ablauf` gilt. Sonst holt sie ein neues und merkt sich `ablauf = uhr() + dauer`. + - `verwerfen()` vergisst das Token, sodass der nächste `aktuell()`-Aufruf ein neues holt. + lang: python + starter: |- + class TokenCache: + def __init__(self, hole, uhr, + puffer=60, + standard=300): + self.hole = hole + self.uhr = uhr + self.puffer = puffer + self.standard = standard + + def aktuell(self): + return self.hole()[ + "access_token"] + + def verwerfen(self): + pass + solution: |- + class TokenCache: + def __init__(self, hole, uhr, + puffer=60, + standard=300): + self.hole = hole + self.uhr = uhr + self.puffer = puffer + self.standard = standard + self.wert = None + self.ablauf = 0 + + def gueltig(self, jetzt): + if self.wert is None: + return False + rest = self.ablauf - jetzt + return rest > self.puffer + + def aktuell(self): + jetzt = self.uhr() + if self.gueltig(jetzt): + return self.wert + d = self.hole() + dauer = d.get("expires_in") + if dauer is None: + dauer = self.standard + wert = d["access_token"] + self.wert = wert + self.ablauf = jetzt + dauer + return wert + + def verwerfen(self): + self.wert = None + setup: |- + class _Quelle: + def __init__(self, dauer=3600): + self.n = 0 + self.dauer = dauer + def __call__(self): + self.n += 1 + antwort = {"access_token": f"tok-{self.n}", "token_type": "Bearer"} + if self.dauer is not None: + antwort["expires_in"] = self.dauer + return antwort + + class _Uhr: + def __init__(self): + self.t = 0 + def __call__(self): + return self.t + tests: + - name: Erster Aufruf holt ein Token + code: |- + q, uhr = _Quelle(), _Uhr() + c = TokenCache(q, uhr) + assert c.aktuell() == "tok-1" + assert q.n == 1, f"{q.n} Abrufe" + - name: Gültiges Token wird wiederverwendet + code: |- + q, uhr = _Quelle(), _Uhr() + c = TokenCache(q, uhr) + c.aktuell() + uhr.t = 3539 + assert c.aktuell() == "tok-1" + assert q.n == 1, f"{q.n} Abrufe" + - name: Im Puffer vor dem Ablauf wird erneuert + code: |- + q, uhr = _Quelle(), _Uhr() + c = TokenCache(q, uhr) + c.aktuell() + uhr.t = 3540 + assert c.aktuell() == "tok-2", ( + "60 s vor Ablauf: neues Token") + uhr.t = 7079 + assert c.aktuell() == "tok-2" + - name: verwerfen erzwingt ein neues Token + code: |- + q, uhr = _Quelle(), _Uhr() + c = TokenCache(q, uhr) + c.aktuell() + c.verwerfen() + assert c.aktuell() == "tok-2" + assert c.aktuell() == "tok-2" + assert q.n == 2, f"{q.n} Abrufe" + - name: Ohne expires_in gilt der Standard + code: |- + q, uhr = _Quelle(dauer=None), _Uhr() + c = TokenCache(q, uhr) + c.aktuell() + uhr.t = 239 + assert c.aktuell() == "tok-1" + uhr.t = 240 + assert c.aktuell() == "tok-2", ( + "300 s Standard, 60 s Puffer") + hints: + - Merke dir neben dem Token den Ablaufzeitpunkt `jetzt + dauer`. + - Ohne gespeichertes Token musst du immer holen, egal was die Uhr sagt. + - '`d.get("expires_in")` liefert `None`, wenn das Feld fehlt.' + explanation: | + Der Cache verbindet zwei Fragen: Habe ich ein Token, und ist es noch lange genug gültig? Der Puffer fängt Laufzeiten und kleine Uhrunterschiede ab. `expires_in` ist laut RFC 6749 nur empfohlen; fehlt es, braucht dein Code einen dokumentierten Standardwert statt eines Absturzes oder eines ewig gültigen Tokens. Weil die Uhr ein Parameter ist, prüft der Test die Grenze bei 3540 Sekunden in Millisekunden. Im Betrieb übergibst du `time.monotonic`, weil `expires_in` eine Dauer ist und eine monotone Uhr nicht springt, wenn jemand die Systemzeit verstellt. + resources: + - title: Python · time.monotonic + url: https://docs.python.org/3/library/time.html#time.monotonic + - *oauth +- id: einmal-erneuern + type: code + title: Repariere die Endlosschleife bei 401 + prompt: | + `rufe(anfrage, token)` hängt sich auf, sobald das Secret falsch ist, und erneuert sogar bei 403. Repariere sie: + + - `anfrage(wert)` sendet eine Anfrage mit dem Token `wert` und liefert eine Antwort mit `.status`. + - `token.aktuell()` liefert das aktuelle Token, `token.verwerfen()` vergisst es. + - Bei 401 verwirfst du das Token und wiederholst die Anfrage **genau einmal** mit `token.aktuell()`. Kommt wieder 401, wirf `AuthFehler`. + - Jede andere Antwort, auch 403, gibst du unverändert zurück. + lang: python + starter: |- + class AuthFehler(Exception): + pass + + def rufe(anfrage, token): + r = anfrage(token.aktuell()) + while r.status >= 400: + token.verwerfen() + wert = token.aktuell() + r = anfrage(wert) + return r + solution: |- + class AuthFehler(Exception): + pass + + def rufe(anfrage, token): + r = anfrage(token.aktuell()) + if r.status != 401: + return r + token.verwerfen() + r = anfrage(token.aktuell()) + if r.status == 401: + raise AuthFehler("401") + return r + setup: |- + class _Antwort: + def __init__(self, status): + self.status = status + + class FakeApi: + """Antwortet 200 auf gültige Tokens, sonst 401 (oder immer 403).""" + def __init__(self, gueltig, verboten=False): + self.gueltig = set(gueltig) + self.verboten = verboten + self.gesehen = [] + def __call__(self, wert): + self.gesehen.append(wert) + if len(self.gesehen) > 5: + raise AssertionError("Mehr als 5 Anfragen – Endlosschleife?") + if self.verboten: + return _Antwort(403) + return _Antwort(200 if wert in self.gueltig else 401) + + class FakeToken: + """Liefert t1, nach verwerfen() t2, dann t3 …""" + def __init__(self): + self.geholt = 0 + self.wert = None + def aktuell(self): + if self.wert is None: + self.geholt += 1 + self.wert = f"t{self.geholt}" + return self.wert + def verwerfen(self): + self.wert = None + tests: + - name: Gültiges Token – eine Anfrage + code: |- + api, tok = FakeApi({"t1"}), FakeToken() + assert rufe(api, tok).status == 200 + assert api.gesehen == ["t1"], api.gesehen + - name: Widerrufenes Token – einmal erneuern + code: |- + api, tok = FakeApi({"t2"}), FakeToken() + assert rufe(api, tok).status == 200 + assert api.gesehen == ["t1", "t2"], ( + api.gesehen) + - name: Zweimal 401 – AuthFehler nach zwei Anfragen + code: |- + api, tok = FakeApi(set()), FakeToken() + try: + rufe(api, tok) + except AuthFehler: + pass + else: + assert False, "AuthFehler erwartet" + assert api.gesehen == ["t1", "t2"], ( + api.gesehen) + - name: 403 – kein neues Token + code: |- + api = FakeApi({"t1"}, verboten=True) + tok = FakeToken() + assert rufe(api, tok).status == 403 + assert tok.geholt == 1, ( + f"{tok.geholt} Tokens geholt") + hints: + - Die Lösung braucht keine Schleife. Schreib die zwei Versuche hintereinander hin. + - Prüfe nach dem ersten Versuch nur auf `401`. Alles andere geht sofort zurück. + explanation: | + Die Schleife im Starter hat keine Obergrenze: Mit falschem Secret liefert jede Erneuerung wieder 401. Außerdem reagiert sie auf 403 und 429, die ein neues Token nicht behebt. Die reparierte Fassung schreibt die zwei Versuche aus. Ein zweites 401 wird zu einem sichtbaren `AuthFehler`, den der Job meldet, statt den Dienst weiter zu belasten. + resources: + - *bearer +- id: refresh-rotation + type: code + title: Refresh-Token erneuern und rotieren + prompt: | + Schreibe `erneuere(sende, ablage)`. + + - Rufe `sende(form)` **genau einmal** auf, mit `form = {"grant_type": "refresh_token", "refresh_token": …}` und dem Token aus `ablage["refresh"]`. `sende` liefert `(status, daten)`. + - Bei Status 200: Enthält `daten` ein neues `refresh_token`, ersetze `ablage["refresh"]` damit. Gib `daten["access_token"]` zurück. + - Bei Status 400 mit `daten["error"] == "invalid_grant"`: wirf `NeuAnmelden`, ohne `ablage` zu ändern. + - Bei jedem anderen Status: wirf `RuntimeError`. + lang: python + starter: |- + class NeuAnmelden(Exception): + pass + + def erneuere(sende, ablage): + ... + solution: |- + class NeuAnmelden(Exception): + pass + + def erneuere(sende, ablage): + form = {"grant_type": + "refresh_token", + "refresh_token": + ablage["refresh"]} + status, d = sende(form) + if status == 200: + alt = ablage["refresh"] + ablage["refresh"] = d.get( + "refresh_token", alt) + return d["access_token"] + fehler = d.get("error") + if status != 400: + raise RuntimeError(status) + if fehler != "invalid_grant": + raise RuntimeError(fehler) + raise NeuAnmelden() + setup: |- + class FakeServer: + def __init__(self, status, daten): + self.antwort = (status, daten) + self.formulare = [] + def __call__(self, form): + self.formulare.append(dict(form)) + return self.antwort + tests: + - name: Formular für den Refresh-Grant + code: |- + s = FakeServer(200, {"access_token": "a"}) + erneuere(s, {"refresh": "r-1"}) + assert s.formulare == [{ + "grant_type": "refresh_token", + "refresh_token": "r-1"}], s.formulare + - name: Rotiertes Token wird gespeichert + code: |- + s = FakeServer(200, {"access_token": "a-2", + "refresh_token": "r-2"}) + ablage = {"refresh": "r-1"} + assert erneuere(s, ablage) == "a-2" + assert ablage == {"refresh": "r-2"}, ablage + - name: Ohne Rotation bleibt das alte Token + code: |- + s = FakeServer(200, {"access_token": "a-2"}) + ablage = {"refresh": "r-1"} + erneuere(s, ablage) + assert ablage == {"refresh": "r-1"}, ablage + - name: invalid_grant – neu anmelden, nichts ändern + code: |- + s = FakeServer(400, {"error": "invalid_grant"}) + ablage = {"refresh": "r-1"} + try: + erneuere(s, ablage) + except NeuAnmelden: + pass + else: + assert False, "NeuAnmelden erwartet" + assert ablage == {"refresh": "r-1"}, ablage + assert len(s.formulare) == 1, ( + "Bei invalid_grant hilft kein Retry.") + - name: Anderer 400 – RuntimeError + code: |- + s = FakeServer(400, {"error": "invalid_request"}) + try: + erneuere(s, {"refresh": "r-1"}) + except NeuAnmelden: + assert False, "Nur invalid_grant heißt neu anmelden." + except RuntimeError: + pass + else: + assert False, "RuntimeError erwartet" + - name: Serverfehler – RuntimeError + code: |- + s = FakeServer(500, {}) + try: + erneuere(s, {"refresh": "r-1"}) + except RuntimeError: + pass + else: + assert False, "RuntimeError erwartet" + explanation: | + Ein Refresh-Token ist ein Zustand, den du verwalten musst. Rotiert der Server, ist das alte Token sofort wertlos; wer das neue nicht speichert, scheitert beim nächsten Lauf. `invalid_grant` heißt, dass die Berechtigung weg ist. Kein Retry bringt sie zurück, nur eine neue Anmeldung durch einen Menschen. Deshalb trennt die Funktion diesen Fall ausdrücklich von anderen Fehlern, die später vielleicht von selbst verschwinden. + resources: + - *oauth + - title: RFC 9700 · Refresh Token Protection + url: https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2 +- id: webhook-signatur + type: code + title: Eine Webhook-Lieferung prüfen + prompt: | + Schreibe `ist_echt(geheim, roh, kopf)` für Webhooks im Format von GitHub. `geheim` und `roh` (der empfangene Körper) sind Bytes, `kopf` ist der Wert des Headers `X-Hub-Signature-256` als String oder `None`. Gib `True` zurück, wenn `kopf` gleich `"sha256="` plus dem HMAC-SHA256 von `roh` als Hexstring ist, sonst `False`. Die Funktion wirft nie eine Exception, auch nicht bei fehlendem oder seltsamem Header. + lang: python + starter: |- + import hashlib + import hmac + + def ist_echt(geheim, roh, kopf): + ... + solution: |- + import hashlib + import hmac + + def ist_echt(geheim, roh, kopf): + if not kopf: + return False + mac = hmac.new(geheim, roh, + hashlib.sha256) + hexwert = mac.hexdigest() + soll = "sha256=" + hexwert + return hmac.compare_digest( + soll.encode("utf-8"), + kopf.encode("utf-8")) + tests: + - name: Testfall aus der GitHub-Dokumentation + code: |- + g = b"It's a Secret to Everybody" + k = ("sha256=757107ea0eb2509fc2112" + "21cce984b8a37570b6d7586c22c46" + "f4379c8b043e17") + assert ist_echt(g, b"Hello, World!", k) + - name: Veränderter Körper wird abgelehnt + code: |- + g = b"It's a Secret to Everybody" + k = ("sha256=757107ea0eb2509fc2112" + "21cce984b8a37570b6d7586c22c46" + "f4379c8b043e17") + assert not ist_echt(g, b"Hello, World?", k) + - name: Fehlender oder falscher Header + code: |- + g, roh = b"geheim-123", b"{}" + assert ist_echt(g, roh, None) is False + assert ist_echt(g, roh, "") is False + assert ist_echt(g, roh, "sha1=abc") is False + - name: Nicht-ASCII im Header wirft nicht + code: |- + g, roh = b"geheim-123", b"{}" + assert ist_echt(g, roh, "sha256=ä") is False + hints: + - '`hmac.new(geheim, roh, hashlib.sha256).hexdigest()` liefert den Hexstring.' + - '`hmac.compare_digest` wirft bei Strings mit Nicht-ASCII-Zeichen. Vergleiche deshalb Bytes: `.encode("utf-8")`.' + explanation: | + Die Signatur entsteht über genau die Bytes, die der Absender geschickt hat. Bei `compare_digest` hängt die Laufzeit nicht davon ab, an welcher Stelle sich zwei Werte unterscheiden; so verrät die Antwortzeit nicht Zeichen für Zeichen die richtige Signatur. Die Python-Dokumentation weist darauf hin, dass Länge und Typ trotzdem erkennbar bleiben können. Den Header bestimmt im Zweifel ein Angreifer. Deshalb darf die Prüfung bei seltsamen Werten nicht abstürzen, sondern lehnt ab. Ein Absturz hieße hier einen Fehler 500 statt einer sauberen Ablehnung. + resources: + - *webhook + - *hmac +- id: signatur-ueber-json + type: bug + title: Echte Lieferungen werden abgelehnt + prompt: | + Seit dem Umbau lehnt der Webhook-Empfänger jede Lieferung von GitHub als gefälscht ab, obwohl das Geheimnis stimmt. `req.body` sind die empfangenen Bytes, `req.json` das daraus geparste Objekt, `kanonisch(daten)` erzeugt sortiertes, kompaktes JSON als Bytes. Finde die Zeile, die das verursacht, und korrigiere sie. + lang: python + code: |- + def ist_echt(geheim, req, kopf): + if not kopf: + return False + mac = hmac.new( + geheim, + kanonisch(req.json), + hashlib.sha256) + hexwert = mac.hexdigest() + soll = "sha256=" + hexwert + return hmac.compare_digest( + soll.encode(), + kopf.encode()) + lines: + - 6 + fix: + - line: 6 + answers: + - req.body, + explanation: | + GitHub signiert genau die Bytes, die es geschickt hat. `kanonisch(req.json)` erzeugt dasselbe Objekt als andere Bytes, mit anderer Reihenfolge und anderen Leerzeichen, und damit eine andere Signatur. In die HMAC-Berechnung gehört deshalb `req.body`, unverändert. Das geparste Objekt darfst du erst benutzen, nachdem die Signatur stimmt. + resources: + - *webhook +- id: rotation-speichern + type: explain + title: Warum wird zuerst gespeichert? + prompt: Erkläre, warum diese Funktion das neue Refresh-Token sofort und atomar speichert und was passiert, wenn + das Speichern scheitert. + lang: python + code: |- + def erneuere(sende, ablage): + alt = ablage.lesen() + neu = sende(alt) + ablage.schreibe_atomar( + neu["refresh_token"]) + return neu["access_token"] + points: + - Der Server rotiert das Refresh-Token; das alte ist nach der Antwort ungültig. + - Nur das gespeicherte neue Token erlaubt den nächsten Lauf; stürzt das Programm vorher ab, ist es verloren. + - Atomar heißt, dass nie eine halb geschriebene Datei zurückbleibt, die beim nächsten Lauf gelesen wird. + - Scheitert das Speichern, endet der nächste Lauf mit invalid_grant, und nur eine neue Anmeldung hilft. + explanation: | + Mit Rotation ist jedes Refresh-Token nur einmal gültig. Zwischen der Antwort des Servers und dem Speichern liegt das einzige Fenster, in dem das neue Token verloren gehen kann. Deshalb steht das Speichern direkt danach und vor allem anderen, und es schreibt über eine temporäre Datei und `os.replace`. Ein altes Token noch einmal zu schicken ist nicht harmlos: Server, die Rotation nach RFC 9700 umsetzen, deuten das als möglichen Diebstahl und können auch das gültige Token widerrufen. + resources: + - title: RFC 9700 · Refresh Token Protection + url: https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2 + - title: Python · os.replace + url: https://docs.python.org/3/library/os.html#os.replace diff --git a/content/automation/modules/benachrichtigungen.yaml b/content/automation/modules/benachrichtigungen.yaml index fbe5566..6440307 100644 --- a/content/automation/modules/benachrichtigungen.yaml +++ b/content/automation/modules/benachrichtigungen.yaml @@ -63,7 +63,7 @@ lesson: | Die Adressen sind Platzhalter. Ein gültig aufgebautes Mailobjekt beweist weder, dass eine Adresse existiert, noch dass der Anbieter den Versand erlaubt. Freie Texte in Headern dürfen keine Zeilenumbrüche enthalten. Die Bibliothek prüft wichtige Formatregeln, aber die Anwendung sollte schon an ihrer Eingabegrenze eindeutige Werte verlangen. - Für HTML-Mail fügt man neben einer Textversion eine Alternative hinzu. Die [Python-Mailbeispiele](https://docs.python.org/3/library/email.examples.html) zeigen solche MIME-Strukturen. Texte aus fremden Quellen müssen beim Einfügen in HTML passend maskiert werden; ein plain-text Bericht ist häufig die einfachere, robustere Wahl. + Mails per IMAP lesen, Anhänge sicher speichern und Termine als ICS verschicken behandelt das Modul [Postfach und Kalender](/automation/postfach-kalender). Für HTML-Mail fügt man neben einer Textversion eine Alternative hinzu. Die [Python-Mailbeispiele](https://docs.python.org/3/library/email.examples.html) zeigen solche MIME-Strukturen. Texte aus fremden Quellen müssen beim Einfügen in HTML passend maskiert werden; ein plain-text Bericht ist häufig die einfachere, robustere Wahl. ```python run from email.message import EmailMessage diff --git a/content/automation/modules/cli-werkzeuge.yaml b/content/automation/modules/cli-werkzeuge.yaml index c52d2d2..3f87b96 100644 --- a/content/automation/modules/cli-werkzeuge.yaml +++ b/content/automation/modules/cli-werkzeuge.yaml @@ -215,7 +215,7 @@ lesson: | log.exception("Berechnung fehlgeschlagen") # ERROR mit Traceback ``` - Die Standardstufe `WARNING` passt gut zu Werkzeugen: Ohne Optionen sieht man nur Probleme, mit `-v` den Ablauf, mit `-vv` alle Details, mit `-q` nur Fehler. [`basicConfig`](https://docs.python.org/3/library/logging.html#logging.basicConfig) wirkt nur beim ersten Aufruf; `force=True` ersetzt eine vorhandene Konfiguration. Sollen Meldungen zusätzlich in eine Datei, übergibst du mehrere Handler: `handlers=[logging.StreamHandler(), logging.FileHandler("sortiere.log", encoding="utf-8")]`. + Die Standardstufe `WARNING` passt gut zu Werkzeugen: Ohne Optionen sieht man nur Probleme, mit `-v` den Ablauf, mit `-vv` alle Details, mit `-q` nur Fehler. [`basicConfig`](https://docs.python.org/3/library/logging.html#logging.basicConfig) wirkt nur beim ersten Aufruf; `force=True` ersetzt eine vorhandene Konfiguration. Sollen Meldungen zusätzlich in eine Datei, übergibst du mehrere Handler: `handlers=[logging.StreamHandler(), logging.FileHandler("sortiere.log", encoding="utf-8")]`. JSON-Logs, Lauf-IDs, Rotation und einen Filter, der Geheimnisse schwärzt, vertieft das Modul [Logs und Geheimnisse](/automation/logs-geheimnisse). ## Typer, Click und Rich diff --git a/content/automation/modules/datenformate.yaml b/content/automation/modules/datenformate.yaml index 7d924da..eb03e25 100644 --- a/content/automation/modules/datenformate.yaml +++ b/content/automation/modules/datenformate.yaml @@ -1,14 +1,16 @@ id: datenformate title: Datenformate zuverlässig verarbeiten level: 2 -minutes: 170 -summary: CSV, JSON, YAML, TOML und Excel lesen, prüfen und exportieren – mit klaren Typen, Encoding und nachvollziehbaren - Fehlern. +minutes: 210 +summary: CSV, JSON, YAML, TOML und Excel lesen, prüfen und exportieren – mit klaren Typen, Encoding, echten + Arbeitsmappen und nachvollziehbaren Fehlern. goals: - Du trennst Parsen, fachliche Validierung und Transformation. - Du verarbeitest CSV mit dem csv-Modul statt durch split. - Du behandelst JSON-Typen, Dezimalzahlen und fehlende Werte ausdrücklich. -- Du wählst sichere YAML-Loader und prüfst Excel-Arbeitsmappen mit ihren Grenzen. +- Du wählst sichere YAML-Loader für Konfiguration. +- Du schreibst Excel-Arbeitsmappen mit mehreren Blättern, Formaten und Formeln und liest sie über Spaltennamen + wieder ein. resources: - &id001 title: Python · csv @@ -17,8 +19,13 @@ resources: url: https://docs.python.org/3/library/json.html - title: PyYAML · Documentation url: https://pyyaml.org/wiki/PyYAMLDocumentation -- title: openpyxl · Tutorial +- &xl + title: openpyxl · Tutorial url: https://openpyxl.readthedocs.io/en/stable/tutorial.html +- &xlstyle + title: openpyxl · Working with styles + url: https://openpyxl.readthedocs.io/en/stable/styles.html +place: unterwegs lesson: | Eine monatliche Auswertung bekommt Verkäufe aus CSV, Einstellungen aus YAML und eine Excel-Vorlage von einem Kollegen. Die Dateien sehen wie Text oder Tabellen aus, haben aber unterschiedliche Typregeln. Die Zeichenfolge `0012` kann eine Artikelnummer sein, ein leeres Feld ein fehlender Wert und `1,50` ein Geldbetrag. Ohne fachlichen Vertrag gehen solche Unterschiede beim Import verloren. @@ -31,8 +38,11 @@ lesson: | ```python run from pathlib import Path pfad = Path("notiz.txt") - pfad.write_text("Größe: 12\n", encoding="utf-8") - print(pfad.read_text(encoding="utf-8").strip()) + pfad.write_text("Größe: 12\n", + encoding="utf-8") + text = pfad.read_text( + encoding="utf-8") + print(text.strip()) ``` Definiere danach ein internes Modell. Eine Artikelnummer bleibt ein String, Mengen werden Integer und Geldbeträge können ganzzahlige Cent oder `Decimal` sein. Mit [`decimal`](https://docs.python.org/3/library/decimal.html) verarbeitest du Dezimalzahlen ohne binäre Rundungsüberraschungen. Erzeuge Decimal aus einem String, nicht aus einem bereits gerundeten Float. @@ -53,11 +63,15 @@ lesson: | ```python run import csv from io import StringIO - text = 'id;titel;menge\n0012;"Bericht; September";3\n' - zeilen = list(csv.DictReader(StringIO(text, newline=""), delimiter=";")) - print(zeilen[0]["id"]) - print(zeilen[0]["titel"]) - print(int(zeilen[0]["menge"])) + text = ('id;titel;menge\n' + '0012;"Bericht; Mai";3\n') + datei = StringIO(text, newline="") + leser = csv.DictReader( + datei, delimiter=";") + zeile = next(leser) + print(zeile["id"]) + print(zeile["titel"]) + print(int(zeile["menge"])) ``` CSV besitzt normalerweise keine verbindlichen Spaltentypen. `DictReader` liefert Strings; auch `"3"` bleibt zunächst Text. Prüfe die erforderlichen Überschriften, melde eine nachvollziehbare Zeilenposition und konvertiere nur Felder, deren Bedeutung feststeht. Ein Sniffer kann bei unbekannten Dateien helfen, aber eine bekannte Exportquelle sollte einen vereinbarten Dialekt haben. @@ -66,13 +80,17 @@ lesson: | import csv from io import StringIO puffer = StringIO(newline="") - writer = csv.DictWriter(puffer, fieldnames=["id", "titel"], delimiter=";") - writer.writeheader() - writer.writerow({"id": "0012", "titel": "Bericht; September"}) + w = csv.DictWriter( + puffer, ["id", "titel"], + delimiter=";") + w.writeheader() + zeile = {"id": "0012", + "titel": "Bericht; Mai"} + w.writerow(zeile) print(puffer.getvalue().strip()) ``` - `DictWriter` übernimmt das Quoting. Die feste Feldreihenfolge macht Exporte stabil. Wenn die Datei in einer Tabellenkalkulation geöffnet wird, können Texte mit bestimmten Anfangszeichen als Formel interpretiert werden. Behandle solche Exporte als eigene Zielumgebung und lege eine geeignete Textstrategie fest; ändere Rohdaten nicht stillschweigend ohne dokumentierten Vertrag. + `DictWriter` übernimmt das Quoting. Die feste Feldreihenfolge macht Exporte stabil. Wenn die Datei in einer Tabellenkalkulation geöffnet wird, können Texte mit bestimmten Anfangszeichen als Formel interpretiert werden. Behandle solche Exporte als eigene Zielumgebung und lege eine geeignete Textstrategie fest; ändere Rohdaten nicht stillschweigend ohne dokumentierten Vertrag. Für Excel-Dateien zeigt der Abschnitt über Formeln weiter unten, wie du Text ausdrücklich als Text speicherst. ## JSON: gültige Syntax ist noch kein Schema @@ -80,11 +98,16 @@ lesson: | ```python run import json - text = '{"id":"0012","menge":3,"aktiv":true,"datum":null}' + text = """{"id": "0012", + "menge": 3, "aktiv": true, + "datum": null}""" daten = json.loads(text) - print(type(daten["menge"]).__name__) - print(daten["aktiv"], daten["datum"]) - print(json.dumps(daten, ensure_ascii=False, sort_keys=True)) + menge = daten["menge"] + print(type(menge).__name__) + print(daten["aktiv"]) + print(daten["datum"]) + print(json.dumps(daten, + sort_keys=True)) ``` Für öffentliche JSON-Schnittstellen solltest du nichtstandardisierte Zahlenwerte wie NaN vermeiden. Python erlaubt beim Schreiben standardmäßig einige solche Werte; `allow_nan=False` lehnt sie ab. Beim Lesen kannst du zusätzliche Regeln setzen. Das ist ein Beispiel dafür, dass das Verhalten einer konkreten Bibliothek breiter sein kann als dein Datenvertrag. @@ -92,9 +115,10 @@ lesson: | ```python run import json try: - json.dumps({"wert": float("nan")}, allow_nan=False) + json.dumps(float("nan"), + allow_nan=False) except ValueError: - print("Kein gültiger endlicher Wert") + print("Kein endlicher Wert") ``` `isinstance(wert, int)` akzeptiert in Python auch `True`, weil bool eine Unterklasse von int ist. Wenn eine Menge eine echte Integerzahl sein muss, ist `type(wert) is int` eine mögliche präzise Prüfung. Trenne ferner ein fehlendes Feld von einem ausdrücklich gesetzten `null`, wenn diese Zustände fachlich verschieden sind. @@ -105,49 +129,137 @@ lesson: | ```python import yaml - konfiguration = yaml.safe_load("name: bericht\nlimit: 20\n") - if not isinstance(konfiguration, dict): - raise ValueError("Konfiguration muss eine Zuordnung sein") + konf = yaml.safe_load( + "name: bericht\nlimit: 20\n") + if not isinstance(konf, dict): + raise ValueError( + "Konfiguration ist keine " + "Zuordnung") ``` YAML-Implementierungen können implizite Typen unterschiedlich auslegen. Quote IDs, Datumsähnliches und absichtlich textuelle Werte, wenn du eine eindeutige Darstellung willst. Für Anwendungskonfiguration mit klarer Tabellenstruktur ist TOML oft übersichtlich. [`tomllib`](https://docs.python.org/3/library/tomllib.html) liest es in der Standardbibliothek. ```python run import tomllib - daten = tomllib.loads('[export]\nformat = "csv"\nlimit = 20\n') + text = ('[export]\n' + 'format = "csv"\n') + daten = tomllib.loads(text) print(daten["export"]["format"]) ``` Unabhängig vom Format brauchst du erlaubte Schlüssel, Wertebereiche und Defaults. Ein Tippfehler wie `limti` sollte nicht unbemerkt einen Standardwert auslösen, wenn dadurch plötzlich alle Datensätze exportiert werden. Gib bei ungültiger Konfiguration einen hilfreichen Fehler aus und starte keine halb konfigurierte Verarbeitung. - ## Excel bewusst als Arbeitsmappe behandeln + ## Excel: Arbeitsmappe, Blätter und Zellen - Eine XLSX-Datei ist eine Arbeitsmappe mit Tabellenblättern, Zellen, Formeln und Formatierung. [`openpyxl`](https://openpyxl.readthedocs.io/en/stable/tutorial.html) kann solche Dateien lokal lesen und schreiben. Die Browserumgebung dieses Kurses enthält openpyxl nicht; die Excel-Aufgabe wird daher lokal durchgeführt. + Eine XLSX-Datei ist ein ZIP-Archiv mit XML-Teilen. Darin liegt eine Arbeitsmappe mit Tabellenblättern, und jede Zelle hat einen Wert, einen Typ und eine Formatierung. [`openpyxl`](https://openpyxl.readthedocs.io/en/stable/tutorial.html) liest und schreibt dieses Format; das alte Binärformat `.xls` kann es nicht. In diesem Kurs läuft openpyxl auch im Browser: Beim ersten `import openpyxl` lädt die Seite das Paket nach. - ```python + ```python run + from openpyxl import Workbook + + buch = Workbook() + blatt = buch.active + blatt.title = "Umsatz" + blatt.append(["Region", "Betrag"]) + blatt.append(["Nord", 120.5]) + blatt.append(["Süd", 80]) + fehler = buch.create_sheet( + "Fehler") + fehler.append(["Zeile", "Grund"]) + buch.save("bericht.xlsx") + print(buch.sheetnames) + print(blatt.dimensions) + ``` + + Eine neue Mappe hat genau ein Blatt namens `Sheet`; `buch.active` liefert es. `append` schreibt eine Zeile unter die letzte belegte. `create_sheet(name, index)` fügt ein Blatt an einer Position ein, ohne Index am Ende. Achtung: `active` ist eine **Position**, kein fest gemerktes Blatt. Fügst du an Position 0 ein, zeigt `active` danach auf das neue Blatt. Greife deshalb beim Lesen und Schreiben über den Namen zu, `buch["Umsatz"]`, statt dich auf das aktive Blatt zu verlassen. + + Beim Lesen öffnet [`load_workbook`](https://openpyxl.readthedocs.io/en/stable/tutorial.html#loading-from-a-file) die Datei. `iter_rows(values_only=True)` liefert Tupel mit Werten statt Zellobjekten. Die Eigenschaft `values` liefert die Werte Zeile für Zeile; die erste ist meist die Kopfzeile. + + ```python run + from openpyxl import Workbook from openpyxl import load_workbook - buch = load_workbook("eingang.xlsx", read_only=True, data_only=True) - try: - blatt = buch["Verkäufe"] - for zeile in blatt.iter_rows(min_row=2, values_only=True): - print(zeile) - finally: - buch.close() + + buch = Workbook() + ws = buch.active + ws.title = "Eingang" + ws.append(["menge", "id"]) + ws.append([3, "0012"]) + buch.save("ein.xlsx") + + ws = load_workbook("ein.xlsx")[ + "Eingang"] + kopf, *zeilen = ws.values + pos = {name: i for i, name + in enumerate(kopf)} + for zeile in zeilen: + print(zeile[pos["id"]], + zeile[pos["menge"]]) ``` - `data_only=True` liest bei Formelzellen den zuletzt gespeicherten Ergebniswert, soweit vorhanden. openpyxl berechnet Formeln nicht neu. Eine fehlende oder alte Berechnung in der Datei kann deshalb zu `None` oder veralteten Ergebnissen führen. Verwechsle erfolgreiches Öffnen nicht mit korrekter Neuberechnung. + Die Kopfzeile wird zu einer Zuordnung von Spaltenname auf Position. So überlebt der Import, wenn jemand Spalten umsortiert oder eine Notizspalte einfügt. Prüfe, dass alle Pflichtspalten vorhanden sind, und melde fehlende mit Namen. Zellen haben Typen: `"0012"` bleibt Text, `3` wird eine Zahl. Leere Zellen ergeben `None`, und eine vollständig leere Zeile in der Mitte ist häufig, weil Menschen Tabellen gliedern. + + Für sehr große Mappen gibt es [Read-only- und Write-only-Modi](https://openpyxl.readthedocs.io/en/stable/optimized.html). Eine im Read-only-Modus geöffnete Mappe musst du mit `close()` schließen. Öffne und speichere die Originaldatei eines Kollegen nicht unter demselben Namen: Das Tutorial warnt, dass openpyxl nicht alle Inhalte einer Excel-Datei liest und Formen beim Speichern verloren gehen. `save` überschreibt außerdem ohne Rückfrage. + + ## Excel: Formeln, Formate und gespeicherte Ergebnisse + + Eine Zelle, deren Text mit `=` beginnt, speichert openpyxl als **Formel**. Es rechnet sie nicht aus. Das tut erst die Tabellenkalkulation beim Öffnen, und sie legt das Ergebnis beim Speichern als zwischengespeicherten Wert in der Datei ab. Formeln stehen in der Datei mit englischen Funktionsnamen und Kommas: Microsoft beschreibt in [Range.FormulaLocal](https://learn.microsoft.com/en-us/office/vba/api/excel.range.formulalocal), dass ein deutsches Excel dieselbe gespeicherte Formel `=SUM(A1:A10)` als `=SUMME(A1:A10)` anzeigt. + + ```python run + from openpyxl import Workbook + from openpyxl import load_workbook + from openpyxl.styles import Font + + buch = Workbook() + ws = buch.active + ws.append(["Region", "Betrag"]) + ws.append(["Nord", 120.5]) + ws.append(["Süd", 80]) + ws["A4"] = "Summe" + ws["B4"] = "=SUM(B2:B3)" + ws["A1"].font = Font(bold=True) + for zeile in range(2, 5): + z = ws.cell(zeile, 2) + z.number_format = "#,##0.00" + ws.freeze_panes = "A2" + buch.save("umsatz.xlsx") + + roh = load_workbook("umsatz.xlsx") + print(roh.active["B4"].value) + werte = load_workbook( + "umsatz.xlsx", data_only=True) + print(werte.active["B4"].value) + ``` - Achte bei großen Mappen auf Read-only- beziehungsweise Write-only-Modi. Prüfe Blattnamen und Überschriften, bevor du feste Spaltenpositionen auswertest. Schreibe einen Bericht zunächst in eine neue Ausgabedatei, damit eine fehlerhafte Transformation nicht die einzige Originalmappe überschreibt. Datumswerte, Formatierung und führende Nullen brauchen gezielte Testdaten. + Ohne Option liest `load_workbook` den Formeltext. Mit `data_only=True` liest es den zwischengespeicherten Wert, und den gibt es hier nicht, weil nie eine Tabellenkalkulation die Datei geöffnet hat: Ergebnis `None`. Bei einer Datei, die Excel gespeichert hat, steht dort der Wert vom letzten Speichern. Er kann veraltet sein, wenn danach jemand die Eingaben per Skript geändert hat. Braucht dein Skript eine Summe, rechne sie in Python aus. Eine Formel in der Ausgabedatei ist für die Menschen, die später Zahlen ändern. + + Formatierung ist getrennt vom Wert. `number_format = "#,##0.00"` zeigt `120.5` als `120,50` in einem deutschen Excel, der gespeicherte Wert bleibt die Zahl. Schreibe deshalb Zahlen als `int` oder `float`, nie als vorformatierten Text wie `"120,50 €"`: Den kann niemand mehr summieren. [Schriften, Füllungen und Zahlenformate](https://openpyxl.readthedocs.io/en/stable/styles.html) setzt du pro Zelle. `freeze_panes = "A2"` hält die Kopfzeile beim Scrollen sichtbar. + + Die Formelregel hat eine Kehrseite. Ein Kundenname, der mit `=` beginnt, würde ebenfalls zur Formel. Bei fremden Texten setzt du nach der Zuweisung den Zelltyp ausdrücklich auf Text. [`Cell.data_type`](https://openpyxl.readthedocs.io/en/stable/api/openpyxl.cell.cell.html) ist `"f"` für Formeln, `"s"` für Text und `"n"` für Zahlen. + + ```python run + from openpyxl import Workbook + + ws = Workbook().active + ws["A1"] = "=2+3" + print(ws["A1"].data_type) + ws["A2"] = "=2+3" + ws["A2"].data_type = "s" + print(ws["A2"].data_type) + ``` + + > [!WARNING] Formeln aus fremden Daten + > Text aus einer API oder einem Formular landet sonst als ausführbare Formel in einer Datei, die jemand mit Excel öffnet. Formeln wie `HYPERLINK` oder Verweise auf andere Dateien sind dort mehr als harmlose Zeichen. ## Häufige Fehler und Praxis - Die häufigsten Defekte entstehen durch implizite Annahmen: Alle CSV-Dateien seien kommagetrennt, jede JSON-Zahl sei eine gültige Menge oder jede Excel-Formel sei frisch berechnet. Erzeuge kleine Referenzdateien mit genau diesen Gegenbeispielen und halte erwartete Normalformen daneben fest. + Die häufigsten Defekte entstehen durch implizite Annahmen: Alle CSV-Dateien seien kommagetrennt, jede JSON-Zahl sei eine gültige Menge, jede Excel-Formel sei frisch berechnet oder die Mengenspalte stehe immer an dritter Stelle. Erzeuge kleine Referenzdateien mit genau diesen Gegenbeispielen und halte erwartete Normalformen daneben fest. + + Bei Excel kommen drei Fehler dazu. Erstens wird `buch.active` als „das richtige Blatt“ benutzt, obwohl jemand ein Deckblatt eingefügt hat. Zweitens werden Beträge als formatierter Text geschrieben. Drittens überschreibt ein Skript die einzige Originalmappe, und was openpyxl nicht liest, etwa Formen, ist weg. > [!PRAXIS] - > Gib Fehler als Zeilennummer, Feldname und kurze Ursache zurück. So kann jemand die Eingabedatei reparieren, ohne deinen Code zu lesen. Bei personenbezogenen Daten gehört nicht automatisch die ganze Zeile ins Log. + > Gib Fehler als Zeilennummer, Feldname und kurze Ursache zurück. So kann jemand die Eingabedatei reparieren, ohne deinen Code zu lesen. Bei personenbezogenen Daten gehört nicht automatisch die ganze Zeile ins Log. Ein Excel-Bericht bekommt ein eigenes Blatt „Fehler“ mit genau diesen Angaben. > [!INTERVIEW] - > „Was prüfst du nach json.loads?“ Die Wurzelform, Pflichtfelder, Typen und fachlichen Wertebereiche. Ein syntaktisch gültiges Dokument kann weiterhin vollständig unbrauchbar sein. + > „Was prüfst du nach json.loads?“ Die Wurzelform, Pflichtfelder, Typen und fachlichen Wertebereiche. Ein syntaktisch gültiges Dokument kann weiterhin vollständig unbrauchbar sein. Bei Excel kommt die Frage dazu, ob du Formeltext oder Ergebnis liest und woher das Ergebnis stammt. > [!TIP] > Ein Roundtrip-Test liest einen erzeugten Export wieder ein und vergleicht fachliche Werte. Er ergänzt einen bekannten Referenzfall, ersetzt ihn aber nicht: Schreiber und Leser könnten denselben Fehler teilen. @@ -158,7 +270,9 @@ lesson: | - CSV braucht einen echten Parser und festgelegten Dialekt. - JSON-Syntax ersetzt keine fachliche Validierung. - YAML wird sicher geladen und danach geprüft. - - Excel-Formeln werden von openpyxl nicht neu berechnet. + - Excel-Blätter sprichst du über ihren Namen an, Spalten über die Kopfzeile. + - openpyxl speichert Formeln und rechnet sie nicht; `data_only` liest nur gespeicherte Ergebnisse. + - Zahlen bleiben Zahlen mit Zahlenformat, fremder Text wird ausdrücklich Text. - Originale bleiben erhalten; Exporte und Fehler sind nachvollziehbar. exercises: - id: parser @@ -183,13 +297,13 @@ exercises: type: order title: Vom Dokument zum Bericht prompt: Ordne die drei Prüfgrenzen und den Export. - lang: python + lang: prose lines: |- - # Bytes mit vereinbartem Encoding lesen - # Format mit passendem Parser einlesen - # Schema und fachliche Werte prüfen - # In das interne Datenmodell umwandeln - # Bericht in eine neue Datei schreiben + Bytes mit vereinbartem Encoding lesen + Format mit passendem Parser einlesen + Schema und fachliche Werte prüfen + In das interne Datenmodell umwandeln + Bericht in eine neue Datei schreiben explanation: Jede Grenze hat eigene Fehler. Erst nach erfolgreicher Validierung sollten fachliche Ergebnisse ausgegeben werden. resources: @@ -202,7 +316,10 @@ exercises: code: |- import csv from io import StringIO - zeile = next(csv.reader(StringIO('1;"a;b";3\n'), delimiter=";")) + text = StringIO('1;"a;b";3\n') + leser = csv.reader(text, + delimiter=";") + zeile = next(leser) print(len(zeile)) print(zeile[1]) expected: |- @@ -219,7 +336,9 @@ exercises: lang: python code: |- import json - d = json.loads('{"aktiv":true,"wert":null,"menge":3}') + d = json.loads( + '{"aktiv": true, "wert": null,' + ' "menge": 3}') print(type(d["aktiv"]).__name__) print(d["wert"] is None) print(type(d["menge"]).__name__) @@ -230,6 +349,27 @@ exercises: explanation: JSON-Bools und null haben eigene Python-Entsprechungen. Die Zahl 3 wird hier als Integer eingelesen. resources: - *id001 +- id: blattreihenfolge + type: output + title: Welches Blatt ist aktiv? + prompt: | + Welche Ausgabe erzeugt dieses Programm? Eine neue Mappe beginnt mit einem einzigen Blatt. + lang: python + code: |- + from openpyxl import Workbook + buch = Workbook() + buch.active.title = "Bericht" + buch.create_sheet("Fehler", 0) + buch.create_sheet("Roh") + print(buch.sheetnames) + print(buch.active.title) + expected: |- + ['Fehler', 'Bericht', 'Roh'] + Fehler + explanation: | + `create_sheet("Fehler", 0)` fügt das Blatt vor `Bericht` ein, `create_sheet("Roh")` hängt es ans Ende. `active` merkt sich nur die Position 0, nicht das Blatt `Bericht`. Nach dem Einfügen steht an Position 0 das Blatt `Fehler`. Deshalb greift robuster Code über den Namen zu: `buch["Bericht"]`. + resources: + - *xl - id: csv-summe type: code title: Mengen aus CSV addieren @@ -242,8 +382,13 @@ exercises: solution: |- import csv from io import StringIO + def summe_csv(text): - return sum(int(z["menge"]) for z in csv.DictReader(StringIO(text, newline=""), delimiter=";")) + leser = csv.DictReader( + StringIO(text, newline=""), + delimiter=";") + return sum(int(z["menge"]) + for z in leser) tests: - name: Mehrere code: assert summe_csv("id;menge\na;2\nb;3\n") == 5 @@ -267,8 +412,10 @@ exercises: solution: |- def menge(daten): wert = daten.get("menge") - if type(wert) is not int or wert < 0: - raise ValueError("menge muss eine nichtnegative Integerzahl sein") + if type(wert) is not int: + raise ValueError("Typ") + if wert < 0: + raise ValueError("negativ") return wert tests: - name: Normal @@ -315,9 +462,13 @@ exercises: solution: |- import csv from io import StringIO + def export_csv(zeilen): out = StringIO(newline="") - writer = csv.DictWriter(out, fieldnames=["id", "titel"], delimiter=";", lineterminator="\n") + writer = csv.DictWriter( + out, ["id", "titel"], + delimiter=";", + lineterminator="\n") writer.writeheader() writer.writerows(zeilen) return out.getvalue() @@ -332,6 +483,206 @@ exercises: machen die Ausgabe reproduzierbar. resources: - *id001 +- id: excel-bericht + type: code + title: Ein Excel-Bericht mit zwei Blättern + prompt: | + Schreibe `schreibe_bericht(pfad, posten)`. `posten` ist eine Liste aus Paaren `(name, betrag)`. Speichere unter `pfad` eine Arbeitsmappe mit genau zwei Blättern in dieser Reihenfolge: + + 1. **„Bericht“**: Zeile 1 enthält `Posten` und `Betrag`, beide fett. Ab Zeile 2 folgt ein Posten pro Zeile. Darunter steht `Summe` mit der Formel `=SUM(B2:Bn)`, wobei `n` die letzte Postenzeile ist. Ohne Posten steht dort statt der Formel die Zahl `0`. + 2. **„Prüfung“**: In `A1` steht `Anzahl`, in `B1` die Zahl der Posten. + + Alle Beträge und die Summe bekommen das Zahlenformat `#,##0.00`. Namen sind immer Text, auch wenn sie mit `=` beginnen. + lang: python + starter: |- + from openpyxl import Workbook + from openpyxl.styles import Font + + FORMAT = "#,##0.00" + + def schreibe_bericht(pfad, posten): + buch = Workbook() + ... + buch.save(pfad) + solution: |- + from openpyxl import Workbook + from openpyxl.styles import Font + + FORMAT = "#,##0.00" + + def schreibe_bericht(pfad, posten): + buch = Workbook() + ws = buch.active + ws.title = "Bericht" + ws.append(["Posten", "Betrag"]) + for z in ws[1]: + z.font = Font(bold=True) + n = 2 + for name, betrag in posten: + a = ws.cell(n, 1, name) + a.data_type = "s" + b = ws.cell(n, 2, betrag) + b.number_format = FORMAT + n += 1 + ws.cell(n, 1, "Summe") + summe = f"=SUM(B2:B{n - 1})" + if not posten: + summe = 0 + s = ws.cell(n, 2, summe) + s.number_format = FORMAT + pruef = buch.create_sheet( + "Prüfung") + pruef.append( + ["Anzahl", len(posten)]) + buch.save(pfad) + tests: + - name: Zwei Blätter in fester Reihenfolge + code: |- + from openpyxl import load_workbook + schreibe_bericht("a.xlsx", + [("Miete", 950), ("Strom", 61.5)]) + buch = load_workbook("a.xlsx") + namen = buch.sheetnames + assert namen == ["Bericht", "Prüfung"], ( + f"Blätter: {namen}") + zahl = buch["Prüfung"]["B1"].value + assert zahl == 2, f"Anzahl: {zahl!r}" + - name: Fette Kopfzeile, Beträge als Zahl mit Format + code: |- + from openpyxl import load_workbook + schreibe_bericht("b.xlsx", + [("Miete", 950), ("Strom", 61.5)]) + ws = load_workbook("b.xlsx")["Bericht"] + kopf = [ws["A1"].value, ws["B1"].value] + assert kopf == ["Posten", "Betrag"], kopf + assert ws["A1"].font.b and ws["B1"].font.b, ( + "Die Kopfzeile ist nicht fett.") + assert ws["B3"].value == 61.5, ( + f"B3 ist {ws['B3'].value!r}") + fmt = ws["B3"].number_format + assert fmt == "#,##0.00", f"Format: {fmt}" + - name: Summenzeile mit Formel und Format + code: |- + from openpyxl import load_workbook + schreibe_bericht("c.xlsx", + [("Miete", 950), ("Strom", 61.5)]) + ws = load_workbook("c.xlsx")["Bericht"] + assert ws["A4"].value == "Summe", ( + f"A4 ist {ws['A4'].value!r}") + assert ws["B4"].value == "=SUM(B2:B3)", ( + f"B4 ist {ws['B4'].value!r}") + fmt = ws["B4"].number_format + assert fmt == "#,##0.00", f"Format: {fmt}" + - name: Ein Name mit = bleibt Text + code: |- + from openpyxl import load_workbook + schreibe_bericht("d.xlsx", [("=2+3", 1)]) + ws = load_workbook("d.xlsx")["Bericht"] + assert ws["A2"].value == "=2+3" + assert ws["A2"].data_type == "s", ( + "Der Name wurde als Formel gespeichert.") + - name: Ohne Posten steht 0 in der Summe + code: |- + from openpyxl import load_workbook + schreibe_bericht("e.xlsx", []) + buch = load_workbook("e.xlsx") + ws = buch["Bericht"] + assert ws["A2"].value == "Summe", ( + f"A2 ist {ws['A2'].value!r}") + assert ws["B2"].value == 0, ( + f"B2 ist {ws['B2'].value!r}") + assert buch["Prüfung"]["B1"].value == 0 + hints: + - Setze `ws.title`, bevor du das zweite Blatt mit `create_sheet` anhängst. + - '`ws[1]` liefert die Zellen der ersten Zeile; `Font(bold=True)` macht sie fett.' + - Schreibe Postenzeilen mit `ws.cell(zeile, spalte, wert)` und setze danach `data_type` und `number_format` auf + der zurückgegebenen Zelle. + explanation: | + Werte, Typen und Formatierung sind getrennte Eigenschaften einer Zelle. Der Betrag bleibt eine Zahl, das Zahlenformat bestimmt nur die Anzeige. Die Summenformel rechnet erst Excel aus, deshalb prüfen die Tests den Formeltext und nicht ein Ergebnis. Der Sonderfall ohne Posten verhindert, dass in `B2` die Formel `=SUM(B2:B1)` steht: ein Zirkelbezug auf die eigene Zelle, der zudem die Kopfzeile einschließt. `data_type = "s"` nach der Zuweisung macht aus einem Namen wie `=2+3` wieder Text. + resources: + - *xl + - *xlstyle +- id: excel-nach-kopf + type: code + title: Spalten über die Kopfzeile finden + prompt: | + Schreibe `lies_posten(pfad)`. Lies das Blatt **„Eingang“**; es muss nicht das erste Blatt sein. Zeile 1 ist die Kopfzeile und enthält die Spalten `id` und `menge` in beliebiger Reihenfolge, eventuell neben weiteren Spalten. Gib für jede weitere Zeile ein Dictionary `{"id": …, "menge": …}` mit den Zellwerten zurück. Überspringe Zeilen, in denen alle Zellen leer sind. Fehlt `id` oder `menge` in der Kopfzeile, wirf `ValueError` mit dem Namen der fehlenden Spalte in der Meldung. + lang: python + setup: |- + from openpyxl import Workbook + + def _mappe(pfad, blaetter): + buch = Workbook() + buch.remove(buch.active) + for name, zeilen in blaetter: + ws = buch.create_sheet(name) + for zeile in zeilen: + ws.append(zeile) + buch.save(pfad) + + _mappe("a.xlsx", [("Eingang", [["menge", "notiz", "id"], [3, "Ecke", "0012"], [None, None, None], [5, None, "0099"]])]) + _mappe("b.xlsx", [("Eingang", [["id", "anzahl"], ["1", 2]])]) + _mappe("c.xlsx", [("Deckblatt", [["Bitte nicht ändern"]]), ("Eingang", [["id", "menge"], ["A7", 1]])]) + starter: |- + from openpyxl import load_workbook + + def lies_posten(pfad): + ... + solution: |- + from openpyxl import load_workbook + + def lies_posten(pfad): + buch = load_workbook(pfad) + ws = buch["Eingang"] + kopf, *zeilen = ws.values + for name in ("id", "menge"): + if name not in kopf: + raise ValueError(name) + posten = [] + for zeile in zeilen: + if set(zeile) == {None}: + continue + w = dict(zip(kopf, zeile)) + posten.append({ + "id": w["id"], + "menge": w["menge"], + }) + return posten + tests: + - name: Spalten nach Namen statt Position + code: |- + p = lies_posten("a.xlsx") + assert p[0] == {"id": "0012", + "menge": 3}, p + - name: Leere Zeilen überspringen + code: |- + p = lies_posten("a.xlsx") + assert p == [ + {"id": "0012", "menge": 3}, + {"id": "0099", "menge": 5}, + ], p + - name: Fehlende Spalte wird benannt + code: |- + try: + lies_posten("b.xlsx") + except ValueError as fehler: + assert "menge" in str(fehler), ( + f"Meldung: {fehler}") + else: + assert False, "ValueError erwartet" + - name: Das Blatt Eingang, nicht das erste + code: |- + p = lies_posten("c.xlsx") + assert p == [{"id": "A7", + "menge": 1}], p + hints: + - '`load_workbook(pfad)["Eingang"]` wählt das Blatt über den Namen.' + - '`kopf, *zeilen = ws.values` trennt die Kopfzeile von den Datenzeilen.' + - '`dict(zip(kopf, zeile))` ordnet jedem Spaltennamen den Wert der Zeile zu. Prüfe vorher mit `in`, ob jede Pflichtspalte im Kopf steht.' + explanation: | + Der Name des Blatts und die Namen der Spalten sind der Vertrag, nicht ihre Positionen. Ein eingefügtes Deckblatt oder eine Notizspalte bricht diesen Import deshalb nicht. Die ID `0012` bleibt Text, weil die Zelle Text enthält; der Import wandelt nichts um, was er nicht verstehen muss. Eine fehlende Pflichtspalte ist ein Fehler der Eingabe und wird mit Namen gemeldet, statt später als `IndexError` oder als leere Liste aufzufallen. + resources: + - *xl - id: id-verlust type: bug title: Die führenden Nullen fehlen @@ -339,13 +690,17 @@ exercises: lang: python code: |- def artikel(zeile): - return {"id": int(zeile["id"]), "menge": int(zeile["menge"])} + menge = int(zeile["menge"]) + nummer = int(zeile["id"]) + return {"id": nummer, + "menge": menge} lines: - - 2 + - 3 fix: - - line: 2 + - line: 3 answers: - - 'return {"id": zeile["id"], "menge": int(zeile["menge"])}' + - nummer = zeile["id"] + - nummer = str(zeile["id"]) explanation: Eine Kennung ist kein Rechenwert. Die Konvertierung in int zerstört ihre Darstellung; nur die fachliche Menge wird numerisch konvertiert. resources: @@ -356,8 +711,13 @@ exercises: prompt: Erkläre die Aussagekraft dieses lokalen Excel-Lesevorgangs. lang: python code: |- - buch = load_workbook("bericht.xlsx", data_only=True, read_only=True) - wert = buch["Übersicht"]["B2"].value + buch = load_workbook( + "bericht.xlsx", + data_only=True, + read_only=True) + blatt = buch["Übersicht"] + wert = blatt["B2"].value + buch.close() points: - data_only liest gespeicherte Formelresultate statt Formeltext. - openpyxl berechnet die Formel nicht neu. @@ -366,30 +726,44 @@ exercises: explanation: Das Lesen ist eine Extraktion aus dem gespeicherten Zustand. Ein numerischer Zellwert beweist nicht, dass die zugrunde liegende Formel gerade mit den aktuellen Eingaben berechnet wurde. resources: - - *id001 + - *xl - id: import-bericht type: practice title: Ein nachvollziehbarer Dateiimport - prompt: Baue einen CSV-zu-JSON-Import mit id als String und menge als nichtnegativem int. Sammle Fehler mit Datenzeilennummer; - schreibe bei einem Fehler keinen neuen Ergebnisbericht. + prompt: Entwirf einen CSV-zu-JSON-Import mit id als String und menge als nichtnegativem int. Sammle Fehler mit + Datenzeilennummer; liefere bei einem Fehler keinen Ergebnisbericht. Vergleiche danach mit der Musterlösung. lang: python solution: |- import csv, json from io import StringIO + FEHLER = (ValueError, KeyError, + TypeError) + def importiere(text): daten, fehler = [], [] - for nummer, zeile in enumerate(csv.DictReader(StringIO(text), delimiter=";"), 2): + leser = csv.DictReader( + StringIO(text), + delimiter=";") + for nr, z in enumerate( + leser, start=2): try: - anzahl = int(zeile["menge"]) - if anzahl < 0: - raise ValueError("negative Menge") - daten.append({"id": zeile["id"], "menge": anzahl}) - except (ValueError, KeyError, TypeError): - fehler.append({"zeile": nummer, "feld": "menge"}) + menge = int(z["menge"]) + if menge < 0: + raise ValueError + daten.append( + {"id": z["id"], + "menge": menge}) + except FEHLER: + fehler.append( + {"zeile": nr, + "feld": "menge"}) if fehler: return None, fehler - return json.dumps(daten, ensure_ascii=False, allow_nan=False), [] + text = json.dumps( + daten, ensure_ascii=False, + allow_nan=False) + return text, [] checklist: - Führende Nullen bleiben erhalten. - Quotierte Felder werden korrekt verarbeitet. diff --git a/content/automation/modules/deployment-ci.yaml b/content/automation/modules/deployment-ci.yaml index b6a0350..c5a3c43 100644 --- a/content/automation/modules/deployment-ci.yaml +++ b/content/automation/modules/deployment-ci.yaml @@ -80,7 +80,7 @@ lesson: | print(fehlende_variablen({"API_URL": "https://example.org", "API_TOKEN": ""}, ["API_URL", "API_TOKEN"])) ``` - Der Fehlerbericht nennt den Variablennamen, nicht den geheimen Wert. Konfiguration hat außerdem fachliche Grenzen: Eine nichtleere URL ist nicht automatisch ein erlaubtes Ziel, und ein Port muss numerisch im gültigen Bereich liegen. Prüfe diese Regeln vor einer externen Wirkung. + Der Fehlerbericht nennt den Variablennamen, nicht den geheimen Wert. Woher Geheimnisse in CI, unter systemd und am Desktop kommen und was bei einem Leak zu tun ist, behandelt das Modul [Logs und Geheimnisse](/automation/logs-geheimnisse). Konfiguration hat außerdem fachliche Grenzen: Eine nichtleere URL ist nicht automatisch ein erlaubtes Ziel, und ein Port muss numerisch im gültigen Bereich liegen. Prüfe diese Regeln vor einer externen Wirkung. ## Geplante CI-Läufe sind begrenzte Jobs diff --git a/content/automation/modules/fehlerfaelle.yaml b/content/automation/modules/fehlerfaelle.yaml new file mode 100644 index 0000000..525d541 --- /dev/null +++ b/content/automation/modules/fehlerfaelle.yaml @@ -0,0 +1,991 @@ +id: fehlerfaelle +title: Fehlerfälle ausführbar machen +level: 3 +minutes: 230 +summary: Uhr, Zufall, Absturz und Duplikate im Test gezielt herbeiführen – Deadline mit Jitter, Retry nach Statuscode, + Lockfile, Checkpoint und Deduplizierung mit sqlite3. +goals: +- Du übergibst Uhr, Wartefunktion und Zufall als Parameter und testest Zeitverhalten ohne echtes Warten. +- Du entscheidest Wiederholungen nach Statuscode und liest Retry-After in Sekunden und als HTTP-Datum. +- Du verhinderst überlappende Läufe mit einem atomar angelegten Lockfile oder flock. +- Du schreibst Checkpoints atomar und setzt nach einem simulierten Absturz an der richtigen Stelle fort. +- Du deduplizierst mit sqlite3 über UNIQUE, INSERT OR IGNORE und rowcount. +resources: +- &zeit + title: Python · time.monotonic + url: https://docs.python.org/3/library/time.html#time.monotonic +- &aws + title: AWS Architecture Blog · Exponential Backoff And Jitter + url: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ +- &retryafter + title: RFC 9110 · Retry-After + url: https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3 +- &osopen + title: Python · os.open + url: https://docs.python.org/3/library/os.html#os.open +- &sqlite + title: Python · sqlite3 + url: https://docs.python.org/3/library/sqlite3.html +place: unterwegs +lesson: | + Im Modul [Robuste Automatisierung](/automation/robustheit) steht, was ein Job bei Teilfehlern tun soll: begrenzt wiederholen, Zustand bestätigen, Duplikate vermeiden. Dieses Modul macht daraus Code, den du im Test scheitern lässt. Ein Fehlerfall, den du nicht herbeiführen kannst, ist ein Fehlerfall, den du nicht testest. Und ein ungetesteter Fehlerpfad ist der Teil des Programms, der im Ernstfall zum ersten Mal läuft. + + Drei Werkzeuge tragen das ganze Modul. Erstens übergibst du Uhr, Wartefunktion und Zufall als Parameter, statt sie im Code fest zu verdrahten. Zweitens baust du Fakes, die auf Kommando 503 liefern oder abstürzen. Drittens legst du Absturzpunkte fest: Zwischen welchen zwei Zeilen darf der Prozess sterben, und was passiert beim nächsten Start? Alle Beispiele laufen im Browser, ohne echte Wartezeiten. + + ## Zeit und Zufall als Parameter + + `time.sleep(30)` in einer Wiederholungsschleife macht jeden Test 30 Sekunden langsam, und `time.monotonic()` lässt sich nicht vorspulen. Die Lösung ist eine kleine Fake-Uhr. Ihr Trick: Die Wartefunktion schläft nicht, sondern stellt die Uhr vor. So bleiben Uhr und Wartezeit im Test konsistent, genau wie im Betrieb. + + ```python run + class Uhr: + def __init__(self, t=0.0): + self.t = t + + def __call__(self): + return self.t + + def schlafe(self, sekunden): + self.t += sekunden + + uhr = Uhr() + uhr.schlafe(2.5) + uhr.schlafe(1) + print(uhr()) + ``` + + Im Betrieb übergibst du [`time.monotonic`](https://docs.python.org/3/library/time.html#time.monotonic) und `time.sleep`, im Test `uhr` und `uhr.schlafe`. Eine monotone Uhr springt nicht, wenn jemand die Systemzeit verstellt oder die Zeitsynchronisation sie korrigiert; für Dauer und Budgets ist sie die richtige Wahl. Für Zufall gilt dasselbe: Übergib eine Funktion wie `random.random` und im Test `lambda: 0.5`. Dann ist jeder Testlauf gleich. + + ## Deadline und Jitter + + Exponentieller Backoff verdoppelt die Pause nach jedem Fehlschlag. Scheitern viele Clients gleichzeitig, etwa weil ein Dienst kurz weg war, wiederholen sie aber auch gleichzeitig und überrollen ihn beim Neustart. **Jitter** verteilt sie. Die Variante „Full Jitter“ aus dem [AWS Architecture Blog](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) wählt eine zufällige Pause zwischen null und der exponentiellen Grenze. + + ```python run + def pause(versuch, zufall, + basis=1, deckel=8): + grenze = basis * 2 ** versuch + return zufall() * min(deckel, + grenze) + + def halb(): + return 0.5 + + for v in range(5): + print(v, pause(v, halb)) + ``` + + Eine Anzahl von Versuchen allein begrenzt die Laufzeit nicht: Fünf Versuche mit je 30 Sekunden Timeout und wachsenden Pausen können Minuten dauern. Deshalb bekommt ein Job eine **Deadline**, ein Zeitbudget für alles zusammen. Vor jeder Pause prüft er, ob sie noch hineinpasst. Passt sie nicht, gibt er sofort auf, statt zu schlafen und danach ohnehin abzubrechen. + + ```python run + def passt(uhr, ende, pause): + return uhr() + pause <= ende + + class Uhr: + def __init__(self): + self.t = 0.0 + def __call__(self): + return self.t + + uhr = Uhr() + ende = uhr() + 10 + uhr.t = 7 + print(passt(uhr, ende, 2)) + print(passt(uhr, ende, 4)) + ``` + + ## Wiederholen nach Statuscode + + Nicht jede Fehlerantwort lohnt einen zweiten Versuch. Die Frage ist immer: Kann dieselbe Anfrage später gelingen, ohne dass sich etwas an ihr ändert? + + | Status | Bedeutung | Wiederholen? | + | --- | --- | --- | + | 429 | zu viele Anfragen | ja, nach Retry-After | + | 502, 503, 504 | Gateway oder Dienst gestört | ja, mit Backoff | + | 400, 422 | Anfrage ungültig | nein | + | 401 | Token ungültig | einmal erneuern (Modul Authentifizierung) | + | 403, 404 | verboten, nicht da | nein | + + Ein 500 ist ein Grenzfall: Manchmal vorübergehend, oft ein Fehler, den deine Anfrage auslöst. Wiederhole automatisch nur, was dokumentiert vorübergehend ist, und nur Operationen, die zweimal ausgeführt nicht schaden. Bei schreibenden Anfragen brauchst du dafür einen Idempotency-Key, siehe [Web-APIs](/automation/web-apis). + + [`Retry-After`](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3) kommt in zwei Formen: als Sekundenzahl `120` oder als HTTP-Datum `Sat, 24 Oct 2026 08:00:30 GMT`. Das Datum liest [`email.utils.parsedate_to_datetime`](https://docs.python.org/3/library/email.utils.html#email.utils.parsedate_to_datetime); ungültige Werte lösen `ValueError` aus. Die Wartezeit ist die Differenz zur aktuellen Zeit, nie negativ. Auch hier ist „jetzt“ ein Parameter. + + ```python run + from datetime import datetime, UTC + from email.utils import ( + parsedate_to_datetime) + + jetzt = datetime(2026, 10, 24, 8, + tzinfo=UTC) + kopf = ("Sat, 24 Oct 2026 " + "08:00:30 GMT") + ziel = parsedate_to_datetime(kopf) + rest = ziel - jetzt + print(rest.total_seconds()) + ``` + + Verlangt ein Dienst eine Pause, die länger ist als dein restliches Budget, brichst du ab und lässt den nächsten geplanten Lauf übernehmen. Die Pause still zu verkürzen hieße, den Dienst früher zu belasten, als er erlaubt. + + ## Lockfile gegen überlappende Läufe + + Ein stündlicher Job braucht ausnahmsweise 70 Minuten. Der nächste Start beginnt, während der erste noch schreibt, und beide verarbeiten dieselben Aufträge. Ein **Lock** verhindert das: Nur wer ihn hat, arbeitet. Die naheliegende Lösung „wenn die Datei nicht existiert, lege sie an“ hat eine Lücke. Zwischen Prüfen und Anlegen kann ein zweiter Prozess dasselbe prüfen. Beide sehen „frei“, beide legen an. + + [`os.open`](https://docs.python.org/3/library/os.html#os.open) mit `O_CREAT | O_EXCL` prüft und legt in einem Schritt an. Existiert die Datei schon, scheitert der Aufruf mit `FileExistsError`. Diese Atomarität garantiert das Betriebssystem. + + ```python run + import contextlib + import os + + class LaeuftSchon(Exception): + pass + + @contextlib.contextmanager + def sperre(pfad): + art = (os.O_CREAT | os.O_EXCL + | os.O_WRONLY) + try: + fd = os.open(pfad, art) + except FileExistsError: + raise LaeuftSchon( + pfad) from None + try: + pid = str(os.getpid()) + os.write(fd, pid.encode()) + os.close(fd) + yield + finally: + os.remove(pfad) + + with sperre("job.lock"): + try: + with sperre("job.lock"): + pass + except LaeuftSchon: + print("abgelehnt") + print(os.path.exists("job.lock")) + ``` + + Achte darauf, wo `finally` steht. Gelöscht wird nur eine Sperre, die dieser Aufruf selbst angelegt hat. Löschte auch der abgelehnte Start die Datei, gäbe er die Sperre des laufenden Jobs frei, und der dritte Start liefe parallel. + + Das Lockfile hat eine Schwäche: Stirbt der Prozess durch `kill -9` oder einen Stromausfall, läuft `finally` nie, und die Datei bleibt liegen. Jeder weitere Start wird abgelehnt, bis jemand sie löscht. Auf Linux ist [`fcntl.flock`](https://docs.python.org/3/library/fcntl.html#fcntl.flock) deshalb oft besser: Den Lock hält der Kernel an der geöffneten Datei, und er verschwindet mit dem Prozess, egal wie dieser endet. Mit `LOCK_NB` wartet `flock` nicht, sondern wirft sofort `BlockingIOError`. Geöffnet wird mit `"a"`: Der Modus `"w"` würde die Datei leeren, auch wenn ein anderer Prozess den Lock hält und dort etwa seine PID notiert hat. + + ```python + import fcntl + import sys + + datei = open(LOCK_PFAD, "a") + try: + fcntl.flock(datei, + fcntl.LOCK_EX + | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit("Läuft schon.") + ``` + + Dieses Beispiel läuft nur lokal; im Browser ist `flock` wirkungslos, deshalb üben die Aufgaben das Lockfile. `flock` ist ein beratender Lock: Er schützt nur gegen Prozesse, die ebenfalls `flock` benutzen. Für Shell-Aufrufe gibt es dasselbe als Befehl `flock -n` aus util-linux. Ein systemd-Timer startet einen Dienst, der noch läuft, nicht ein zweites Mal, und `systemctl start` auf einen laufenden Dienst bewirkt ebenfalls nichts. Startet aber jemand das Skript direkt oder über cron, schützt systemd nicht (siehe [Zeitsteuerung](/automation/zeitsteuerung)). + + ## Checkpoint und Wiederaufnahme + + Ein Job verarbeitet 500 Aufträge und stirbt bei Nummer 312. Ohne gespeicherten Fortschritt beginnt er wieder bei 1. Ein **Checkpoint** hält fest, wie weit er gekommen ist. Zwei Fragen entscheiden über seine Qualität: Wann wird er geschrieben, und wie? + + **Wann:** nach der bestätigten Wirkung. Stirbt der Prozess zwischen Wirkung und Checkpoint, macht der nächste Lauf diesen einen Auftrag noch einmal. Das nennt man *at least once*; die Wirkung muss dann idempotent sein. Schreibst du den Checkpoint vorher, geht bei einem Absturz genau dieser Auftrag verloren (*at most once*). Für Arbeit, die nicht verloren gehen darf, ist die erste Reihenfolge richtig. + + **Wie:** atomar. Eine halb geschriebene Checkpoint-Datei ist schlimmer als keine. Schreibe in eine temporäre Datei im selben Verzeichnis und ersetze das Ziel mit [`os.replace`](https://docs.python.org/3/library/os.html#os.replace). Das schützt gegen einen Absturz des Prozesses. Gegen einen Stromausfall braucht es zusätzlich `os.fsync` vor dem Austausch, wie im Modul [Robuste Automatisierung](/automation/robustheit). + + ```python run + import json + import os + from pathlib import Path + + def lies_stand(pfad): + p = Path(pfad) + if not p.exists(): + return 0 + return json.loads( + p.read_text())["erledigt"] + + def schreibe_stand(pfad, n): + tmp = f"{pfad}.tmp" + daten = {"erledigt": n} + text = json.dumps(daten) + Path(tmp).write_text(text) + os.replace(tmp, pfad) + + schreibe_stand("stand.json", 3) + print(lies_stand("stand.json")) + print(lies_stand("fehlt.json")) + ``` + + Eine kaputte Checkpoint-Datei behandelst du als Fehler. `json.loads` wirft dann `ValueError`, und das soll den Job anhalten. „Konnte nicht lesen, also fange ich von vorn an“ hieße, 312 Aufträge ein zweites Mal auszuführen. + + Den Absturz simulierst du mit einer Wirkung, die beim ersten Aufruf für einen bestimmten Auftrag eine Exception wirft. Danach startest du denselben Lauf noch einmal und prüfst zweierlei: Wo setzt er fort, und wie oft wurde jede Wirkung ausgeführt? + + ## Deduplizierung mit sqlite3 + + Ein Poller liest alle zehn Minuten die letzten 50 Ereignisse einer API. Die meisten kennt er schon. Er soll nur neue melden, auch nach einem Neustart. Eine Python-Menge vergisst alles beim Prozessende; eine Datenbank mit Eindeutigkeitsregel nicht. [`sqlite3`](https://docs.python.org/3/library/sqlite3.html) gehört zur Standardbibliothek und läuft auch im Browser. + + ```python run + import sqlite3 + + con = sqlite3.connect(":memory:") + con.execute( + "CREATE TABLE gesehen (" + " quelle TEXT NOT NULL," + " quell_id TEXT NOT NULL," + " UNIQUE (quelle, quell_id))") + NEU = ("INSERT OR IGNORE INTO " + "gesehen VALUES (?, ?)") + for qid in ["e1", "e2", "e1"]: + cur = con.execute( + NEU, ("api", qid)) + print(qid, cur.rowcount) + ``` + + `UNIQUE (quelle, quell_id)` macht die Kombination eindeutig; dieselbe ID aus einer anderen Quelle ist ein anderes Ereignis. [`INSERT OR IGNORE`](https://www.sqlite.org/lang_conflict.html) überspringt Zeilen, die die Regel verletzen würden, statt `IntegrityError` zu werfen. [`cursor.rowcount`](https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.rowcount) sagt danach, ob eingefügt wurde: 1 für neu, 0 für bekannt. So entscheidet eine einzige Anweisung „neu oder nicht“, und zwar atomar, auch wenn zwei Prozesse gleichzeitig schreiben. + + `sqlite3` öffnet bei Änderungen implizit eine Transaktion. Erst `con.commit()` macht sie dauerhaft; `with con:` committet am Ende des Blocks oder rollt bei einer Exception zurück. Wer das Commit vergisst, sieht seine Zeilen in derselben Verbindung, aber nach einem Neustart sind sie weg. Und wieder gilt die Reihenfolge: Ist die Wirkung eine E-Mail, speichere zuerst „geplant“ und markiere nach dem Versand „gesendet“, wie bei der Outbox im Modul [Benachrichtigungen](/automation/benachrichtigungen). + + > [!PRAXIS] + > Teste jeden Fehlerpfad, den dein Runbook erwähnt. Steht dort „bei Absturz neu starten“, gehört ein Test dazu, der zwischen Wirkung und Checkpoint abstürzt und danach die Zahl der Wirkungen zählt. + + ## Häufige Fehler + + - **Echte Zeit im Test.** `time.sleep` macht Tests langsam, und eine echte Uhr lässt sich nicht vorspulen. Uhr und Wartefunktion übergeben. + - **Retry auf alles.** 400 und 404 werden durch Warten nicht besser, nur langsamer. + - **Retry-After nur als Zahl.** `int("Sat, 24 Oct …")` wirft mitten im Fehlerpfad. + - **Prüfen, dann anlegen.** Zwei Prozesse sehen gleichzeitig „frei“. `O_EXCL` oder `flock` benutzen. + - **Fremde Sperre gelöscht.** `finally` räumt auch auf, wenn das Anlegen gescheitert ist. + - **Checkpoint vor der Wirkung.** Nach einem Absturz fehlt ein Auftrag, still. + - **Kaputter Checkpoint heißt Neustart.** Lieber anhalten als alles doppelt ausführen. + - **Commit vergessen.** Die Deduplizierung vergisst nach dem Neustart alles. + + > [!INTERVIEW] + > „Wie testest du ein Retry mit Backoff, ohne dass der Test eine Minute dauert?“ Ich übergebe Uhr, Wartefunktion und Zufall als Parameter. Im Test stellt die Wartefunktion eine Fake-Uhr vor, statt zu schlafen. Dann prüfe ich die Liste der Pausen und dass die Funktion vor Ablauf der Deadline aufgibt. + + > [!MERKE] + > Wirkung zuerst, dann Checkpoint: Ein Absturz wiederholt dann höchstens einen Auftrag, statt einen zu verlieren. + + ## Zusammenfassung + + - Uhr, Wartefunktion und Zufall sind Parameter; die Fake-Wartefunktion stellt die Fake-Uhr vor. + - Full Jitter wählt die Pause zufällig zwischen null und der exponentiellen Grenze; eine Deadline begrenzt die Gesamtzeit. + - Wiederholt werden 429 und 502 bis 504, nicht 400, 401, 403 oder 404. Retry-After gibt es als Zahl und als Datum. + - `O_CREAT | O_EXCL` legt ein Lockfile atomar an; `flock` verschwindet mit dem Prozess. + - Checkpoints entstehen nach der Wirkung und werden atomar mit `os.replace` geschrieben. + - `INSERT OR IGNORE` mit `UNIQUE` und `rowcount` entscheidet „neu oder bekannt“ in einer Anweisung; ohne Commit ist nichts gespeichert. +exercises: +- id: welcher-status + type: choice + title: Welche Antworten lohnen einen zweiten Versuch? + prompt: Ein Import liest Daten per GET. Bei welchen Antworten wiederholt er die Anfrage automatisch nach einer Pause? + options: + - text: "`429 Too Many Requests`" + correct: true + why: Der Dienst bittet um weniger Tempo. Nach der Pause aus Retry-After kann dieselbe Anfrage gelingen. + - text: "`503 Service Unavailable`" + correct: true + why: Der Dienst ist vorübergehend nicht verfügbar. Mit Backoff ist ein späterer Versuch sinnvoll. + - text: "`400 Bad Request`" + correct: false + why: Die Anfrage selbst ist fehlerhaft. Dieselbe Anfrage scheitert beim nächsten Mal genauso. + - text: "`404 Not Found`" + correct: false + why: Die Ressource gibt es nicht. Warten ändert daran nichts. + - text: "`401 Unauthorized`" + correct: false + why: Hier hilft kein Warten, sondern genau eine Token-Erneuerung, und die ist eine eigene Regel. + explanation: Wiederholt wird, was vorübergehend ist und bei unveränderter Anfrage später gelingen kann. Fehler in + der Anfrage, fehlende Ressourcen und Rechte werden durch Zeit nicht besser. + resources: + - *retryafter +- id: lauf-reihenfolge + type: order + title: Ein Lauf, der Abstürze übersteht + prompt: Bring die Schritte eines Laufs in die richtige Reihenfolge, der überlappende Starts verhindert und nach einem + Absturz fortsetzt. + lang: prose + lines: |- + Lock atomar anlegen oder abbrechen + Checkpoint lesen und bei Fehler anhalten + Nächsten offenen Auftrag ausführen + Bestätigung der Wirkung prüfen + Checkpoint atomar schreiben + Lock freigeben + explanation: Der Lock kommt zuerst, damit kein zweiter Lauf denselben Checkpoint liest. Der Checkpoint folgt der + bestätigten Wirkung. So wiederholt ein Absturz höchstens einen Auftrag, statt einen zu verlieren. + resources: + - *osopen +- id: jitter-vorhersagen + type: output + title: Pausen mit festem Zufall + prompt: Welche Ausgabe erzeugt dieses Programm? + lang: python + code: |- + def pause(versuch, zufall): + grenze = min(8, 2 ** versuch) + return zufall() * grenze + + halb = lambda: 0.5 + print([pause(v, halb) + for v in range(5)]) + expected: '[0.5, 1.0, 2.0, 4.0, 4.0]' + explanation: | + Die Grenzen sind 1, 2, 4, 8 und wieder 8, weil der Deckel bei 8 liegt. Mit dem festen Zufallswert 0.5 ist jede Pause die halbe Grenze. Im Betrieb liefert `random.random` Werte zwischen 0 und 1, und die Pausen verteilen sich. Der feste Wert macht das Verhalten im Test vorhersagbar. + resources: + - *aws +- id: rowcount-vorhersagen + type: output + title: Neu oder schon bekannt? + prompt: Welche Ausgabe erzeugt dieses Programm? + lang: python + code: |- + import sqlite3 + con = sqlite3.connect(":memory:") + con.execute("CREATE TABLE g" + " (id TEXT UNIQUE)") + NEU = ("INSERT OR IGNORE " + "INTO g VALUES (?)") + for i in ["a", "b", "a"]: + cur = con.execute(NEU, (i,)) + print(i, cur.rowcount) + alle = "SELECT COUNT(*) FROM g" + cur = con.execute(alle) + print(cur.fetchone()[0]) + expected: |- + a 1 + b 1 + a 0 + 2 + explanation: | + Das zweite `a` verletzt die Eindeutigkeit. `INSERT OR IGNORE` überspringt es ohne Fehler, und `rowcount` ist 0. Die Tabelle enthält deshalb zwei Zeilen. Genau diese 1 oder 0 entscheidet in einem Poller, ob ein Ereignis gemeldet wird. + resources: + - *sqlite +- id: absturz-vorhersagen + type: output + title: Was passiert nach dem Absturz? + prompt: | + Welche Ausgabe erzeugt dieses Programm? `lauf(absturz=3)` simuliert einen Absturz direkt nach der Wirkung für Auftrag 3. + lang: python + code: |- + wirkung = [] + stand = {"bis": 0} + + def lauf(absturz=None): + start = stand["bis"] + 1 + for nr in range(start, 5): + wirkung.append(nr) + if nr == absturz: + raise RuntimeError(nr) + stand["bis"] = nr + + try: + lauf(absturz=3) + except RuntimeError: + pass + lauf() + print(wirkung) + expected: '[1, 2, 3, 3, 4]' + explanation: | + Der erste Lauf führt 1, 2 und 3 aus und stirbt, bevor `stand` auf 3 steht. Der zweite Lauf beginnt deshalb bei 3, nicht bei 4. Auftrag 3 wirkt zweimal: *at least once*. Darum muss die Wirkung idempotent sein oder einen Schlüssel haben, an dem der Empfänger Duplikate erkennt. + resources: + - *osopen +- id: retry-after-lesen + type: code + title: Retry-After in beiden Formen + prompt: | + Schreibe `wartezeit(status, retry_after, jetzt, standard=5)`. + + - Bei Status 429, 502, 503 oder 504 gibt sie die Wartezeit in Sekunden als `float` zurück, bei allen anderen Status `None` („nicht wiederholen“). + - `retry_after` ist der Header als String oder `None`. Besteht er nur aus Ziffern, sind das Sekunden. Sonst versuche ihn mit `email.utils.parsedate_to_datetime` als HTTP-Datum zu lesen; die Wartezeit ist dann die Differenz zu `jetzt` (eine `datetime` mit Zeitzone), mindestens 0. + - Fehlt der Header oder ist er unlesbar, gilt `standard`. + lang: python + starter: |- + from email.utils import ( + parsedate_to_datetime) + + def wartezeit(status, retry_after, + jetzt, standard=5): + if status >= 400: + return float(retry_after) + return None + solution: |- + from email.utils import ( + parsedate_to_datetime) + + NOCHMAL = {429, 502, 503, 504} + + def bis_datum(text, jetzt): + d = parsedate_to_datetime(text) + s = (d - jetzt).total_seconds() + return max(0.0, s) + + def wartezeit(status, retry_after, + jetzt, standard=5): + if status not in NOCHMAL: + return None + if retry_after is None: + return float(standard) + text = retry_after.strip() + ok = text.isascii() + if ok and text.isdigit(): + return float(text) + try: + s = bis_datum(text, jetzt) + except ValueError: + s = float(standard) + return s + setup: |- + from datetime import datetime, UTC + JETZT = datetime(2026, 10, 24, 8, 0, 0, tzinfo=UTC) + tests: + - name: Sekunden + code: |- + w = wartezeit(429, "120", JETZT) + assert w == 120.0, w + - name: HTTP-Datum + code: |- + d = "Sat, 24 Oct 2026 08:00:30 GMT" + w = wartezeit(503, d, JETZT) + assert w == 30.0, w + - name: Datum in der Vergangenheit + code: |- + d = "Sat, 24 Oct 2026 07:59:00 GMT" + w = wartezeit(503, d, JETZT) + assert w == 0.0, w + - name: Fehlender oder unlesbarer Header + code: |- + assert wartezeit(502, None, JETZT) == 5.0 + w = wartezeit(504, "bald", JETZT, 7) + assert w == 7.0, w + - name: Nicht wiederholbare Status + code: |- + for s in (400, 401, 403, 404, 422): + w = wartezeit(s, "10", JETZT) + assert w is None, f"{s}: {w}" + hints: + - Prüfe zuerst den Status. Für alles außerhalb von 429, 502, 503 und 504 ist die Antwort `None`. + - '`"120".isdigit()` ist wahr, `"Sat, 24 Oct …".isdigit()` falsch.' + - '`parsedate_to_datetime` wirft bei Unsinn `ValueError`. Die Differenz zweier `datetime` hat `.total_seconds()`.' + explanation: | + Der Starter wiederholt bei jedem Fehler und stürzt an der Datumsform ab, genau dann, wenn der Dienst ohnehin überlastet ist. Die Lösung trennt zwei Fragen: ob wiederholt wird (Status) und wie lange gewartet wird (Header). Ein Datum in der Vergangenheit ergibt 0, nicht eine negative Pause. Weil `jetzt` ein Parameter ist, prüft der Test das Datum ohne Abhängigkeit von der echten Uhrzeit. `isascii()` schließt Ziffern wie `2` aus, die `isdigit()` sonst akzeptiert. + resources: + - *retryafter + - title: Python · email.utils.parsedate_to_datetime + url: https://docs.python.org/3/library/email.utils.html#email.utils.parsedate_to_datetime +- id: deadline-jitter + type: code + title: Wiederholen mit Deadline und Jitter + prompt: | + Schreibe `versuche(aktion, uhr, schlafe, zufall, budget)`. + + - `aktion()` liefert einen HTTP-Status. Bei 429, 502, 503 oder 504 wird wiederholt, jeder andere Status wird sofort zurückgegeben. + - Die Pause vor Wiederholung Nummer `k` (ab 0) ist `zufall() * min(8, 2 ** k)`. + - Die Deadline ist `uhr() + budget`, gemessen beim Start. Würde die nächste Pause über die Deadline hinausreichen, wirf `TimeoutError`, ohne zu schlafen. + - `schlafe(sekunden)` wartet. Benutze für Zeit nur `uhr` und `schlafe`. + lang: python + starter: |- + import time + + NOCHMAL = {429, 502, 503, 504} + + def versuche(aktion, uhr, schlafe, + zufall, budget): + ... + solution: |- + NOCHMAL = {429, 502, 503, 504} + + def versuche(aktion, uhr, schlafe, + zufall, budget): + ende = uhr() + budget + k = 0 + while True: + status = aktion() + if status not in NOCHMAL: + return status + grenze = min(8, 2 ** k) + pause = zufall() * grenze + if uhr() + pause > ende: + raise TimeoutError() + schlafe(pause) + k += 1 + setup: |- + class Uhr: + def __init__(self, t=1000.0): + self.t = t + self.pausen = [] + def __call__(self): + return self.t + def schlafe(self, s): + self.pausen.append(s) + self.t += s + + def folge(*werte): + """Liefert die Status nacheinander; danach immer den letzten.""" + rest = list(werte) + def aktion(): + return rest.pop(0) if len(rest) > 1 else rest[0] + return aktion + tests: + - name: Erfolg ohne Pause + code: |- + u = Uhr() + s = versuche(folge(200), u, + u.schlafe, lambda: 0.5, 10) + assert s == 200 and u.pausen == [] + - name: Zweimal 503, dann Erfolg + code: |- + u = Uhr() + s = versuche(folge(503, 503, 200), + u, u.schlafe, lambda: 0.5, 10) + assert s == 200, s + assert u.pausen == [0.5, 1.0], ( + u.pausen) + - name: Jitter kommt aus zufall + code: |- + u = Uhr() + versuche(folge(429, 429, 200), u, + u.schlafe, lambda: 0.25, 10) + assert u.pausen == [0.25, 0.5], ( + u.pausen) + - name: Deadline – aufgeben statt schlafen + code: |- + u = Uhr() + try: + versuche(folge(503), u, + u.schlafe, lambda: 1.0, 5) + except TimeoutError: + pass + else: + assert False, "TimeoutError erwartet" + assert u.pausen == [1.0, 2.0], ( + f"Pausen: {u.pausen}") + assert u.t <= 1005, "über die Deadline" + - name: 400 wird nicht wiederholt + code: |- + u = Uhr() + s = versuche(folge(400, 200), u, + u.schlafe, lambda: 0.5, 10) + assert s == 400 and u.pausen == [] + hints: + - Merke dir die Deadline einmal am Anfang. Danach fragst du vor jeder Pause `uhr()`. + - Prüfe die Deadline vor `schlafe`, nicht danach. + - 'Mit `lambda: 1.0` sind die Pausen 1, 2 und 4. Bei Budget 5 passt die dritte nicht mehr.' + explanation: | + Die Funktion kennt keine echte Zeit. Deshalb kann der Test einen Ausfall von Minuten in Millisekunden nachspielen und genau nachzählen, welche Pausen entstanden. Die Deadline wird vor der Pause geprüft: Ein Job, der weiß, dass er es nicht mehr schafft, gibt sofort auf und lässt dem nächsten Lauf die Arbeit. Jitter steckt nur im Faktor `zufall()`; mit `random.random` streuen die Pausen im Betrieb, im Test sind sie fest. + resources: + - *aws + - *zeit +- id: lockfile + type: code + title: Repariere das Lockfile + prompt: | + `sperre(pfad)` soll verhindern, dass zwei Läufe gleichzeitig arbeiten. Die jetzige Fassung hat zwei Fehler: Zwischen Prüfen und Anlegen kann ein zweiter Prozess dazwischenkommen, und ein abgelehnter Start löscht die Sperre des laufenden Jobs. Repariere sie: + + - Lege die Datei mit `os.open` und `os.O_CREAT | os.O_EXCL | os.O_WRONLY` an und schreibe die eigene PID hinein. + - Existiert sie schon, wirf `LaeuftSchon` und lass die vorhandene Datei unverändert. + - Nach dem `with`-Block, auch nach einer Exception darin, löschst du die Datei, aber nur, wenn dieser Aufruf sie angelegt hat. + lang: python + starter: |- + import contextlib + import os + from pathlib import Path + + class LaeuftSchon(Exception): + pass + + @contextlib.contextmanager + def sperre(pfad): + try: + if os.path.exists(pfad): + raise LaeuftSchon(pfad) + Path(pfad).write_text( + str(os.getpid())) + yield + finally: + os.remove(pfad) + solution: |- + import contextlib + import os + + class LaeuftSchon(Exception): + pass + + @contextlib.contextmanager + def sperre(pfad): + art = (os.O_CREAT | os.O_EXCL + | os.O_WRONLY) + try: + fd = os.open(pfad, art) + except FileExistsError: + raise LaeuftSchon( + pfad) from None + try: + pid = str(os.getpid()) + os.write(fd, pid.encode()) + os.close(fd) + yield + finally: + os.remove(pfad) + setup: |- + import contextlib + import os + import pathlib + + @contextlib.contextmanager + def wettlauf(): + """Simuliert einen zweiten Prozess: Prüfungen auf Existenz sehen „frei“.""" + alt_os, alt_path = os.path.exists, pathlib.Path.exists + os.path.exists = lambda *a, **k: False + pathlib.Path.exists = lambda *a, **k: False + try: + yield + finally: + os.path.exists, pathlib.Path.exists = alt_os, alt_path + tests: + - name: Sperre enthält die PID und verschwindet + code: |- + from pathlib import Path + with sperre("a.lock"): + inhalt = Path("a.lock").read_text() + assert inhalt == str(os.getpid()) + assert not Path("a.lock").exists() + - name: Zweiter Start wird abgelehnt + code: |- + from pathlib import Path + with sperre("b.lock"): + try: + with sperre("b.lock"): + pass + except LaeuftSchon: + pass + else: + assert False, "LaeuftSchon" + assert Path("b.lock").exists(), ( + "Die laufende Sperre ist weg.") + - name: Fremde Sperre bleibt unangetastet + code: |- + from pathlib import Path + Path("c.lock").write_text("12345") + try: + with sperre("c.lock"): + pass + except LaeuftSchon: + pass + text = Path("c.lock").read_text() + assert text == "12345", ( + f"Sperre verändert: {text!r}") + - name: Atomar, auch im Wettlauf + code: |- + from pathlib import Path + Path("d.lock").write_text("12345") + with wettlauf(): + try: + with sperre("d.lock"): + pass + except LaeuftSchon: + pass + text = Path("d.lock").read_text() + assert text == "12345", ( + "Ohne O_EXCL überschrieben") + - name: Exception im Block gibt frei + code: |- + from pathlib import Path + try: + with sperre("e.lock"): + raise KeyError("Absturz") + except KeyError: + pass + assert not Path("e.lock").exists() + hints: + - '`os.open(pfad, os.O_CREAT | os.O_EXCL | os.O_WRONLY)` wirft `FileExistsError`, wenn es die Datei schon gibt.' + - Das `try … finally`, das löscht, beginnt erst, nachdem das Anlegen geklappt hat. + - '`os.write` erwartet Bytes: `str(os.getpid()).encode()`.' + explanation: | + `O_EXCL` lässt das Betriebssystem Prüfen und Anlegen in einem Schritt erledigen; zwischen beiden kann kein anderer Prozess mehr dazwischenkommen. Der Test „Wettlauf“ spielt genau diese Lücke nach, indem jede Existenzprüfung „frei“ meldet. Das zweite Problem steckt in der Position von `finally`: Es darf nur aufräumen, was dieser Aufruf besitzt. Bleibt nach `kill -9` eine Datei liegen, entfernt sie ein Mensch nach Prüfung der PID, oder du nimmst gleich `flock`. + resources: + - *osopen + - title: Python · contextlib.contextmanager + url: https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager +- id: checkpoint + type: code + title: Nach dem Absturz fortsetzen + prompt: | + Schreibe `verarbeite(auftraege, anwenden, pfad)`. + + - `auftraege` ist eine Liste von IDs in fester Reihenfolge. Unter `pfad` liegt der Checkpoint als JSON `{"erledigt": n}`: die Zahl der Aufträge vom Anfang der Liste, die schon erledigt sind. Fehlt die Datei, ist `n` gleich 0. + - Rufe `anwenden(id)` für jeden noch offenen Auftrag auf und schreibe **danach** den neuen Checkpoint, atomar über eine temporäre Datei und `os.replace`. + - Wirft `anwenden`, lass die Exception durch; der Checkpoint steht dann beim letzten erledigten Auftrag. + - Ist die Datei kein gültiges JSON, wirf `ValueError`, bevor irgendein Auftrag läuft. + - Gib die Zahl der in diesem Lauf erledigten Aufträge zurück. + lang: python + starter: |- + import json + import os + from pathlib import Path + + def verarbeite(auftraege, anwenden, + pfad): + ... + solution: |- + import json + import os + from pathlib import Path + + def lies(pfad): + p = Path(pfad) + if not p.exists(): + return 0 + text = p.read_text() + daten = json.loads(text) + return daten["erledigt"] + + def schreibe(pfad, n): + tmp = f"{pfad}.tmp" + daten = {"erledigt": n} + text = json.dumps(daten) + Path(tmp).write_text(text) + os.replace(tmp, pfad) + + def verarbeite(auftraege, anwenden, + pfad): + start = lies(pfad) + offen = auftraege[start:] + for i, auftrag in enumerate( + offen, start + 1): + anwenden(auftrag) + schreibe(pfad, i) + return len(offen) + setup: |- + import json + import os + + class Absturz(Exception): + pass + + class Wirkung: + """Merkt sich jede Wirkung; stürzt beim ersten Aufruf für `bei` ab.""" + def __init__(self, bei=None): + self.bei = bei + self.liste = [] + def __call__(self, auftrag): + self.liste.append(auftrag) + if auftrag == self.bei: + self.bei = None + raise Absturz(auftrag) + + def stand(pfad): + with open(pfad, encoding="utf-8") as f: + return json.load(f)["erledigt"] + tests: + - name: Erster Lauf erledigt alles + code: |- + w = Wirkung() + n = verarbeite(list("abcd"), w, "s1") + assert n == 4 and w.liste == list("abcd") + assert stand("s1") == 4, stand("s1") + - name: Absturz bei c – Checkpoint steht auf 2 + code: |- + w = Wirkung(bei="c") + try: + verarbeite(list("abcd"), w, "s2") + except Absturz: + pass + else: + assert False, "Absturz verschluckt" + assert stand("s2") == 2, stand("s2") + - name: Zweiter Lauf setzt bei c fort + code: |- + w = Wirkung(bei="c") + try: + verarbeite(list("abcd"), w, "s3") + except Absturz: + pass + n = verarbeite(list("abcd"), w, "s3") + assert n == 2, n + assert w.liste == list("abccd"), ( + w.liste) + - name: Checkpoint über os.replace + code: |- + ersetzt, alt = [], os.replace + def zaehle(a, b): + ersetzt.append(b) + alt(a, b) + os.replace = zaehle + try: + verarbeite(list("ab"), Wirkung(), + "s4") + finally: + os.replace = alt + assert len(ersetzt) >= 2, ersetzt + - name: Kaputter Checkpoint hält an + code: |- + with open("s5", "w") as f: + f.write("{kaputt") + w = Wirkung() + try: + verarbeite(list("ab"), w, "s5") + except ValueError: + pass + else: + assert False, "ValueError erwartet" + assert w.liste == [], w.liste + hints: + - Lies den Stand vor der Schleife. `json.loads` wirft bei kaputtem Inhalt schon `ValueError` (`JSONDecodeError` ist eine Unterklasse). + - '`auftraege[start:]` sind die offenen Aufträge.' + - Schreibe in `pfad + ".tmp"` und ersetze danach mit `os.replace`. + explanation: | + Der Checkpoint folgt der Wirkung. Stirbt der Lauf bei `c`, steht er auf 2, und der nächste Lauf beginnt bei `c`: Diese eine Wirkung läuft doppelt, keine fehlt. Der Test zählt das ausdrücklich nach. `os.replace` sorgt dafür, dass ein Absturz des Prozesses während des Schreibens den alten Checkpoint stehen lässt, statt eine halbe Datei zu hinterlassen; gegen Stromausfall käme `os.fsync` dazu. Eine kaputte Datei hält den Job an; ein stiller Neustart bei 0 würde alles doppelt ausführen. + resources: + - title: Python · os.replace + url: https://docs.python.org/3/library/os.html#os.replace + - title: Python · json.JSONDecodeError + url: https://docs.python.org/3/library/json.html#json.JSONDecodeError +- id: dedup-sqlite + type: code + title: Nur neue Ereignisse melden + prompt: | + Schreibe `neue(con, quelle, ids)`. In der Datenbank `con` gibt es die Tabelle `gesehen (quelle TEXT, quell_id TEXT, UNIQUE (quelle, quell_id))`. Füge jede ID aus `ids` mit `INSERT OR IGNORE` ein und gib die Liste der IDs zurück, die wirklich neu eingefügt wurden, in der Reihenfolge von `ids`. Mache die Änderungen dauerhaft, bevor die Funktion zurückkehrt. + lang: python + starter: |- + import sqlite3 + + def neue(con, quelle, ids): + bekannt = set() + ergebnis = [] + for i in ids: + if i not in bekannt: + bekannt.add(i) + ergebnis.append(i) + return ergebnis + solution: |- + import sqlite3 + + NEU = ("INSERT OR IGNORE INTO " + "gesehen VALUES (?, ?)") + + def neue(con, quelle, ids): + ergebnis = [] + with con: + for i in ids: + cur = con.execute( + NEU, (quelle, i)) + if cur.rowcount == 1: + ergebnis.append(i) + return ergebnis + setup: |- + import sqlite3 + + def datenbank(name): + con = sqlite3.connect(name) + con.execute( + "CREATE TABLE gesehen (quelle TEXT NOT NULL, quell_id TEXT NOT NULL, UNIQUE (quelle, quell_id))") + con.commit() + return con + tests: + - name: Erster Abruf – alles neu + code: |- + con = datenbank("a.db") + r = neue(con, "api", ["e1", "e2"]) + assert r == ["e1", "e2"], r + - name: Zweiter Abruf – nur Neues + code: |- + con = datenbank("b.db") + neue(con, "api", ["e1", "e2"]) + r = neue(con, "api", ["e2", "e3"]) + assert r == ["e3"], r + - name: Duplikat im selben Abruf zählt einmal + code: |- + con = datenbank("c.db") + r = neue(con, "api", ["e1", "e1"]) + assert r == ["e1"], r + - name: Gleiche ID, andere Quelle ist neu + code: |- + con = datenbank("d.db") + neue(con, "api", ["e1"]) + r = neue(con, "rss", ["e1"]) + assert r == ["e1"], r + - name: Übersteht einen Neustart + code: |- + con = datenbank("e.db") + neue(con, "api", ["e1"]) + neu = sqlite3.connect("e.db") + r = neue(neu, "api", ["e1", "e2"]) + assert r == ["e2"], ( + f"{r} – Commit vergessen?") + hints: + - '`con.execute(...)` liefert einen Cursor; `cursor.rowcount` ist 1, wenn eingefügt wurde, und 0, wenn die Zeile ignoriert wurde.' + - '`with con:` committet am Ende des Blocks.' + explanation: | + Der Starter dedupliziert nur im Speicher und nur innerhalb eines Aufrufs; nach einem Neustart ist alles wieder „neu“. Die Datenbank entscheidet dagegen mit der Eindeutigkeitsregel, und `rowcount` meldet das Ergebnis jeder einzelnen Anweisung. Weil die Prüfung in der Datenbank passiert, ist sie auch dann richtig, wenn zwei Prozesse gleichzeitig einfügen. Ohne Commit sieht eine zweite Verbindung die Zeilen nicht, genau wie ein Neustart. + resources: + - *sqlite + - title: SQLite · ON CONFLICT + url: https://www.sqlite.org/lang_conflict.html +- id: checkpoint-zu-frueh + type: bug + title: Nach dem Absturz fehlt ein Auftrag + prompt: | + Nach einem Absturz in `anwenden` setzt der Job beim nächsten Start fort, aber genau der Auftrag, bei dem er abgestürzt ist, wird nie ausgeführt. Finde die fehlerhaften Zeilen und korrigiere sie. + lang: python + code: |- + def lauf(jobs, stand, anwenden): + start = stand.lesen() + for nr in range(start, + len(jobs)): + stand.schreiben(nr + 1) + anwenden(jobs[nr]) + lines: + - 5 + - 6 + fix: + - line: 5 + answers: + - anwenden(jobs[nr]) + - line: 6 + answers: + - stand.schreiben(nr + 1) + explanation: | + Der Checkpoint steht vor der Wirkung. Stirbt der Prozess in `anwenden`, gilt der Auftrag schon als erledigt, und der nächste Lauf überspringt ihn: *at most once*. Mit der Wirkung zuerst wiederholt ein Absturz höchstens diesen einen Auftrag. Das ist für Arbeit, die nicht verloren gehen darf, die richtige Reihenfolge; die Wirkung muss dafür idempotent sein. + resources: + - *osopen +- id: flock-erklaeren + type: explain + title: Warum verschwindet dieser Lock von selbst? + prompt: Erkläre, was diese Zeilen tun und warum nach einem `kill -9` kein verwaister Lock zurückbleibt. Nenne auch + eine Grenze. + lang: python + code: |- + datei = open(LOCK_PFAD, "a") + try: + fcntl.flock(datei, + fcntl.LOCK_EX + | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit("Läuft schon.") + points: + - LOCK_EX fordert einen exklusiven Lock auf der geöffneten Datei an. + - Mit LOCK_NB wartet der Aufruf nicht, sondern wirft sofort BlockingIOError, wenn ein anderer Prozess den Lock hält. + - Der Kernel hält den Lock an der offenen Datei und gibt ihn frei, wenn der Prozess endet, auch durch kill -9. + - Ob die Datei existiert, spielt keine Rolle; sie muss nicht gelöscht werden. + - flock ist beratend und schützt nur gegen Prozesse, die ebenfalls flock auf dieselbe Datei benutzen. + explanation: | + Anders als beim Lockfile mit `O_EXCL` ist hier nicht die Existenz der Datei der Lock, sondern ein Zustand im Kernel, der an der geöffneten Datei hängt. Endet der Prozess, schließt der Kernel alle seine Dateien und gibt damit auch den Lock frei. Deshalb gibt es keinen verwaisten Lock nach einem Absturz. Die Grenze: `flock` ist beratend. Ein Programm, das die Datei ohne `flock` öffnet und schreibt, wird nicht aufgehalten, und über Netzwerkdateisysteme hinweg verhält sich `flock` je nach System anders. + resources: + - title: Python · fcntl.flock + url: https://docs.python.org/3/library/fcntl.html#fcntl.flock + - title: man 2 flock + url: https://man7.org/linux/man-pages/man2/flock.2.html +- id: fehlerplan + type: practice + title: Ein Fehlerplan für deinen Job + prompt: | + Nimm einen Automationsjob, den du gebaut hast oder bauen willst, zum Beispiel den Wochenbericht aus den Projekten. Schreibe für vier Fehlerfälle je zwei Sätze: Was passiert im Betrieb, welcher Fake oder Parameter löst den Fall im Test aus, und was erwartet der Test? Ein Fall betrifft die Zeit, einer einen Absturz zwischen Wirkung und Checkpoint. + lang: prose + checklist: + - Jeder Fall nennt den Parameter oder Fake, der ihn auslöst (Uhr, Wartefunktion, Zufall, Fake-API, Absturzpunkt). + - Ein Fall prüft eine Deadline oder ein Retry-After, ohne echt zu warten. + - Ein Fall stürzt zwischen Wirkung und Checkpoint ab und zählt danach die Wirkungen. + - Jede Erwartung ist beobachtbar, zum Beispiel eine Liste von Pausen, ein Exception-Typ oder eine Zahl. + - Mindestens ein Fall endet bewusst mit einem Abbruch statt mit einem weiteren Versuch. + explanation: Ein Fehlerplan macht aus „sollte robust sein“ eine Liste von Tests. Die Checkliste prüft nur, ob jeder + Fall auslösbar und beobachtbar ist; welche Fälle für deinen Job zählen, entscheidest du. + resources: + - *zeit diff --git a/content/automation/modules/logs-geheimnisse.yaml b/content/automation/modules/logs-geheimnisse.yaml new file mode 100644 index 0000000..4fc36fe --- /dev/null +++ b/content/automation/modules/logs-geheimnisse.yaml @@ -0,0 +1,919 @@ +id: logs-geheimnisse +title: Logs und Geheimnisse im Betrieb +level: 3 +minutes: 210 +summary: JSON-Logs mit Lauf-ID, Rotation und einem Filter, der Geheimnisse schwärzt – und Secrets aus keyring, .env, + systemd-Credentials und GitHub Actions, samt Plan für ein geleaktes Token. +goals: +- Du schreibst einen JSON-Formatter und gibst jedem Eintrag eine Lauf-ID, per contextvars-Filter am Handler oder per + LoggerAdapter. +- Du entscheidest zwischen Journal und Dateirotation und konfigurierst RotatingFileHandler so, dass er wirklich rotiert. +- Du schreibst einen Filter, der Tokens in Logmeldungen schwärzt, und kennst seine Grenzen. +- Du wählst die passende Quelle für Secrets – keyring, .env, systemd LoadCredential oder GitHub-Actions-Secrets. +- Du reagierst auf ein geleaktes Token in der richtigen Reihenfolge. +resources: +- &logging + title: Python · logging + url: https://docs.python.org/3/library/logging.html +- &handlers + title: Python · logging.handlers + url: https://docs.python.org/3/library/logging.handlers.html +- &contextvars + title: Python · contextvars + url: https://docs.python.org/3/library/contextvars.html +- &systemd + title: systemd.exec · Credentials + url: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Credentials +- &actions + title: GitHub Docs · Using secrets in GitHub Actions + url: https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions +place: unterwegs +lesson: | + Um drei Uhr ist der Bericht fehlgeschlagen. Das Logfile hat 4000 Zeilen aus drei Läufen, die sich überlappt haben, und keine Zeile sagt, zu welchem Lauf sie gehört. In Zeile 812 steht außerdem `Authorization: Bearer eyJ…`, ein vollständiges Token. Ab jetzt ist das Logfile selbst ein Geheimnis: Wer es in ein Ticket kopiert, gibt den Zugang weiter. + + Beides gehört zusammen. Ein Job soll über sich so viel verraten, dass du einen Fehler findest, und nichts, womit jemand anders etwas anfangen kann. Die Grundlagen von `logging` (Logger, Handler, Formatter, Stufen) stehen im Modul [Kommandozeilen-Werkzeuge](/automation/cli-werkzeuge). Dieses Modul baut darauf auf. + + ## JSON-Logs: eine Zeile, ein Ereignis + + Eine Zeile wie `WARNING bericht: 3 Zeilen verworfen` liest ein Mensch gut. Eine Maschine muss sie mit regulären Ausdrücken zerlegen. Schreibt jeder Eintrag ein JSON-Objekt in eine Zeile, kannst du mit `jq`, `journalctl -o json` oder einem Logsystem nach Feldern filtern: alle Fehler eines Laufs, alle Einträge eines Moduls. Ein eigener [Formatter](https://docs.python.org/3/library/logging.html#formatter-objects) überschreibt dafür `format`. + + ```python run + import json + import logging + from sys import stdout + + class JsonLog(logging.Formatter): + def format(self, r): + d = {"stufe": r.levelname, + "logger": r.name} + d["text"] = r.getMessage() + if r.exc_info: + d["fehler"] = repr( + r.exc_info[1]) + return json.dumps( + d, ensure_ascii=False) + + h = logging.StreamHandler(stdout) + h.setFormatter(JsonLog()) + log = logging.getLogger("bericht") + log.handlers = [h] + log.propagate = False + log.warning("%d verworfen", 3) + try: + 1 / 0 + except ZeroDivisionError: + log.exception("Summe kaputt") + ``` + + `r.getMessage()` setzt die Argumente ein, genau wie bei der normalen Ausgabe. `json.dumps` maskiert Zeilenumbrüche in Meldungen als `\n`; ein Eintrag bleibt dadurch immer eine Zeile, auch wenn eine Exception-Meldung mehrzeilig ist. Das Beispiel schreibt nur Typ und Meldung der Exception. Den vollständigen Traceback liefert `self.formatException(r.exc_info)`, er kann aber Werte aus deinem Code enthalten. `handlers = [h]` ersetzt hier die Handler früherer Läufe im Browser; in einem Programm konfigurierst du einmal in `main()`. + + Ein Zeitstempel fehlt im Beispiel absichtlich. Läuft der Job unter systemd, versieht das Journal jede Zeile mit Zeit, Unit und Prozess. Schreibst du in eine eigene Datei, ergänze `r.created`, die Unix-Zeit des Eintrags. + + ## Lauf-ID: welcher Eintrag zu welchem Lauf gehört + + Überlappen sich zwei Läufe, mischen sich ihre Zeilen. Eine **Lauf-ID** in jedem Eintrag trennt sie wieder. Dafür gibt es zwei Wege. + + Der [`LoggerAdapter`](https://docs.python.org/3/library/logging.html#loggeradapter-objects) hängt jedem Eintrag feste Zusatzfelder an. Du musst ihn aber an jede Funktion weitergeben, die loggt. Und ohne den Parameter `merge_extra=True` (seit Python 3.13) ignoriert er das `extra` einzelner Aufrufe. + + ```python run + import logging + from sys import stdout + + h = logging.StreamHandler(stdout) + h.setFormatter(logging.Formatter( + "%(lauf)s %(message)s")) + log = logging.getLogger("job") + log.handlers = [h] + log.propagate = False + a = logging.LoggerAdapter( + log, {"lauf": "a1b2"}) + a.warning("Import gestartet") + ``` + + Der zweite Weg braucht keine Weitergabe. Eine [`ContextVar`](https://docs.python.org/3/library/contextvars.html) hält die ID des aktuellen Laufs, und ein Filter schreibt sie in jeden Eintrag. `asyncio` gibt jeder Task eine Kopie des Kontexts; zwei gleichzeitige Läufe im selben Prozess behalten so ihre eigene ID. + + ```python run + import contextvars + import logging + from sys import stdout + + lauf_id = contextvars.ContextVar( + "lauf_id", default="-") + + class LaufFilter(logging.Filter): + def filter(self, r): + r.lauf = lauf_id.get() + return True + + FMT = ("%(lauf)s %(name)s " + "%(message)s") + h = logging.StreamHandler(stdout) + h.addFilter(LaufFilter()) + h.setFormatter( + logging.Formatter(FMT)) + log = logging.getLogger("job") + log.handlers = [h] + log.propagate = False + lauf_id.set("c3d4") + log.info("unsichtbar") + log.warning("Seite 2 fehlt") + logging.getLogger("job.http" + ).warning("Timeout") + ``` + + Der Filter hängt am **Handler**, nicht am Logger. Die Dokumentation sagt ausdrücklich, dass [Filter an einem Logger](https://docs.python.org/3/library/logging.html#filter-objects) nur Einträge sehen, die direkt an diesem Logger entstehen, nicht die von Kind-Loggern wie `job.http`. Ein Handler sieht alles, was bei ihm ankommt. + + Woher kommt die ID? Unter systemd steht in der Umgebungsvariablen [`INVOCATION_ID`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#%24INVOCATION_ID) eine eindeutige ID für jeden Start der Unit. Das Journal speichert sie als Feld `_SYSTEMD_INVOCATION_ID`; nimmst du sie als Lauf-ID, findest du mit `journalctl _SYSTEMD_INVOCATION_ID=…` alle Zeilen, die dein Programm in diesem Lauf geschrieben hat. Die Meldungen von systemd selbst zu Start und Ende tragen das Feld `INVOCATION_ID`; seit systemd 257 fasst `journalctl -u --invocation=…` beides zusammen. Ohne systemd genügt `uuid.uuid4().hex[:12]`. + + ## Journal oder Dateirotation + + Läuft dein Job als systemd-Dienst, schreibe nach stderr. Das Journal sammelt die Zeilen, versieht sie mit Metadaten und löscht alte Einträge nach den Grenzen in `journald.conf`. Eine eigene Logdatei brauchst du dann nicht. + + Ohne systemd, etwa bei einem Werkzeug, das du von Hand startest, wächst eine Logdatei sonst unbegrenzt. [`RotatingFileHandler`](https://docs.python.org/3/library/logging.handlers.html#rotatingfilehandler) beginnt eine neue Datei, sobald die aktuelle `maxBytes` erreichen würde, und behält `backupCount` alte. Die Dokumentation warnt: Ist einer der beiden Werte 0, rotiert er nie. + + ```python run + import logging + from logging.handlers import ( + RotatingFileHandler) + from pathlib import Path + + h = RotatingFileHandler( + "job.log", maxBytes=50, + backupCount=2) + log = logging.getLogger("rot") + log.handlers = [h] + log.propagate = False + for i in range(20): + log.warning("Zeile %02d", i) + h.close() + liste = Path(".").glob("job.log*") + print(sorted(map(str, liste))) + ``` + + Aus 20 Zeilen bleiben drei Dateien; die ältesten Einträge sind gelöscht. [`TimedRotatingFileHandler`](https://docs.python.org/3/library/logging.handlers.html#timedrotatingfilehandler) mit `when="midnight"` rotiert stattdessen täglich. Beide Handler sind für einen Prozess gebaut. Schreiben mehrere Prozesse in dieselbe Datei, empfiehlt das [Logging Cookbook](https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes) einen eigenen Sammelprozess oder einen `QueueHandler`. + + ## Ein Filter, der Geheimnisse schwärzt + + Die beste Regel ist, Geheimnisse gar nicht erst zu loggen. Ein Filter ist das Sicherheitsnetz für den Fall, dass es doch passiert: eine Debug-Ausgabe der Header, eine Exception mit URL. Er ersetzt bekannte Werte und typische Muster, bevor der Eintrag geschrieben wird. + + ```python run + import logging + import re + from sys import stdout + + MUSTER = re.compile( + r"(Bearer\s+|token=)[^\s&]+") + + class Schwaerzen(logging.Filter): + def filter(self, r): + text = r.getMessage() + r.msg = MUSTER.sub( + r"\1***", text) + r.args = None + return True + + h = logging.StreamHandler(stdout) + h.addFilter(Schwaerzen()) + log = logging.getLogger("http") + log.handlers = [h] + log.propagate = False + log.warning("GET /x?token=abc123") + log.warning("Header: Bearer %s", + "eyJhbGciOi") + ``` + + `r.getMessage()` liefert den fertigen Text, auch wenn das Geheimnis als Argument kam. Danach steht der geschwärzte Text in `r.msg`, und `r.args = None` verhindert, dass die Argumente ein zweites Mal eingesetzt werden. Seit Python 3.12 darf ein Filter auch einen neuen `LogRecord` zurückgeben, statt den alten zu verändern; dann sehen andere Handler das Original. + + Die Grenzen solltest du kennen. Der Filter sieht die Meldung, aber nicht den Traceback, den erst der Formatter erzeugt. Er findet, was zu seinen Mustern passt, aber kein Token in Base64 oder in einem `extra`-Feld. Am zuverlässigsten schwärzt er die Werte, die dein Programm tatsächlich kennt: Lies die Geheimnisse beim Start und gib sie dem Filter mit. + + ## Woher Geheimnisse kommen + + Ein Token im Quellcode landet im Repository und von dort in jedem Klon. Je nach Umgebung gibt es bessere Orte. + + | Quelle | Passt für | Achtung | + | --- | --- | --- | + | keyring | Skripte am eigenen Desktop | braucht einen entsperrten Schlüsselbund | + | `.env` | lokale Entwicklung | in `.gitignore`, nur für dich lesbar | + | `LoadCredential` | systemd-Dienste | Datei nur für den Dienst lesbar | + | `EnvironmentFile` | Einstellungen | nicht für Geheimnisse | + | Actions-Secrets | CI-Läufe | nicht in Forks, nicht in Befehlszeilen | + + [keyring](https://pypi.org/project/keyring/) spricht den Schlüsselbund des Desktops an, unter Linux meist über den Secret Service. `keyring.get_password(dienst, name)` liefert `None`, wenn nichts gespeichert ist. Auf einem Server ohne Desktop-Sitzung gibt es oft keinen nutzbaren Schlüsselbund. + + ```python + import keyring + + token = keyring.get_password( + "bericht", "api") + if token is None: + raise SystemExit( + "Kein Token: keyring " + "set bericht api") + ``` + + [python-dotenv](https://pypi.org/project/python-dotenv/) liest eine Datei `.env` in die Umgebung. `load_dotenv()` überschreibt dabei keine Variablen, die schon gesetzt sind. Die Datei gehört in `.gitignore`; ins Repository kommt eine `.env.example` mit leeren Werten. + + Für Dienste ist systemd der richtige Ort. Die Dokumentation zu [`Environment=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Environment=) sagt ausdrücklich, dass Umgebungsvariablen für Geheimnisse ungeeignet sind: Sie sind über D-Bus für unprivilegierte Clients sichtbar und werden an Kindprozesse vererbt. Stattdessen gibt es [`LoadCredential=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#LoadCredential=ID:PATH). + + ```ini + [Service] + ExecStart=/opt/bericht/run.py + EnvironmentFile=/etc/bericht.env + LoadCredential=api_token + ``` + + Ohne Pfad sucht systemd die Datei `api_token` in `/etc/credstore/`, bei User-Diensten in `~/.config/credstore/`. Der Dienst findet eine schreibgeschützte Kopie unter `$CREDENTIALS_DIRECTORY/api_token`, die nur sein Benutzer lesen kann, nach Möglichkeit in nicht auslagerbarem Speicher. Mit `systemd-creds encrypt` verschlüsselst du die Datei, und `LoadCredentialEncrypted=` entschlüsselt sie erst beim Start. `EnvironmentFile=` bleibt für Einstellungen ohne Geheimnis, etwa die URL. + + In GitHub Actions legst du das Token als Repository- oder Environment-Secret an und reichst es als Umgebungsvariable an genau den Schritt weiter, der es braucht. GitHub schwärzt den Wert in der Ausgabe, aber [nicht davon abgeleitete Werte](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions); mit `::add-mask::` maskierst du sie selbst. An Läufe aus Forks werden Secrets nicht weitergegeben. + + ```yaml + - run: python bericht.py + env: + TOKEN: ${{ secrets.TOKEN }} + ``` + + ## Wenn ein Token geleakt ist + + Ein Token ist in einen öffentlichen Commit geraten, in ein Ticket, in ein geteiltes Logfile. Die Reihenfolge der nächsten Schritte entscheidet, wie viel Schaden entsteht. + + 1. **Widerrufen.** Sofort beim Anbieter, vor allem anderen. Ab dem Moment des Leaks musst du davon ausgehen, dass jemand anders es hat. + 2. **Ersetzen.** Neues Token erzeugen und an allen Stellen eintragen, die das alte nutzten: credstore, Actions-Secret, Schlüsselbund. + 3. **Prüfen.** Im Audit-Log des Anbieters nachsehen, ob das alte Token zwischen Leak und Widerruf benutzt wurde. + 4. **Aufräumen.** Das Token aus Code, Konfiguration und Logs entfernen. Die Git-Historie umzuschreiben ist ein optionaler letzter Schritt, nie ein Ersatz für Schritt 1: Klone, Forks und Caches behalten den alten Stand. + 5. **Ursache abstellen.** `.gitignore`, [Secret Scanning und Push Protection](https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning), ein Filter im Logging. + + Der häufigste Fehler ist die umgekehrte Reihenfolge: zuerst den Commit löschen, in der Hoffnung, dass es niemand gesehen hat. Das kostet Zeit, in der das Token gültig bleibt. + + > [!PRAXIS] + > Schreibe ins Runbook eines Jobs, wo sein Token liegt, wie man es widerruft und wo das neue eingetragen wird. Wer um drei Uhr ein Leak bemerkt, soll nicht erst suchen müssen. + + ## Häufige Fehler + + - **Filter am falschen Logger.** Er sieht die Einträge von Kind-Loggern nicht. An den Handler hängen. + - **`RotatingFileHandler` ohne `backupCount`.** Mit 0 rotiert er nie, die Datei wächst weiter. + - **Mehrere Prozesse, eine rotierende Datei.** Einträge gehen verloren oder landen in der falschen Datei. + - **Token in `Environment=`.** Für andere Benutzer über `systemctl show` lesbar. + - **`.env` im Repository.** Einmal gepusht, ist es ein Leak. + - **Erst aufräumen, dann widerrufen.** Das Token bleibt gültig, während du die Historie umschreibst. + + > [!INTERVIEW] + > „Wie gibst du einem systemd-Dienst ein API-Token?“ Nicht über `Environment=`, weil Umgebungsvariablen über D-Bus sichtbar sind und vererbt werden. Ich lege die Datei in `/etc/credstore/`, am besten mit `systemd-creds` verschlüsselt, und gebe sie mit `LoadCredential=` an den Dienst. Das Programm liest sie aus `$CREDENTIALS_DIRECTORY`. + + > [!MERKE] + > Bei einem Leak zuerst widerrufen. Alles andere kommt danach. + + ## Zusammenfassung + + - JSON-Logs schreiben ein Ereignis pro Zeile; `getMessage()` setzt die Argumente ein, `json.dumps` hält die Zeile zusammen. + - Eine Lauf-ID kommt per `ContextVar` und Filter am Handler in jeden Eintrag, auch von Kind-Loggern. Unter systemd passt `INVOCATION_ID`. + - Unter systemd schreibst du nach stderr; sonst rotiert `RotatingFileHandler`, wenn `maxBytes` und `backupCount` größer als 0 sind. + - Ein Schwärzungsfilter ist ein Netz, keine Erlaubnis zum Loggen von Geheimnissen. + - keyring für den Desktop, `.env` für die Entwicklung, `LoadCredential` für Dienste, Secrets für Actions. + - Leak: widerrufen, ersetzen, prüfen, aufräumen, Ursache abstellen. +exercises: +- id: quelle-waehlen + type: choice + title: Wohin mit dem Token? + prompt: Ein Bericht läuft als systemd-Dienst auf einem Server ohne Desktop und braucht ein API-Token. Was ist die + passende Lösung? + options: + - text: Die Datei in `/etc/credstore/api_token` ablegen und mit `LoadCredential=api_token` an den Dienst geben. + correct: true + why: Der Dienst liest das Token aus `$CREDENTIALS_DIRECTORY`; nur sein Benutzer kommt an die Kopie. + - text: "`Environment=API_TOKEN=…` in die Unit schreiben." + correct: false + why: Laut systemd-Dokumentation sind Umgebungsvariablen einer Unit über D-Bus für unprivilegierte Clients sichtbar + und werden an Kindprozesse vererbt. + - text: Das Token mit keyring im Schlüsselbund speichern. + correct: false + why: Ohne Desktop-Sitzung gibt es meist keinen entsperrten Schlüsselbund, den ein Dienst nutzen könnte. + - text: Das Token in die `config.toml` im Repository schreiben, das Repository ist privat. + correct: false + why: Private Repositorys werden geklont, gesichert und manchmal öffentlich. Das Token wäre in jeder Kopie. + explanation: Für Dienste sind systemd-Credentials gebaut. Sie trennen das Geheimnis von der Unit-Datei und von der + Umgebung und lassen sich mit `systemd-creds` verschlüsselt ablegen. + resources: + - *systemd +- id: leak-reihenfolge + type: order + title: Ein Token ist öffentlich geworden + prompt: Ein Token steht in einem öffentlichen Commit. Bring die Schritte in eine sinnvolle Reihenfolge. + lang: prose + lines: |- + Token beim Anbieter widerrufen + Neues Token erzeugen und überall eintragen + Im Audit-Log prüfen, ob das alte Token benutzt wurde + Token aus Code, Konfiguration und Logs entfernen + Ursache abstellen, etwa mit Push Protection + alternatives: + - [1, 3, 2, 4, 5] + - [1, 2, 3, 5, 4] + - [1, 3, 2, 5, 4] + explanation: Entscheidend ist der erste Schritt. Solange das Token gültig ist, kann jeder es benutzen, der den Commit + gesehen hat. Ersetzen und Prüfen dürfen ihre Plätze tauschen, ebenso Aufräumen und Ursache abstellen; Widerrufen + kommt immer zuerst. + resources: + - title: GitHub Docs · About secret scanning + url: https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning +- id: adapter-vorhersagen + type: output + title: Welche Lauf-ID gewinnt? + prompt: Welche Ausgabe erzeugt dieses Programm (Python 3.14)? + lang: python + code: |- + import logging + from sys import stdout + + h = logging.StreamHandler(stdout) + h.setFormatter(logging.Formatter( + "%(lauf)s %(message)s")) + log = logging.getLogger("ad") + log.handlers = [h] + log.propagate = False + a = logging.LoggerAdapter( + log, {"lauf": "A"}) + a.warning("start") + a.warning("x", extra={"lauf": "B"}) + b = logging.LoggerAdapter( + log, {"lauf": "A"}, + merge_extra=True) + b.warning("y", extra={"lauf": "B"}) + expected: |- + A start + A x + B y + explanation: | + Ohne `merge_extra` ersetzt der Adapter das `extra` jedes Aufrufs durch sein eigenes; das `B` beim zweiten Aufruf geht verloren. Mit `merge_extra=True`, verfügbar seit Python 3.13, werden beide zusammengeführt, und der Wert des einzelnen Aufrufs gewinnt. + resources: + - *logging +- id: filter-am-logger + type: output + title: Wer sieht den Filter? + prompt: Welche Ausgabe erzeugt dieses Programm? + lang: python + code: |- + import logging + from sys import stdout + + class Markiere(logging.Filter): + def filter(self, r): + r.msg = "[M] " + r.msg + return True + + job = logging.getLogger("job") + h = logging.StreamHandler(stdout) + job.handlers = [h] + job.propagate = False + job.addFilter(Markiere()) + job.warning("a") + logging.getLogger( + "job.http").warning("b") + expected: |- + [M] a + b + explanation: | + Ein Filter am Logger `job` sieht nur Einträge, die direkt an `job` entstehen. Der Eintrag von `job.http` wandert zwar zum Handler von `job`, aber am Filter vorbei. Hängt der Filter am Handler (`h.addFilter`), erwischt er beide. Für Lauf-IDs und Schwärzen ist das der entscheidende Unterschied. + resources: + - title: Python · Filter Objects + url: https://docs.python.org/3/library/logging.html#filter-objects +- id: handler-gap + type: gap + title: Rotierende JSON-Logs einrichten + prompt: Ergänze die Einrichtung so, dass die Datei bei 1 MB rotiert, fünf alte Dateien bleiben und jeder Eintrag + als JSON mit Lauf-ID geschrieben wird. + lang: python + code: |- + h = RotatingFileHandler( + "job.log", + ⟦maxBytes⟧=1_000_000, + ⟦backupCount⟧=5) + h.⟦setFormatter⟧(JsonLog()) + h.⟦addFilter⟧(LaufFilter()) + log.handlers = [h] + lauf_id.⟦set⟧(os.environ.get( + "INVOCATION_ID", "lokal")) + gaps: + - label: Größengrenze + hint: Der Parameter heißt nach der Größe in Bytes. + - label: Zahl alter Dateien + hint: Ist er 0, rotiert der Handler nie. + - label: Formatter setzen + hint: Handler bekommen ihren Formatter mit einer set-Methode. + - label: Filter anhängen + hint: Dieselbe Methode gibt es an Loggern und Handlern. + - label: ContextVar setzen + hint: Eine ContextVar hat get und … + explanation: '`maxBytes` und `backupCount` müssen beide größer als 0 sein, sonst rotiert nichts. Formatter und Filter + hängen am Handler; so tragen auch Einträge von Kind-Loggern die Lauf-ID. Unter systemd liefert `INVOCATION_ID` + eine ID, die auch im Journal steht.' + resources: + - *handlers + - *contextvars +- id: json-formatter + type: code + title: Ein JSON-Formatter + prompt: | + Schreibe die Klasse `JsonLog(logging.Formatter)`. `format(r)` gibt pro Eintrag eine Zeile JSON zurück mit den Schlüsseln `stufe` (Name der Stufe), `logger` (Name des Loggers), `text` (die fertige Meldung mit eingesetzten Argumenten) und `lauf` (das Attribut `lauf` des Eintrags oder `None`, falls es fehlt). Hat der Eintrag eine Exception, kommt `fehler` mit dem Namen ihrer Klasse dazu. Umlaute bleiben lesbar, nicht als `\u00f6`. + lang: python + starter: |- + import json + import logging + + class JsonLog(logging.Formatter): + def format(self, r): + return r.msg + solution: |- + import json + import logging + + class JsonLog(logging.Formatter): + def format(self, r): + d = {"stufe": r.levelname, + "logger": r.name} + d["text"] = r.getMessage() + d["lauf"] = getattr( + r, "lauf", None) + e = r.exc_info + if e: + k = e[0].__name__ + d["fehler"] = k + return json.dumps( + d, ensure_ascii=False) + setup: |- + import io + import itertools + import json + import logging + + _nr = itertools.count() + + def logger_mit(formatter): + """Neuer Logger, der in einen StringIO-Puffer schreibt.""" + puffer = io.StringIO() + h = logging.StreamHandler(puffer) + h.setFormatter(formatter) + log = logging.getLogger(f"test{next(_nr)}") + log.handlers = [h] + log.propagate = False + log.setLevel(logging.DEBUG) + return log, puffer + tests: + - name: Felder und eingesetzte Argumente + code: |- + log, out = logger_mit(JsonLog()) + log.warning("%d Zeilen", 3) + d = json.loads(out.getvalue()) + assert d["stufe"] == "WARNING", d + assert d["text"] == "3 Zeilen", d + assert d["logger"] == log.name, d + assert d["lauf"] is None, d + - name: Lauf-ID aus extra + code: |- + log, out = logger_mit(JsonLog()) + log.info("ok", extra={"lauf": "a1"}) + d = json.loads(out.getvalue()) + assert d["lauf"] == "a1", d + - name: Exception als Klassenname + code: |- + log, out = logger_mit(JsonLog()) + try: + 1 / 0 + except ZeroDivisionError: + log.exception("kaputt") + d = json.loads(out.getvalue()) + assert d.get("fehler") == ( + "ZeroDivisionError"), d + - name: Eine Zeile, Umlaute lesbar + code: |- + log, out = logger_mit(JsonLog()) + log.error("Größe\nfalsch") + text = out.getvalue() + assert text.count("\n") == 1, ( + f"mehrere Zeilen: {text!r}") + assert "Größe" in text, text + hints: + - '`r.getMessage()` liefert die Meldung mit eingesetzten Argumenten.' + - '`getattr(r, "lauf", None)` liest ein Attribut, das vielleicht fehlt.' + - '`r.exc_info` ist bei einer Exception ein Tupel `(Klasse, Exception, Traceback)`.' + explanation: | + Der Formatter macht aus einem `LogRecord` genau eine Zeile. `getMessage()` statt `msg` setzt die Argumente ein; sonst stünde dort `%d Zeilen`. `json.dumps` maskiert den Zeilenumbruch, also bleibt auch eine mehrzeilige Meldung eine Zeile, und Werkzeuge wie `jq` lesen Zeile für Zeile. `ensure_ascii=False` hält Umlaute lesbar. Der Klassenname der Exception reicht zum Filtern; den vollen Traceback gibst du nur aus, wenn du weißt, dass keine Geheimnisse darin stehen. + resources: + - title: Python · Formatter Objects + url: https://docs.python.org/3/library/logging.html#formatter-objects + - title: Python · LogRecord attributes + url: https://docs.python.org/3/library/logging.html#logrecord-attributes +- id: lauf-id-filter + type: code + title: Lauf-ID für alle Logger + prompt: | + Schreibe drei Teile: + + - `lauf_id`: eine `ContextVar` mit dem Standardwert `"-"`. + - `LaufFilter(logging.Filter)`: setzt in jedem Eintrag das Attribut `lauf` auf den aktuellen Wert von `lauf_id` und lässt den Eintrag durch. + - `einrichten(logger, stream)`: hängt an `logger` genau einen `StreamHandler(stream)` mit dem Format `"%(lauf)s %(name)s: %(message)s"` und dem Filter. Auch Einträge von Kind-Loggern wie `.http` sollen die Lauf-ID bekommen. + lang: python + starter: |- + import contextvars + import logging + + lauf_id = contextvars.ContextVar( + "lauf_id", default="-") + + class LaufFilter(logging.Filter): + def filter(self, r): + return True + + def einrichten(logger, stream): + ... + solution: |- + import contextvars + import logging + + lauf_id = contextvars.ContextVar( + "lauf_id", default="-") + + class LaufFilter(logging.Filter): + def filter(self, r): + r.lauf = lauf_id.get() + return True + + FORMAT = ("%(lauf)s %(name)s: " + "%(message)s") + + def einrichten(logger, stream): + h = logging.StreamHandler( + stream) + h.setFormatter( + logging.Formatter(FORMAT)) + h.addFilter(LaufFilter()) + logger.handlers = [h] + setup: |- + import contextvars + import io + import itertools + import logging + + _nr = itertools.count() + + def neuer_logger(): + log = logging.getLogger(f"job{next(_nr)}") + log.propagate = False + log.setLevel(logging.INFO) + return log + tests: + - name: Lauf-ID im Eintrag + code: |- + log, out = neuer_logger(), io.StringIO() + einrichten(log, out) + def lauf(): + lauf_id.set("L7") + log.warning("start") + contextvars.Context().run(lauf) + zeile = out.getvalue().strip() + assert zeile == f"L7 {log.name}: start", ( + zeile) + - name: Auch der Kind-Logger + code: |- + log, out = neuer_logger(), io.StringIO() + einrichten(log, out) + kind = logging.getLogger(log.name + ".http") + def lauf(): + lauf_id.set("L8") + kind.warning("Timeout") + contextvars.Context().run(lauf) + zeile = out.getvalue().strip() + assert zeile.startswith("L8 "), ( + f"Zeile: {zeile!r}") + - name: Ohne gesetzte ID steht - + code: |- + log, out = neuer_logger(), io.StringIO() + einrichten(log, out) + contextvars.Context().run( + log.warning, "ohne") + zeile = out.getvalue().strip() + assert zeile.startswith("- "), zeile + - name: Zwei Läufe, zwei IDs + code: |- + log, out = neuer_logger(), io.StringIO() + einrichten(log, out) + def lauf(i): + lauf_id.set(i) + log.warning("x") + contextvars.Context().run(lauf, "A") + contextvars.Context().run(lauf, "B") + ids = [z.split()[0] for z in + out.getvalue().splitlines()] + assert ids == ["A", "B"], ids + hints: + - '`lauf_id.get()` liefert den Wert im aktuellen Kontext oder den Standardwert.' + - Ein Filter am Logger sieht keine Einträge von Kind-Loggern. Hänge ihn an den Handler. + explanation: | + Die `ContextVar` trägt die ID durch den Code, ohne dass jede Funktion einen Parameter dafür braucht. Der Filter liest sie in dem Moment, in dem ein Eintrag entsteht. Er hängt am Handler, weil nur der Handler auch die Einträge von `job.http` sieht. Die Tests laufen jeweils in einem frischen `Context`; so sieht jeder Lauf nur seine eigene ID, wie zwei asyncio-Tasks, die jede eine Kopie des Kontexts bekommen. + resources: + - *contextvars + - title: Python · Filter Objects + url: https://docs.python.org/3/library/logging.html#filter-objects +- id: schwaerzen + type: code + title: Geheimnisse im Log schwärzen + prompt: | + Schreibe `Schwaerzen(logging.Filter)`. Der Konstruktor bekommt eine Liste bekannter Geheimnisse. `filter(r)` holt die fertige Meldung, ersetzt darin jedes bekannte Geheimnis durch `***` und nach `Bearer ` alles bis zum nächsten Leerzeichen ebenfalls durch `***` (aus `Bearer eyJ9.x` wird `Bearer ***`). Danach steht der Text in `r.msg`, `r.args` ist `None`, und der Eintrag wird durchgelassen. Leere Strings in der Liste werden ignoriert. + lang: python + starter: |- + import logging + import re + + class Schwaerzen(logging.Filter): + def __init__(self, werte): + super().__init__() + self.werte = werte + + def filter(self, r): + for w in self.werte: + r.msg = r.msg.replace( + w, "***") + return True + solution: |- + import logging + import re + + BEARER = re.compile( + r"Bearer\s+\S+") + + class Schwaerzen(logging.Filter): + def __init__(self, werte): + super().__init__() + self.werte = [ + w for w in werte if w] + + def filter(self, r): + text = r.getMessage() + for w in self.werte: + text = text.replace( + w, "***") + text = BEARER.sub( + "Bearer ***", text) + r.msg = text + r.args = None + return True + setup: |- + import io + import itertools + import logging + + _nr = itertools.count() + + def logger_mit(filt): + puffer = io.StringIO() + h = logging.StreamHandler(puffer) + h.addFilter(filt) + log = logging.getLogger(f"s{next(_nr)}") + log.handlers = [h] + log.propagate = False + return log, puffer + tests: + - name: Geheimnis als Argument + code: |- + log, out = logger_mit( + Schwaerzen(["geheim-123"])) + log.warning("Token %s", "geheim-123") + text = out.getvalue().strip() + assert text == "Token ***", text + - name: Bearer-Header + code: |- + log, out = logger_mit(Schwaerzen([])) + log.warning("Authorization: Bearer eyJ9.x") + text = out.getvalue().strip() + assert text == "Authorization: Bearer ***", ( + text) + - name: Normale Meldungen bleiben gleich + code: |- + log, out = logger_mit( + Schwaerzen(["geheim-123"])) + log.warning("%d Dateien", 3) + assert out.getvalue().strip() == ( + "3 Dateien"), out.getvalue() + - name: Leerer Wert in der Liste + code: |- + log, out = logger_mit( + Schwaerzen(["", "geheim-123"])) + log.warning("Import fertig") + text = out.getvalue().strip() + assert text == "Import fertig", ( + f"zerstört: {text!r}") + hints: + - '`r.getMessage()` setzt die Argumente ein; danach steht das Geheimnis im Text, egal wie es übergeben wurde.' + - '`"abc".replace("", "***")` fügt `***` zwischen jedem Zeichen ein. Filtere leere Werte vorher heraus.' + - '`re.compile(r"Bearer\s+\S+")` findet das Wort nach `Bearer`.' + explanation: | + Der Starter schaut nur in `r.msg`, also in die Vorlage `"Token %s"`. Das Geheimnis kommt aber als Argument und steht erst nach `getMessage()` im Text. Nach dem Ersetzen muss `r.args` auf `None`, sonst versucht der Formatter, Argumente in einen Text ohne Platzhalter einzusetzen. Der leere String ist ein echter Betriebsfall: Ist eine Umgebungsvariable nicht gesetzt, landet `""` in der Liste, und ohne Prüfung würde jede Meldung unlesbar. + resources: + - title: Python · Filter Objects + url: https://docs.python.org/3/library/logging.html#filter-objects + - title: Python · re.sub + url: https://docs.python.org/3/library/re.html#re.sub +- id: credential-lesen + type: code + title: Ein Geheimnis aus systemd oder der Umgebung + prompt: | + Schreibe `lade_geheimnis(name, umgebung)`. `umgebung` ist ein Dictionary wie `os.environ`. + + - Steht in `umgebung` der Schlüssel `CREDENTIALS_DIRECTORY` und gibt es dort eine Datei `name`, gib ihren Inhalt ohne Zeilenumbruch am Ende zurück. Ist sie leer, gilt das Geheimnis als fehlend: `LookupError`, ohne Rückfall auf die Umgebung. + - Sonst gib `umgebung[name.upper()]` zurück, wenn der Wert nicht leer ist (für die Entwicklung). + - Sonst wirf `LookupError` mit `name` in der Meldung. + lang: python + starter: |- + import os + from pathlib import Path + + def lade_geheimnis(name, umgebung): + return umgebung[name.upper()] + solution: |- + from pathlib import Path + + def aus_datei(ordner, name): + if not ordner: + return None + datei = Path(ordner) / name + if not datei.is_file(): + return None + text = datei.read_text() + return text.rstrip("\n") + + def lade_geheimnis(name, umgebung): + ordner = umgebung.get( + "CREDENTIALS_DIRECTORY") + wert = aus_datei(ordner, name) + if wert is None: + wert = umgebung.get( + name.upper()) + if not wert: + raise LookupError(name) + return wert + setup: |- + from pathlib import Path + Path("creds").mkdir() + Path("creds/api_token").write_text("aus-datei\n") + tests: + - name: Credential-Datei hat Vorrang + code: |- + u = {"CREDENTIALS_DIRECTORY": "creds", + "API_TOKEN": "aus-umgebung"} + w = lade_geheimnis("api_token", u) + assert w == "aus-datei", repr(w) + - name: Umgebung als Rückfall + code: |- + u = {"API_TOKEN": "aus-umgebung"} + w = lade_geheimnis("api_token", u) + assert w == "aus-umgebung", repr(w) + - name: Datei fehlt im Credentials-Ordner + code: |- + u = {"CREDENTIALS_DIRECTORY": "creds", + "SMTP_PASS": "pw"} + w = lade_geheimnis("smtp_pass", u) + assert w == "pw", repr(w) + - name: Leere Credential-Datei – LookupError + code: |- + from pathlib import Path + Path("leer").mkdir() + Path("leer/api_token").write_text("\n") + u = {"CREDENTIALS_DIRECTORY": "leer", + "API_TOKEN": "aus-umgebung"} + try: + lade_geheimnis("api_token", u) + except LookupError: + pass + else: + assert False, "Leere Datei: LookupError" + - name: Nichts da oder leer – LookupError + code: |- + for u in ({}, {"API_TOKEN": ""}): + try: + lade_geheimnis("api_token", u) + except LookupError as e: + assert "api_token" in str(e) + else: + assert False, f"{u}: LookupError" + hints: + - '`umgebung.get("CREDENTIALS_DIRECTORY")` ist `None`, wenn der Dienst keine Credentials hat.' + - '`Path(ordner) / name` baut den Pfad; `is_file()` prüft, ob es die Datei gibt.' + explanation: | + Unter systemd mit `LoadCredential=api_token` liegt das Token als Datei in `$CREDENTIALS_DIRECTORY` und nicht in der Umgebung. Dateien enden oft mit einem Zeilenumbruch, der im HTTP-Header einen Fehler auslöst; `rstrip("\n")` entfernt ihn. Die Umgebung bleibt als bewusster Rückfall für die Entwicklung. Weil `umgebung` ein Parameter ist, testest du alle Wege ohne echte Umgebungsvariablen, und die Fehlermeldung nennt nur den Namen, nie einen Wert. + resources: + - *systemd +- id: logs-auswerten + type: code + title: Fehler je Lauf zählen + prompt: | + Schreibe `fehler_je_lauf(zeilen)`. `zeilen` sind Zeilen eines Logs; die meisten sind JSON-Objekte mit den Schlüsseln `stufe` und `lauf`, einige stammen von anderen Programmen und sind kein JSON. Zähle je Lauf die Einträge mit `stufe` `"ERROR"` oder `"CRITICAL"` und gib ein Dictionary `{lauf: anzahl}` zurück. Läufe ohne Fehler erscheinen nicht. Zeilen, die kein JSON-Objekt sind, überspringst du. + lang: python + starter: |- + import json + + def fehler_je_lauf(zeilen): + zaehler = {} + for z in zeilen: + d = json.loads(z) + zaehler[d["lauf"]] = 1 + return zaehler + solution: |- + import json + from collections import Counter + + SCHWER = {"ERROR", "CRITICAL"} + + def lies(zeile): + try: + d = json.loads(zeile) + except ValueError: + return {} + if isinstance(d, dict): + return d + return {} + + def fehler_je_lauf(zeilen): + zaehler = Counter() + for z in zeilen: + d = lies(z) + stufe = d.get("stufe") + if stufe in SCHWER: + lauf = d.get("lauf") + zaehler[lauf] += 1 + return dict(zaehler) + tests: + - name: Zählt je Lauf + code: |- + z = ['{"stufe": "ERROR", "lauf": "a"}', + '{"stufe": "INFO", "lauf": "a"}', + '{"stufe": "ERROR", "lauf": "a"}', + '{"stufe": "CRITICAL", "lauf": "b"}'] + r = fehler_je_lauf(z) + assert r == {"a": 2, "b": 1}, r + - name: Läufe ohne Fehler fehlen + code: |- + z = ['{"stufe": "WARNING", "lauf": "c"}'] + assert fehler_je_lauf(z) == {} + - name: Fremde Zeilen überspringen + code: |- + z = ["Traceback (most recent call last):", + "", + "42", + '{"stufe": "ERROR", "lauf": "d"}'] + r = fehler_je_lauf(z) + assert r == {"d": 1}, r + hints: + - '`json.loads` wirft bei Nicht-JSON `ValueError`.' + - '`"42"` ist gültiges JSON, aber kein Objekt. Prüfe mit `isinstance(d, dict)`.' + explanation: | + JSON-Logs sind Daten, die du mit ein paar Zeilen Python auswertest, statt sie mit regulären Ausdrücken zu zerlegen. Echte Logs sind aber nie ganz sauber: Ein Traceback, eine Leerzeile oder die Ausgabe eines anderen Programms landet dazwischen. Die Funktion bleibt robust, indem sie fremde Zeilen überspringt, statt beim ersten Fehler abzubrechen; eine Zahl wie `42` ist gültiges JSON, aber kein Eintrag. + resources: + - title: Python · json.loads + url: https://docs.python.org/3/library/json.html#json.loads +- id: environment-in-unit + type: bug + title: Ein Kollege sieht das Token + prompt: | + Ein Kollege ohne Root-Rechte hat mit `systemctl show bericht.service` das API-Token gesehen. Das Programm liest das Token bereits mit `lade_geheimnis("api_token", os.environ)` aus der vorigen Aufgabe, die Datei liegt in `/etc/credstore/api_token`. Finde die Zeile, die das Token offenlegt, und ersetze sie. + lang: ini + code: |- + [Unit] + Description=Täglicher Bericht + + [Service] + Type=oneshot + ExecStart=/opt/bericht/run.py + Environment=API_TOKEN=geheim-123 + lines: + - 7 + fix: + - line: 7 + answers: + - LoadCredential=api_token + - LoadCredential=api_token:/etc/credstore/api_token + explanation: | + Werte aus `Environment=` sind Eigenschaften der Unit und für unprivilegierte Clients über D-Bus lesbar, also auch über `systemctl show`. Außerdem erbt jeder Kindprozess sie. `LoadCredential=api_token` liest die Datei aus `/etc/credstore/` und legt eine Kopie in `$CREDENTIALS_DIRECTORY` ab, die nur der Benutzer des Dienstes lesen kann. Das alte Token gilt nach diesem Vorfall als geleakt und wird widerrufen. + resources: + - *systemd +- id: actions-secret + type: explain + title: Ein Secret im Workflow + prompt: Erkläre, was dieser Ausschnitt eines GitHub-Actions-Workflows mit dem Secret macht, was GitHub dabei schützt + und was nicht. + lang: yaml + code: |- + steps: + - run: python -m pytest + env: + TOKEN: ${{ secrets.TOKEN }} + - run: python bericht.py + env: + TOKEN: ${{ secrets.TOKEN }} + points: + - Das Secret kommt als Umgebungsvariable nur in die Schritte, die es nennen, nicht in die Befehlszeile. + - GitHub ersetzt den genauen Wert in der Ausgabe durch Sternchen, abgeleitete oder umgeformte Werte aber nicht. + - Läufe aus Forks bekommen das Secret nicht; Tests, die es brauchen, scheitern dort. + - Die Tests sollten mit Fakes laufen und das echte Token gar nicht brauchen. + explanation: | + `secrets.TOKEN` wird zur Laufzeit eingesetzt und nur an die genannten Schritte weitergegeben. GitHub empfiehlt, Secrets nicht über die Befehlszeile zu übergeben, weil andere Prozesse diese sehen können; die Umgebungsvariable ist der bessere Weg. Die Schwärzung in der Ausgabe erkennt nur den exakten Wert. Gibt ein Programm das Token in Base64 oder als Teil einer URL aus, steht es lesbar im Log. Dass die Testschritte das Secret bekommen, ist hier unnötig: Tests mit Fakes brauchen kein echtes Token, und weniger Schritte mit Zugang heißt weniger Stellen, an denen es herauskommen kann. + resources: + - *actions diff --git a/content/automation/modules/postfach-kalender.yaml b/content/automation/modules/postfach-kalender.yaml new file mode 100644 index 0000000..73e1ae1 --- /dev/null +++ b/content/automation/modules/postfach-kalender.yaml @@ -0,0 +1,1037 @@ +id: postfach-kalender +title: Postfach und Kalender automatisieren +level: 2 +minutes: 230 +summary: Mails per IMAP lesen und mit Flags als Verarbeitungszustand markieren, Anhänge sicher speichern und versenden, + Termine als ICS nach RFC 5545 erzeugen und lesen – Kalender-APIs als Konzept. +goals: +- Du erklärst das IMAP-Modell aus Postfach, UID, UIDVALIDITY und Flags. +- Du liest Nachrichten mit imaplib über UIDs, ohne sie als gelesen zu markieren, und setzt ein Flag erst nach + erfolgreicher Verarbeitung. +- Du liest Anhänge mit dem email-Paket, speicherst sie unter sicheren Namen und versendest eigene Anhänge. +- Du erzeugst und liest ICS-Dateien mit CRLF, Zeilenfaltung, Escaping, UID und DTSTAMP. +- Du ordnest die Kalender-APIs von Google und Microsoft ein – OAuth-Scopes und inkrementelle Synchronisation. +resources: +- &imaplib + title: Python · imaplib + url: https://docs.python.org/3/library/imaplib.html +- &imap + title: RFC 9051 · IMAP4rev2 + url: https://www.rfc-editor.org/rfc/rfc9051.html +- &emailmsg + title: Python · email.message + url: https://docs.python.org/3/library/email.message.html +- &ics + title: RFC 5545 · iCalendar + url: https://www.rfc-editor.org/rfc/rfc5545.html +- &gsync + title: Google Calendar API · Synchronize resources efficiently + url: https://developers.google.com/workspace/calendar/api/guides/sync +place: unterwegs +lesson: | + Rechnungen kommen als PDF per Mail. Jeden Montag lädt jemand sie herunter, benennt sie um, legt sie in einen Ordner und trägt die Fälligkeit in den Kalender ein. Das lässt sich automatisieren. Aber ein Postfach ist ein gemeinsamer Zustand: Menschen lesen darin, verschieben, markieren. Ein Job, der alles als gelesen markiert oder eine Mail löscht, bevor er sie sicher verarbeitet hat, richtet Schaden an, den niemand sofort bemerkt. + + Dieses Modul zeigt, wie ein Job im Postfach Spuren hinterlässt, die Menschen und Job gleichermaßen verstehen, wie er Anhänge sicher annimmt und wie er Termine so erzeugt, dass jeder Kalender sie liest. Die ausführbaren Beispiele arbeiten mit Fakes, deren Antworten genauso aussehen wie die von `imaplib`; der Code für echte Server ist zum Lesen gedacht und gegen einen lokalen Testserver geprüft. + + ## Das IMAP-Modell: Postfach, UID und Flags + + Bei [IMAP](https://www.rfc-editor.org/rfc/rfc9051.html) bleiben die Mails auf dem Server. Ein Konto hat mehrere Postfächer (Ordner) wie `INBOX`. Jede Nachricht hat zwei Nummern. Die **Sequenznummer** ist ihre aktuelle Position im Postfach; löscht jemand eine frühere Nachricht, rutscht sie nach. Die **UID** dagegen bleibt. Sie steigt mit jeder neuen Nachricht und ändert sich laut RFC nicht, solange der Wert **UIDVALIDITY** des Postfachs gleich bleibt. Ein Job arbeitet deshalb immer mit UIDs und merkt sich zusätzlich UIDVALIDITY. Ändert sich der Wert, sind alle gemerkten UIDs ungültig, und der Job muss neu abgleichen. + + Jede Nachricht trägt **Flags**. Systemflags beginnen mit Backslash: `\Seen` (gelesen), `\Flagged`, `\Answered`, `\Deleted`. Dazu kommen **Schlüsselwörter** ohne Backslash, etwa `$Verarbeitet`. Ob du eigene Schlüsselwörter anlegen darfst, sagt der Server beim Auswählen des Postfachs: Enthält `PERMANENTFLAGS` das Zeichen `\*`, ist es erlaubt. + + Damit hast du einen Verarbeitungszustand, der im Postfach selbst steht. Der Job sucht Nachrichten ohne `$Verarbeitet`, verarbeitet sie und setzt das Schlüsselwort erst danach. `\Seen` taugt dafür nicht: Liest ein Mensch die Rechnung vorher, würde der Job sie überspringen. Eine Alternative ist ein Ordner „Erledigt“, in den der Job verschiebt; Server mit der Erweiterung [MOVE](https://www.rfc-editor.org/rfc/rfc6851.html) können das in einem Schritt. + + Eine Falle steckt im Abholen. `FETCH … BODY[]` setzt laut RFC [`\Seen` implizit](https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.5). `BODY.PEEK[]` liefert dieselben Bytes, ohne etwas zu verändern. + + ## imaplib: verbinden, suchen, abholen + + [`imaplib`](https://docs.python.org/3/library/imaplib.html) gehört zur Standardbibliothek. Alle Befehle mit UIDs laufen über `imap.uid(befehl, …)`. Jeder Befehl liefert ein Paar aus Status und Daten, etwa `("OK", [b"1 2"])` für eine Suche. + + ```python + import imaplib + import ssl + + FLAG = "$Verarbeitet" + ctx = ssl.create_default_context() + with imaplib.IMAP4_SSL( + HOST, ssl_context=ctx, + timeout=30) as imap: + imap.login(USER, PASSWORT) + imap.select("INBOX") + typ, daten = imap.uid( + "SEARCH", None, + "UNKEYWORD", FLAG) + for uid in daten[0].split(): + typ, teile = imap.uid( + "FETCH", uid, + "(BODY.PEEK[])") + verarbeite(teile[0][1]) + imap.uid( + "STORE", uid, + "+FLAGS", f"({FLAG})") + ``` + + Drei Details sind wichtig. Erstens prüft `IMAP4_SSL` das Zertifikat nur, wenn du einen Kontext aus `ssl.create_default_context()` übergibst; ohne ihn ist die Verbindung laut Dokumentation verschlüsselt, aber der Server nicht geprüft. Zweitens schickt `with` am Ende `LOGOUT`. Drittens ist die Antwort auf `FETCH` eine Liste, in der die Nachricht als Tupel `(kopf, bytes)` steckt. Die Dokumentation warnt, dass die Liste zusätzliche, unaufgeforderte Einträge enthalten kann; robuster Code sucht das Tupel, statt sich auf `teile[0][1]` zu verlassen. + + Dieses Beispiel ist zum Lesen gedacht. Gegen einen echten Server läuft es am Rechner, am besten zuerst gegen einen lokalen Testserver wie [GreenMail](https://greenmail-mail-test.github.io/greenmail/) in Docker. Große Anbieter wie Microsoft 365 und Gmail erwarten für IMAP OAuth 2.0 über den Mechanismus XOAUTH2 ([Microsoft](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth), [Google](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol)); `imaplib` unterstützt das über `authenticate`. Die Token-Logik dafür kennst du aus dem Modul [API-Authentifizierung](/automation/api-authentifizierung). + + ## Flags als Verarbeitungszustand, getestet mit einem Fake + + Ein Fake mit denselben Antwortformen wie `imaplib` macht den Ablauf im Browser prüfbar. Er hat je IMAP-Befehl eine Methode, merkt sich, welche UIDs noch offen sind, und antwortet wie `imaplib` mit Status und Datenliste. + + ```python run + class FakeImap: + def __init__(self, mails): + self.mails = mails + self.offen = list(mails) + + def uid(self, befehl, *args): + f = getattr(self, befehl) + return "OK", f(*args) + + def SEARCH(self, *kriterien): + offen = self.offen + return [b" ".join(offen)] + + def FETCH(self, uid, teil): + roh = self.mails[uid] + kopf = b"UID " + uid + return [(kopf, roh), b")"] + + def STORE(self, uid, *flags): + self.offen.remove(uid) + return [] + + FLAG = "$Verarbeitet" + imap = FakeImap({b"1": b"Mail 1", + b"2": b"Mail 2"}) + imap.uid("STORE", b"1", "+FLAGS", + f"({FLAG})") + print(imap.uid("SEARCH", None, + "UNKEYWORD", FLAG)) + ``` + + Mit diesem Fake prüfst du die Fälle, die im echten Postfach heikel sind: Eine Verarbeitung scheitert, und die Mail muss unmarkiert bleiben. Der nächste Lauf findet sie wieder. Weil das Flag erst nach Erfolg gesetzt wird, ist der Ablauf *at least once*; die Verarbeitung selbst muss also mit einem zweiten Versuch für dieselbe Mail umgehen können, etwa über einen eindeutigen Schlüssel wie die `Message-ID`. + + ## Anhänge lesen und sicher speichern + + Das [email-Paket](https://docs.python.org/3/library/email.message.html) zerlegt die Rohdaten. Mit `policy.default` bekommst du ein `EmailMessage`-Objekt, dessen `iter_attachments()` die Anhänge liefert. `get_filename()` dekodiert den Dateinamen, auch Umlaute in kodierter Form, und `get_content()` liefert bei einem PDF die Bytes. + + ```python run + from email import policy + from email.message import ( + EmailMessage) + from email.parser import ( + BytesParser) + + mail = EmailMessage() + mail["Subject"] = "Rechnung 4711" + mail.set_content("Siehe Anhang.") + mail.add_attachment( + b"%PDF-1.7 ...", + maintype="application", + subtype="pdf", + filename="../../.bashrc") + roh = mail.as_bytes() + + msg = BytesParser( + policy=policy.default + ).parsebytes(roh) + for t in msg.iter_attachments(): + print(t.get_filename()) + print(t.get_content()[:8]) + ``` + + Der Dateiname kommt vom Absender, und der hat hier `../../.bashrc` geschrieben. Wer `ordner / teil.get_filename()` speichert, schreibt außerhalb des Zielordners. Ein lokaler Test mit GreenMail zeigt dasselbe: `get_filename()` reicht den Namen unverändert durch. Behandle ihn deshalb wie jede fremde Eingabe: nur den letzten Pfadteil nehmen, auf erlaubte Zeichen beschränken, führende Punkte entfernen, einen Ersatznamen vorsehen und vorhandene Dateien nicht überschreiben. + + ```python run + import re + from pathlib import Path + + FREMD = re.compile( + r"[^A-Za-z0-9._-]") + + def sicher(name): + name = Path(name or "").name + name = FREMD.sub("_", name) + name = name.lstrip(".") + return name or "anhang" + + namen = ["../../.bashrc", ".."] + namen.append("Rechnung Okt*.pdf") + for n in namen: + print(sicher(n)) + ``` + + `iter_attachments()` liefert nur die direkten Unterteile einer Nachricht. Steckt ein Anhang in einer weitergeleiteten Mail, findest du ihn mit `msg.walk()` und `get_content_disposition() == "attachment"`. Prüfe außerdem den Inhalt, nicht nur die Endung: Ein PDF beginnt mit `%PDF-`. Begrenze die Größe, bevor du speicherst. + + Anhänge versendest du mit derselben Klasse. [`add_attachment`](https://docs.python.org/3/library/email.message.html#email.message.EmailMessage.add_attachment) macht aus der Mail automatisch eine `multipart/mixed`-Nachricht; der Versand selbst läuft wie im Modul [Benachrichtigungen](/automation/benachrichtigungen) über `smtplib`. + + ```python run + from email.message import ( + EmailMessage) + + mail = EmailMessage() + mail["From"] = "job@example.org" + mail["To"] = "team@example.org" + mail["Subject"] = "Bericht" + mail.set_content("Bericht anbei.") + mail.add_attachment( + b"%PDF-1.7 ...", + maintype="application", + subtype="pdf", + filename="bericht.pdf") + print(mail.get_content_type()) + teile = mail.iter_parts() + print([t.get_content_type() + for t in teile]) + ``` + + ## ICS: Termine als Datei + + Eine `.ics`-Datei nach [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545.html) versteht jeder Kalender. Sie besteht aus Zeilen der Form `NAME:Wert`, getrennt durch **CRLF**, also `\r\n`. Ein Kalender braucht `VERSION:2.0` und `PRODID`, jeder Termin (`VEVENT`) braucht `UID` und `DTSTAMP`; `DTSTART` ist Pflicht, solange die Datei keine `METHOD` angibt. + + ```text + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//lernen//bericht//DE + BEGIN:VEVENT + UID:rechnung-4711@example.org + DTSTAMP:20261024T080000Z + DTSTART:20261103T080000Z + DTEND:20261103T083000Z + SUMMARY:Rechnung 4711 fällig + END:VEVENT + END:VCALENDAR + ``` + + Die **UID** identifiziert den Termin dauerhaft. Erzeugst du denselben Termin noch einmal mit derselben UID, aktualisiert ein Kalender ihn, statt einen zweiten anzulegen. Damit eine geänderte Fassung die alte ersetzt, erhöhst du zusätzlich [`SEQUENCE`](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.7.4). Eine stabile UID aus fachlichen Daten wie der Rechnungsnummer macht den Export also idempotent. Zeiten mit angehängtem `Z` sind UTC, eindeutig und ohne Zeitzonendefinition. Wer lokale Zeiten mit `TZID=Europe/Berlin` schreibt, muss die Zeitzone als `VTIMEZONE` mitliefern. + + Texte werden [escaped](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.11): Backslash, Semikolon und Komma bekommen einen Backslash davor, ein Zeilenumbruch wird zu `\n`. Doppelpunkte bleiben, wie sie sind. Die Reihenfolge zählt: Erst den Backslash ersetzen, sonst verdoppelst du die gerade eingefügten. + + ```python run + ERSATZ = [("\\", "\\\\"), + (";", "\\;"), + (",", "\\,"), + ("\n", "\\n")] + + def esc(t): + for alt, neu in ERSATZ: + t = t.replace(alt, neu) + return t + + print(esc("Raum 3, OG; Tür B")) + ``` + + Lange Zeilen werden **gefaltet**: Nach [RFC 5545 §3.1](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.1) sollen Zeilen höchstens 75 **Oktetts** lang sein, ohne den Zeilenumbruch („SHOULD NOT be longer“). Die Fortsetzung beginnt mit einem Leerzeichen, das beim Lesen samt CRLF entfernt wird. Gezählt werden Bytes, nicht Zeichen: `ä` braucht in UTF-8 zwei. Ein Leser muss deshalb **zuerst entfalten** und erst dann Zeilen zerlegen. + + ```python run + import re + + FALTE = re.compile(r"\r\n[ \t]") + ics = ("SUMMARY:Quartals\r\n" + " bericht\r\n" + "UID:r-4711\r\n") + flach = FALTE.sub("", ics) + for z in flach.splitlines(): + n, _, w = z.partition(":") + print(n, "=", w) + ``` + + Eine ICS-Datei verschickst du als Anhang mit dem Typ `text/calendar`. Für echte Einladungen mit Zusagen und Absagen gibt es das Protokoll [iTIP](https://www.rfc-editor.org/rfc/rfc5546.html) mit `METHOD:REQUEST`; für einen Eintrag in den eigenen Kalender reicht `METHOD:PUBLISH` oder eine Datei ohne `METHOD`. + + ## Kalender-APIs als Konzept: Google und Microsoft Graph + + Statt Dateien zu verschicken, kann ein Job Termine direkt anlegen. Die [Google Calendar API](https://developers.google.com/workspace/calendar/api/v3/reference/events) und [Microsoft Graph](https://learn.microsoft.com/en-us/graph/api/resources/event) arbeiten mit JSON und OAuth 2.0. Für den Kalender einer Person brauchst du deren Zustimmung zu einem Scope wie Kalender lesen oder schreiben; für Dienstkonten in Firmen legt ein Administrator fest, auf welche Kalender sie zugreifen dürfen. Ein ganztägiger Termin hat bei Google ein Ende, das **nicht** mehr dazugehört: Der Termin am 3. November endet am 4. + + ```python + termin = { + "summary": "Rechnung fällig", + "start": { + "date": "2026-11-03"}, + "end": { + "date": "2026-11-04"}, + } + ``` + + Wer Termine liest und lokal spiegelt, sollte nicht jedes Mal alles laden. Beide APIs bieten inkrementelle Synchronisation. Google liefert am Ende einer vollständigen Liste ein `nextSyncToken`; der nächste Abruf mit `syncToken` bringt nur Änderungen seitdem, auch gelöschte Termine mit Status `cancelled`. Ist das Token abgelaufen, antwortet Google mit **410 Gone**, und laut [Anleitung](https://developers.google.com/workspace/calendar/api/guides/sync) verwirft der Client seinen lokalen Stand und lädt alles neu. Microsoft Graph macht dasselbe mit [Delta-Abfragen](https://learn.microsoft.com/en-us/graph/delta-query-events): `@odata.nextLink` für die nächste Seite, `@odata.deltaLink` für den nächsten Abgleich. + + > [!PRAXIS] + > Lass einen neuen Postfach-Job zuerst gegen ein eigenes Testpostfach oder einen lokalen Testserver laufen, mit echten Beispielmails ohne echte Daten. Prüfe danach im Mailprogramm, dass keine Mail plötzlich gelesen, verschoben oder verschwunden ist. + + ## Häufige Fehler + + - **Sequenznummern speichern.** Nach dem nächsten Löschen zeigen sie auf andere Mails. UIDs und UIDVALIDITY merken. + - **`BODY[]` statt `BODY.PEEK[]`.** Der Job markiert alles als gelesen. + - **Flag vor der Verarbeitung.** Scheitert sie, gilt die Mail trotzdem als erledigt. + - **Dateiname vom Absender übernommen.** `../../` schreibt außerhalb des Ordners. + - **ICS mit `\n` statt `\r\n`** oder nach Zeichen statt Bytes gefaltet. Strenge Kalender lehnen die Datei ab. + - **Neue UID bei jedem Export.** Jeder Lauf legt den Termin noch einmal an. + - **410 als Fehler gemeldet.** Bei Google heißt das „neu synchronisieren“, nicht „abbrechen“. + + > [!INTERVIEW] + > „Wie merkst du dir in einem Mail-Job, was schon verarbeitet ist?“ Im Postfach selbst, mit einem eigenen Schlüsselwort wie `$Verarbeitet`, das ich nach erfolgreicher Verarbeitung setze. Ich hole Mails mit `BODY.PEEK[]`, damit sie für Menschen ungelesen bleiben, und arbeite mit UIDs. Wenn der Server keine eigenen Schlüsselwörter erlaubt, verschiebe ich in einen Ordner oder speichere UIDVALIDITY und UID in einer Datenbank. + + > [!MERKE] + > Abholen mit `BODY.PEEK[]`, markieren nach dem Erfolg, Dateinamen nie ungeprüft übernehmen. + + ## Zusammenfassung + + - IMAP-Nachrichten haben Sequenznummern (Position) und UIDs (stabil, solange UIDVALIDITY gleich bleibt). + - Eigene Schlüsselwörter wie `$Verarbeitet` sind ein Verarbeitungszustand im Postfach, wenn `PERMANENTFLAGS` `\*` enthält. + - `BODY.PEEK[]` holt ohne `\Seen`; das Flag kommt nach der erfolgreichen Verarbeitung. + - `IMAP4_SSL` prüft das Zertifikat nur mit `ssl.create_default_context()`. + - Anhänge liest `iter_attachments()`; ihre Namen behandelst du als fremde Eingabe. + - ICS braucht CRLF, Faltung nach 75 Oktetts, Escaping, eine stabile UID und DTSTAMP. + - Kalender-APIs synchronisieren inkrementell; ein ungültiges Token heißt neu laden. +exercises: +- id: uid-statt-nummer + type: choice + title: Warum UIDs? + prompt: Ein Job merkt sich, welche Mails er verarbeitet hat. Warum speichert er UIDs statt Sequenznummern? + options: + - text: Sequenznummern rücken nach, wenn eine frühere Nachricht gelöscht wird; UIDs bleiben, solange UIDVALIDITY + gleich ist. + correct: true + why: Die Sequenznummer ist nur die aktuelle Position. Die UID ist die dauerhafte Kennung im Postfach. + - text: UIDs sind über alle Ordner eines Kontos eindeutig. + correct: false + why: UIDs gelten je Postfach. Dieselbe UID kann in INBOX und Archiv verschiedene Nachrichten meinen. + - text: UIDs ändern sich nie, auch nicht, wenn UIDVALIDITY wechselt. + correct: false + why: Ein neuer UIDVALIDITY-Wert heißt gerade, dass alte UIDs nicht mehr gelten. + - text: Sequenznummern gibt es nur für ungelesene Nachrichten. + correct: false + why: Jede Nachricht hat eine Sequenznummer, ihre Position im Postfach. + explanation: Wer Sequenznummern speichert, markiert nach dem nächsten Löschen die falschen Mails. UIDs zusammen + mit UIDVALIDITY sind die stabile Adresse einer Nachricht. + resources: + - *imap +- id: postfach-ablauf + type: order + title: Ein Postfach-Lauf + prompt: Bring die Schritte eines Jobs in die richtige Reihenfolge, der Rechnungen aus dem Postfach holt. + lang: prose + lines: |- + Verbindung mit TLS und Zertifikatsprüfung öffnen + Postfach auswählen und UIDVALIDITY prüfen + UIDs ohne $Verarbeitet suchen + Nachricht mit BODY.PEEK[] holen + Anhang prüfen und unter sicherem Namen speichern + $Verarbeitet für diese UID setzen + explanation: UIDVALIDITY wird vor der Suche geprüft. Ändert sie sich, gelten UIDs nicht mehr, die der Job oder ein + Mensch irgendwo notiert hat, etwa im Blatt „Fehler“ oder im Log, und der Job gleicht neu ab. Das Flag steht am + Ende, damit eine gescheiterte Verarbeitung beim nächsten Lauf wieder auftaucht. + resources: + - *imaplib +- id: ics-escape + type: output + title: Sonderzeichen im Termin + prompt: Welche Ausgabe erzeugt dieses Programm? + lang: python + code: |- + def esc(t): + t = t.replace("\\", "\\\\") + t = t.replace(";", "\\;") + t = t.replace(",", "\\,") + return t.replace("\n", "\\n") + + titel = "Review; Raum 3, OG" + print("SUMMARY:" + esc(titel)) + expected: SUMMARY:Review\; Raum 3\, OG + explanation: | + Semikolon und Komma trennen in ICS Werte und Listen. In einem Text bekommen sie deshalb einen Backslash. Der Doppelpunkt nach `SUMMARY` ist das Trennzeichen zwischen Name und Wert; Doppelpunkte im Wert würden nicht escaped. + resources: + - title: RFC 5545 · TEXT + url: https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.11 +- id: zeichen-oktetts + type: output + title: Zeichen oder Bytes? + prompt: Welche Ausgabe erzeugt dieses Programm? + lang: python + code: |- + zeile = "SUMMARY:Größenprüfung" + print(len(zeile)) + print(len(zeile.encode("utf-8"))) + expected: |- + 21 + 24 + explanation: | + `ö`, `ß` und `ü` brauchen in UTF-8 je zwei Bytes. RFC 5545 begrenzt Zeilen auf 75 Oktetts, also Bytes. Wer nach `len(zeile)` faltet, erzeugt bei Umlauten zu lange Zeilen. + resources: + - title: RFC 5545 · Content Lines + url: https://www.rfc-editor.org/rfc/rfc5545.html#section-3.1 +- id: imap-befehle + type: gap + title: Suchen, holen, markieren + prompt: Ergänze die imaplib-Aufrufe. Gesucht sind Nachrichten ohne `$Verarbeitet`; sie werden geholt, ohne als + gelesen zu gelten, und danach markiert. + lang: python + code: |- + typ, daten = imap.⟦uid⟧( + "SEARCH", None, + "⟦UNKEYWORD⟧", + "$Verarbeitet") + uid = daten[0].split()[0] + typ, teile = imap.uid( + "FETCH", uid, + "(⟦BODY.PEEK[]⟧)") + imap.uid("STORE", uid, + "⟦+FLAGS⟧", + "($Verarbeitet)") + gaps: + - label: Befehle mit UIDs + hint: Eine Methode schickt jeden Befehl mit UIDs statt Sequenznummern. + - label: Suchkriterium + hint: Das Gegenteil von KEYWORD. + accept: ["unkeyword"] + - label: Abholen ohne \Seen + hint: Wie BODY[], nur ohne Spuren. + - label: Flag hinzufügen + hint: FLAGS ersetzt alle, mit Plus kommt eines dazu. + accept: ["+FLAGS.SILENT"] + explanation: '`uid` sorgt dafür, dass alle Nummern UIDs sind. `UNKEYWORD` findet Nachrichten ohne das Schlüsselwort, + `BODY.PEEK[]` holt sie, ohne `\Seen` zu setzen, und `+FLAGS` fügt `$Verarbeitet` hinzu, ohne die übrigen Flags + anzutasten.' + resources: + - *imaplib +- id: postfach-verarbeiten + type: code + title: Rechnungen aus dem Postfach holen + prompt: | + Schreibe `abarbeiten(imap, verarbeite)`. `imap` verhält sich wie ein `imaplib.IMAP4`-Objekt mit ausgewähltem Postfach. + + - Suche mit `imap.uid("SEARCH", None, "UNKEYWORD", "$Verarbeitet")`. Die Antwort ist `("OK", [b"1 2 3"])`. + - Hole jede Nachricht mit `imap.uid("FETCH", uid, "(BODY.PEEK[])")`. In der Antwortliste steckt die Nachricht als Tupel `(kopf, rohdaten)`; die Liste kann davor und danach andere Einträge enthalten. + - Rufe `verarbeite(rohdaten)` auf. Gelingt das, setze `imap.uid("STORE", uid, "+FLAGS", "($Verarbeitet)")`. Wirft `verarbeite`, setze nichts und mach mit der nächsten UID weiter. + - Gib die Liste der UIDs zurück, bei denen `verarbeite` gescheitert ist, als Bytes, so wie `SEARCH` sie liefert. + lang: python + starter: |- + FLAG = "$Verarbeitet" + + def abarbeiten(imap, verarbeite): + typ, daten = imap.uid( + "SEARCH", None, + "UNKEYWORD", FLAG) + for uid in daten[0].split(): + imap.uid( + "STORE", uid, + "+FLAGS", f"({FLAG})") + typ, teile = imap.uid( + "FETCH", uid, + "(BODY[])") + verarbeite(teile[0][1]) + return [] + solution: |- + FLAG = "$Verarbeitet" + + def rohdaten(teile): + for teil in teile: + if isinstance(teil, tuple): + return teil[1] + raise LookupError("keine Mail") + + def abarbeiten(imap, verarbeite): + typ, daten = imap.uid( + "SEARCH", None, + "UNKEYWORD", FLAG) + fehler = [] + for uid in daten[0].split(): + typ, teile = imap.uid( + "FETCH", uid, + "(BODY.PEEK[])") + try: + roh = rohdaten(teile) + verarbeite(roh) + except Exception: + fehler.append(uid) + continue + imap.uid( + "STORE", uid, + "+FLAGS", f"({FLAG})") + return fehler + setup: |- + class FakeImap: + """Antwortet wie imaplib (Formen geprüft gegen einen lokalen GreenMail-Server).""" + def __init__(self, mails, laerm=()): + self.mails = dict(mails) + self.flags = {u: set() for u in self.mails} + self.laerm = set(laerm) + self.log = [] + + def _pruefe(self, *_): + raise AssertionError("Bitte imap.uid(...) benutzen: Sequenznummern verschieben sich.") + fetch = search = store = _pruefe + + def uid(self, befehl, *args): + self.log.append((befehl, args)) + if befehl == "SEARCH": + assert args[-2:] == ("UNKEYWORD", "$Verarbeitet"), f"Suche: {args}" + offen = [u for u, f in self.flags.items() if "$Verarbeitet" not in f] + return "OK", [b" ".join(offen)] + if befehl == "FETCH": + uid, teil = args + if "PEEK" not in teil: + self.flags[uid].add("\\Seen") + kopf = f"{int(uid)} (UID {int(uid)} BODY[] {{{len(self.mails[uid])}}}".encode() + antwort = [(kopf, self.mails[uid]), b")"] + if uid in self.laerm: + antwort.insert(0, b"9 (FLAGS (\\Seen))") + return "OK", antwort + if befehl == "STORE": + uid, art, flags = args + assert art == "+FLAGS", f"STORE {art}" + self.flags[uid].update(flags.strip("()").split()) + return "OK", [f"{int(uid)} (FLAGS ({' '.join(sorted(self.flags[uid]))}) UID {int(uid)})".encode()] + raise AssertionError(f"unerwarteter Befehl {befehl}") + + def scheitert_bei(*inhalte): + gesehen = [] + def verarbeite(roh): + gesehen.append(roh) + if roh in inhalte: + raise ValueError("kaputtes PDF") + verarbeite.gesehen = gesehen + return verarbeite + tests: + - name: Alles verarbeitet und markiert + code: |- + imap = FakeImap({b"1": b"A", b"2": b"B"}) + v = scheitert_bei() + r = abarbeiten(imap, v) + assert r == [], r + assert v.gesehen == [b"A", b"B"] + for u in (b"1", b"2"): + assert "$Verarbeitet" in imap.flags[u] + - name: Ungelesen bleibt ungelesen + code: |- + imap = FakeImap({b"1": b"A"}) + abarbeiten(imap, scheitert_bei()) + assert "\\Seen" not in imap.flags[b"1"], ( + "BODY[] setzt \\Seen – BODY.PEEK[] nicht") + - name: Fehler – nicht markieren, weitermachen + code: |- + imap = FakeImap({b"1": b"A", b"2": b"B", + b"3": b"C"}) + v = scheitert_bei(b"B") + r = abarbeiten(imap, v) + assert r == [b"2"], r + assert v.gesehen == [b"A", b"B", b"C"] + assert "$Verarbeitet" not in imap.flags[b"2"] + assert "$Verarbeitet" in imap.flags[b"3"] + - name: Nächster Lauf findet nur den Rest + code: |- + imap = FakeImap({b"1": b"A", b"2": b"B"}) + abarbeiten(imap, scheitert_bei(b"B")) + v = scheitert_bei() + abarbeiten(imap, v) + assert v.gesehen == [b"B"], v.gesehen + - name: Zusätzliche Einträge in der Antwort + code: |- + imap = FakeImap({b"1": b"A"}, laerm=[b"1"]) + v = scheitert_bei() + abarbeiten(imap, v) + assert v.gesehen == [b"A"], v.gesehen + hints: + - Setze das Flag erst nach `verarbeite`, in einem Zweig, den eine Exception überspringt. + - Durchsuche die FETCH-Antwort nach dem ersten Eintrag, der ein Tupel ist. + - '`try … except Exception` um `verarbeite`, dann `continue`.' + explanation: | + Der Starter markiert vor der Verarbeitung und holt mit `BODY[]`: Jede Mail gilt danach als gelesen, und eine gescheiterte Rechnung ist trotzdem „erledigt“. Die Lösung holt mit `BODY.PEEK[]`, sucht das Tupel in der Antwort, statt `teile[0][1]` zu vertrauen, und setzt `$Verarbeitet` nur im Erfolgsfall. Eine gescheiterte Mail bleibt unmarkiert und taucht im nächsten Lauf wieder auf. Die Formen der Fake-Antworten stammen aus einem Lauf gegen einen lokalen GreenMail-Server. + resources: + - *imaplib + - *imap +- id: anhaenge-speichern + type: code + title: Anhänge unter sicheren Namen speichern + prompt: | + Schreibe `sichere_anhaenge(roh, ordner)`. `roh` sind die Bytes einer Mail, `ordner` ist ein vorhandener Ordner. + + - Lies die Mail mit `BytesParser(policy=policy.default)` und speichere jeden Teil aus `iter_attachments()` mit `get_content()` als Datei in `ordner`. + - Der Dateiname ist der letzte Pfadteil von `get_filename()`. Ersetze darin jedes Zeichen außer `A–Z`, `a–z`, `0–9`, `.`, `_` und `-` durch `_` und entferne führende Punkte. Bleibt nichts übrig oder fehlt der Name, heißt die Datei `anhang`. + - Gibt es die Datei schon, hänge vor der Endung `-2`, `-3` … an. + - Gib die Liste der gespeicherten Dateinamen zurück. + lang: python + starter: |- + from email import policy + from email.parser import ( + BytesParser) + from pathlib import Path + + def sichere_anhaenge(roh, ordner): + msg = BytesParser( + policy=policy.default + ).parsebytes(roh) + namen = [] + teile = msg.iter_attachments() + for teil in teile: + name = teil.get_filename() + ziel = Path(ordner) / name + ziel.write_bytes( + teil.get_content()) + namen.append(name) + return namen + solution: |- + import re + from email import policy + from email.parser import ( + BytesParser) + from pathlib import Path + + FREMD = re.compile( + r"[^A-Za-z0-9._-]") + + def sicher(name): + name = Path(name or "").name + name = FREMD.sub("_", name) + name = name.lstrip(".") + return name or "anhang" + + def frei(ordner, name): + p = Path(name) + stamm, ext = p.stem, p.suffix + ziel, n = ordner / name, 2 + while ziel.exists(): + neu = f"{stamm}-{n}{ext}" + ziel = ordner / neu + n += 1 + return ziel + + def sichere_anhaenge(roh, ordner): + ordner = Path(ordner) + msg = BytesParser( + policy=policy.default + ).parsebytes(roh) + namen = [] + teile = msg.iter_attachments() + for teil in teile: + name = teil.get_filename() + name = sicher(name) + ziel = frei(ordner, name) + daten = teil.get_content() + ziel.write_bytes(daten) + namen.append(ziel.name) + return namen + setup: |- + from email.message import EmailMessage + from pathlib import Path + + def mail_mit(*namen): + m = EmailMessage() + m["Subject"] = "Rechnungen" + m["From"] = "rechnung@example.org" + m["To"] = "eingang@example.org" + m.set_content("Im Anhang.") + for i, name in enumerate(namen): + m.add_attachment(f"%PDF-1.7 Nr. {i}".encode(), maintype="application", + subtype="pdf", filename=name) + return m.as_bytes() + + def ordner(name): + p = Path(name) + p.mkdir() + return p + tests: + - name: Ein normaler Anhang + code: |- + o = ordner("a") + r = sichere_anhaenge( + mail_mit("rechnung.pdf"), o) + assert r == ["rechnung.pdf"], r + inhalt = (o / "rechnung.pdf").read_bytes() + assert inhalt == b"%PDF-1.7 Nr. 0" + - name: Pfad aus dem Namen wird ignoriert + code: |- + o = ordner("b") + r = sichere_anhaenge( + mail_mit("../../.bashrc"), o) + assert r == ["bashrc"], r + assert (o / "bashrc").exists() + assert not Path(".bashrc").exists() + - name: Seltsame Zeichen und leere Namen + code: |- + o = ordner("c") + r = sichere_anhaenge( + mail_mit("Rechnung Okt*.pdf", ".."), o) + assert r == ["Rechnung_Okt_.pdf", + "anhang"], r + - name: Gleiche Namen überschreiben nichts + code: |- + o = ordner("d") + r = sichere_anhaenge(mail_mit( + "r.pdf", "r.pdf", "r.pdf"), o) + assert r == ["r.pdf", "r-2.pdf", + "r-3.pdf"], r + b = (o / "r-2.pdf").read_bytes() + assert b == b"%PDF-1.7 Nr. 1", b + - name: Mail ohne Anhang + code: |- + o = ordner("e") + assert sichere_anhaenge( + mail_mit(), o) == [] + hints: + - '`Path("../../.bashrc").name` ist `".bashrc"`.' + - '`re.sub(r"[^A-Za-z0-9._-]", "_", name)` ersetzt alles andere.' + - Für `-2`, `-3` zerlegst du den Namen mit `Path(name).stem` und `.suffix`. + explanation: | + Der Dateiname eines Anhangs ist fremde Eingabe wie ein Formularfeld. Der Starter übernimmt ihn direkt und schreibt mit `../../.bashrc` außerhalb des Ordners; mit zwei gleichnamigen Anhängen überschreibt er den ersten. Die Lösung nimmt nur den letzten Pfadteil, lässt eine kleine Zeichenmenge zu, entfernt führende Punkte gegen versteckte Dateien und `..`, und sucht einen freien Namen. Ein lokaler Test gegen GreenMail zeigt, dass `get_filename()` einen Namen wie `../../etc/passwd` unverändert liefert. + resources: + - *emailmsg + - title: Python · email.parser + url: https://docs.python.org/3/library/email.parser.html +- id: ics-falten + type: code + title: Lange ICS-Zeilen falten + prompt: | + Schreibe `falte(zeile)`. Sie teilt eine ICS-Zeile so auf, dass jede Teilzeile in UTF-8 höchstens 75 Bytes lang ist, und verbindet die Teile mit `"\r\n "` (CRLF und ein Leerzeichen). Das Leerzeichen zählt zur Länge der Fortsetzungszeile. Zeichen werden nie zerteilt. Entfernt man alle `"\r\n "`, entsteht wieder die ursprüngliche Zeile. + lang: python + starter: |- + def falte(zeile): + teile = [] + while zeile: + teile.append(zeile[:75]) + zeile = zeile[75:] + return "\r\n ".join(teile) + solution: |- + def falte(zeile, grenze=75): + teile, aktuell = [], "" + for zeichen in zeile: + neu = aktuell + zeichen + laenge = len(neu.encode()) + if laenge > grenze: + teile.append(aktuell) + aktuell = " " + zeichen + else: + aktuell = neu + teile.append(aktuell) + return "\r\n".join(teile) + setup: |- + def bytes_je_zeile(text): + return [len(z.encode()) for z in text.split("\r\n")] + tests: + - name: Kurze Zeilen bleiben gleich + code: |- + z = "SUMMARY:Kurz" + assert falte(z) == z, falte(z) + - name: Lange ASCII-Zeile + code: |- + z = "DESCRIPTION:" + "x" * 100 + f = falte(z) + assert bytes_je_zeile(f) == [75, 38], ( + bytes_je_zeile(f)) + assert f.replace("\r\n ", "") == z + - name: Umlaute zählen doppelt + code: |- + z = "SUMMARY:" + "ä" * 80 + f = falte(z) + laengen = bytes_je_zeile(f) + assert max(laengen) <= 75, laengen + assert f.replace("\r\n ", "") == z + - name: Das Leerzeichen zählt mit + code: |- + z = "DESCRIPTION:" + "x" * 200 + f = falte(z) + assert bytes_je_zeile(f) == [75, 75, 64], ( + bytes_je_zeile(f)) + - name: Kein Zeichen wird zerteilt + code: |- + z = "X" * 74 + "ü" + "Y" + f = falte(z) + assert f == "X" * 74 + "\r\n üY", ( + repr(f)) + hints: + - Baue die Teilzeile Zeichen für Zeichen auf und miss mit `len(teil.encode())`. + - Eine Fortsetzungszeile beginnt mit `" "`; das Leerzeichen zählt mit. + explanation: | + Der Starter zählt Zeichen statt Bytes und rechnet das Leerzeichen am Anfang jeder Fortsetzung nicht mit; die zweite Zeile wird so 76 Bytes lang. Bei Umlauten wird es schlimmer: 75 `ä` sind 150 Bytes. RFC 5545 misst in Oktetts, und beim Lesen wird die Faltung durch Entfernen von CRLF und genau einem Leerzeichen rückgängig gemacht. Weil die Lösung über Zeichen läuft und Bytes nur misst, kann sie kein `ü` zwischen zwei Zeilen zerreißen. + resources: + - title: RFC 5545 · Content Lines + url: https://www.rfc-editor.org/rfc/rfc5545.html#section-3.1 +- id: ics-termin + type: code + title: Einen Termin als VEVENT schreiben + prompt: | + Schreibe `vevent(uid, beginn, ende, titel, jetzt)`. `beginn`, `ende` und `jetzt` sind `datetime`-Werte mit Zeitzone. Gib diese sieben Zeilen zurück, jede mit `\r\n` abgeschlossen: + + ```text + BEGIN:VEVENT + UID: + DTSTAMP: + DTSTART: + DTEND: + SUMMARY: + END:VEVENT + ``` + + Zeiten stehen in UTC im Format `20261103T080000Z`. Im Titel escapest du Backslash, Semikolon, Komma und Zeilenumbruch nach RFC 5545. Fehlt einer Zeit die Zeitzone, oder liegt `ende` nicht nach `beginn`, wirf `ValueError`. + lang: python + starter: |- + def vevent(uid, beginn, ende, + titel, jetzt): + zeilen = [ + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{jetzt}", + f"DTSTART:{beginn}", + f"DTEND:{ende}", + f"SUMMARY:{titel}", + "END:VEVENT", + ] + return "\n".join(zeilen) + solution: |- + from datetime import UTC + + FORMAT = "%Y%m%dT%H%M%SZ" + ERSATZ = [("\\", "\\\\"), + (";", "\\;"), + (",", "\\,"), + ("\n", "\\n")] + + def utc(zeit): + if zeit.tzinfo is None: + raise ValueError(zeit) + z = zeit.astimezone(UTC) + return z.strftime(FORMAT) + + def esc(t): + for alt, neu in ERSATZ: + t = t.replace(alt, neu) + return t + + def vevent(uid, beginn, ende, + titel, jetzt): + start = utc(beginn) + stop = utc(ende) + if ende <= beginn: + raise ValueError("Ende") + zeilen = [ + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{utc(jetzt)}", + f"DTSTART:{start}", + f"DTEND:{stop}", + f"SUMMARY:{esc(titel)}", + "END:VEVENT", + ] + text = "\r\n".join(zeilen) + return text + "\r\n" + setup: |- + from datetime import datetime, UTC + from zoneinfo import ZoneInfo + + BERLIN = ZoneInfo("Europe/Berlin") + JETZT = datetime(2026, 10, 24, 8, 0, tzinfo=UTC) + tests: + - name: Aufbau mit CRLF + code: |- + a = datetime(2026, 11, 3, 8, tzinfo=UTC) + e = datetime(2026, 11, 3, 9, tzinfo=UTC) + t = vevent("r-4711@example.org", a, e, + "Rechnung fällig", JETZT) + assert t == ( + "BEGIN:VEVENT\r\n" + "UID:r-4711@example.org\r\n" + "DTSTAMP:20261024T080000Z\r\n" + "DTSTART:20261103T080000Z\r\n" + "DTEND:20261103T090000Z\r\n" + "SUMMARY:Rechnung fällig\r\n" + "END:VEVENT\r\n"), repr(t) + - name: Berliner Zeit wird UTC + code: |- + a = datetime(2026, 11, 3, 9, tzinfo=BERLIN) + e = datetime(2026, 11, 3, 10, tzinfo=BERLIN) + t = vevent("x", a, e, "T", JETZT) + assert "DTSTART:20261103T080000Z\r\n" in t, t + - name: Escaping in der richtigen Reihenfolge + code: |- + a = datetime(2026, 11, 3, 8, tzinfo=UTC) + e = datetime(2026, 11, 3, 9, tzinfo=UTC) + t = vevent("x", a, e, + "C:\\Bericht; Q3, final", JETZT) + soll = "SUMMARY:C:\\\\Bericht\\; Q3\\, final\r\n" + assert soll in t, repr(t) + - name: Ungültige Zeiten + code: |- + a = datetime(2026, 11, 3, 8, tzinfo=UTC) + ohne = datetime(2026, 11, 3, 9) + for ende in (a, ohne): + try: + vevent("x", a, ende, "T", JETZT) + except ValueError: + pass + else: + assert False, f"{ende!r}" + hints: + - '`zeit.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")` liefert das UTC-Format.' + - Ersetze den Backslash zuerst, dann Semikolon, Komma und Zeilenumbruch. + - Prüfe `zeit.tzinfo is None` selbst. `astimezone` würde eine Zeit ohne Zone stillschweigend als Ortszeit deuten, und ein Vergleich mit einer Zeit mit Zone wirft `TypeError` statt `ValueError`. + explanation: | + Der Starter schreibt `datetime`-Werte im Python-Format, trennt mit `\n` und escaped nichts. Ein strenger Kalender lehnt das ab, ein toleranter zeigt falsche Zeiten. Die Lösung rechnet jede Zeit in UTC um und hängt `Z` an; so ist der Termin ohne `VTIMEZONE` eindeutig. 9 Uhr in Berlin ist am 3. November 8 Uhr UTC, weil die Sommerzeit dann vorbei ist. Eine Zeit ohne Zone wäre „irgendwo 9 Uhr“ und wird abgelehnt. + resources: + - *ics + - title: Python · datetime.astimezone + url: https://docs.python.org/3/library/datetime.html#datetime.datetime.astimezone +- id: ics-lesen + type: code + title: Termine aus einer ICS-Datei lesen + prompt: | + Schreibe `lies_termine(text)`. Gib für jeden `VEVENT` ein Dictionary mit den Schlüsseln `uid`, `summary` und `dtstart` zurück, soweit die Eigenschaften vorkommen. + + - Entfalte zuerst: CRLF oder LF, gefolgt von einem Leerzeichen oder Tab, wird entfernt. + - Eine Zeile ist `NAME;parameter=…:wert`. Nimm den Namen bis zum ersten `;` oder `:`, den Wert ab dem ersten `:`. + - `summary` wird zurück-escaped: `\n` und `\N` werden Zeilenumbruch, `\,`, `\;` und `\\` werden Komma, Semikolon und Backslash. `dtstart` und `uid` bleiben roh. + lang: python + starter: |- + def lies_termine(text): + termine = [] + for z in text.split("\r\n"): + if z[:8] == "SUMMARY:": + termine.append( + {"summary": z[8:]}) + return termine + solution: |- + import re + + FALTE = re.compile(r"\r?\n[ \t]") + ESC = re.compile(r"\\(.)") + FELDER = {"UID": "uid", + "SUMMARY": "summary", + "DTSTART": "dtstart"} + + def ersetze(m): + if m[1] in "nN": + return "\n" + return m[1] + + def unesc(w): + return ESC.sub(ersetze, w) + + def lies_termine(text): + text = FALTE.sub("", text) + termine, akt = [], None + for z in text.splitlines(): + teile = z.partition(":") + kopf, _, wert = teile + name = kopf.split(";")[0] + if z == "BEGIN:VEVENT": + akt = {} + elif z == "END:VEVENT": + termine.append(akt) + akt = None + elif akt is None: + continue + elif name in FELDER: + if name == "SUMMARY": + wert = unesc(wert) + k = FELDER[name] + akt[k] = wert + return termine + tests: + - name: Ein einfacher Termin + code: |- + t = ("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" + "UID:a1\r\nSUMMARY:Review\r\n" + "DTSTART:20261103T080000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n") + r = lies_termine(t) + assert r == [{"uid": "a1", + "summary": "Review", + "dtstart": "20261103T080000Z"}], r + - name: Gefaltete Zeile + code: |- + t = ("BEGIN:VEVENT\r\nSUMMARY:Abgabe Quart\r\n" + " alsbericht\r\nEND:VEVENT\r\n") + r = lies_termine(t) + assert r == [{"summary": + "Abgabe Quartalsbericht"}], r + - name: Parameter und Escaping + code: |- + t = ("BEGIN:VEVENT\r\n" + "DTSTART;TZID=Europe/Berlin:20261103T090000\r\n" + "SUMMARY:Raum 3\\, OG\\; Zugang: B\\nKarte\r\n" + "END:VEVENT\r\n") + r = lies_termine(t)[0] + assert r["dtstart"] == "20261103T090000", r + assert r["summary"] == ( + "Raum 3, OG; Zugang: B\nKarte"), r + - name: Backslash vor n bleibt Backslash + code: |- + t = ("BEGIN:VEVENT\r\n" + "SUMMARY:C:\\\\neu\r\nEND:VEVENT\r\n") + r = lies_termine(t)[0]["summary"] + assert r == "C:\\neu", repr(r) + - name: Zwei Termine, nur LF + code: |- + t = ("BEGIN:VEVENT\nUID:1\nEND:VEVENT\n" + "BEGIN:VEVENT\nUID:2\nEND:VEVENT\n") + r = lies_termine(t) + assert r == [{"uid": "1"}, {"uid": "2"}], r + hints: + - '`re.sub(r"\r?\n[ \t]", "", text)` entfaltet.' + - '`zeile.partition(":")` trennt am ersten Doppelpunkt; der Name endet vor dem ersten `;`.' + - Ersetze Escapes in einem Durchgang von links nach rechts, etwa mit `re.sub(r"\\(.)", …)`. Nacheinander ausgeführte `replace` verwechseln `\\n` mit `\n`. + explanation: | + Ein Leser muss in der Reihenfolge des RFC arbeiten: erst entfalten, dann Zeilen zerlegen, dann Werte zurück-escapen. Der Starter scheitert schon an der Faltung und an LF-Dateien. Beim Zurück-Escapen ist die Falle `\\n`: Das ist ein escaped Backslash, gefolgt von einem `n`, kein Zeilenumbruch. Ein regulärer Ausdruck, der jeden Backslash mit dem folgenden Zeichen als Paar liest, löst das in einem Durchgang. Parameter wie `TZID` gehören zum Namen und werden für dieses Ergebnis abgeschnitten; ein vollständiger Parser wertet sie aus. + resources: + - *ics + - title: Python · re.sub + url: https://docs.python.org/3/library/re.html#re.sub +- id: body-statt-peek + type: bug + title: Neue Rechnungen erscheinen als gelesen + prompt: | + Seit der Job läuft, sieht die Buchhaltung neue Rechnungen im Mailprogramm nicht mehr fett, sondern als gelesen. Der Job soll nur lesen und danach `$Verarbeitet` setzen. Finde die Zeile, die das verursacht, und korrigiere sie. + lang: python + code: |- + def hole(imap, uid): + typ, teile = imap.uid( + "FETCH", uid, + "(BODY[])") + for t in teile: + if isinstance(t, tuple): + return t[1] + raise LookupError(uid) + lines: + - 4 + fix: + - line: 4 + answers: + - '"(BODY.PEEK[])")' + - '"BODY.PEEK[]")' + explanation: | + `BODY[]` setzt laut RFC 9051 das Flag `\Seen` implizit. `BODY.PEEK[]` liefert dieselben Bytes und lässt die Flags in Ruhe. Für die Buchhaltung bleibt die Mail dadurch ungelesen, bis ein Mensch sie öffnet; der Zustand des Jobs steht in `$Verarbeitet`. + resources: + - title: RFC 9051 · FETCH + url: https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.5 +- id: kalender-sync + type: explain + title: Einen Kalender inkrementell spiegeln + prompt: Erkläre, wie diese Funktion einen Google-Kalender lokal spiegelt, warum sie bei `Gone` (HTTP 410) alles löscht + und wann sie das neue Token speichert. + lang: python + code: |- + def sync(api, lokal): + try: + r = api.liste(lokal.token) + except Gone: + lokal.leeren() + r = api.liste(None) + for e in r["items"]: + status = e.get("status") + if status == "cancelled": + lokal.loesche(e["id"]) + else: + lokal.speichere(e) + neu = r["nextSyncToken"] + lokal.token = neu + points: + - Mit Token liefert die API nur Änderungen seit dem letzten Abgleich, ohne Token alles. + - Gelöschte Termine kommen mit Status cancelled und werden lokal entfernt. + - 410 heißt, das Token ist ungültig; der lokale Stand wird verworfen und vollständig neu geladen. + - Das neue Token wird erst gespeichert, nachdem alle Änderungen verarbeitet sind. + explanation: | + Inkrementelle Synchronisation spart Abrufe: Das Sync-Token steht für „alles bis hier ist bekannt“. Google liefert gelöschte Termine in diesem Modus mit, damit der Client sie entfernen kann. Wird das Token ungültig, etwa nach Änderungen an Berechtigungen, antwortet Google mit 410, und die Anleitung verlangt, den lokalen Stand zu verwerfen und neu zu laden. Das Token wird zuletzt gespeichert: Stürzt der Job mittendrin ab, wiederholt der nächste Lauf dieselben Änderungen, statt welche zu überspringen. Der Ausschnitt nimmt an, dass `api.liste` alle Seiten zusammenfasst; das `nextSyncToken` steht bei Google nur auf der letzten. + resources: + - *gsync + - title: Microsoft Graph · Delta query for events + url: https://learn.microsoft.com/en-us/graph/delta-query-events diff --git a/content/automation/modules/robustheit.yaml b/content/automation/modules/robustheit.yaml index d287567..5660a32 100644 --- a/content/automation/modules/robustheit.yaml +++ b/content/automation/modules/robustheit.yaml @@ -73,7 +73,7 @@ lesson: | Die letzte fehlgeschlagene Ausführung wirft erneut; sie wird nicht als `None` zurückgegeben. Pausen gibt es nur zwischen Versuchen. Ein Retry-Budget kann außerdem eine Gesamtdauer und Kosten begrenzen. Einzelne Request-Timeouts addieren sich, deshalb kann ein Job trotz kurzer Einzelgrenzen lange laufen. - Zeitmessung mit [`time.monotonic`](https://docs.python.org/3/library/time.html#time.monotonic) ist für solche Laufzeitbudgets geeignet. Injiziere die Uhr ebenfalls, wenn du die Grenze präzise testen willst. Grenzen sollten vor dem nächsten teuren Schritt geprüft werden und nicht erst nach beliebig viel zusätzlicher Arbeit. + Zeitmessung mit [`time.monotonic`](https://docs.python.org/3/library/time.html#time.monotonic) ist für solche Laufzeitbudgets geeignet. Wie du Budgets, Sperren, Checkpoints und Deduplizierung im Test gezielt scheitern lässt, zeigt das Modul [Fehlerfälle ausführbar machen](/automation/fehlerfaelle). Injiziere die Uhr ebenfalls, wenn du die Grenze präzise testen willst. Grenzen sollten vor dem nächsten teuren Schritt geprüft werden und nicht erst nach beliebig viel zusätzlicher Arbeit. ## Idempotenz braucht einen fachlichen Schlüssel @@ -161,7 +161,7 @@ lesson: | ## Beobachtung und Wiederaufnahme - [`logging`](https://docs.python.org/3/library/logging.html) bietet Schweregrade und strukturierbare Ereignisse. Ein nützliches Log enthält Job-ID, Phase, Anzahl und Fehlerklasse. Ein Erfolgseintrag sollte nach der Bestätigung entstehen. Für vertrauliche Eingaben loggst du Referenzen oder redigierte Ausschnitte statt Rohdaten. + [`logging`](https://docs.python.org/3/library/logging.html) bietet Schweregrade und strukturierbare Ereignisse; JSON-Logs mit Lauf-ID stehen im Modul [Logs und Geheimnisse](/automation/logs-geheimnisse). Ein nützliches Log enthält Job-ID, Phase, Anzahl und Fehlerklasse. Ein Erfolgseintrag sollte nach der Bestätigung entstehen. Für vertrauliche Eingaben loggst du Referenzen oder redigierte Ausschnitte statt Rohdaten. ```python run ereignis = {"job_id": "lauf-42", "phase": "abgeschlossen", "verarbeitet": 12, "fehler": 0} diff --git a/content/automation/modules/scraping-browser.yaml b/content/automation/modules/scraping-browser.yaml index 416eec5..973b9cd 100644 --- a/content/automation/modules/scraping-browser.yaml +++ b/content/automation/modules/scraping-browser.yaml @@ -1,27 +1,37 @@ id: scraping-browser title: Scraping & Browser-Automatisierung level: 2 -minutes: 180 +minutes: 240 summary: HTML strukturiert auswerten und dynamische Oberflächen mit Playwright bedienen – mit stabilen Selektoren, - klaren Grenzen und reproduzierbaren Fixtures. + gespeichertem Login, Waits mit Zeitgrenze, Belegen bei Fehlern und gemocktem Netzwerk. goals: - Du wählst API, statisches HTML oder Browser nach der tatsächlichen Datenquelle. - Du extrahierst HTML mit Parsern und behandelst fehlende Elemente. -- Du nutzt Playwright-Locators und zustandsbezogenes Warten. +- Du nutzt Playwright-Locators und zustandsbezogenes Warten mit bewussten Zeitgrenzen. - Du prüfst Ziel-URLs und trennst Extraktion von schreibenden Aktionen. +- Du speicherst einen Login mit storage_state, zeichnest bei Fehlern Trace und Screenshot auf und ersetzt + Netzwerkantworten mit route. resources: - &id001 title: Beautiful Soup · Documentation url: https://www.crummy.com/software/BeautifulSoup/bs4/doc/ - title: Playwright · Locators url: https://playwright.dev/python/docs/locators -- title: Playwright · Auto-waiting - url: https://playwright.dev/python/docs/actionability -- title: Python · HTMLParser - url: https://docs.python.org/3/library/html.parser.html +- &pwauth + title: Playwright · Authentication + url: https://playwright.dev/python/docs/auth +- &pwnet + title: Playwright · Network + url: https://playwright.dev/python/docs/network +- &pwtrace + title: Playwright · Trace viewer + url: https://playwright.dev/python/docs/trace-viewer +place: pc lesson: | Ein interner Katalog hat keine Exportfunktion. Du möchtest regelmäßig Titel und Detail-Links für einen Bericht übernehmen. Die Seite kann die Daten schon im HTML enthalten, sie später per JavaScript laden oder eine dokumentierte API anbieten. Die richtige Lösung beginnt damit, diese Quelle zu erkennen, statt sofort einen Browser zu starten. + Dieses Modul ist als „Am PC“ markiert, weil Playwright einen echten Browser auf deinem Rechner braucht. Lektion und die meisten Übungen gehen auch unterwegs: Die Playwright-Logik ist dort hinter Fakes geprüft, nur die letzte Praxisaufgabe startet einen Browser. + Automatisiere nur zugängliche, für deinen Auftrag erlaubte Inhalte. Die Beispiele verwenden lokale HTML-Fixtures oder reservierte Beispieldomains. Sie umgehen keine Anmeldung oder Zugriffskontrolle. Das technische Ziel ist eine überprüfbare Extraktion, deren Ergebnis sich bei einer Layoutänderung sichtbar als fehlerhaft meldet. ## Die passende Zugriffsebene wählen @@ -132,6 +142,99 @@ lesson: | Schreibende Aktionen wie Löschen, Bestellen oder Senden brauchen einen ausdrücklich autorisierten Auftrag und eine überprüfbare Auswahl. Ein Scraping-Job sollte solche Funktionen gar nicht anbieten, wenn seine Aufgabe nur Lesen ist. Ein Loginzustand kann Zugangsdaten und Sitzungsrechte enthalten; behandle gespeicherte Browserzustände wie Geheimnisse und lege sie nicht ins Repository. + ## Login-Zustand speichern mit storage_state + + Viele interne Werkzeuge verlangen eine Anmeldung. Bei jedem Lauf neu einzuloggen kostet Zeit, löst Sicherheitsabfragen aus und belastet die Anmeldung des Anbieters. Playwright kann den Zustand eines Browser-Kontexts speichern: Cookies und Local Storage, mit `indexed_db=True` (seit Playwright 1.51) auch IndexedDB. Manche Anwendungen, etwa mit Firebase Authentication, legen ihr Login-Token dort ab; ohne den Parameter fehlt es dann still. [`context.storage_state(path=…)`](https://playwright.dev/python/docs/auth) schreibt ihn als JSON-Datei, `browser.new_context(storage_state=…)` lädt ihn in einen neuen Kontext. Session Storage gehört laut Dokumentation nicht dazu. + + Den ersten Login macht ein Mensch, in einem sichtbaren Browser. Das Skript wartet mit einem Ziel, nämlich der Startseite nach dem Login, und mit einer großzügigen Grenze: + + ```python + from playwright.sync_api import sync_playwright + + STATE = "playwright/.auth/katalog.json" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + context = browser.new_context() + page = context.new_page() + page.goto("https://katalog.example.org/login") + page.wait_for_url("**/start", timeout=300_000) + context.storage_state(path=STATE) + browser.close() + ``` + + Spätere Läufe laden den Zustand und prüfen, ob die Sitzung noch gilt. Landet die Seite wieder beim Login, ist sie abgelaufen; der Job meldet das deutlich, statt auf der Login-Seite nach Kursen zu suchen. + + ```python + context = browser.new_context(storage_state=STATE) + page = context.new_page() + page.goto("https://katalog.example.org/kurse") + if "/login" in page.url: + raise SystemExit("Sitzung abgelaufen: neu anmelden") + ``` + + Die Datei enthält Sitzungs-Cookies, mit denen sich jeder als du ausgeben kann. Die Playwright-Dokumentation empfiehlt einen Ordner `playwright/.auth` in `.gitignore`. Behandle die Datei wie ein Passwort: nur für dich lesbar, nie im Repository, nie in einem Ticket. Eine Sicherheitsabfrage beim Login automatisierst du nicht weg; sie ist der Grund, warum ein Mensch den ersten Login macht. + + ## Warten mit Ziel und Zeitgrenze + + Playwright wartet bei Aktionen wie `click` automatisch, bis das Element bedienbar ist, standardmäßig bis zu 30 Sekunden; `page.set_default_timeout()` ändert das. Assertions mit [`expect`](https://playwright.dev/python/docs/test-assertions) prüfen wiederholt, bis ihr Zustand eintritt, standardmäßig 5 Sekunden; `timeout=` setzt die Grenze für eine einzelne Prüfung. Hängt ein Ergebnis an einer Netzwerkantwort, wartest du auf genau diese Antwort. + + ```python + page.set_default_timeout(15_000) + with page.expect_response("**/api/kurse") as info: + page.get_by_role("button", name="Laden").click() + print(info.value.status) + expect(page.get_by_role("row")).to_have_count(12, timeout=10_000) + ``` + + Ein fester Schlaf ist kein Warten mit Ziel. Die Dokumentation von [`page.wait_for_timeout`](https://playwright.dev/python/docs/api/class-page#page-wait-for-timeout) ist deutlich: „Never wait for timeout in production. Tests that wait for time are inherently flaky.“ + + Eine überschrittene Grenze ist eine Exception, und zwar in zwei Formen. Aktionen wie `click` werfen `playwright.sync_api.TimeoutError`, `expect` wirft `AssertionError`. Fange sie nicht weg. Sie sind der Moment, in dem du Belege sammelst. + + ## Belege bei Fehlern: Tracing und Screenshots + + Ein nächtlicher Browser-Job scheitert, und im Log steht nur „Timeout 15000ms exceeded“. Was war auf der Seite zu sehen? Ein [Trace](https://playwright.dev/python/docs/trace-viewer) zeichnet jeden Schritt auf: Aktionen, DOM-Schnappschüsse, Screenshots, Netzwerk und Konsole. Er entsteht nur, wenn du ihn vor dem Lauf startest. Gespeichert wird er nur, wenn du beim Stoppen einen Pfad angibst. Ein Screenshot der ganzen Seite ergänzt ihn. + + ```python + context.tracing.start(screenshots=True, snapshots=True, + sources=True) + try: + lauf(page) + except Exception: + page.screenshot(path="fehler.png", full_page=True) + context.tracing.stop(path="trace.zip") + raise + else: + context.tracing.stop() + ``` + + Ohne Pfad verwirft `tracing.stop()` die Aufzeichnung; erfolgreiche Läufe hinterlassen so keine Dateien. Das `raise` am Ende ist wichtig: Der Job soll trotzdem scheitern. `playwright show-trace trace.zip` öffnet den Trace am Rechner. Auch [trace.playwright.dev](https://trace.playwright.dev) zeigt ihn an und lädt ihn laut Dokumentation vollständig im Browser, ohne Daten zu übertragen. + + Ein Trace enthält, was der Browser gesehen hat, also auch Cookies, Header und Formulareingaben. Er ist so vertraulich wie die `storage_state`-Datei. Bewahre Belege nur so lange auf, wie du sie zur Diagnose brauchst. + + ## Netzwerk mocken mit route + + [`page.route(muster, handler)`](https://playwright.dev/python/docs/network) fängt Anfragen ab, bevor sie das Netz erreichen. Der Handler entscheidet für jede Anfrage genau einmal: `route.fulfill(…)` antwortet selbst, `route.abort()` bricht ab, `route.continue_()` lässt sie durch. Seit Playwright 1.29 nimmt `fulfill` mit `json=` eine Python-Struktur. + + ```python + KURSE = [{"titel": "Python", "plaetze": 3}] + + def handler(route): + url = route.request.url + if url.endswith("/api/kurse"): + route.fulfill(json=KURSE) + elif "tracker" in url: + route.abort() + else: + route.continue_() + + page.route("**/*", handler) + ``` + + Damit testest du die Extraktion gegen feste Daten, simulierst einen Fehler 500 oder eine leere Liste und blockierst Tracker, die einen Test nur langsamer machen. Das Muster ist ein Glob, der **die ganze URL** treffen muss: `**` steht für beliebige Zeichen einschließlich `/`, `*` für beliebige Zeichen außer `/`. `"/api/kurse"` trifft deshalb `http://katalog.test/api/kurse` nicht, `"**/api/kurse"` schon. Ein lokaler Lauf zeigt genau das: Mit dem kurzen Muster kommen die echten Daten durch. + + Zwei Grenzen gehören dazu. Anfragen eines Service Workers sieht `route` unter Umständen nicht; `browser.new_context(service_workers="block")` schaltet ihn ab. Und Mocks gehören in Tests. Ein Job, der echte Daten abholen soll, darf nicht stillschweigend gegen gemockte Antworten laufen. + ## Häufige Fehler und Wartung Ein Parser, der plötzlich null Datensätze liefert, kann eine leere Quelle oder einen kaputten Selektor sehen. Diese Fälle sind ohne zusätzliche Prüfung nicht unterscheidbar. Nutze bekannte Strukturmerkmale und zähle erwartete Container. Speichere für Tests kleine anonymisierte HTML-Fixtures, damit Änderungen am Extraktor reproduzierbar sind. @@ -158,9 +261,12 @@ lesson: | - Wähle API, HTML-Parser oder Browser anhand der Quelle. - Nutze Struktur und prüfe fehlende oder mehrdeutige Treffer. - Löse Links auf und begrenze tatsächliche Ziele. - - Locators und Assertions ersetzen pauschale Schlafzeiten. + - Locators und Assertions ersetzen pauschale Schlafzeiten; jede Wartezeit hat eine bewusste Grenze. - Extraktion und schreibende Aktionen bleiben getrennte Aufträge. - Fixtures machen Layoutänderungen überprüfbar. + - `storage_state` spart den Login und ist so vertraulich wie ein Passwort. + - Bei Fehlern sichern Trace und Screenshot, was der Browser gesehen hat; der Job scheitert trotzdem. + - `route` ersetzt Antworten in Tests; das Muster muss die ganze URL treffen. exercises: - id: zugriff type: choice @@ -340,6 +446,208 @@ exercises: dass dann zufällig der erste falsche Wert verwendet wird. resources: - *id001 +- id: route-handler + type: code + title: Ein Handler für route + prompt: | + Schreibe `handler(route)` für `page.route("**/*", handler)`. `route.request` hat `.url` und `.method`. Entscheide für jede Anfrage genau einmal: + + - Host ist nicht `katalog.test`: `route.abort()`. + - Jede Methode außer `GET` und `HEAD` auf einen Pfad, der mit `/api/` beginnt: `route.fulfill(status=403, json={"fehler": "nur lesen"})`. Der Scraper darf nichts verändern. + - `GET` auf den Pfad `/api/kurse`: `route.fulfill(json=KURSE)`. + - Alles andere: `route.continue_()`. + + Prüfe den Host mit `urllib.parse.urlsplit`, nicht mit `in`. + lang: python + starter: |- + from urllib.parse import urlsplit + + KURSE = [{"titel": "Python", "plaetze": 3}] + + def handler(route): + url = route.request.url + if "/api/kurse" in url: + route.fulfill(json=KURSE) + route.continue_() + solution: |- + from urllib.parse import urlsplit + + KURSE = [{"titel": "Python", "plaetze": 3}] + + def handler(route): + anfrage = route.request + teile = urlsplit(anfrage.url) + if teile.hostname != "katalog.test": + route.abort() + elif (anfrage.method not in ("GET", "HEAD") + and teile.path.startswith("/api/")): + route.fulfill(status=403, + json={"fehler": "nur lesen"}) + elif (anfrage.method == "GET" + and teile.path == "/api/kurse"): + route.fulfill(json=KURSE) + else: + route.continue_() + setup: |- + class _Anfrage: + def __init__(self, url, method): + self.url = url + self.method = method + + class FakeRoute: + """Merkt sich die Entscheidungen wie Playwrights Route (fulfill, abort, continue_).""" + def __init__(self, url, method="GET"): + self.request = _Anfrage(url, method) + self.aufrufe = [] + def fulfill(self, **kw): + self.aufrufe.append(("fulfill", kw)) + def abort(self, error_code="failed"): + self.aufrufe.append(("abort", {})) + def continue_(self, **kw): + self.aufrufe.append(("continue_", kw)) + + def entscheide(url, method="GET"): + r = FakeRoute(url, method) + handler(r) + assert len(r.aufrufe) == 1, f"{url}: {len(r.aufrufe)} Entscheidungen statt genau einer" + return r.aufrufe[0] + tests: + - name: Kurse aus der Fixture + code: |- + art, kw = entscheide("http://katalog.test/api/kurse") + assert art == "fulfill", art + assert kw.get("json") == KURSE, kw + - name: Fremde Hosts werden blockiert + code: |- + for url in ["https://tracker.example.com/p.js", + "http://katalog.test.evil.example/api/kurse"]: + art, _ = entscheide(url) + assert art == "abort", f"{url}: {art}" + - name: Schreibende API-Aufrufe werden abgelehnt + code: |- + for m in ("POST", "PUT", "PATCH", "DELETE"): + art, kw = entscheide("http://katalog.test/api/buchen/7", m) + assert art == "fulfill" and kw.get("status") == 403, (m, art, kw) + - name: Seiten und Bilder laufen weiter + code: |- + for url in ["http://katalog.test/kurse", + "http://katalog.test/logo.png"]: + art, _ = entscheide(url) + assert art == "continue_", f"{url}: {art}" + hints: + - '`urlsplit(url).hostname` liefert den Host ohne Port, `.path` den Pfad.' + - Jeder Zweig ruft genau eine der drei Methoden auf. Eine `if … elif … else`-Kette sorgt dafür. + explanation: | + Playwright erwartet, dass ein Handler jede Anfrage genau einmal erledigt. Der Starter ruft nach `fulfill` zusätzlich `continue_` auf, und er erkennt `katalog.test.evil.example` als eigenen Host, weil `in` nur Teilstrings sucht. `urlsplit` vergleicht den echten Hostnamen. Die 403 für `POST` macht aus dem Test eine Sicherung: Versucht der Scraper doch zu buchen, scheitert die Anfrage, statt etwas zu verändern. + resources: + - *pwnet + - title: Playwright · Route + url: https://playwright.dev/python/docs/api/class-route +- id: belege-bei-fehler + type: code + title: Trace und Screenshot nur bei Fehlern + prompt: | + Schreibe `mit_belegen(context, page, aktion, name)`. + + - Starte `context.tracing.start(screenshots=True, snapshots=True)` und rufe dann `aktion(page)` auf. + - Gelingt sie, stoppe mit `context.tracing.stop()` ohne Pfad und gib das Ergebnis von `aktion` zurück. + - Wirft sie, speichere `page.screenshot(path=f"{name}.png", full_page=True)` und `context.tracing.stop(path=f"{name}.zip")` und wirf danach die ursprüngliche Exception weiter. + - Scheitert der Screenshot selbst, wird der Trace trotzdem gespeichert, und es kommt die ursprüngliche Exception durch. + lang: python + starter: |- + def mit_belegen(context, page, aktion, name): + context.tracing.start(screenshots=True, + snapshots=True) + try: + return aktion(page) + except Exception: + page.screenshot(path=f"{name}.png") + return None + finally: + context.tracing.stop() + solution: |- + import contextlib + + def mit_belegen(context, page, aktion, name): + context.tracing.start(screenshots=True, + snapshots=True) + try: + ergebnis = aktion(page) + except Exception: + with contextlib.suppress(Exception): + page.screenshot(path=f"{name}.png", + full_page=True) + context.tracing.stop(path=f"{name}.zip") + raise + context.tracing.stop() + return ergebnis + setup: |- + class FakeTracing: + def __init__(self): + self.log = [] + def start(self, **kw): + self.log.append(("start", kw)) + def stop(self, path=None): + self.log.append(("stop", path)) + + class FakeContext: + def __init__(self): + self.tracing = FakeTracing() + + class FakePage: + def __init__(self, kaputt=False): + self.kaputt = kaputt + self.bilder = [] + def screenshot(self, **kw): + if self.kaputt: + raise RuntimeError("Seite abgestürzt") + self.bilder.append(kw) + + def scheitert(page): + raise TimeoutError("Timeout 15000ms exceeded") + tests: + - name: Erfolg – Trace verwerfen, Ergebnis zurück + code: |- + ctx, page = FakeContext(), FakePage() + r = mit_belegen(ctx, page, lambda p: 42, "lauf") + assert r == 42, r + assert ctx.tracing.log == [ + ("start", {"screenshots": True, "snapshots": True}), + ("stop", None)], ctx.tracing.log + assert page.bilder == [] + - name: Fehler – Screenshot, Trace, Exception + code: |- + ctx, page = FakeContext(), FakePage() + try: + mit_belegen(ctx, page, scheitert, "lauf-7") + except TimeoutError: + pass + else: + assert False, "Die Exception wurde verschluckt." + assert page.bilder == [{"path": "lauf-7.png", + "full_page": True}], page.bilder + assert ctx.tracing.log[-1] == ("stop", "lauf-7.zip"), ( + ctx.tracing.log) + - name: Kaputter Screenshot verdeckt nichts + code: |- + ctx, page = FakeContext(), FakePage(kaputt=True) + try: + mit_belegen(ctx, page, scheitert, "lauf-8") + except TimeoutError: + pass + except RuntimeError: + assert False, "Der Screenshot-Fehler verdeckt die Ursache." + assert ctx.tracing.log[-1] == ("stop", "lauf-8.zip"), ( + ctx.tracing.log) + hints: + - Im `except`-Zweig sammelst du die Belege und beendest mit einem nackten `raise`. + - '`contextlib.suppress(Exception)` lässt einen Fehler beim Screenshot ins Leere laufen.' + explanation: | + Der Starter verschluckt die Exception mit `return None`, und der Job meldet Erfolg. Außerdem stoppt er den Trace immer ohne Pfad, sodass gerade im Fehlerfall nichts gespeichert wird. Die Lösung sammelt Belege nur dann, wenn sie gebraucht werden, und wirft danach dieselbe Exception weiter. Ein Screenshot einer abgestürzten Seite kann selbst scheitern; dieser Folgefehler darf weder den Trace verhindern noch die eigentliche Ursache verdecken. + resources: + - *pwtrace + - title: Python · contextlib.suppress + url: https://docs.python.org/3/library/contextlib.html#contextlib.suppress - id: selektor type: bug title: Die fehlende Karte wird verschwiegen @@ -361,6 +669,30 @@ exercises: von einem erfolgreichen leeren Ergebnis. resources: - *id001 +- id: route-muster + type: bug + title: Der Mock greift nicht + prompt: | + Der Test soll die Kursliste aus einer Fixture bekommen, lädt aber die echten Daten von `http://katalog.test/api/kurse`. Finde die Zeile, die das verursacht, und korrigiere sie. + lang: python + code: |- + def test_kurse(page): + page.route("/api/kurse", liefere_kurse) + page.goto("http://katalog.test/") + page.get_by_role("button", name="Laden").click() + expect(page.get_by_role("row")).to_have_count(2) + lines: + - 2 + fix: + - line: 2 + answers: + - page.route("**/api/kurse", liefere_kurse) + - page.route("http://katalog.test/api/kurse", liefere_kurse) + - page.route("**/api/kurse*", liefere_kurse) + explanation: | + Ein Glob in `page.route` muss die ganze URL treffen. `"/api/kurse"` passt nicht auf `http://katalog.test/api/kurse`, also geht die Anfrage ans Netz. `"**/api/kurse"` lässt beliebige Zeichen davor zu. Ein lokaler Lauf mit Playwright 1.63 zeigt genau dieses Verhalten. Nur wenn der Kontext eine `base_url` hat, löst Playwright ein Muster ohne `*` relativ dazu auf. + resources: + - *pwnet - id: warten type: explain title: Warum eine Assertion nach dem Klick? @@ -381,26 +713,84 @@ exercises: - id: fixture-browser type: practice title: Ein lokaler Katalogtest - prompt: Lege eine lokale HTML-Seite mit Suchfeld, Suchen-Button und Kursüberschrift an. Bediene sie mit Playwright - und prüfe die Extraktion. + prompt: | + Am PC mit installiertem Playwright: Baue einen Test gegen einen Katalog, den du selbst kontrollierst. Liefere Login-Seite, Katalogseite und `/api/kurse` über `context.route` aus, ganz ohne Server. Melde dich einmal an und speichere den Zustand mit `storage_state`; ein zweiter Kontext lädt ihn und kommt ohne Login auf die Katalogseite. Klicke „Laden“, warte auf die Antwort von `/api/kurse` und prüfe die Zeilen. Bei einem Fehler entstehen Screenshot und Trace, bei Erfolg keine Dateien. lang: python solution: |- - from playwright.sync_api import sync_playwright, expect + from pathlib import Path + from playwright.sync_api import expect, sync_playwright + + LOGIN = """ + """ + KATALOG = """

Kurse

+ """ + KURSE = [{"titel": "Python"}, {"titel": "Rust"}] + STATE = Path("playwright/.auth/katalog.json") + + def server(route): + req = route.request + if req.url.endswith("/api/kurse"): + route.fulfill(json=KURSE) + elif req.url.endswith("/login"): + route.fulfill(body=LOGIN, content_type="text/html") + elif "sitzung=" in (req.header_value("cookie") or ""): + route.fulfill(body=KATALOG, content_type="text/html") + else: + route.fulfill(body='', + content_type="text/html") + + def anmelden(browser): + context = browser.new_context() + context.route("http://katalog.test/**", server) + page = context.new_page() + page.goto("http://katalog.test/login") + page.get_by_label("Name").fill("test") + page.get_by_role("button", name="Anmelden").click() + page.wait_for_url("**/kurse", timeout=10_000) + STATE.parent.mkdir(parents=True, exist_ok=True) + context.storage_state(path=STATE) + context.close() + + def katalog_pruefen(page): + page.goto("http://katalog.test/kurse") + if "/login" in page.url: + raise RuntimeError("Sitzung abgelaufen") + with page.expect_response("**/api/kurse") as info: + page.get_by_role("button", name="Laden").click() + assert info.value.status == 200 + expect(page.get_by_role("row")).to_have_count(2) + with sync_playwright() as p: browser = p.chromium.launch() - page = browser.new_page() - page.set_content('

Python

') - page.get_by_label("Suche").fill("Python") - page.get_by_role("button", name="Suchen", exact=True).click() - expect(page.get_by_role("heading", name="Python", exact=True)).to_be_visible() - browser.close() + anmelden(browser) + context = browser.new_context(storage_state=STATE) + context.route("http://katalog.test/**", server) + context.tracing.start(screenshots=True, snapshots=True) + page = context.new_page() + try: + katalog_pruefen(page) + except Exception: + page.screenshot(path="fehler.png", full_page=True) + context.tracing.stop(path="trace.zip") + raise + else: + context.tracing.stop() + finally: + browser.close() checklist: - - Der Test benutzt eine selbst kontrollierte lokale Fixture. - - Selektoren verwenden Label, Rolle oder stabile Test-ID. - - Fehlende Überschrift lässt die Prüfung scheitern. - - Es gibt keine pauschalen sleep-Aufrufe. - - Browser und temporäre Daten werden aufgeräumt. - explanation: Die Fixture prüft die Bedien- und Selektorlogik ohne fremden Dienst. Eine echte dynamische Suchfunktion - benötigt zusätzlich ihren eigenen Ergebnis- und Ladezustandstest. + - Alle Seiten und die API kommen aus `route`; der Test braucht kein Netz und keinen Server. + - Der zweite Kontext lädt `storage_state` und landet ohne Login auf der Katalogseite. + - '`playwright/.auth/` steht in `.gitignore`.' + - Gewartet wird auf die Antwort von `/api/kurse` und auf die Zeilen, nie mit festen Pausen. + - Ein absichtlich falscher Button-Name erzeugt `fehler.png` und `trace.zip`, und der Test scheitert. + - Ein erfolgreicher Lauf hinterlässt keinen Trace. + explanation: | + Die Musterlösung lief so mit Playwright 1.63 und Chromium. Der Katalog liegt vollständig in `route`; dadurch prüfst du Login-Zustand, Warten und Extraktion reproduzierbar, ohne einen fremden Dienst zu berühren. Das Cookie ist ein Platzhalter, der Ablauf aber derselbe wie bei einer echten Anmeldung. Probiere den Fehlerfall bewusst aus und öffne den Trace mit `playwright show-trace trace.zip`: Dort siehst du jeden Schritt mit Schnappschuss. resources: - - *id001 + - *pwauth + - *pwtrace diff --git a/content/automation/modules/web-apis.yaml b/content/automation/modules/web-apis.yaml index ce9ec74..40e3e1a 100644 --- a/content/automation/modules/web-apis.yaml +++ b/content/automation/modules/web-apis.yaml @@ -50,7 +50,7 @@ lesson: | Die Standardbibliothek bietet mit [`urllib.parse.urlencode`](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode) denselben Grundbaustein für eigene URL-Konstruktion. - Ein Token gilt für einen bestimmten Dienst. Folge extern gelieferten nächsten URLs nicht ungeprüft mit denselben Authentifizierungsheadern zu beliebigen Hosts. Bei Redirects und absoluten Paginierungslinks musst du den Zielvertrag des Clients und der API kennen. Eine reine Cursor-Paginierung an einer festgelegten Basisadresse macht diese Grenze oft leichter kontrollierbar. + Ein Token gilt für einen bestimmten Dienst. Wie Tokens entstehen, ablaufen und genau einmal erneuert werden, zeigt das Modul [API-Authentifizierung](/automation/api-authentifizierung). Folge extern gelieferten nächsten URLs nicht ungeprüft mit denselben Authentifizierungsheadern zu beliebigen Hosts. Bei Redirects und absoluten Paginierungslinks musst du den Zielvertrag des Clients und der API kennen. Eine reine Cursor-Paginierung an einer festgelegten Basisadresse macht diese Grenze oft leichter kontrollierbar. ## Erfolg in mehreren Schichten prüfen @@ -117,7 +117,7 @@ lesson: | print(sekunden_header("Mon, 28 Sep 2026 12:00:00 GMT")) ``` - Überschreitet eine verlangte Pause dein Jobbudget, brich ab oder plane einen späteren Lauf. Verkürze sie nicht still und belaste den Dienst früher als erlaubt. Ein Retry-Budget umfasst Anzahl, Gesamtzeit und gegebenenfalls Kosten. Ein Endlosloop ist keine Robustheit. + Das Modul [Fehlerfälle ausführbar machen](/automation/fehlerfaelle) liest Retry-After auch als HTTP-Datum und baut daraus eine Wiederholung mit Deadline, die du im Test mit einer Fake-Uhr prüfst. Überschreitet eine verlangte Pause dein Jobbudget, brich ab oder plane einen späteren Lauf. Verkürze sie nicht still und belaste den Dienst früher als erlaubt. Ein Retry-Budget umfasst Anzahl, Gesamtzeit und gegebenenfalls Kosten. Ein Endlosloop ist keine Robustheit. ## Testen mit nachvollziehbaren Fakes diff --git a/content/automation/modules/zeitsteuerung.yaml b/content/automation/modules/zeitsteuerung.yaml index 9f52bd3..82ea5ee 100644 --- a/content/automation/modules/zeitsteuerung.yaml +++ b/content/automation/modules/zeitsteuerung.yaml @@ -118,7 +118,7 @@ lesson: | ## Überschneidung und verpasste Arbeit fachlich lösen - Ein Timer aktiviert einen bereits aktiven Dienst nicht als zweite gleichnamige Instanz. Das verhindert jedoch nicht jeden anderen Startweg, etwa einen parallel manuell gestarteten Python-Prozess. Wenn mehrere Startquellen möglich sind, brauche einen gemeinsamen Lock oder eine Datenbankregel. Eine Datei bloß auf Existenz zu prüfen und danach anzulegen ist zwischen zwei Prozessen nicht atomar. + Ein Timer aktiviert einen bereits aktiven Dienst nicht als zweite gleichnamige Instanz. Das verhindert jedoch nicht jeden anderen Startweg, etwa einen parallel manuell gestarteten Python-Prozess. Wenn mehrere Startquellen möglich sind, brauche einen gemeinsamen Lock oder eine Datenbankregel. Eine Datei bloß auf Existenz zu prüfen und danach anzulegen ist zwischen zwei Prozessen nicht atomar; ein atomares Lockfile und `flock` übst du im Modul [Fehlerfälle ausführbar machen](/automation/fehlerfaelle). ```python run def faellig(letzter_erfolg, jetzt, intervall): diff --git a/content/automation/projects.yaml b/content/automation/projects.yaml index 73da1ab..f07d285 100644 --- a/content/automation/projects.yaml +++ b/content/automation/projects.yaml @@ -148,8 +148,10 @@ projects: brief: '## Auftrag Baue ein CLI, das aus einer genehmigten API eine paginierte Liste von Ereignissen abruft und neue - Ereignisse in einem Bericht zusammenfasst. Entwickle zunächst mit JSON-Fixtures und einem Fake-Sender. - Danach kann ein bewusst konfigurierter ntfy- oder anderer Nachrichtenadapter verwendet werden. + Ereignisse in einem Bericht zusammenfasst. Die API verlangt OAuth 2.0: Access-Tokens gelten eine Stunde, + erneuert wird mit einem Refresh-Token, das der Server bei jeder Erneuerung rotiert. Entwickle zunächst + mit JSON-Fixtures, einem Fake-Token-Server und einem Fake-Sender. Danach kann ein bewusst konfigurierter + ntfy- oder anderer Nachrichtenadapter verwendet werden. ## Vertrag @@ -160,6 +162,12 @@ projects: passenden Empfängervertrag solltest du keine Exactly-once-Zustellung versprechen. + Das Access-Token wird kurz vor Ablauf erneuert; kommt trotzdem ein 401, erneuert der Abruf genau einmal + und wiederholt die Anfrage einmal. Ein rotiertes Refresh-Token wird atomar gespeichert, bevor das neue + Access-Token benutzt wird. `invalid_grant` beendet den Lauf mit der Meldung, dass eine neue Anmeldung + nötig ist. + + ## Betrieb Ein systemd-Timer startet das Werkzeug mit explizitem Arbeitsverzeichnis und Konfiguration. Echte @@ -168,6 +176,7 @@ projects: zum Ablauf.' skills: - HTTP + - OAuth 2.0 - Pagination - SQLite - Fakes @@ -182,6 +191,12 @@ projects: title: Abrufadapter detail: Setze Timeouts, Statusprüfung, Seitenlimit und erkenne wiederholte Cursor. Der Fake bildet dieselbe Schnittstelle ab. + - id: oauth-refresh + title: Token-Erneuerung mit Rotation + detail: Baue einen Token-Cache mit injizierter Uhr und Puffer vor dem Ablauf. Ein Fake-Token-Server rotiert + das Refresh-Token und kann 401, abgelaufene Tokens und `invalid_grant` liefern. Speichere das neue + Refresh-Token über eine temporäre Datei und `os.replace`; im Betrieb liegt es als systemd-Credential oder im + Schlüsselbund, nie im Repository. - id: schritt-3 title: Änderungen bestimmen detail: Vergleiche stabile IDs. Eine reine Funktion erstellt Nachrichtenkandidaten ohne Versand. @@ -218,11 +233,125 @@ projects: text: Ein Abbruch lässt offene Zustellungen nachvollziehbar zurück. - id: abnahme-6 text: Timer und manueller Aufruf verwenden denselben fachlichen Ablauf. + - id: token-erneuerung + text: Ein Test mit Fake-Token-Server zeigt genau eine Erneuerung bei 401, einen Abbruch beim zweiten 401 und ein + gespeichertes rotiertes Refresh-Token; `invalid_grant` führt ohne Wiederholung zu einer klaren Meldung. stretch: - Eine zweite Quelle mit eigenem Adapter ergänzen. - Zusammenfassung pro Zeitfenster statt Einzelmeldungen anbieten. - portfolio: Führe den zweiten identischen Abruf und einen simulierten Zustellfehler vor. Erkläre die - Lücke zwischen „Dienst hat angenommen“ und „lokal als zugestellt gespeichert“. + portfolio: Führe den zweiten identischen Abruf, einen simulierten Zustellfehler und ein abgelaufenes Token vor. + Erkläre die Lücke zwischen „Dienst hat angenommen“ und „lokal als zugestellt gespeichert“ und warum der + Abruf bei 401 genau einmal erneuert. + capstone: false +- id: postfach-rechnungen + title: Rechnungseingang aus dem Postfach + level: 2 + hours: 24 + summary: Am PC – ein Job liest Rechnungen per IMAP, speichert Anhänge sicher, führt eine Excel-Übersicht und + legt Fälligkeiten als ICS-Termine an. + brief: '## Auftrag + + Ein kleines Büro bekommt Rechnungen als PDF per Mail an ein gemeinsames Postfach. Bisher lädt jemand sie jeden + Montag herunter, trägt Betrag und Fälligkeit in eine Excel-Liste ein und setzt sich Erinnerungen im Kalender. + Baue einen Job, der das übernimmt, ohne dass Menschen im Postfach etwas davon merken: Mails bleiben ungelesen, + nichts wird gelöscht oder verschoben. + + + ## Vertrag + + Eine Rechnungsmail hat einen Betreff nach dem Muster `Rechnung RE-2026-0042 · fällig 03.11.2026 · 120,50 EUR` + und genau einen PDF-Anhang. Der Job holt Nachrichten ohne das Schlüsselwort `$Verarbeitet` mit `BODY.PEEK[]`, + speichert den Anhang unter einem sicheren Namen, ergänzt die Excel-Übersicht und schreibt eine ICS-Datei mit + einem Termin am Fälligkeitstag. Erst danach setzt er `$Verarbeitet`. Mails, die dem Muster nicht folgen, + landen mit Grund im Blatt „Fehler“ und bleiben unmarkiert. Dieselbe Rechnung zweimal erzeugt keine zweite Zeile + und keinen zweiten Termin. + + + ## Betrieb + + Der Job läuft als systemd-Dienst mit Timer. Das IMAP-Passwort oder OAuth-Token kommt per `LoadCredential=`, auf + dem eigenen Desktop alternativ aus dem Schlüsselbund. `flock` verhindert überlappende Läufe. Logs sind JSON-Zeilen + mit `INVOCATION_ID` als Lauf-ID und enthalten weder Zugangsdaten noch vollständige Mailtexte. + + + ## Übungsrahmen + + Dieses Projekt braucht einen Rechner. Arbeite gegen ein lokales GreenMail in Docker oder ein eigenes Testpostfach, + nie gegen ein echtes Firmenpostfach. Verwende erfundene Rechnungen. KI-Werkzeuge dürfen Tipparbeit wie Boilerplate + beschleunigen. Den Vertrag, die Fehlerbehandlung und die Fehlersuche schreibst und begründest du selbst. + + + Quellen: [imaplib](https://docs.python.org/3/library/imaplib.html), [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051.html), + [email.message](https://docs.python.org/3/library/email.message.html), [openpyxl](https://openpyxl.readthedocs.io/en/stable/tutorial.html), + [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545.html), [systemd.exec Credentials](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Credentials).' + skills: + - IMAP + - email + - openpyxl + - iCalendar + - Regex + - systemd + - Logging + steps: + - id: testpostfach + title: Testpostfach und Fixture-Mails + detail: Starte GreenMail lokal oder richte ein Testkonto ein. Erzeuge Mails mit `EmailMessage` und `smtplib` + – normale Rechnung, Dublette, fehlende Fälligkeit, zwei Anhänge, Dateiname `../../x.pdf`, eine Mail ohne Rechnung. + - id: vertrag + title: Betreffmuster und Fehlerregeln + detail: Schreibe den regulären Ausdruck für den Betreff mit benannten Gruppen und Tests für Grenzfälle wie + Beträge mit Tausenderpunkt. Lege fest, welcher Fehler eine Mail unmarkiert lässt und wie er im Blatt „Fehler“ + steht. + - id: imap-adapter + title: IMAP-Adapter mit Fake + detail: TLS mit `ssl.create_default_context()`, UIDs, `UNKEYWORD`, `BODY.PEEK[]`, `+FLAGS` nach Erfolg. Speichere + UIDVALIDITY und reagiere auf einen neuen Wert. Ein Fake mit denselben Antwortformen deckt die Tests ab. + - id: anhaenge + title: Anhänge prüfen und ablegen + detail: Sichere Namen, keine Überschreibungen, Größengrenze, `%PDF-` am Anfang. Erkenne Dubletten an + Rechnungsnummer oder Prüfsumme, nicht am Dateinamen. + - id: excel + title: Excel-Übersicht + detail: Blätter „Rechnungen“ und „Fehler“, Beträge als Zahlen mit Zahlenformat, Datum als Datum, eine + Summenformel, Kopfzeile fixiert. Schreibe in eine temporäre Datei und ersetze die Übersicht erst danach. + - id: kalender + title: Fälligkeiten als ICS + detail: Ein Termin pro Rechnung mit stabiler UID aus der Rechnungsnummer, UTC-Zeiten oder ganztägigem Datum, + CRLF, Faltung und Escaping. Prüfe die Datei mit einem zweiten Parser oder durch Import in einen Testkalender. + - id: betrieb + title: Dienst, Timer, Credential, Lock + detail: Service und Timer mit absoluten Pfaden, `LoadCredential=` für das Geheimnis, `flock` gegen Überlappung, + JSON-Logs nach stderr. Prüfe mit `systemctl show`, dass das Geheimnis nicht in der Unit sichtbar ist. + - id: fehlerfaelle + title: Fehlerfälle ausführen + detail: Teste einen Absturz zwischen Speichern und Flag, eine wiederholte Mail, einen neuen UIDVALIDITY-Wert, + einen nicht erreichbaren Server und einen zweiten gleichzeitigen Start. + - id: uebergabe + title: README und Runbook + detail: Beschreibe Einrichtung mit GreenMail, Betrieb, wo das Geheimnis liegt, wie man es widerruft und wie + man eine einzelne Mail erneut verarbeiten lässt. + acceptance: + - id: ohne-netz + text: Unit-Tests laufen mit Fakes ohne Netzwerk und ohne echtes Postfach. + - id: ungelesen + text: Nach einem Lauf sind alle Mails für Menschen weiter ungelesen; nur erfolgreich verarbeitete tragen `$Verarbeitet`. + - id: sichere-namen + text: Kein Anhang wird außerhalb des Zielordners gespeichert oder überschreibt eine vorhandene Datei. + - id: idempotent + text: Ein zweiter Lauf mit denselben Mails erzeugt keine zusätzliche Zeile in Excel und keinen zusätzlichen Termin. + - id: excel-zahlen + text: Beträge sind in Excel Zahlen und summierbar; eine abgebrochene Erstellung lässt die alte Übersicht intakt. + - id: geheimnisse + text: Zugangsdaten stehen weder im Repository noch in Logs noch in `Environment=`. + - id: ueberlappung + text: Ein zweiter gleichzeitiger Start bricht mit klarer Meldung ab, ohne etwas zu verarbeiten. + stretch: + - IDLE (seit Python 3.14 `IMAP4.idle()`) statt Timer, mit Rückfall auf den Timer. + - OAuth 2.0 mit XOAUTH2 gegen ein Testkonto eines großen Anbieters. + - Termine direkt über die Google Calendar API oder Microsoft Graph statt als Datei. + portfolio: Zeige das Testpostfach vor und nach dem Lauf, die Excel-Übersicht und einen importierten Termin. Führe + einen Anhang mit `../../` im Namen und einen Absturz zwischen Speichern und Flag vor und erkläre, warum danach + weder eine Datei außerhalb des Ordners noch eine doppelte Zeile entsteht. capstone: false - id: abschluss-team-digest title: Team-Digest als betreibbarer Dienst diff --git a/src/python/packages.ts b/src/python/packages.ts index 44c1024..d0c04fd 100644 --- a/src/python/packages.ts +++ b/src/python/packages.ts @@ -15,8 +15,20 @@ export const TEST_WHEELS = [ /** Code that needs the wheels: it imports pytest or Hypothesis, or a test calls the harness helper `run_pytest`. */ export const USES_TESTS = /\b(?:pytest|hypothesis|run_pytest)\b/; +// openpyxl for the Excel exercises of the automation course. Pure Python like its only dependency et_xmlfile, and +// not in Pyodide's lock file either, so it loads by file name too. +export const EXCEL_WHEELS = [ + 'openpyxl-3.1.5-py2.py3-none-any.whl', + 'et_xmlfile-2.0.0-py3-none-any.whl', +]; + +/** Code that imports openpyxl. */ +export const USES_EXCEL = /\bopenpyxl\b/; + /** `name==version` for every wheel (wheel file names are `name-version-…`). */ -export const testPins = () => TEST_WHEELS.map((file) => { +const pins = (wheels: string[]) => wheels.map((file) => { const [name, version] = file.split('-'); return `${name}==${version}`; }); +export const testPins = () => pins(TEST_WHEELS); +export const excelPins = () => pins(EXCEL_WHEELS); diff --git a/src/python/worker.ts b/src/python/worker.ts index 333ea34..0f0dce1 100644 --- a/src/python/worker.ts +++ b/src/python/worker.ts @@ -1,7 +1,7 @@ /// // Runs Python in Pyodide, off the main thread. The main thread terminates this worker on timeout. import harness from './harness.py?raw'; -import { TEST_WHEELS, USES_TESTS } from './packages.ts'; +import { EXCEL_WHEELS, TEST_WHEELS, USES_EXCEL, USES_TESTS } from './packages.ts'; type Pyodide = { FS: { writeFile(path: string, data: string): void }; @@ -43,7 +43,8 @@ self.onmessage = async (event: MessageEvent) if (job.kind === 'boot') { self.postMessage({ id: job.id, result: { ready: true } }); return; } if (job.kind === 'prepare') { // Before the imports: resolving `import pytest` through the lock file would ask for wheels that are not there. - if (USES_TESTS.test(job.code)) await pyodide.loadPackage(TEST_WHEELS.map((file) => `${self.location.origin}/pyodide/${file}`)); + const wheels = [...(USES_TESTS.test(job.code) ? TEST_WHEELS : []), ...(USES_EXCEL.test(job.code) ? EXCEL_WHEELS : [])]; + if (wheels.length) await pyodide.loadPackage(wheels.map((file) => `${self.location.origin}/pyodide/${file}`)); try { await pyodide.loadPackagesFromImports(job.code); } catch { /* unknown imports surface as ImportError in Python */ } if (/zoneinfo|ZoneInfo/.test(job.code)) await pyodide.loadPackage('tzdata'); // Exercises that only call run_pytest import pytest inside the timed run; its first import takes a second or two. diff --git a/tooling/verify-code.ts b/tooling/verify-code.ts index 6923449..edd4e91 100644 --- a/tooling/verify-code.ts +++ b/tooling/verify-code.ts @@ -29,7 +29,7 @@ import { assemble, fixedCode, gapSolution, normalizeOutput } from '../src/engine import { languageOf } from '../src/engine/highlight.ts'; import { compareSql } from '../src/engine/sql.ts'; import type { PgEngine } from '../src/pg/engine.ts'; -import { testPins, USES_TESTS } from '../src/python/packages.ts'; +import { excelPins, testPins, USES_EXCEL, USES_TESTS } from '../src/python/packages.ts'; import { nodeEngine } from './pg-node.ts'; import type { BugExercise, GapExercise, OrderExercise, OutputExercise, SqlExercise } from '../src/content/types.ts'; @@ -60,21 +60,21 @@ function exec(command: string, args: string[], options: { input?: string; cwd?: }); } -// pandas and numpy in the browser's versions, pinned by Pyodide's lock file, plus pytest and Hypothesis in the -// versions of the wheels the browser loads. +// pandas and numpy in the browser's versions, pinned by Pyodide's lock file, plus pytest, Hypothesis and openpyxl in +// the versions of the wheels the browser loads. const DATA_PACKAGES = ['numpy', 'pandas', 'python-dateutil', 'pytz', 'six']; const USES_DATA = /^\s*(?:import|from)\s+(?:pandas|numpy)\b/m; let packagePython: Promise | null = null; function packageEnvironment(): Promise { packagePython ??= (async () => { const lock = JSON.parse(readFileSync(join(ROOT, 'node_modules/pyodide/pyodide-lock.json'), 'utf8')); - const pins = [...DATA_PACKAGES.map((name) => `${name}==${lock.packages[name].version}`), ...testPins()]; + const pins = [...DATA_PACKAGES.map((name) => `${name}==${lock.packages[name].version}`), ...testPins(), ...excelPins()]; const dir = join(ROOT, 'node_modules/.cache/verify-python', createHash('sha256').update(pins.join(' ')).digest('hex').slice(0, 12)); const bin = join(dir, 'bin/python'); if (existsSync(bin)) return bin; for (const args of [['venv', '--quiet', '--python', '3.14', dir], ['pip', 'install', '--quiet', '--python', bin, ...pins]]) { const result = await exec('uv', args, { timeout: 300000 }); - if (result.code !== 0) throw new Error(`uv ${args[0]} fehlgeschlagen (für pandas- und pytest-Übungen wird uv gebraucht):\n${result.stderr.trim()}`); + if (result.code !== 0) throw new Error(`uv ${args[0]} fehlgeschlagen (für pandas-, pytest- und openpyxl-Übungen wird uv gebraucht):\n${result.stderr.trim()}`); } return bin; })(); @@ -83,7 +83,7 @@ function packageEnvironment(): Promise { async function python(job: { code: string; setup?: string; tests?: { code: string }[] } & Record): Promise { const source = [job.setup ?? '', job.code, ...(job.tests ?? []).map((test) => test.code)].join('\n'); - const interpreter = job.kind === 'pytest' || USES_DATA.test(source) || USES_TESTS.test(source) ? await packageEnvironment() : 'python3'; + const interpreter = job.kind === 'pytest' || USES_DATA.test(source) || USES_TESTS.test(source) || USES_EXCEL.test(source) ? await packageEnvironment() : 'python3'; const result = await exec(interpreter, [HARNESS], { input: JSON.stringify(job), timeout: 15000 }); if (result.timedOut) throw new Error('Zeitüberschreitung (15 s)'); if (result.code !== 0) throw new Error(result.stderr.trim().split('\n').slice(-5).join('\n')); diff --git a/tooling/vite-plugins.ts b/tooling/vite-plugins.ts index ea18c99..b116046 100644 --- a/tooling/vite-plugins.ts +++ b/tooling/vite-plugins.ts @@ -75,8 +75,8 @@ const PYODIDE_FILES = ['pyodide.mjs', 'pyodide.asm.mjs', 'pyodide.asm.wasm', 'py /** * Self-hosts the Pyodide runtime under /pyodide/ (copied from node_modules into public/, not committed), * plus the extra wheels exercises may import (vendor/pyodide: tzdata, beautifulsoup4, PyYAML, pandas with numpy, - * pytest and Hypothesis). Wheels must match the file names and hashes in node_modules/pyodide/pyodide-lock.json; - * Hypothesis is not in the lock file and loads by file name (src/python/packages.ts). + * pytest, Hypothesis and openpyxl). Wheels must match the file names and hashes in node_modules/pyodide/pyodide-lock.json; + * Hypothesis and openpyxl are not in the lock file and load by file name (src/python/packages.ts). */ export function pyodide(): Plugin { return { diff --git a/vendor/pyodide/et_xmlfile-2.0.0-py3-none-any.whl b/vendor/pyodide/et_xmlfile-2.0.0-py3-none-any.whl new file mode 100644 index 0000000..52a7eec Binary files /dev/null and b/vendor/pyodide/et_xmlfile-2.0.0-py3-none-any.whl differ diff --git a/vendor/pyodide/openpyxl-3.1.5-py2.py3-none-any.whl b/vendor/pyodide/openpyxl-3.1.5-py2.py3-none-any.whl new file mode 100644 index 0000000..e1dccd4 Binary files /dev/null and b/vendor/pyodide/openpyxl-3.1.5-py2.py3-none-any.whl differ