#!/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). 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"} 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 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) cmd = [python_exe, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-p", PLUGIN_NAME] + list(knoten_liste) zeit_ueberschritten = False absturz = None 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: proc.communicate(timeout=zeitlimit) except subprocess.TimeoutExpired: zeit_ueberschritten = True try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except (ProcessLookupError, PermissionError): pass 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 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 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 = [] for python_exe, knoten_liste in gruppen.items(): zeit, meldungen, absturz = pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimit) if zeit: zeit_ueberschritten = True if absturz: absturzmeldungen.append(absturz) 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 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 = gruppen_ausfuehren( gruppen, kontext["wurzel"], home_dir, plugin_dir, zeitlimit, ) befunde = list(absturzmeldungen) 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) 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 = 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) if ergebnis == "FEHLT": sammel_meldung = sammelfehler_fuer(knoten, sammelfehler) if sammel_meldung is not None: ergebnis = "ROT" meldung = "Sammelfehler: " + sammel_meldung else: unklar = True if ergebnis in ("GRUEN", "UEBERSPRUNGEN", "ABGEWAEHLT"): luecke = True test_ergebnisse.append({"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung}) 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))) 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") 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())