.vault-tools (ohne trading-plattform-pruefung und watermarks-remover), .claude/agents, .claude/skills, .claude/workflows, .claude/settings.json. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1151 lines
44 KiB
Python
1151 lines
44 KiB
Python
#!/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 <kopie> --mutanten <mutanten.toml>
|
|
[--nur ID,ID,...] [--nur-muster] [--bericht <datei.json>]
|
|
[--zeitlimit <sekunden>]
|
|
|
|
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())
|