From ba6b41f19d69aff50cb3ed798a36b962e25d1093 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 4 Nov 2026 21:25:20 +0000 Subject: [PATCH] fix: run hygiene extras on a tomllib interpreter, skip cleanly without one (#581) hygiene.sh ran _hygiene_extras.py with the bare python3, which is 3.10 on the WSL laptop, so the extras mode died with ModuleNotFoundError: tomllib and the default scan printed a traceback. Run it on the newest of python3.13/3.12/3.11, fall back to tomli, and with neither report the scan as skipped (advisory / error row) instead of crashing. Co-Authored-By: Claude Opus 5.5 --- agents/conductors/hygiene/_hygiene_extras.py | 26 +++++++- agents/conductors/hygiene/hygiene.sh | 18 ++++-- tests/test_hygiene_extras_toml_fallback.py | 63 ++++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 tests/test_hygiene_extras_toml_fallback.py diff --git a/agents/conductors/hygiene/_hygiene_extras.py b/agents/conductors/hygiene/_hygiene_extras.py index 08025a58..0354688e 100644 --- a/agents/conductors/hygiene/_hygiene_extras.py +++ b/agents/conductors/hygiene/_hygiene_extras.py @@ -49,7 +49,14 @@ import json import re import sys -import tomllib + +try: # Python >= 3.11 + import tomllib +except ModuleNotFoundError: # older interpreter: tomli is the same parser, if installed + try: + import tomli as tomllib # type: ignore[no-redef] + except ModuleNotFoundError: + tomllib = None # main() reports the scan as skipped rather than crashing from pathlib import Path import sys @@ -286,6 +293,23 @@ def main() -> int: output.add_argument("--summary", action="store_true") args = parser.parse_args() + if tomllib is None: + reason = ( + f"skipped: needs Python >= 3.11 (tomllib) or tomli to read pyproject.toml; " + f"ran on Python {sys.version_info.major}.{sys.version_info.minor}" + ) + if args.json_row: + # Same envelope as the normal row; "error" + a null count is what + # hygiene.sh's own helper-failed row uses, so no reader sees "clean". + print(json.dumps({"count": None, "delegate": "/bug", "findings": [], + "kind": "finding", "mode": "extras", + "status": "error", "summary": reason}, sort_keys=True)) + elif args.summary: + print(f"-1|{reason}") # count -1 renders as "advisory", never "clean" + else: + print(reason) + return 0 + findings, skipped = missing(args.root) if args.summary: diff --git a/agents/conductors/hygiene/hygiene.sh b/agents/conductors/hygiene/hygiene.sh index e7cec623..f08205d3 100755 --- a/agents/conductors/hygiene/hygiene.sh +++ b/agents/conductors/hygiene/hygiene.sh @@ -63,6 +63,16 @@ set -uo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +# _hygiene_extras.py parses pyproject.toml with `tomllib` (Python >= 3.11), but a +# bare `python3` can be older (3.10 on the WSL laptop), where the extras mode +# crashed instead of scanning (PyAutoBrain#581). Run it on the newest interpreter +# present; the helper itself still degrades to a "skipped" row if none has it. +TOML_PY="python3" +for _py in python3.13 python3.12 python3.11; do + if command -v "$_py" >/dev/null 2>&1; then TOML_PY="$_py"; break; fi +done +unset _py source "$HERE/../../_common.sh" # PYAUTO_ROOT is exported/defaulted by _common.sh. Scan the canonical checkouts @@ -312,7 +322,7 @@ prescan_optdeps() { # hand-added and silently drift. The symptom is a script red in smoke and green # in release; the fix is always the install set, never the script. prescan_extras() { - python3 "$HERE/_hygiene_extras.py" --root "$ROOT" --summary + "$TOML_PY" "$HERE/_hygiene_extras.py" --root "$ROOT" --summary } # refs: file/folder references in user-facing *_workspace and HowTo* prose @@ -670,7 +680,7 @@ emit_json_row() { # mode # this output, and one bad row would blank the whole hygiene section. helper_row() { # helper-module mode local out - if out="$(python3 "$HERE/$1" --root "$ROOT" --json-row 2>/dev/null)" \ + if out="$("${3:-python3}" "$HERE/$1" --root "$ROOT" --json-row 2>/dev/null)" \ && [[ -n "$out" ]] \ && python3 -c 'import json,sys; json.loads(sys.argv[1])' "$out" 2>/dev/null; then printf '%s' "$out" @@ -683,7 +693,7 @@ emit_json_row() { # mode if [[ "$m" == "escapes" ]]; then helper_row _hygiene_escapes.py escapes; return; fi if [[ "$m" == "refs" ]]; then helper_row _hygiene_refs.py refs; return; fi if [[ "$m" == "optdeps" ]]; then helper_row _hygiene_optdeps.py optdeps; return; fi - if [[ "$m" == "extras" ]]; then helper_row _hygiene_extras.py extras; return; fi + if [[ "$m" == "extras" ]]; then helper_row _hygiene_extras.py extras "$TOML_PY"; return; fi local res count summary kind status res="$(prescan "$m")"; count="${res%%|*}"; summary="${res#*|}"; kind="${MODE_KIND[$m]}" if [[ "$kind" == "advisory" || "$count" == "-1" ]]; then status="advisory" @@ -793,7 +803,7 @@ elif [[ "$mode" == "optdeps" ]]; then python3 "$HERE/_hygiene_optdeps.py" --root "$ROOT" elif [[ "$mode" == "extras" ]]; then echo "Optional dependencies the workspace-validation smoke leg never installs (read-only scan):" - python3 "$HERE/_hygiene_extras.py" --root "$ROOT" + "$TOML_PY" "$HERE/_hygiene_extras.py" --root "$ROOT" elif [[ "$mode" == "config" ]]; then echo "Library config keys absent downstream + orphan config files (read-only scan):" python3 "$HERE/_hygiene_config.py" --root "$ROOT" --detail \ diff --git a/tests/test_hygiene_extras_toml_fallback.py b/tests/test_hygiene_extras_toml_fallback.py new file mode 100644 index 00000000..95311f18 --- /dev/null +++ b/tests/test_hygiene_extras_toml_fallback.py @@ -0,0 +1,63 @@ +"""_hygiene_extras.py degrades to a "skipped" row without tomllib (PyAutoBrain#581). + +`tomllib` is Python >= 3.11, and a bare `python3` can be older (3.10 on the WSL +laptop), where the extras mode used to die with ModuleNotFoundError. CI runs +3.12+, so the old-interpreter path is simulated by blocking both `tomllib` and +its `tomli` fallback before the helper is imported. +""" + +import json +import subprocess +import sys +from pathlib import Path + +HELPER = ( + Path(__file__).resolve().parents[1] + / "agents" / "conductors" / "hygiene" / "_hygiene_extras.py" +) + +_BLOCK = """ +import importlib.abc, runpy, sys +class _Block(importlib.abc.MetaPathFinder): + def find_spec(self, name, path=None, target=None): + if name in ("tomllib", "tomli"): + raise ModuleNotFoundError(name) +sys.modules.pop("tomllib", None) +sys.meta_path.insert(0, _Block()) +sys.argv = [sys.argv[1]] + sys.argv[2:] +runpy.run_path(sys.argv[0], run_name="__main__") +""" + + +def _run(tmp_path, *flags): + return subprocess.run( + [sys.executable, "-c", _BLOCK, str(HELPER), "--root", str(tmp_path), *flags], + capture_output=True, + text=True, + ) + + +def test_json_row_without_tomllib_is_an_error_row_not_a_traceback(tmp_path): + result = _run(tmp_path, "--json-row") + # runpy re-raises SystemExit(0) from main(); a traceback would mean the import crashed. + assert "Traceback" not in result.stderr, result.stderr + row = json.loads(result.stdout) + assert row["mode"] == "extras" + assert row["status"] == "error" + assert row["count"] is None + assert "Python >= 3.11" in row["summary"] + + +def test_summary_without_tomllib_renders_advisory_not_clean(tmp_path): + result = _run(tmp_path, "--summary") + assert "Traceback" not in result.stderr, result.stderr + count, _, text = result.stdout.strip().partition("|") + assert count == "-1" # hygiene.sh renders -1 as "advisory"; 0 would read "clean" + assert text.startswith("skipped:") + + +def test_hygiene_sh_runs_extras_on_a_tomllib_interpreter(): + script = HELPER.parent / "hygiene.sh" + text = script.read_text() + assert 'helper_row _hygiene_extras.py extras "$TOML_PY"' in text + assert 'python3 "$HERE/_hygiene_extras.py"' not in text