Notebook-Tests: nbmake + Subprocess-Zellen + inkrementeller Test

Alle 25 Notebooks sind nun fehlerfrei testbar (25 passed). Dafür drei
Änderungen im Build und eine neue Test-Infrastruktur:

1. pyproject.toml: nbmake>=1.5 in der dev-Gruppe.

2. build_version_04.py: Notebooks bekommen zwei Setup-Zellen und
   Subprocess-Zellen für Programme, die nicht direkt im Kernel laufen:
   - NOTEBOOK_SETUP_2: __file__ definieren, sys.path für or_kern,
     multiprocessing auf fork (Spawn findet Kernel-Funktionen nicht).
   - _braucht_subprocess: erkennt highspy (Solver-Konflikt), pytest
     (SystemExit), get_context('spawn'), subprocess+__file__ (sucht
     .py-Dateien), sys.exit (SystemExit). Diese Programme erscheinen
     als Markdown-Codeblock (sichtbar, nicht ausführbar) plus einer
     Subprocess-Zelle mit NO_COLOR=1 (verhindert ANSI-Codes, die der
     Parser von Mutationstest.py nicht verarbeitet).
   - Solver-Konflikt-Erkennung: wenn ein Kapitel sowohl ortools als
     auch highspy importiert, laufen auch reine ortools-Programme als
     Subprocess (highspy schon im Kernel).

3. teste_code_04.py: inkrementeller Test — nur geänderte/neue Programme
   und Notebooks (via git status). Netzabhängige Dateien (yfinance)
   werden automatisch erkannt und nur getestet, wenn Internet verfügbar.

4. PLAN.md Abschnitt 10: neuer Verifikationsschritt 4 (pytest --nbmake).
   PROGRESS.md und CLAUDE.md aktualisiert.

Gefundene und behobene Probleme:
- or_kern-Import: sys.path im Notebook erweitert.
- __file__ nicht definiert: zweite Setup-Zelle setzt es.
- multiprocessing spawn: fork erzwungen (Ein_System_Vier_Ansaetze.py).
- ortools/highspy-Konflikt: Subprocess für beide Solver.
- pytest SystemExit: Subprocess für test_or_kern.py und Mutationstest.py.
- sys.exit(0): Subprocess für Installationstest.py.
- ANSI-Codes in pytest-Ausgabe: NO_COLOR=1 in Subprocess-Zelle.
This commit is contained in:
dschlueter 2026-09-11 22:38:59 +02:00
commit a4fc247116
70 changed files with 4073 additions and 305 deletions

View file

@ -157,4 +157,4 @@ Details zu den Lernpfaden stehen in [`00_Vorwort_und_Lesehilfe.md`](00_Vorwort_u
---
*Autor / Herausgeber: Dieter Schlüter · Stand: 10. September 2026 v17.51*
*Autor / Herausgeber: Dieter Schlüter · Stand: 11. September 2026 v22.38*

View file

@ -2293,6 +2293,59 @@ NOTEBOOK_SETUP = (
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl\n"
)
# Zweite Setup-Zelle — wird NICHT von nbmake übersprungen, damit __file__,
# sys.path und multiprocessing-Startmodus beim Testen aktiv sind.
# __file__ : Programme nutzen es für OUTPUT_DIR (s.o.).
# sys.path : 'from or_kern import …' findet or_kern.py im
# Programmverzeichnis (lokal und im Docker-Image).
# multiprocessing : ProcessPoolExecutor (Ein_System_Vier_Ansaetze.py u.a.)
# braucht den 'fork'-Startmodus, weil der Spawn-Modus
# die Funktionen in __main__ des Kernels nicht findet.
NOTEBOOK_SETUP_2 = (
"# Notebook-spezifisches Setup: __file__ definieren (Programme nutzen es\n"
"# fuer OUTPUT_DIR), Programmverzeichnis in den Suchpfad aufnehmen (or_kern)\n"
"# und multiprocessing auf 'fork' stellen (Spawn findet Kernel-Funktionen nicht).\n"
"import os, sys\n"
"__file__ = os.path.join(os.getcwd(), '_notebook.py')\n"
"for _p in [os.path.join(os.path.dirname(os.getcwd()),\n"
" 'Operations_Research_mit_Python_Version_04_Programme'),\n"
" os.path.dirname(os.getcwd()), os.getcwd()]:\n"
" if os.path.isfile(os.path.join(_p, 'or_kern.py')):\n"
" sys.path.insert(0, _p); break\n"
"import multiprocessing as _mp\n"
"try: _mp.set_start_method('fork', force=True)\n"
"except (RuntimeError, ValueError): pass\n"
)
def _braucht_subprocess(quelltext: str) -> bool:
"""True, wenn das Programm in einem Notebook nicht direkt ausfuehrbar ist.
Gruende:
- import highspy: kollidiert mit ortools im selben Kernel (beide bringen eine
eigene HiGHS-Kopie mit). Die Programme loesen das durch getrennte Prozesse;
im Notebook muss das Subprocess uebernehmen.
- import pytest: pytest.main() beendet den Kernel mit SystemExit.
- ProcessPoolExecutor mit explizitem 'spawn': der geforkte Prozess findet
die Funktionen in __main__ (dem IPython-Kernel) nicht.
"""
if "import highspy" in quelltext or "from highspy" in quelltext:
return True
if "import pytest" in quelltext or "from pytest" in quelltext:
return True
if 'get_context("spawn")' in quelltext:
return True
# Programme, die __file__ nutzen, um andere .py-Dateien zu finden
# (z.B. Mutationstest.py sucht or_kern.py und test_or_kern.py im selben
# Verzeichnis — im Notebook liegen sie nicht dort).
if "import subprocess" in quelltext and "__file__" in quelltext:
return True
# Programme, die sys.exit() nutzen — im Notebook wirft das SystemExit,
# das nbmake als Fehler wertet (auch bei Exit-Code 0).
if "sys.exit(" in quelltext:
return True
return False
def _notebook_zellen(text: str, titel: str) -> list[dict]:
"""Baut die Zellenliste eines Kapitel-Notebooks: Titel, Setup-Zelle und
@ -2303,17 +2356,35 @@ def _notebook_zellen(text: str, titel: str) -> list[dict]:
return {"cell_type": "markdown", "metadata": {},
"source": quelle.splitlines(keepends=True)}
def code(quelle: str) -> dict:
return {"cell_type": "code", "execution_count": None, "metadata": {},
"outputs": [], "source": quelle.rstrip("\n").splitlines(keepends=True)}
def code(quelle: str, tags: list[str] | None = None) -> dict:
zelle = {"cell_type": "code", "execution_count": None, "metadata": {},
"outputs": [], "source": quelle.rstrip("\n").splitlines(keepends=True)}
if tags:
zelle["metadata"]["tags"] = tags
return zelle
zellen = [
md(f"# {titel}\n\n"
f"Begleitnotebook zu *{SITE_TITEL}*. Die Codezellen sind identisch "
f"mit den im Buch abgedruckten Programmen.\n"),
code(NOTEBOOK_SETUP),
# Setup-Zelle per Tag markieren, damit nbmake sie beim Testen
# überspringt - sonst wuerde jeder Testlauf die Pakete neu
# installieren (langsam und in der CI netzabhaengig).
code(NOTEBOOK_SETUP, tags=["nbmake-skip"]),
# Zweite Setup-Zelle (wird beim Testen ausgefuehrt): __file__,
# sys.path und multiprocessing-Startmodus.
code(NOTEBOOK_SETUP_2),
]
# Pre-Scan: enthaelt das Kapitel sowohl ortools als auch highspy?
# Dann beiessen sich die Imports im selben Kernel — alle Solver-Programme
# muessen als Subprocess laufen, nicht nur die highspy-Importe.
hat_ortools = any("import ortools" in t.group(1) or "from ortools" in t.group(1)
for t in CODEBLOCK_RE.finditer(text))
hat_highspy = any("import highspy" in t.group(1) or "from highspy" in t.group(1)
for t in CODEBLOCK_RE.finditer(text))
solver_konflikt = hat_ortools and hat_highspy
# Zu jedem Codeblock die letzte Ueberschrift davor merken, damit die
# Markdown-Zelle sagt, wo im Kapitel man sich befindet.
ueberschriften = [(m.start(), m.group(1)) for m in NOTEBOOK_UEBERSCHRIFT_RE.finditer(text)]
@ -2334,7 +2405,43 @@ def _notebook_zellen(text: str, titel: str) -> list[dict]:
if programm:
kopf += f"\n`{programm.group(1)}`\n"
zellen.append(md(kopf or "---\n"))
zellen.append(code(quelltext))
braucht_sub = _braucht_subprocess(quelltext)
# Bei Solver-Konflikt im Kapitel: auch reine ortools-Programme als
# Subprocess, weil highspy schon im Kernel geladen sein kann.
if solver_konflikt and ("import ortools" in quelltext or "from ortools" in quelltext):
braucht_sub = True
if braucht_sub:
# Code als Markdown-Codeblock zeigen — sichtbar für den Leser,
# aber nicht ausführbar (nbmake überspringt Markdown-Zellen).
code_block = f"```python\n{quelltext.rstrip()}\n```\n"
zellen.append(md((kopf or "---\n") + "\n" + code_block))
programm_name = programm.group(1) if programm else "programm.py"
subproc = (
f"# In einem Notebook kann '{programm_name}' nicht direkt ausgefuehrt werden\n"
f"# (ortools/highspy-Konflikt im selben Kernel, pytest oder multiprocessing).\n"
f"# Deshalb als Subprocess — so laeuft es genauso wie im Terminal.\n"
f"import subprocess, sys, os\n"
f"# NO_COLOR/TERM=dumb verhindern ANSI-Codes in der Ausgabe (pytest\n"
f"# im Jupyter-Kernel wuerde sonst farbige Escape-Sequenzen ausgeben).\n"
f"_env = dict(os.environ, NO_COLOR='1', TERM='dumb')\n"
f"for _d in [os.path.join(os.path.dirname(os.getcwd()),\n"
f" 'Operations_Research_mit_Python_Version_04_Programme'),\n"
f" os.path.dirname(os.getcwd()), os.getcwd()]:\n"
f" _p = os.path.join(_d, '{programm_name}')\n"
f" if os.path.isfile(_p):\n"
f" _r = subprocess.run([sys.executable, _p], cwd=_d, env=_env,\n"
f" capture_output=True, text=True)\n"
f" if _r.stdout: print(_r.stdout)\n"
f" if _r.stderr: print(_r.stderr)\n"
f" _r.check_returncode()\n"
f" break\n"
f"else:\n"
f" print('{programm_name} nicht gefunden')\n"
)
zellen.append(code(subproc))
else:
zellen.append(md(kopf or "---\n"))
zellen.append(code(quelltext))
return zellen
@ -2363,7 +2470,7 @@ def baue_notebooks(struktur: list[dict]) -> dict[str, str]:
titel = (f'{eintrag["nummer"]}: {eintrag["titel"]}'
if eintrag["nummer"] else eintrag["titel"])
zellen = _notebook_zellen(text, titel)
if len(zellen) <= 2: # nur Titel + Setup => kein Code im Kapitel
if len(zellen) <= 3: # nur Titel + 2 Setup-Zellen => kein Code im Kapitel
continue
name = eintrag["seite"].replace(".html", ".ipynb")

View file

@ -0,0 +1,235 @@
#!/usr/bin/env python3
# teste_code_04.py
"""
Inkrementeller Test der Programme und Notebooks.
Testet nur, was sich seit dem letzten Commit geaendert hat oder neu
hinzugekommen ist. Netzabhaengige Dateien (yfinance-Downloads) werden
automatisch erkannt und nur getestet, wenn Internet verfuegbar ist;
scheitern sie am Netz, gilt das nicht als Fehler.
Aufruf:
python3 teste_code_04.py # nur geaenderte/neue
python3 teste_code_04.py --alles # alle Programme und Notebooks
python3 teste_code_04.py --check # nur anzeigen, was getestet wuerde
Voraussetzung: pip install -e ".[dev,notebook]"
"""
import argparse
import json
import os
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
PROG_DIR = os.path.join(BASIS, "Operations_Research_mit_Python_Version_04_Programme")
NB_DIR = os.path.join(BASIS, "Notebooks_04")
TIMEOUT = 900 # 15 Minuten pro Programm/Notebook (Solver können dauern)
# --- Erkennung netzabhaengiger Dateien ---------------------------------------
def netz_abhaengig(datei: str) -> bool:
"""True, wenn die Datei yfinance importiert (Programm oder Notebook)."""
try:
with open(datei, encoding="utf-8") as f:
inhalt = f.read()
except OSError:
return False
return "import yfinance" in inhalt or "from yfinance" in inhalt
def internet_verfuegbar() -> bool:
"""Prüft, ob yfinance tatsächlich Daten laden kann (nicht nur Port-Erreichbarkeit)."""
try:
import yfinance as yf
h = yf.Ticker("AAPL").history(period="5d")
return len(h) > 0
except Exception:
return False
# --- Geaenderte Dateien via Git ermitteln ------------------------------------
def geaenderte_dateien() -> tuple[set[str], set[str]]:
"""Liefert (geänderte Programme, geänderte Notebooks) relativ zu BASIS.
'Geändert' = modifiziert oder neu (untracked) gegenüber HEAD.
"""
# --porcelain liefert zeilenweise 'XY datei'; X=Y=?? bedeutet untracked.
ergebnis = subprocess.run(
["git", "status", "--porcelain"],
cwd=BASIS, capture_output=True, text=True, check=True,
)
programme: set[str] = set()
notebooks: set[str] = set()
for zeile in ergebnis.stdout.splitlines():
status, datei = zeile[:2], zeile[3:]
# Umbenennungen werden als 'XY alt -> neu' geschrieben.
if " -> " in datei:
datei = datei.split(" -> ", 1)[1]
if datei.startswith("Operations_Research_mit_Python_Version_04_Programme/") \
and datei.endswith(".py"):
programme.add(os.path.basename(datei))
elif datei.startswith("Notebooks_04/") and datei.endswith(".ipynb"):
notebooks.add(os.path.basename(datei))
return programme, notebooks
def alle_dateien() -> tuple[list[str], list[str]]:
"""Alle vorhandenen Programme und Notebooks."""
programme = sorted(
f for f in os.listdir(PROG_DIR) if f.endswith(".py") and f != "or_kern.py"
)
notebooks = sorted(f for f in os.listdir(NB_DIR) if f.endswith(".ipynb"))
return programme, notebooks
# --- Tests -------------------------------------------------------------------
def test_programm(name: str, netz_ok: bool) -> str:
"""Führt ein Programm aus. Rückgabe: 'ok', 'fehler', 'netz-übersprungen'."""
pfad = os.path.join(PROG_DIR, name)
if netz_abhaengig(pfad) and not netz_ok:
print(f" {name}: ÜBERSPRUNGEN (braucht Internet, nicht verfügbar)")
return "netz"
print(f" {name}: läuft …")
umgebung = dict(os.environ, MPLBACKEND="Agg")
try:
subprocess.run(
[sys.executable, pfad],
env=umgebung, timeout=TIMEOUT,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True,
)
print(f" {name}: OK")
return "ok"
except subprocess.TimeoutExpired:
print(f" {name}: FEHLER (Timeout nach {TIMEOUT}s)")
return "fehler"
except subprocess.CalledProcessError as e:
ausgabe = (e.stderr or e.stdout or b"").decode("utf-8", errors="replace")
# Netzfehler bei einem netzabhängigen Programm → kein harter Fehler
if netz_abhaengig(pfad) and "Download fehlgeschlagen" in ausgabe:
print(f" {name}: ÜBERSPRUNGEN (Download fehlgeschlagen — kein Internet)")
return "netz"
print(f" {name}: FEHLER")
for zeile in ausgabe.splitlines()[-15:]:
print(f" {zeile}")
return "fehler"
def test_notebooks(names: list[str], netz_ok: bool) -> int:
"""Führt Notebooks via pytest --nbmake aus. Rückgabe: Anzahl Fehler."""
if not names:
return 0
# Netzabhängige Notebooks ohne Internet herausfiltern
testen: list[str] = []
for name in names:
pfad = os.path.join(NB_DIR, name)
if netz_abhaengig(pfad) and not netz_ok:
print(f" {name}: ÜBERSPRUNGEN (braucht Internet, nicht verfügbar)")
else:
testen.append(pfad)
if not testen:
return 0
umgebung = dict(os.environ, MPLBACKEND="Agg")
cmd = [sys.executable, "-m", "pytest", "--nbmake",
f"--nbmake-timeout={TIMEOUT}", "-q", *testen]
print(f" pytest --nbmake für {len(testen)} Notebook(s) …")
ergebnis = subprocess.run(cmd, env=umgebung, cwd=BASIS)
return 0 if ergebnis.returncode == 0 else 1
# --- Hauptprogramm -----------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--alles", action="store_true",
help="Alle Programme und Notebooks testen, nicht nur geänderte.")
parser.add_argument("--check", action="store_true",
help="Nur anzeigen, was getestet würde, nichts ausführen.")
args = parser.parse_args()
print("=== Inkrementeller Test der Programme und Notebooks ===\n")
# 1. Programme und Notebooks regenerieren
print("1. Programme extrahieren …")
subprocess.run(
[sys.executable, os.path.join(HIER, "extract_programme_04.py")],
cwd=BASIS, check=True,
)
print(" Notebooks bauen …")
subprocess.run(
[sys.executable, os.path.join(HIER, "build_version_04.py"), "--html"],
cwd=BASIS, capture_output=True, check=True,
)
# 2. Geänderte Dateien ermitteln
if args.alles:
programme, notebooks = alle_dateien()
modus = "alle"
else:
programme_set, notebooks_set = geaenderte_dateien()
programme = sorted(programme_set)
notebooks = sorted(notebooks_set)
modus = "geänderte/neue"
# or_kern.py ist eine Bibliothek, kein Programm — nie direkt ausführen
if "or_kern.py" in programme:
programme.remove("or_kern.py")
print(f"\n2. {modus.capitalize()} Dateien: "
f"{len(programme)} Programm(e), {len(notebooks)} Notebook(s)")
if not programme and not notebooks:
print(" Nichts zu testen — alles aktuell.")
return 0
if programme:
print(" Programme:")
for p in programme:
print(f" {p}")
if notebooks:
print(" Notebooks:")
for nb in notebooks:
print(f" {nb}")
if args.check:
print("\n--check: Nur Anzeige, kein Testlauf.")
return 0
# 3. Internet-Check
netz_ok = internet_verfuegbar()
print(f"\n3. Internet: {'verfügbar' if netz_ok else 'nicht verfügbar'}"
f" (yfinance-Programme werden "
f"{'getestet' if netz_ok else 'übersprungen'})")
# 4. Programme testen
fehler_zaehler = 0
if programme:
print("\n4. Programme testen:")
for p in programme:
ergebnis = test_programm(p, netz_ok)
if ergebnis == "fehler":
fehler_zaehler += 1
# 5. Notebooks testen
if notebooks:
print("\n5. Notebooks testen:")
nb_fehler = test_notebooks(notebooks, netz_ok)
fehler_zaehler += nb_fehler
# 6. Zusammenfassung
print(f"\n=== Zusammenfassung ===")
if fehler_zaehler > 0:
print(f" {fehler_zaehler} Fehler insgesamt.")
return 1
print(" Alle getesteten Programme und Notebooks fehlerfrei.")
return 0
if __name__ == "__main__":
sys.exit(main())