#!/usr/bin/env python3 """Allgemeiner Mutantenlaeufer aus einer mutanten.toml. Zweck: Fuer eine Liste von Mutanten (Textersetzungen in einer Datei) prueft dieses Skript, ob die dazu genannten Tests unter jedem Mutanten wie erwartet rot werden ("getoetet"). Es liest dazu genau eine Datei im Format aus dem Entwurf (Abschnitt 2), nicht Prosa aus einem Plan. Aufruf (in WSL, Python ab 3.11, nur Standardbibliothek): python mutanten_lauf.py --wurzel --mutanten [--nur ID,ID,...] [--nur-muster] [--bericht ] [--zeitlimit ] Geltungsbereich: Dieses Werkzeug ist ein Fangnetz, kein Nachweis. Es sagt, ob die genannten Tests unter einer Aenderung fallen - nicht, ob sie an der gemeinten Stelle fallen. Das zeigt nur die mitgelieferte Fehlermeldung, das Urteil bleibt beim Menschen. Benannte Luecken der Abdichtung (Entwurf, Abschnitt 5): Ein Unterprozess, den ein Test mit absolutem Pfad startet, ist nicht abgedichtet. Ein Socket ohne Proxy-Nutzung ist nicht abgedichtet. Ein HTTP-Client mit trust_env=False oder eigenem Transport ist nicht abgedichtet. Dateien mit absolutem Pfad ausserhalb von HOME sind nicht abgedichtet. Die eigentliche Sperre bleibt die Netzsperre der Testdateien selbst und eine Kopie ohne Geheimnisdateien (der Schluessel "ohne" in mutanten.toml). pytest-Konfiguration (seit 2026-10-06): Fuer jeden pytest-Aufruf bestimmt das Skript die Konfigurationsdatei so, wie pytest 9.1.1 sie selbst suchen wuerde (_pytest/config/findpaths.py: determine_setup, locate_config, get_common_ancestor), sucht aber nie oberhalb der Wurzel, und gibt sie mit -c vor. Ohne Konfiguration in der Wurzel bekommt pytest eine leere Datei aus dem Plugin-Ordner, dazu --rootdir und --confcutdir. Grund: Unter dem Windows-Temp fand pytest eine fremde pyproject.toml, deren addopts nicht zu -p no:cacheprovider passten, und jeder Test der Grundlinie stand als FEHLT da (Plankritik 2 von C1 im Projekt After Install, 2026-10-05). Die Wurzel wird vorher mit realpath aufgeloest: pytest rechnet vom Arbeitsordner aus, den der Kernel ohne Verknuepfungen meldet; ueber einen Symlink angegeben, passten die Knotennamen sonst nicht (Pruefung vom 2026-10-06). Benannte Luecken: tox.ini und setup.cfg werden zeilenweise nach ihrem Abschnitt durchsucht wie in iniconfig 2.3.0 (_parse.py, _parseline), eine Datei, die iniconfig nicht lesen kann, uebergeht der Laeufer, pytest bricht dort mit 4 ab; ein setup.cfg mit [pytest] (bei pytest selbst ein Fehler) gilt hier als keine Konfiguration; ohne Konfiguration in der Wurzel ist config.inipath die leere Datei, ini-Werte vom Typ paths aus -o oder PYTEST_ADDOPTS gelten dann relativ zum Plugin-Ordner; iniconfig trennt Zeilen mit splitlines, der Laeufer auch. Liegt rootdir unter der Wurzel (Konfiguration oder setup.py in einem Unterordner der Tests), bildet pytest die Knotennamen relativ dazu, und rot_bei passt nicht mehr: Die Grundlinie meldet FEHLT, die Kopfzeile nennt die Datei. Endet pytest mit Rueckgabewert 3 (interner Fehler) oder 4 (Bedienfehler, ExitCode in _pytest/config/__init__.py), steht das Ende seiner Ausgabe in der Grundlinie als eigener Befund je Interpreter und unter einem Mutanten, als eine Zeile mit der Ursache vorn, in der Meldung der Tests ohne Bericht und ohne Sammelfehler. Ein Sammelfehler bleibt ROT, auch wenn pytest dabei mit 4 endet: Ein Knoten in einer Datei, die sich nicht sammeln laesst, ergibt zusaetzlich "found no collectors for" (Treiber-Fall 11). Spezifikation: .vault-tools/plan-kopie/mutanten_lauf_ENTWURF.md Entscheidungen, die der Entwurf offen liess (siehe Bericht des Executers): Verhalten bei einer Zieldatei, die kein gueltiges UTF-8 ist, wird wie ein Formatbefund behandelt (Abbruch mit Exit 2), weil das Skript sonst keine verlaessliche Aussage ueber die Trefferzahl treffen kann. """ import argparse import hashlib import json import os import re import shutil import signal import subprocess import sys import tempfile import tomllib import traceback from pathlib import Path PLUGIN_NAME = "_mutanten_lauf_plugin" PLUGIN_QUELLTEXT = '''"""Pytest-Plugin des Mutantenlaeufers, wird in einen Temp-Ordner geschrieben. Schreibt je Bericht eine JSON-Zeile in die Datei aus der Umgebungsvariable MUTANTEN_LAUF_ERGEBNIS. Siehe mutanten_lauf_ENTWURF.md, Abschnitt 3. """ import json import os def _ergebnis_pfad(): return os.environ.get("MUTANTEN_LAUF_ERGEBNIS") def _schreiben(datensatz): pfad = _ergebnis_pfad() if not pfad: return with open(pfad, "a", encoding="utf-8") as datei: datei.write(json.dumps(datensatz, ensure_ascii=True)) datei.write("\\n") datei.flush() def _erste_zeile(text): for zeile in str(text).splitlines(): zeile = zeile.strip() if zeile: return zeile[:300] return "" def _meldung_aus_longrepr(longrepr): if longrepr is None: return "" crash = getattr(longrepr, "reprcrash", None) meldung = getattr(crash, "message", None) if meldung: return str(meldung)[:300] return _erste_zeile(longrepr) def pytest_runtest_logreport(report): meldung = "" if report.outcome == "failed": meldung = _meldung_aus_longrepr(getattr(report, "longrepr", None)) datensatz = { "art": "test", "knoten": report.nodeid, "phase": report.when, "ergebnis": report.outcome, "xfail": hasattr(report, "wasxfail"), "meldung": meldung, } _schreiben(datensatz) def pytest_collectreport(report): if report.outcome == "failed": meldung = _meldung_aus_longrepr(getattr(report, "longrepr", None)) _schreiben({"art": "sammeln", "knoten": report.nodeid, "meldung": meldung}) def pytest_deselected(items): for item in items: _schreiben({"art": "abgewaehlt", "knoten": item.nodeid}) ''' TOP_LEVEL_SCHLUESSEL = {"format", "python", "ohne", "kuerzel", "python_je_datei", "mutant"} MUTANT_SCHLUESSEL = {"id", "datei", "aus", "wird", "rot_bei", "hinweis"} # Reihenfolge wie config_names in locate_config, pytest 9.1.1. PYTEST_KONFIG_NAMEN = ( "pytest.toml", ".pytest.toml", "pytest.ini", ".pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg", ) LEERE_KONFIG_NAME = "pytest.ini" PYTEST_ABBRUCH = {3: "pytest-Abbruch mit internem Fehler", 4: "pytest-Bedienfehler"} AUSGABE_ENDE_ZEILEN = 20 class RuecksetzenGescheitert(Exception): def __init__(self, mutant_id, datei): super().__init__("Ruecksetzen gescheitert bei Mutant {0} ({1})".format(mutant_id, datei)) class SigtermUnterbrechung(Exception): """Wird aus dem SIGTERM-Handler ausgeloest, damit die finally-Bloecke (Ruecksetzen der Zieldatei, Aufraeumen der Temp-Ordner) laufen. Siehe Nachbesserung 2026-09-28, N4.""" def _sigterm_werfen(signum, frame): raise SigtermUnterbrechung() def ist_relativer_pfad(pfad): if not isinstance(pfad, str) or pfad == "": return False if pfad.startswith("/") or pfad.startswith("~"): return False if len(pfad) >= 2 and pfad[1] == ":": return False teile = pfad.split("/") if ".." in teile: return False return True def zeilen_aus_text(text): if text == "": return [] if text.endswith("\n"): text = text[:-1] return text.split("\n") def positionen_finden(datei_zeilen, muster_zeilen): treffer = [] n = len(muster_zeilen) if n == 0: return treffer obergrenze = len(datei_zeilen) - n + 1 for i in range(max(obergrenze, 0)): if datei_zeilen[i:i + n] == muster_zeilen: treffer.append(i) return treffer def kuerzel_aufloesen(eintrag, kuerzel_tabelle): if "::" not in eintrag: return None vorderteil, rest = eintrag.split("::", 1) if vorderteil in kuerzel_tabelle: return kuerzel_tabelle[vorderteil] + "::" + rest if ist_relativer_pfad(vorderteil) and vorderteil.endswith(".py"): return vorderteil + "::" + rest return None def interpreter_gueltig(pfad): return isinstance(pfad, str) and pfad != "" and "~" not in pfad and pfad.startswith("/") \ and os.path.isfile(pfad) and os.access(pfad, os.X_OK) def lade_und_validiere(args): """Schritt 1 des Ablaufs: Vorbedingungen und Format (Entwurf, Abschnitt 3.1).""" befunde = [] wurzel = Path(args.wurzel) wurzel_ok = wurzel.exists() and wurzel.is_dir() if not wurzel_ok: befunde.append("Wurzel fehlt oder ist kein Ordner: {0}".format(wurzel)) if wurzel_ok: git_pfad = wurzel / ".git" if git_pfad.exists() or git_pfad.is_symlink(): befunde.append("Wurzel enthaelt .git: {0}".format(git_pfad)) mutanten_pfad = Path(args.mutanten) daten = None try: with open(mutanten_pfad, "rb") as datei: daten = tomllib.load(datei) except FileNotFoundError: befunde.append("Mutanten-Datei fehlt: {0}".format(mutanten_pfad)) except tomllib.TOMLDecodeError as exc: befunde.append("TOML nicht lesbar: {0}".format(exc)) except OSError as exc: befunde.append("TOML nicht lesbar: {0}".format(exc)) kontext = { "wurzel": wurzel, "mutanten_pfad": mutanten_pfad, "python": None, "ohne": [], "kuerzel": {}, "python_je_datei": {}, "mutanten": [], } if daten is None: return befunde, kontext if not isinstance(daten, dict): befunde.append("Mutanten-Datei ist auf oberster Ebene keine Tabelle") return befunde, kontext unbekannt = set(daten.keys()) - TOP_LEVEL_SCHLUESSEL for schluessel in sorted(unbekannt): befunde.append("unbekannter Schluessel auf oberster Ebene: {0}".format(schluessel)) if "format" not in daten: befunde.append("format fehlt") else: format_wert = daten.get("format") if not isinstance(format_wert, int) or isinstance(format_wert, bool) or format_wert != 1: befunde.append("format muss die Ganzzahl 1 sein, ist {0!r}".format(format_wert)) if "python" not in daten: befunde.append("python fehlt") else: python_wert = daten.get("python") if not isinstance(python_wert, str) or python_wert == "": befunde.append("python ist kein nicht-leerer Text") elif "~" in python_wert: befunde.append("python enthaelt ~: {0}".format(python_wert)) elif not python_wert.startswith("/"): befunde.append("python ist kein absoluter Pfad: {0}".format(python_wert)) elif not (os.path.isfile(python_wert) and os.access(python_wert, os.X_OK)): befunde.append("python fehlt oder ist nicht ausfuehrbar: {0}".format(python_wert)) else: kontext["python"] = python_wert ohne_wert = [] if "ohne" in daten: roh = daten.get("ohne") if not isinstance(roh, list): befunde.append("ohne ist keine Liste") else: for eintrag in roh: if not ist_relativer_pfad(eintrag): befunde.append("ohne enthaelt keinen relativen Pfad: {0!r}".format(eintrag)) else: ohne_wert.append(eintrag) kontext["ohne"] = ohne_wert if wurzel_ok: for eintrag in ohne_wert: ziel = wurzel / eintrag if ziel.exists() or ziel.is_symlink(): befunde.append("Pfad aus ohne existiert: {0}".format(eintrag)) kuerzel_wert = {} if "kuerzel" in daten: roh = daten.get("kuerzel") if not isinstance(roh, dict): befunde.append("kuerzel ist keine Tabelle") else: for name, pfad in roh.items(): if not ist_relativer_pfad(pfad) or not str(pfad).endswith(".py"): befunde.append("kuerzel {0} zeigt auf keinen gueltigen relativen Pfad: {1!r}".format(name, pfad)) else: kuerzel_wert[name] = pfad kontext["kuerzel"] = kuerzel_wert pjd_wert = {} if "python_je_datei" in daten: roh = daten.get("python_je_datei") if not isinstance(roh, dict): befunde.append("python_je_datei ist keine Tabelle") else: for pfad, interpreter in roh.items(): if not ist_relativer_pfad(pfad) or not str(pfad).endswith(".py"): befunde.append("python_je_datei-Schluessel ist kein gueltiger relativer Pfad: {0!r}".format(pfad)) continue if not interpreter_gueltig(interpreter): befunde.append("Interpreter in python_je_datei fehlt oder ist nicht ausfuehrbar: {0!r}".format(interpreter)) continue pjd_wert[pfad] = interpreter kontext["python_je_datei"] = pjd_wert if "mutant" not in daten: befunde.append("mutant fehlt") mutant_liste_roh = [] else: mutant_liste_roh = daten.get("mutant") if not isinstance(mutant_liste_roh, list) or len(mutant_liste_roh) == 0: befunde.append("mutant ist keine nicht-leere Liste") mutant_liste_roh = [] gesehene_ids = set() mutanten_normalisiert = [] for index, eintrag in enumerate(mutant_liste_roh): vorspann = "Mutant Nr. {0}".format(index + 1) if not isinstance(eintrag, dict): befunde.append("{0} ist keine Tabelle".format(vorspann)) continue unbekannt = set(eintrag.keys()) - MUTANT_SCHLUESSEL for schluessel in sorted(unbekannt): befunde.append("{0}: unbekannter Schluessel {1}".format(vorspann, schluessel)) mid = eintrag.get("id") if not isinstance(mid, str) or mid == "": befunde.append("{0}: id fehlt oder ist leer".format(vorspann)) mid = None elif mid in gesehene_ids: befunde.append("doppelte id: {0}".format(mid)) else: gesehene_ids.add(mid) bezeichner = mid if mid else vorspann datei = eintrag.get("datei") datei_ok = False if not isinstance(datei, str) or datei == "": befunde.append("{0}: datei fehlt oder ist leer".format(bezeichner)) elif not ist_relativer_pfad(datei): befunde.append("{0}: datei ist kein gueltiger relativer Pfad: {1!r}".format(bezeichner, datei)) elif wurzel_ok: ziel = wurzel / datei if ziel.is_symlink(): befunde.append("{0}: datei ist eine Verknuepfung: {1}".format(bezeichner, datei)) elif not ziel.is_file(): befunde.append("{0}: datei existiert nicht: {1}".format(bezeichner, datei)) else: datei_ok = True aus = eintrag.get("aus") if not isinstance(aus, str) or aus == "": befunde.append("{0}: aus fehlt oder ist leer".format(bezeichner)) aus = None wird = eintrag.get("wird") if not isinstance(wird, str): befunde.append("{0}: wird fehlt oder ist kein Text".format(bezeichner)) wird = None if aus is not None and wird is not None and aus == wird: befunde.append("{0}: aus ist gleich wird (wirkungslos)".format(bezeichner)) rot_bei_roh = eintrag.get("rot_bei") rot_bei = [] if not isinstance(rot_bei_roh, list) or len(rot_bei_roh) == 0: befunde.append("{0}: rot_bei ist keine nicht-leere Liste".format(bezeichner)) else: for roheintrag in rot_bei_roh: if not isinstance(roheintrag, str) or roheintrag == "": befunde.append("{0}: rot_bei enthaelt keinen nicht-leeren Text: {1!r}".format(bezeichner, roheintrag)) continue aufgeloest = kuerzel_aufloesen(roheintrag, kuerzel_wert) if aufgeloest is None: befunde.append("{0}: unbekanntes Kuerzel oder ungueltiger Eintrag in rot_bei: {1!r}".format(bezeichner, roheintrag)) else: rot_bei.append((roheintrag, aufgeloest)) hinweis = eintrag.get("hinweis") if "hinweis" in eintrag and not isinstance(hinweis, str): befunde.append("{0}: hinweis ist kein Text".format(bezeichner)) hinweis = None if mid is not None and datei_ok and aus is not None and wird is not None and rot_bei: mutanten_normalisiert.append({ "id": mid, "datei": datei, "aus": aus, "wird": wird, "rot_bei": rot_bei, "hinweis": hinweis, }) kontext["mutanten"] = mutanten_normalisiert return befunde, kontext def muster_pruefen(mutant, wurzel): """Schritt 2 des Ablaufs (Entwurf, Abschnitt 3.2).""" ziel = wurzel / mutant["datei"] rohbytes = ziel.read_bytes() try: text = rohbytes.decode("utf-8", errors="strict") except UnicodeDecodeError as exc: return {"kodierungsfehler": str(exc)} datei_zeilen = text.split("\n") aus_zeilen = zeilen_aus_text(mutant["aus"]) wird_zeilen = zeilen_aus_text(mutant["wird"]) treffer_positionen = positionen_finden(datei_zeilen, aus_zeilen) return { "kodierungsfehler": None, "treffer": len(treffer_positionen), "start": treffer_positionen[0] if len(treffer_positionen) == 1 else None, "aus_zeilen": aus_zeilen, "wird_zeilen": wird_zeilen, "cr_in_datei": "\r" in text, } def gruppiere_nach_interpreter(knoten_liste, standard_python, python_je_datei): gruppen = {} for knoten in knoten_liste: dateipfad = knoten.split("::", 1)[0] interpreter = python_je_datei.get(dateipfad, standard_python) gruppen.setdefault(interpreter, []).append(knoten) return gruppen def _ini_hat_abschnitt(pfad, abschnitt): """Suche nach der Abschnittszeile wie _parseline in iniconfig 2.3.0: "[" in Spalte 0, Kommentare nach # und ; entfernt, dann "]" am Ende. Eine nicht lesbare Datei zaehlt als Treffer, damit pytest den Fehler selbst meldet.""" try: text = pfad.read_text(encoding="utf-8", errors="replace") except OSError: return True for zeile in text.splitlines(): zeile = zeile.rstrip() if not zeile.startswith("["): continue for zeichen in "#;": zeile = zeile.split(zeichen)[0].rstrip() if zeile.endswith("]") and zeile[1:-1] == abschnitt: return True return False def _ist_pytest_konfig(pfad): """Nachbau von load_config_dict_from_file(pfad) is not None, pytest 9.1.1.""" name = pfad.name if name in ("pytest.toml", ".pytest.toml", "pytest.ini", ".pytest.ini"): return True if name == "tox.ini": return _ini_hat_abschnitt(pfad, "pytest") if name == "setup.cfg": return _ini_hat_abschnitt(pfad, "tool:pytest") if name == "pyproject.toml": try: daten = tomllib.loads(pfad.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): return True werkzeug = daten.get("tool", {}) tool_pytest = werkzeug.get("pytest", {}) if isinstance(werkzeug, dict) else {} return isinstance(tool_pytest, dict) and bool(tool_pytest) return False def _innerhalb(pfad, wurzel): return pfad == wurzel or wurzel in pfad.parents def _existiert(pfad): """Wie safe_exists in pytest 9.1.1: ein zu langer Name ist kein Absturz.""" try: return pfad.exists() except (OSError, ValueError): return False def _konfig_suchen(startordner, wurzel): """Nachbau von locate_config, pytest 9.1.1; die Suche endet an der Wurzel.""" erstes_pyproject = None for start in startordner: for basis in (start, *start.parents): if not _innerhalb(basis, wurzel): break for name in PYTEST_KONFIG_NAMEN: pfad = basis / name if pfad.is_file(): if name == "pyproject.toml" and erstes_pyproject is None: erstes_pyproject = pfad if _ist_pytest_konfig(pfad): return pfad return erstes_pyproject def _gemeinsamer_vorfahr(pfade, wurzel): """Nachbau von get_common_ancestor, pytest 9.1.1, mit der Wurzel als Aufrufordner.""" vorfahr = None for pfad in pfade: if not _existiert(pfad): continue if vorfahr is None: vorfahr = pfad elif vorfahr in pfad.parents or pfad == vorfahr: continue elif pfad in vorfahr.parents: vorfahr = pfad else: try: vorfahr = Path(os.path.commonpath([str(pfad), str(vorfahr)])) except ValueError: pass if vorfahr is None: return wurzel if vorfahr.is_file(): return vorfahr.parent return vorfahr def pytest_konfig_bestimmen(wurzel, knoten_liste): """Konfiguration und rootdir eines Aufrufs, wie determine_setup in pytest 9.1.1 sie ohne -c bestimmt, aber nie oberhalb der Wurzel gesucht. Rueckgabe (konfig, rootdir); konfig ist None, wenn die Wurzel keine hat.""" wurzel = Path(os.path.realpath(str(wurzel))) ordner = [] for knoten in knoten_liste: pfad = wurzel / knoten.split("::", 1)[0] if _existiert(pfad): ordner.append(pfad if pfad.is_dir() else pfad.parent) vorfahr = _gemeinsamer_vorfahr(ordner, wurzel) if not _innerhalb(vorfahr, wurzel): vorfahr = wurzel konfig = _konfig_suchen([vorfahr], wurzel) if konfig is not None: return konfig, konfig.parent for basis in (vorfahr, *vorfahr.parents): if not _innerhalb(basis, wurzel): break if (basis / "setup.py").is_file(): return None, basis if ordner != [vorfahr]: konfig = _konfig_suchen(ordner, wurzel) if konfig is not None: return konfig, konfig.parent return None, wurzel def pytest_konfig_argumente(wurzel, knoten_liste, leere_konfig): """Die Konfiguration der Wurzel mit -c; sonst die leere Datei mit --rootdir und --confcutdir. Ohne --confcutdir laege confcutdir im Ordner der leeren Datei, und pytest laede conftest.py-Dateien oberhalb der Wurzel (_pytest/config/__init__.py, pytest 9.1.1).""" konfig, rootdir = pytest_konfig_bestimmen(wurzel, knoten_liste) if konfig is not None: return ["-c", str(konfig)] return ["-c", str(leere_konfig), "--rootdir", str(rootdir), "--confcutdir", str(rootdir)] def pytest_konfig_beschreibung(wurzel, knoten_liste): konfig, rootdir = pytest_konfig_bestimmen(wurzel, knoten_liste) if konfig is not None: return "{0} (aus der Wurzel)".format(konfig) return "keine in der Wurzel, leere Datei; rootdir {0}".format(rootdir) def ausgabe_ende(stdout_bytes, stderr_bytes): """Die letzten nicht leeren Zeilen von stdout und stderr, je hoechstens 300 Zeichen.""" text = ( (stdout_bytes or b"").decode("utf-8", errors="replace") + "\n" + (stderr_bytes or b"").decode("utf-8", errors="replace") ) zeilen = [zeile.rstrip()[:300] for zeile in text.split("\n") if zeile.strip()] return zeilen[-AUSGABE_ENDE_ZEILEN:] def kernzeile(ende, rueckgabe): """Die Zeile, die die Ursache nennt: bei 4 die erste mit "error" ausser der Gebrauchszeile von argparse, bei 3 die letzte mit INTERNALERROR> (danach folgt noch die Summenzeile, Treiber-Fall 46), ohne Treffer die letzte.""" if rueckgabe == 4: for zeile in ende: if "error" in zeile.lower() and not zeile.startswith("ERROR: usage:"): return zeile.strip() if rueckgabe == 3: interne = [zeile for zeile in ende if zeile.startswith("INTERNALERROR>")] if interne: return interne[-1].strip() return ende[-1].strip() if ende else "(keine Ausgabe)" def umgebung_abdichten(home_dir, plugin_dir, ergebnis_pfad): """Abdichtung nach Entwurf, Abschnitt 5. Siehe Kopfkommentar fuer die Luecken.""" env = os.environ.copy() env["HOME"] = str(home_dir) for schluessel in ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): env[schluessel] = "http://127.0.0.1:9" for schluessel in ("no_proxy", "NO_PROXY"): env.pop(schluessel, None) env["PYTHONDONTWRITEBYTECODE"] = "1" env["COLUMNS"] = "300" env["MUTANTEN_LAUF_ERGEBNIS"] = ergebnis_pfad vorhanden = env.get("PYTHONPATH", "") env["PYTHONPATH"] = str(plugin_dir) + (os.pathsep + vorhanden if vorhanden else "") return env def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimit): ergebnis_datei = tempfile.NamedTemporaryFile(prefix="mutanten_lauf_ergebnis_", suffix=".jsonl", delete=False) ergebnis_datei.close() ergebnis_pfad = ergebnis_datei.name env = umgebung_abdichten(home_dir, plugin_dir, ergebnis_pfad) konfig_argumente = pytest_konfig_argumente(wurzel, knoten_liste, Path(plugin_dir) / LEERE_KONFIG_NAME) cmd = ( [python_exe, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-p", PLUGIN_NAME] + konfig_argumente + list(knoten_liste) ) zeit_ueberschritten = False absturz = None abbruch = None ausgabe = (b"", b"") proc = None try: try: proc = subprocess.Popen( cmd, cwd=str(wurzel), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) except OSError as exc: absturz = "pytest-Aufruf gescheitert ({0}): {1}".format(python_exe, exc) if proc is not None: try: ausgabe = proc.communicate(timeout=zeitlimit) except subprocess.TimeoutExpired: zeit_ueberschritten = True try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except (ProcessLookupError, PermissionError): pass ausgabe = proc.communicate() except BaseException: # Auch ein SIGTERM (SigtermUnterbrechung) darf keinen pytest-Prozess # zurueck lassen: Prozessgruppe beenden, dann weiterwerfen, damit die # finally-Bloecke der Aufrufer (Ruecksetzen der Zieldatei, Aufraeumen # der Temp-Ordner) laufen. Siehe Nachbesserung 2026-09-28, N4. try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except (ProcessLookupError, PermissionError): pass try: proc.communicate() except Exception: pass raise if not zeit_ueberschritten and proc.returncode in PYTEST_ABBRUCH: ende = ausgabe_ende(*ausgabe) abbruch = { "lang": "{0} (Rueckgabewert {1}) mit {2}; Ende der Ausgabe:\n {3}".format( PYTEST_ABBRUCH[proc.returncode], proc.returncode, python_exe, "\n ".join(ende) or "(keine Ausgabe)", ), "kurz": "{0} (Rueckgabewert {1}): {2}".format( PYTEST_ABBRUCH[proc.returncode], proc.returncode, kernzeile(ende, proc.returncode), ), } meldungen = [] try: with open(ergebnis_pfad, "r", encoding="utf-8") as datei: for zeile in datei: zeile = zeile.strip() if not zeile: continue try: meldungen.append(json.loads(zeile)) except ValueError: pass except OSError: pass return zeit_ueberschritten, meldungen, absturz, abbruch finally: # Muss auch bei einem SIGTERM mitten im Lauf greifen (N4): sonst bleibt die # Ergebnisdatei liegen, obwohl der pytest-Prozess schon beendet ist. try: os.remove(ergebnis_pfad) except OSError: pass def gruppen_ausfuehren(gruppen, wurzel, home_dir, plugin_dir, zeitlimit): reports = {} deselected = set() sammelfehler = {} zeit_ueberschritten = False absturzmeldungen = [] abbrueche = {} for python_exe, knoten_liste in gruppen.items(): zeit, meldungen, absturz, abbruch = pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimit) if zeit: zeit_ueberschritten = True if absturz: absturzmeldungen.append(absturz) if abbruch: abbrueche[python_exe] = abbruch for m in meldungen: art = m.get("art") if art == "test": reports.setdefault(m.get("knoten"), []).append({ "phase": m.get("phase"), "ergebnis": m.get("ergebnis"), "xfail": bool(m.get("xfail")), "meldung": m.get("meldung", ""), }) elif art == "sammeln": sammelfehler[m.get("knoten")] = m.get("meldung", "") elif art == "abgewaehlt": deselected.add(m.get("knoten")) return reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche def ergebnis_bestimmen(knoten, reports, deselected): if knoten in deselected: return "ABGEWAEHLT", "" phasen = reports.get(knoten, []) for p in phasen: if p["ergebnis"] == "failed" and not p["xfail"]: return "ROT", p["meldung"] for p in phasen: if p["ergebnis"] == "skipped" or p["xfail"]: return "UEBERSPRUNGEN", p["meldung"] for p in phasen: if p["phase"] == "call" and p["ergebnis"] == "passed": return "GRUEN", "" return "FEHLT", "" def sammelfehler_fuer(knoten, sammelfehler_tabelle): dateipfad = knoten.split("::", 1)[0] for cf_knoten, meldung in sammelfehler_tabelle.items(): cf_datei = cf_knoten.split("::", 1)[0] if cf_knoten == dateipfad or cf_datei == dateipfad: return meldung if dateipfad == cf_knoten or dateipfad.startswith(cf_knoten + "::") or dateipfad.startswith(cf_knoten + "/"): return meldung return None def baseline_pruefen(kontext, ausgewaehlte_mutanten, home_dir, plugin_dir, zeitlimit): """Schritt 3 des Ablaufs (Entwurf, Abschnitt 3.3).""" alle_tests = [] gesehen = set() for mutant in ausgewaehlte_mutanten: for _, knoten in mutant["rot_bei"]: if knoten not in gesehen: gesehen.add(knoten) alle_tests.append(knoten) gruppen = gruppiere_nach_interpreter(alle_tests, kontext["python"], kontext["python_je_datei"]) reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche = gruppen_ausfuehren( gruppen, kontext["wurzel"], home_dir, plugin_dir, zeitlimit, ) befunde = list(absturzmeldungen) abgebrochen = set() for python_exe, knoten_liste in gruppen.items(): if python_exe in abbrueche: befunde.append("Grundlinie: " + abbrueche[python_exe]["lang"]) abgebrochen.update(knoten_liste) ergebnis_je_knoten = {} for knoten in alle_tests: if zeit_ueberschritten: status, meldung = "ZEIT", "Zeitlimit in der Grundlinie ueberschritten" else: status, meldung = ergebnis_bestimmen(knoten, reports, deselected) if status == "FEHLT" and knoten in abgebrochen: ergebnis_je_knoten[knoten] = {"ergebnis": status, "meldung": "pytest abgebrochen, siehe Befunde"} continue ergebnis_je_knoten[knoten] = {"ergebnis": status, "meldung": meldung} if status != "GRUEN": zusatz = " ({0})".format(meldung) if meldung else "" befunde.append("Grundlinie: {0} ist {1}{2}".format(knoten, status, zusatz)) return befunde, ergebnis_je_knoten def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen, wurzel, home_dir, plugin_dir, python_je_datei, standard_python, zeitlimit): """Schritt 4 des Ablaufs, ein Mutant (Entwurf, Abschnitt 3.4).""" ziel = wurzel / mutant["datei"] original_bytes = ziel.read_bytes() sha_vorher = hashlib.sha256(original_bytes).hexdigest() datei_zeilen = original_bytes.decode("utf-8").split("\n") neue_zeilen = datei_zeilen[:treffer_start] + wird_zeilen + datei_zeilen[treffer_start + len(aus_zeilen):] neuer_inhalt = "\n".join(neue_zeilen).encode("utf-8") status = "UNKLAR" test_ergebnisse = [] try: ziel.write_bytes(neuer_inhalt) knoten_liste = [knoten for _, knoten in mutant["rot_bei"]] gruppen = gruppiere_nach_interpreter(knoten_liste, standard_python, python_je_datei) reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche = gruppen_ausfuehren( gruppen, wurzel, home_dir, plugin_dir, zeitlimit, ) if zeit_ueberschritten: status = "ZEIT" for eintrag, knoten in mutant["rot_bei"]: test_ergebnisse.append({"eintrag": eintrag, "knoten": knoten, "ergebnis": "ZEIT", "meldung": ""}) else: unklar = bool(absturzmeldungen) luecke = False if not reports and not sammelfehler and not deselected and not absturzmeldungen: unklar = True for eintrag, knoten in mutant["rot_bei"]: ergebnis, meldung = ergebnis_bestimmen(knoten, reports, deselected) abbruch = None if ergebnis == "FEHLT": sammel_meldung = sammelfehler_fuer(knoten, sammelfehler) if sammel_meldung is not None: ergebnis = "ROT" meldung = "Sammelfehler: " + sammel_meldung else: unklar = True abbruch = abbrueche.get(python_je_datei.get(knoten.split("::", 1)[0], standard_python)) if abbruch: meldung = abbruch["kurz"] if ergebnis in ("GRUEN", "UEBERSPRUNGEN", "ABGEWAEHLT"): luecke = True eintrag_bericht = {"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung} if ergebnis == "FEHLT" and abbruch: eintrag_bericht["ausgabe"] = abbruch["lang"] test_ergebnisse.append(eintrag_bericht) if unklar: status = "UNKLAR" elif luecke: status = "LUECKE" else: status = "OK" finally: ziel.write_bytes(original_bytes) sha_nachher = hashlib.sha256(ziel.read_bytes()).hexdigest() if sha_nachher != sha_vorher: raise RuecksetzenGescheitert(mutant["id"], mutant["datei"]) return status, test_ergebnisse def format_meldung(meldung, laenge): meldung = (meldung or "").replace("\n", " ") if len(meldung) > laenge: meldung = meldung[:max(laenge - 3, 0)] + "..." return meldung def format_mutant_zeile(mutant, status, test_ergebnisse, muster_ergebnis): stuecke = [] for eintrag in test_ergebnisse: stuecke.append("{0}={1}({2})".format(eintrag["eintrag"], eintrag["ergebnis"], format_meldung(eintrag["meldung"], 120))) if status == "MUSTER": stuecke.append("Trefferzahl={0}".format(muster_ergebnis["treffer"])) if muster_ergebnis.get("cr_in_datei"): stuecke.append("Datei hat CR") zeile = "{0} {1} {2}".format(mutant["id"], status, " ".join(stuecke)) return zeile.rstrip() def python_version_ermitteln(interpreter): try: ergebnis = subprocess.run([interpreter, "--version"], capture_output=True, text=True, timeout=30) text = (ergebnis.stdout or "") + (ergebnis.stderr or "") return text.strip() except OSError as exc: return "nicht ermittelbar ({0})".format(exc) def kopfzeilen_ausgeben(kontext, ausgewaehlte_mutanten): print("wurzel: {0}".format(kontext["wurzel"])) print("mutanten-datei: {0}".format(kontext["mutanten_pfad"])) print("mutanten gesamt: {0}, ausgewaehlt: {1}".format(len(kontext["mutanten"]), len(ausgewaehlte_mutanten))) interpreter_menge = {kontext["python"]} interpreter_menge.update(kontext["python_je_datei"].values()) for interpreter in sorted(interpreter_menge): print("python {0}: {1}".format(interpreter, python_version_ermitteln(interpreter))) alle_knoten = [] for mutant in ausgewaehlte_mutanten: for _, knoten in mutant["rot_bei"]: if knoten not in alle_knoten: alle_knoten.append(knoten) gruppen = gruppiere_nach_interpreter(alle_knoten, kontext["python"], kontext["python_je_datei"]) for interpreter, knoten_liste in gruppen.items(): print("pytest-konfiguration (Grundlinie, {0}): {1}".format( interpreter, pytest_konfig_beschreibung(kontext["wurzel"], knoten_liste))) def zusammenfassung_rechnen(mutant_liste): zaehler = {"OK": 0, "LUECKE": 0, "UNKLAR": 0, "ZEIT": 0, "MUSTER": 0} for m in mutant_liste: if m["status"] in zaehler: zaehler[m["status"]] += 1 zaehler["gesamt"] = len(mutant_liste) return zaehler def zusammenfassung_zeile(z): return "ergebnis: {0} Mutanten, {1} OK, {2} LUECKE, {3} UNKLAR, {4} ZEIT, {5} MUSTER".format( z["gesamt"], z["OK"], z["LUECKE"], z["UNKLAR"], z["ZEIT"], z["MUSTER"], ) def parse_args(argv=None): parser = argparse.ArgumentParser(description="Allgemeiner Mutantenlaeufer aus mutanten.toml") parser.add_argument("--wurzel", required=True) parser.add_argument("--mutanten", required=True) parser.add_argument("--nur") parser.add_argument("--nur-muster", dest="nur_muster", action="store_true") parser.add_argument("--bericht") parser.add_argument("--zeitlimit", type=float, default=900.0) return parser.parse_args(argv) def ablauf(args, bericht): befunde, kontext = lade_und_validiere(args) bericht["wurzel"] = str(kontext["wurzel"]) alle_ids = [m["id"] for m in kontext["mutanten"]] ausgewaehlte_ids = None if args.nur: ausgewaehlte_ids = [x.strip() for x in args.nur.split(",") if x.strip() != ""] for x in ausgewaehlte_ids: if x not in alle_ids: befunde.append("--nur nennt eine unbekannte id: {0}".format(x)) bericht["befunde"] = list(befunde) if befunde: return 2 if ausgewaehlte_ids is not None: ausgewaehlte_mutanten = [m for m in kontext["mutanten"] if m["id"] in ausgewaehlte_ids] else: ausgewaehlte_mutanten = list(kontext["mutanten"]) wurzel = kontext["wurzel"] kopfzeilen_ausgeben(kontext, ausgewaehlte_mutanten) muster_ergebnisse = {} for mutant in ausgewaehlte_mutanten: ergebnis = muster_pruefen(mutant, wurzel) muster_ergebnisse[mutant["id"]] = ergebnis if ergebnis.get("kodierungsfehler"): befunde.append("{0}: Zieldatei ist kein UTF-8: {1}".format(mutant["id"], ergebnis["kodierungsfehler"])) bericht["befunde"] = list(befunde) if befunde: return 2 if args.nur_muster: mutant_ergebnisliste = [] alle_ok = True for mutant in ausgewaehlte_mutanten: ergebnis = muster_ergebnisse[mutant["id"]] if ergebnis["treffer"] == 1: status = "OK" else: status = "MUSTER" alle_ok = False eintrag = { "id": mutant["id"], "datei": mutant["datei"], "status": status, "hinweis": mutant.get("hinweis"), "treffer": ergebnis["treffer"], "tests": [], } mutant_ergebnisliste.append(eintrag) bericht["mutanten"] = mutant_ergebnisliste print(format_mutant_zeile(mutant, status, [], ergebnis)) if mutant.get("hinweis"): print(" " + mutant["hinweis"]) zusammenfassung = zusammenfassung_rechnen(mutant_ergebnisliste) bericht["zusammenfassung"] = zusammenfassung print(zusammenfassung_zeile(zusammenfassung)) return 0 if alle_ok else 1 plugin_dir = Path(tempfile.mkdtemp(prefix="mutanten_lauf_plugin_")) home_dir = Path(tempfile.mkdtemp(prefix="mutanten_lauf_home_")) aktueller_mutant_id = None try: (plugin_dir / (PLUGIN_NAME + ".py")).write_text(PLUGIN_QUELLTEXT, encoding="utf-8") (plugin_dir / LEERE_KONFIG_NAME).write_text("", encoding="utf-8") baseline_befunde, baseline_ergebnisse = baseline_pruefen( kontext, ausgewaehlte_mutanten, home_dir, plugin_dir, args.zeitlimit, ) bericht["grundlinie"] = baseline_ergebnisse if baseline_befunde: bericht["befunde"] = list(befunde) + baseline_befunde return 2 bericht["mutanten"] = [] for mutant in ausgewaehlte_mutanten: muster = muster_ergebnisse[mutant["id"]] if muster["treffer"] != 1: eintrag = { "id": mutant["id"], "datei": mutant["datei"], "status": "MUSTER", "hinweis": mutant.get("hinweis"), "treffer": muster["treffer"], "tests": [], } bericht["mutanten"].append(eintrag) print(format_mutant_zeile(mutant, "MUSTER", [], muster)) if mutant.get("hinweis"): print(" " + mutant["hinweis"]) continue aktueller_mutant_id = mutant["id"] status, test_ergebnisse = mutant_anwenden_und_pruefen( mutant, muster["start"], muster["aus_zeilen"], muster["wird_zeilen"], wurzel, home_dir, plugin_dir, kontext["python_je_datei"], kontext["python"], args.zeitlimit, ) aktueller_mutant_id = None eintrag = { "id": mutant["id"], "datei": mutant["datei"], "status": status, "hinweis": mutant.get("hinweis"), "treffer": muster["treffer"], "tests": test_ergebnisse, } bericht["mutanten"].append(eintrag) print(format_mutant_zeile(mutant, status, test_ergebnisse, muster)) if mutant.get("hinweis"): print(" " + mutant["hinweis"]) zusammenfassung = zusammenfassung_rechnen(bericht["mutanten"]) bericht["zusammenfassung"] = zusammenfassung print(zusammenfassung_zeile(zusammenfassung)) alle_ok = all(m["status"] == "OK" for m in bericht["mutanten"]) return 0 if alle_ok else 1 except SigtermUnterbrechung: zeile = ( "ABBRUCH: SIGTERM, Mutant {0} zurueckgesetzt".format(aktueller_mutant_id) if aktueller_mutant_id else "ABBRUCH: SIGTERM" ) print(zeile) bericht["befunde"] = list(befunde) + [zeile] return 3 except RuecksetzenGescheitert as exc: bericht["befunde"] = list(befunde) + [str(exc)] return 2 finally: shutil.rmtree(plugin_dir, ignore_errors=True) shutil.rmtree(home_dir, ignore_errors=True) def ausgabe_schreiben(bericht, args): if args.bericht: try: with open(args.bericht, "w", encoding="utf-8") as datei: json.dump(bericht, datei, ensure_ascii=True, indent=2) except OSError as exc: print("Bericht konnte nicht geschrieben werden: {0}".format(exc), file=sys.stderr) if bericht.get("befunde"): print("befunde:") for b in bericht["befunde"]: print(" - {0}".format(b)) def main(argv=None): signal.signal(signal.SIGTERM, _sigterm_werfen) args = parse_args(argv) bericht = { "wurzel": args.wurzel, "mutanten_datei": args.mutanten, "befunde": [], "grundlinie": {}, "mutanten": [], "zusammenfassung": {}, } try: exit_code = ablauf(args, bericht) except Exception: bericht["befunde"] = bericht.get("befunde", []) + ["unerwarteter Abbruch:\n" + traceback.format_exc()] exit_code = 3 ausgabe_schreiben(bericht, args) return exit_code if __name__ == "__main__": sys.exit(main())