Skip to content

Commit c9ef044

Browse files
Jammy2211claude
authored andcommitted
add autobuild repro_command for triage script reproduction
Emits the exact `(cd <ws> && env KEY=val ... python3 <script>)` command autobuild would have used to run a single workspace script — same defaults + per-pattern overrides as `env_config.build_env_for_script`, but starts from `{}` instead of `os.environ.copy()` so the output is portable across shells. Intended for triage handoffs: when pasting a failing script into a chat or issue, the reader gets a self-contained reproduction that matches autobuild's environment exactly (PYAUTO_TEST_MODE, PYAUTO_SMALL_DATASETS, etc.), not just `python3 <file>` which silently omits them. Wired into the dispatcher under a new "Triage support" section. Verified end-to-end against the 2026-05-20 release-prep run: spot-check of the Cluster A `imaging/visualization.py` failure reproduces the recorded `AssertionError: dataset.png missing` verbatim. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c7eccd1 commit c9ef044

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

autobuild/repro_command.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Emit the shell reproduction command for a single workspace script.
2+
3+
Given a path to a workspace script (e.g.
4+
`autogalaxy_workspace_test/scripts/imaging/visualization.py`), print the
5+
exact shell command `autobuild run_python` would have used to execute
6+
it — including all environment variables from the workspace's
7+
`config/build/env_vars.yaml` (defaults + matching per-pattern overrides).
8+
9+
Output format (single line):
10+
11+
(cd <workspace_name> && env KEY=val ... python3 <script_relpath>)
12+
13+
Run from the PyAutoLabs base directory. The output is portable as long
14+
as the caller `cd`s to PyAutoLabs root first.
15+
16+
Usage:
17+
autobuild repro_command <script_path>
18+
19+
Exit codes:
20+
0 on success (command printed to stdout)
21+
2 on usage error (script not found, workspace root not found)
22+
"""
23+
24+
import argparse
25+
import shlex
26+
import sys
27+
from pathlib import Path
28+
from typing import Dict, Optional
29+
30+
from env_config import _pattern_matches, load_env_config
31+
32+
33+
def _find_workspace_root(script: Path) -> Optional[Path]:
34+
"""Walk up from `script` to find a dir containing config/build/env_vars.yaml."""
35+
for candidate in (script.parent, *script.parents):
36+
if (candidate / "config" / "build" / "env_vars.yaml").is_file():
37+
return candidate
38+
return None
39+
40+
41+
def canonical_env_for_script(file: Path, env_config: Optional[dict]) -> Dict[str, str]:
42+
"""Like `env_config.build_env_for_script`, but starts from `{}` instead of
43+
`os.environ.copy()`.
44+
45+
The result is what autobuild *adds* to the environment, independent of the
46+
developer's local shell. This is the right form for a portable reproduction
47+
command — the chat-side reader inherits their shell's env and just gets the
48+
autobuild-specific overrides prepended.
49+
"""
50+
if env_config is None:
51+
return {}
52+
53+
env: Dict[str, str] = {}
54+
55+
for key, value in env_config.get("defaults", {}).items():
56+
env[key] = str(value)
57+
58+
file_path_no_ext = str(file.with_suffix(""))
59+
for override in env_config.get("overrides", []):
60+
pattern = override["pattern"]
61+
if _pattern_matches(file, file_path_no_ext, pattern):
62+
for var_name in override.get("unset", []):
63+
env.pop(var_name, None)
64+
for key, value in override.get("set", {}).items():
65+
env[key] = str(value)
66+
67+
return env
68+
69+
70+
def repro_command(script_path: str) -> str:
71+
"""Compute the one-line shell repro command for `script_path`.
72+
73+
Raises FileNotFoundError if the script doesn't exist or no workspace
74+
root with env_vars.yaml is found walking up.
75+
"""
76+
script = Path(script_path).resolve()
77+
if not script.is_file():
78+
raise FileNotFoundError(f"script not found: {script_path}")
79+
80+
workspace_root = _find_workspace_root(script)
81+
if workspace_root is None:
82+
raise FileNotFoundError(
83+
f"no workspace root with config/build/env_vars.yaml found "
84+
f"walking up from {script_path}"
85+
)
86+
87+
env_config_path = workspace_root / "config" / "build" / "env_vars.yaml"
88+
env_config = load_env_config(env_config_path)
89+
env = canonical_env_for_script(script, env_config)
90+
91+
workspace_name = workspace_root.name
92+
script_rel = script.relative_to(workspace_root)
93+
94+
env_parts = [f"{k}={shlex.quote(v)}" for k, v in env.items()]
95+
if env_parts:
96+
env_prefix = "env " + " ".join(env_parts) + " "
97+
else:
98+
env_prefix = ""
99+
100+
return f"(cd {shlex.quote(workspace_name)} && {env_prefix}python3 {shlex.quote(str(script_rel))})"
101+
102+
103+
def main(argv=None) -> int:
104+
parser = argparse.ArgumentParser(
105+
prog="autobuild repro_command",
106+
description=__doc__.strip().splitlines()[0],
107+
)
108+
parser.add_argument(
109+
"script_path",
110+
help="Path to a workspace script (absolute or relative to cwd).",
111+
)
112+
args = parser.parse_args(argv)
113+
114+
try:
115+
print(repro_command(args.script_path))
116+
except FileNotFoundError as e:
117+
print(f"autobuild repro_command: {e}", file=sys.stderr)
118+
return 2
119+
return 0
120+
121+
122+
if __name__ == "__main__":
123+
sys.exit(main())

bin/autobuild

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ SUBCOMMAND_ORDER=(
4242
generate_release_notes
4343
create_analysis_issue
4444
tag_and_merge
45+
"# Triage support"
46+
repro_command
4547
"# Meta"
4648
help
4749
)
@@ -62,6 +64,7 @@ declare -A SHORT_DESC=(
6264
[generate_release_notes]="Generate release notes from merged PRs and create GitHub Releases"
6365
[create_analysis_issue]="Open a GitHub issue with the release report and assign Copilot"
6466
[tag_and_merge]="Commit and tag every library repo for a release"
67+
[repro_command]="Emit the shell command autobuild uses to run one script (for triage handoffs)"
6568
[help]="List subcommands or show details for one"
6669
)
6770

@@ -377,6 +380,35 @@ cmd_tag_and_merge() {
377380
bash "$AUTOBUILD_DIR/tag_and_merge.sh" "$@"
378381
}
379382

383+
# ----- Triage support -----
384+
385+
help_repro_command() {
386+
cat <<'EOF'
387+
autobuild repro_command <script_path>
388+
389+
Print the exact shell command `autobuild run_python` would have used to
390+
execute one workspace script — including all environment variables from
391+
the workspace's config/build/env_vars.yaml (defaults + matching
392+
per-pattern overrides).
393+
394+
Intended for triage handoffs: when pasting a failing script into a chat
395+
or issue, run this first so the reader gets a self-contained command
396+
that reproduces the autobuild failure exactly (not just `python3 <file>`,
397+
which omits PYAUTO_TEST_MODE / PYAUTO_SMALL_DATASETS / etc.).
398+
399+
Arguments:
400+
script_path Path to a workspace script (absolute or relative to cwd)
401+
402+
Output (single line, to stdout):
403+
(cd <workspace_name> && env KEY=val ... python3 <script_relpath>)
404+
405+
Run the printed command from the PyAutoLabs base directory.
406+
EOF
407+
}
408+
cmd_repro_command() {
409+
_python_in_autobuild repro_command.py "$@"
410+
}
411+
380412
# ----- Meta -----
381413

382414
help_help() {

tests/test_repro_command.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for autobuild/repro_command.py.
2+
3+
These mirror the tmp_path + monkeypatch.chdir pattern used by
4+
test_workspace_config_precedence.py — build a fake workspace tree,
5+
run the helper, assert the emitted command string.
6+
"""
7+
8+
import sys
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
AUTOBUILD_DIR = Path(__file__).parent.parent / "autobuild"
14+
sys.path.insert(0, str(AUTOBUILD_DIR))
15+
16+
import repro_command # noqa: E402
17+
18+
19+
def _make_fake_workspace(tmp_path: Path, name: str, env_yaml: str) -> Path:
20+
ws = tmp_path / name
21+
(ws / "config" / "build").mkdir(parents=True)
22+
(ws / "config" / "build" / "env_vars.yaml").write_text(env_yaml)
23+
(ws / "scripts" / "imaging").mkdir(parents=True)
24+
return ws
25+
26+
27+
def test_emits_env_prefix_from_defaults(tmp_path):
28+
ws = _make_fake_workspace(
29+
tmp_path,
30+
"fake_ws",
31+
"""\
32+
defaults:
33+
PYAUTO_TEST_MODE: "2"
34+
JAX_ENABLE_X64: "True"
35+
""",
36+
)
37+
script = ws / "scripts" / "imaging" / "modeling.py"
38+
script.write_text("# placeholder\n")
39+
40+
cmd = repro_command.repro_command(str(script))
41+
42+
assert cmd.startswith("(cd fake_ws && env ")
43+
assert "PYAUTO_TEST_MODE=2" in cmd
44+
assert "JAX_ENABLE_X64=True" in cmd
45+
assert cmd.endswith("python3 scripts/imaging/modeling.py)")
46+
47+
48+
def test_override_set_takes_precedence_over_default(tmp_path):
49+
ws = _make_fake_workspace(
50+
tmp_path,
51+
"fake_ws",
52+
"""\
53+
defaults:
54+
PYAUTO_TEST_MODE: "2"
55+
overrides:
56+
- pattern: "imaging/"
57+
set:
58+
PYAUTO_TEST_MODE: "0"
59+
""",
60+
)
61+
script = ws / "scripts" / "imaging" / "modeling.py"
62+
script.write_text("# placeholder\n")
63+
64+
cmd = repro_command.repro_command(str(script))
65+
66+
assert "PYAUTO_TEST_MODE=0" in cmd
67+
assert "PYAUTO_TEST_MODE=2" not in cmd
68+
69+
70+
def test_override_unset_removes_default(tmp_path):
71+
ws = _make_fake_workspace(
72+
tmp_path,
73+
"fake_ws",
74+
"""\
75+
defaults:
76+
PYAUTO_TEST_MODE: "2"
77+
PYAUTO_SMALL_DATASETS: "1"
78+
overrides:
79+
- pattern: "imaging/"
80+
unset: [PYAUTO_SMALL_DATASETS]
81+
""",
82+
)
83+
script = ws / "scripts" / "imaging" / "modeling.py"
84+
script.write_text("# placeholder\n")
85+
86+
cmd = repro_command.repro_command(str(script))
87+
88+
assert "PYAUTO_TEST_MODE=2" in cmd
89+
assert "PYAUTO_SMALL_DATASETS" not in cmd
90+
91+
92+
def test_no_overrides_match_just_defaults(tmp_path):
93+
ws = _make_fake_workspace(
94+
tmp_path,
95+
"fake_ws",
96+
"""\
97+
defaults:
98+
KEY_A: "1"
99+
overrides:
100+
- pattern: "guides/"
101+
unset: [KEY_A]
102+
""",
103+
)
104+
script = ws / "scripts" / "imaging" / "modeling.py"
105+
script.write_text("# placeholder\n")
106+
107+
cmd = repro_command.repro_command(str(script))
108+
109+
assert "KEY_A=1" in cmd
110+
111+
112+
def test_script_not_found_raises(tmp_path):
113+
with pytest.raises(FileNotFoundError, match="script not found"):
114+
repro_command.repro_command(str(tmp_path / "nope.py"))
115+
116+
117+
def test_no_workspace_root_raises(tmp_path):
118+
orphan = tmp_path / "orphan.py"
119+
orphan.write_text("# no workspace above me\n")
120+
with pytest.raises(FileNotFoundError, match="no workspace root"):
121+
repro_command.repro_command(str(orphan))
122+
123+
124+
def test_empty_env_config_emits_no_env_prefix(tmp_path):
125+
ws = tmp_path / "fake_ws"
126+
(ws / "config" / "build").mkdir(parents=True)
127+
(ws / "config" / "build" / "env_vars.yaml").write_text("# empty\n")
128+
script_dir = ws / "scripts"
129+
script_dir.mkdir()
130+
script = script_dir / "foo.py"
131+
script.write_text("# placeholder\n")
132+
133+
cmd = repro_command.repro_command(str(script))
134+
135+
assert cmd == "(cd fake_ws && python3 scripts/foo.py)"

0 commit comments

Comments
 (0)