Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions autobuild/repro_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Emit the shell reproduction command for a single workspace script.

Given a path to a workspace script (e.g.
`autogalaxy_workspace_test/scripts/imaging/visualization.py`), print the
exact shell command `autobuild run_python` would have used to execute
it — including all environment variables from the workspace's
`config/build/env_vars.yaml` (defaults + matching per-pattern overrides).

Output format (single line):

(cd <workspace_name> && env KEY=val ... python3 <script_relpath>)

Run from the PyAutoLabs base directory. The output is portable as long
as the caller `cd`s to PyAutoLabs root first.

Usage:
autobuild repro_command <script_path>

Exit codes:
0 on success (command printed to stdout)
2 on usage error (script not found, workspace root not found)
"""

import argparse
import shlex
import sys
from pathlib import Path
from typing import Dict, Optional

from env_config import _pattern_matches, load_env_config


def _find_workspace_root(script: Path) -> Optional[Path]:
"""Walk up from `script` to find a dir containing config/build/env_vars.yaml."""
for candidate in (script.parent, *script.parents):
if (candidate / "config" / "build" / "env_vars.yaml").is_file():
return candidate
return None


def canonical_env_for_script(file: Path, env_config: Optional[dict]) -> Dict[str, str]:
"""Like `env_config.build_env_for_script`, but starts from `{}` instead of
`os.environ.copy()`.

The result is what autobuild *adds* to the environment, independent of the
developer's local shell. This is the right form for a portable reproduction
command — the chat-side reader inherits their shell's env and just gets the
autobuild-specific overrides prepended.
"""
if env_config is None:
return {}

env: Dict[str, str] = {}

for key, value in env_config.get("defaults", {}).items():
env[key] = str(value)

file_path_no_ext = str(file.with_suffix(""))
for override in env_config.get("overrides", []):
pattern = override["pattern"]
if _pattern_matches(file, file_path_no_ext, pattern):
for var_name in override.get("unset", []):
env.pop(var_name, None)
for key, value in override.get("set", {}).items():
env[key] = str(value)

return env


def repro_command(script_path: str) -> str:
"""Compute the one-line shell repro command for `script_path`.

Raises FileNotFoundError if the script doesn't exist or no workspace
root with env_vars.yaml is found walking up.
"""
script = Path(script_path).resolve()
if not script.is_file():
raise FileNotFoundError(f"script not found: {script_path}")

workspace_root = _find_workspace_root(script)
if workspace_root is None:
raise FileNotFoundError(
f"no workspace root with config/build/env_vars.yaml found "
f"walking up from {script_path}"
)

env_config_path = workspace_root / "config" / "build" / "env_vars.yaml"
env_config = load_env_config(env_config_path)
env = canonical_env_for_script(script, env_config)

workspace_name = workspace_root.name
script_rel = script.relative_to(workspace_root)

env_parts = [f"{k}={shlex.quote(v)}" for k, v in env.items()]
if env_parts:
env_prefix = "env " + " ".join(env_parts) + " "
else:
env_prefix = ""

return f"(cd {shlex.quote(workspace_name)} && {env_prefix}python3 {shlex.quote(str(script_rel))})"


def main(argv=None) -> int:
parser = argparse.ArgumentParser(
prog="autobuild repro_command",
description=__doc__.strip().splitlines()[0],
)
parser.add_argument(
"script_path",
help="Path to a workspace script (absolute or relative to cwd).",
)
args = parser.parse_args(argv)

try:
print(repro_command(args.script_path))
except FileNotFoundError as e:
print(f"autobuild repro_command: {e}", file=sys.stderr)
return 2
return 0


if __name__ == "__main__":
sys.exit(main())
32 changes: 32 additions & 0 deletions bin/autobuild
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ SUBCOMMAND_ORDER=(
generate_release_notes
create_analysis_issue
tag_and_merge
"# Triage support"
repro_command
"# Meta"
help
)
Expand All @@ -62,6 +64,7 @@ declare -A SHORT_DESC=(
[generate_release_notes]="Generate release notes from merged PRs and create GitHub Releases"
[create_analysis_issue]="Open a GitHub issue with the release report and assign Copilot"
[tag_and_merge]="Commit and tag every library repo for a release"
[repro_command]="Emit the shell command autobuild uses to run one script (for triage handoffs)"
[help]="List subcommands or show details for one"
)

Expand Down Expand Up @@ -377,6 +380,35 @@ cmd_tag_and_merge() {
bash "$AUTOBUILD_DIR/tag_and_merge.sh" "$@"
}

# ----- Triage support -----

help_repro_command() {
cat <<'EOF'
autobuild repro_command <script_path>

Print the exact shell command `autobuild run_python` would have used to
execute one workspace script — including all environment variables from
the workspace's config/build/env_vars.yaml (defaults + matching
per-pattern overrides).

Intended for triage handoffs: when pasting a failing script into a chat
or issue, run this first so the reader gets a self-contained command
that reproduces the autobuild failure exactly (not just `python3 <file>`,
which omits PYAUTO_TEST_MODE / PYAUTO_SMALL_DATASETS / etc.).

Arguments:
script_path Path to a workspace script (absolute or relative to cwd)

Output (single line, to stdout):
(cd <workspace_name> && env KEY=val ... python3 <script_relpath>)

Run the printed command from the PyAutoLabs base directory.
EOF
}
cmd_repro_command() {
_python_in_autobuild repro_command.py "$@"
}

# ----- Meta -----

help_help() {
Expand Down
135 changes: 135 additions & 0 deletions tests/test_repro_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Tests for autobuild/repro_command.py.

These mirror the tmp_path + monkeypatch.chdir pattern used by
test_workspace_config_precedence.py — build a fake workspace tree,
run the helper, assert the emitted command string.
"""

import sys
from pathlib import Path

import pytest

AUTOBUILD_DIR = Path(__file__).parent.parent / "autobuild"
sys.path.insert(0, str(AUTOBUILD_DIR))

import repro_command # noqa: E402


def _make_fake_workspace(tmp_path: Path, name: str, env_yaml: str) -> Path:
ws = tmp_path / name
(ws / "config" / "build").mkdir(parents=True)
(ws / "config" / "build" / "env_vars.yaml").write_text(env_yaml)
(ws / "scripts" / "imaging").mkdir(parents=True)
return ws


def test_emits_env_prefix_from_defaults(tmp_path):
ws = _make_fake_workspace(
tmp_path,
"fake_ws",
"""\
defaults:
PYAUTO_TEST_MODE: "2"
JAX_ENABLE_X64: "True"
""",
)
script = ws / "scripts" / "imaging" / "modeling.py"
script.write_text("# placeholder\n")

cmd = repro_command.repro_command(str(script))

assert cmd.startswith("(cd fake_ws && env ")
assert "PYAUTO_TEST_MODE=2" in cmd
assert "JAX_ENABLE_X64=True" in cmd
assert cmd.endswith("python3 scripts/imaging/modeling.py)")


def test_override_set_takes_precedence_over_default(tmp_path):
ws = _make_fake_workspace(
tmp_path,
"fake_ws",
"""\
defaults:
PYAUTO_TEST_MODE: "2"
overrides:
- pattern: "imaging/"
set:
PYAUTO_TEST_MODE: "0"
""",
)
script = ws / "scripts" / "imaging" / "modeling.py"
script.write_text("# placeholder\n")

cmd = repro_command.repro_command(str(script))

assert "PYAUTO_TEST_MODE=0" in cmd
assert "PYAUTO_TEST_MODE=2" not in cmd


def test_override_unset_removes_default(tmp_path):
ws = _make_fake_workspace(
tmp_path,
"fake_ws",
"""\
defaults:
PYAUTO_TEST_MODE: "2"
PYAUTO_SMALL_DATASETS: "1"
overrides:
- pattern: "imaging/"
unset: [PYAUTO_SMALL_DATASETS]
""",
)
script = ws / "scripts" / "imaging" / "modeling.py"
script.write_text("# placeholder\n")

cmd = repro_command.repro_command(str(script))

assert "PYAUTO_TEST_MODE=2" in cmd
assert "PYAUTO_SMALL_DATASETS" not in cmd


def test_no_overrides_match_just_defaults(tmp_path):
ws = _make_fake_workspace(
tmp_path,
"fake_ws",
"""\
defaults:
KEY_A: "1"
overrides:
- pattern: "guides/"
unset: [KEY_A]
""",
)
script = ws / "scripts" / "imaging" / "modeling.py"
script.write_text("# placeholder\n")

cmd = repro_command.repro_command(str(script))

assert "KEY_A=1" in cmd


def test_script_not_found_raises(tmp_path):
with pytest.raises(FileNotFoundError, match="script not found"):
repro_command.repro_command(str(tmp_path / "nope.py"))


def test_no_workspace_root_raises(tmp_path):
orphan = tmp_path / "orphan.py"
orphan.write_text("# no workspace above me\n")
with pytest.raises(FileNotFoundError, match="no workspace root"):
repro_command.repro_command(str(orphan))


def test_empty_env_config_emits_no_env_prefix(tmp_path):
ws = tmp_path / "fake_ws"
(ws / "config" / "build").mkdir(parents=True)
(ws / "config" / "build" / "env_vars.yaml").write_text("# empty\n")
script_dir = ws / "scripts"
script_dir.mkdir()
script = script_dir / "foo.py"
script.write_text("# placeholder\n")

cmd = repro_command.repro_command(str(script))

assert cmd == "(cd fake_ws && python3 scripts/foo.py)"