Mutantenlaeufer: pytest-Konfiguration wie pytest, nie oberhalb der Wurzel; Abbruch von pytest mit Ursache statt FEHLT; Gleichwertigkeitsprobe, Treiber 46 Faelle; zwei Pruefrunden

Anlass: Plankritik 2 von C1 (After Install), fremde pyproject.toml im Windows-Temp.
Gegenprobe an sechs mutanten.toml im Vault: gleiche Ergebnisse, C1 unter Windows-Temp jetzt 69 OK statt 101 FEHLT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-06 17:41:05 +02:00
1 parent 612cd7fb54
commit ff7854cf39
8 files changed
+3457 -19

No files matched your search

+264 -9
View File
@@ -24,6 +24,37 @@ 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):
@@ -119,6 +150,20 @@ def pytest_deselected(items):
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):
@@ -427,6 +472,170 @@ def gruppiere_nach_interpreter(knoten_liste, standard_python, python_je_datei):
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()
@@ -448,10 +657,17 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
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)
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:
@@ -465,14 +681,14 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
if proc is not None:
try:
proc.communicate(timeout=zeitlimit)
ausgabe = 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()
ausgabe = proc.communicate()
except BaseException:
# Auch ein SIGTERM (SigtermUnterbrechung) darf keinen pytest-Prozess
# zurueck lassen: Prozessgruppe beenden, dann weiterwerfen, damit die
@@ -487,6 +703,17 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
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:
@@ -502,7 +729,7 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
except OSError:
pass
return zeit_ueberschritten, meldungen, absturz
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.
@@ -518,12 +745,15 @@ def gruppen_ausfuehren(gruppen, wurzel, home_dir, plugin_dir, zeitlimit):
sammelfehler = {}
zeit_ueberschritten = False
absturzmeldungen = []
abbrueche = {}
for python_exe, knoten_liste in gruppen.items():
zeit, meldungen, absturz = pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimit)
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":
@@ -537,7 +767,7 @@ def gruppen_ausfuehren(gruppen, wurzel, home_dir, plugin_dir, zeitlimit):
sammelfehler[m.get("knoten")] = m.get("meldung", "")
elif art == "abgewaehlt":
deselected.add(m.get("knoten"))
return reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen
return reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche
def ergebnis_bestimmen(knoten, reports, deselected):
@@ -578,17 +808,25 @@ def baseline_pruefen(kontext, ausgewaehlte_mutanten, home_dir, plugin_dir, zeitl
alle_tests.append(knoten)
gruppen = gruppiere_nach_interpreter(alle_tests, kontext["python"], kontext["python_je_datei"])
reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen = gruppen_ausfuehren(
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 ""
@@ -615,7 +853,7 @@ def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen,
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(
reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche = gruppen_ausfuehren(
gruppen, wurzel, home_dir, plugin_dir, zeitlimit,
)
@@ -630,6 +868,7 @@ def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen,
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:
@@ -637,9 +876,15 @@ def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen,
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
test_ergebnisse.append({"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung})
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"
@@ -692,6 +937,15 @@ def kopfzeilen_ausgeben(kontext, ausgewaehlte_mutanten):
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):
@@ -788,6 +1042,7 @@ def ablauf(args, bericht):
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,