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
26 changes: 26 additions & 0 deletions autohands/env_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@
MANAGED_ENV_PREFIXES = ("PYAUTO_",)


# The per-script env profile pair, each as (canonical, legacy) filenames.
# The canonical names are preferred; the legacy env_vars*.yaml names are
# accepted only during the #161 step-6 rename migration window and DIE at the
# step-6 cleanup (docs/env_profile_redesign.md §7) — a later stage-3 PR removes
# the legacy fallbacks once every workspace has renamed.
PROFILE_NAMES = {
"smoke": ("profile_smoke.yaml", "env_vars.yaml"),
"release": ("profile_release.yaml", "env_vars_release.yaml"),
}


def find_profile(build_dir: Path, kind: str) -> Optional[Path]:
"""Return the profile file of the given kind ("smoke"/"release") in
``build_dir``, or None if neither name exists.

The canonical name (``profile_smoke.yaml`` / ``profile_release.yaml``) is
preferred; the legacy ``env_vars*.yaml`` name is accepted during the #161
step-6 rename migration window (docs/env_profile_redesign.md §7).
"""
for name in PROFILE_NAMES[kind]:
candidate = build_dir / name
if candidate.is_file():
return candidate
return None


def load_env_config(config_path: Path) -> dict:
"""Load and return the parsed env_vars.yaml."""
with open(config_path) as f:
Expand Down
18 changes: 11 additions & 7 deletions autohands/repro_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
`autogalaxy_workspace_test/scripts/imaging/visualization.py`), print the
exact shell command `autohands 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).
`config/build/profile_smoke.yaml` (defaults + matching per-pattern
overrides; the legacy `env_vars.yaml` name is accepted during the #161
step-6 migration window).

Output format (single line):

Expand All @@ -27,13 +29,15 @@
from pathlib import Path
from typing import Dict, Optional

from env_config import apply_profile, load_env_config
from env_config import apply_profile, find_profile, 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."""
"""Walk up from `script` to find a dir containing a smoke profile
(canonical `config/build/profile_smoke.yaml`, or the legacy
`env_vars.yaml` name during the #161 step-6 migration window)."""
for candidate in (script.parent, *script.parents):
if (candidate / "config" / "build" / "env_vars.yaml").is_file():
if find_profile(candidate / "config" / "build", "smoke") is not None:
return candidate
return None

Expand All @@ -57,7 +61,7 @@ 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.
root with a smoke profile is found walking up.
"""
script = Path(script_path).resolve()
if not script.is_file():
Expand All @@ -66,11 +70,11 @@ def repro_command(script_path: str) -> str:
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"no workspace root with config/build/profile_smoke.yaml found "
f"walking up from {script_path}"
)

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

Expand Down
8 changes: 5 additions & 3 deletions autohands/result_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ class RunReport:
project: str
directory: str
run_type: str # "script", "notebook", or "generate"
# Which env profile the scripts actually ran under ("env_vars.yaml",
# "env_vars_release.yaml", "none"). Recorded so a report states the surface
# it measured — two runs are otherwise incomparable (PyAutoHeart#83 §5.3).
# Which env profile the scripts actually ran under ("profile_smoke.yaml",
# "profile_release.yaml", "none"; the legacy env_vars*.yaml names are also
# accepted during the #161 step-6 migration window). Recorded so a report
# states the surface it measured — two runs are otherwise incomparable
# (PyAutoHeart#83 §5.3).
env_profile: str = "unknown"
results: List[ScriptResult] = dataclasses.field(default_factory=list)
started_at: str = dataclasses.field(
Expand Down
10 changes: 6 additions & 4 deletions autohands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"--env-config",
type=str,
default=None,
help="Path to env_vars.yaml for per-script environment configuration",
help="Path to profile_smoke.yaml for per-script environment configuration",
)

args = parser.parse_args()
Expand Down Expand Up @@ -68,12 +68,14 @@
else:
visualise_dict = None

# env_vars.yaml: explicit flag > workspace config/build/ > none
# smoke profile: explicit flag > workspace config/build/ (canonical name
# preferred, legacy env_vars.yaml accepted during migration) > none
env_config_path = None
if args.env_config:
env_config_path = Path(args.env_config)
elif (WORKSPACE_BUILD_CONFIG / "env_vars.yaml").exists():
env_config_path = WORKSPACE_BUILD_CONFIG / "env_vars.yaml"
else:
from env_config import find_profile
env_config_path = find_profile(WORKSPACE_BUILD_CONFIG, "smoke")

if __name__ == "__main__":
report = None
Expand Down
10 changes: 6 additions & 4 deletions autohands/run_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"--env-config",
type=str,
default=None,
help="Path to env_vars.yaml for per-script environment configuration",
help="Path to profile_smoke.yaml for per-script environment configuration",
)

args = parser.parse_args()
Expand All @@ -45,12 +45,14 @@
else:
no_run_list = no_run_data or []

# env_vars.yaml: explicit flag > workspace config/build/ > none
# smoke profile: explicit flag > workspace config/build/ (canonical name
# preferred, legacy env_vars.yaml accepted during migration) > none
env_config_path = None
if args.env_config:
env_config_path = Path(args.env_config)
elif (WORKSPACE_BUILD_CONFIG / "env_vars.yaml").exists():
env_config_path = WORKSPACE_BUILD_CONFIG / "env_vars.yaml"
else:
from env_config import find_profile
env_config_path = find_profile(WORKSPACE_BUILD_CONFIG, "smoke")

if __name__ == "__main__":
report = None
Expand Down
23 changes: 18 additions & 5 deletions autohands/validate_env_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@

import yaml

from env_config import _pattern_matches, apply_profile, is_jax_marked # noqa: F401
from env_config import ( # noqa: F401
PROFILE_NAMES,
_pattern_matches,
apply_profile,
is_jax_marked,
)

ALLOWED_TOP_KEYS = {"defaults", "overrides", "args_default", "derive_jax_markers"}
ALLOWED_OVERRIDE_KEYS = {"pattern", "set", "unset"}
PROFILE_FILES = ("env_vars.yaml", "env_vars_release.yaml")


def resolve_clean(script: Path, cfg: dict) -> dict[str, str]:
Expand Down Expand Up @@ -143,10 +147,19 @@ def validate_workspace(
)
errors: list[str] = []
warnings: list[str] = []
for fname in PROFILE_FILES:
p = root / "config" / "build" / fname
build_dir = root / "config" / "build"
for kind, (canonical, legacy) in PROFILE_NAMES.items():
canonical_p = build_dir / canonical
legacy_p = build_dir / legacy
if canonical_p.is_file() and legacy_p.is_file():
errors.append(
f"{canonical} AND legacy {legacy} both exist — ambiguous; "
"keep exactly one"
)
continue
p = canonical_p if canonical_p.is_file() else legacy_p
if not p.is_file():
errors.append(f"{fname}: missing")
errors.append(f"{canonical}: missing (legacy {legacy} also absent)")
continue
e, w = check_profile(p, scripts, strict_derivation, strict_markers)
errors += e
Expand Down
6 changes: 6 additions & 0 deletions docs/env_profile_redesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ carry the concept; the per-PR curated `smoke_tests.txt` gate keeps the word
"smoke"). Mechanical rename, last migration step, after consumers are on the
single resolver.

**Step 6 status (2026-07-23):** the tooling half landed — every PyAutoHands
reader now accepts BOTH the canonical and legacy names (canonical preferred)
via `env_config.find_profile`, so workspace repos can rename with no breakage
window. A later stage-3 PR removes the legacy `env_vars*.yaml` fallbacks once
every workspace has renamed.

## 8. Migration path (each step green on its own)

1. `validate_env_profiles` check (§6) against the *current* files — lands
Expand Down
2 changes: 1 addition & 1 deletion docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ installation. Four pieces, spread over three organs plus PyAutoNerves:
Each workspace owns its own build config under `<workspace>/config/build/`:

- **`no_run.yaml`** — flat list of script/notebook patterns to skip during execution. **Required**: every build target must own one (an empty file is valid and skips nothing). `run.py` raises `FileNotFoundError` if it is missing.
- **`env_vars.yaml`** — defaults + per-pattern overrides for environment variables
- **`profile_smoke.yaml`** / **`profile_release.yaml`** — defaults + per-pattern overrides for environment variables (legacy names `env_vars*.yaml` accepted during the step-6 migration window)
- **`visualise_notebooks.yaml`** — flat list of notebook stems to run when the `--visualise` flag is used. Optional: a workspace without one simply has nothing marked for visualisation.

`config/build/` is the **single source of truth** — `autohands/config/` holds no
Expand Down
49 changes: 49 additions & 0 deletions tests/test_profile_discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Tests for env_config.find_profile — dual-name profile discovery.

Migration step 6 of docs/env_profile_redesign.md (#161): every reader accepts
BOTH the canonical name (profile_smoke.yaml / profile_release.yaml) and the
legacy env_vars*.yaml name, with the canonical preferred.
"""

import sys
from pathlib import Path

AUTOHANDS_DIR = Path(__file__).parent.parent / "autohands"
sys.path.insert(0, str(AUTOHANDS_DIR))

from env_config import PROFILE_NAMES, find_profile # noqa: E402


def test_canonical_only_returns_canonical(tmp_path):
(tmp_path / "profile_smoke.yaml").write_text("defaults: {}\n")
assert find_profile(tmp_path, "smoke") == tmp_path / "profile_smoke.yaml"


def test_legacy_only_returns_legacy(tmp_path):
(tmp_path / "env_vars.yaml").write_text("defaults: {}\n")
assert find_profile(tmp_path, "smoke") == tmp_path / "env_vars.yaml"


def test_both_present_prefers_canonical(tmp_path):
(tmp_path / "profile_smoke.yaml").write_text("defaults: {}\n")
(tmp_path / "env_vars.yaml").write_text("defaults: {}\n")
assert find_profile(tmp_path, "smoke") == tmp_path / "profile_smoke.yaml"


def test_neither_present_returns_none(tmp_path):
assert find_profile(tmp_path, "smoke") is None


def test_release_kind_canonical_and_legacy(tmp_path):
(tmp_path / "env_vars_release.yaml").write_text("defaults: {}\n")
assert find_profile(tmp_path, "release") == tmp_path / "env_vars_release.yaml"
(tmp_path / "profile_release.yaml").write_text("defaults: {}\n")
assert find_profile(tmp_path, "release") == tmp_path / "profile_release.yaml"


def test_profile_names_shape():
assert PROFILE_NAMES["smoke"] == ("profile_smoke.yaml", "env_vars.yaml")
assert PROFILE_NAMES["release"] == (
"profile_release.yaml",
"env_vars_release.yaml",
)
19 changes: 19 additions & 0 deletions tests/test_repro_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ def test_emits_env_prefix_from_defaults(tmp_path):
assert cmd.endswith("python3 scripts/imaging/modeling.py)")


def test_canonical_profile_name_is_discovered(tmp_path):
# The post-step-6 layout: profile_smoke.yaml (not env_vars.yaml) must be
# found walking up and loaded.
ws = tmp_path / "fake_ws"
(ws / "config" / "build").mkdir(parents=True)
(ws / "config" / "build" / "profile_smoke.yaml").write_text(
'defaults:\n PYAUTO_TEST_MODE: "2"\n'
)
(ws / "scripts" / "imaging").mkdir(parents=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 cmd.endswith("python3 scripts/imaging/modeling.py)")


def test_override_set_takes_precedence_over_default(tmp_path):
ws = _make_fake_workspace(
tmp_path,
Expand Down
47 changes: 47 additions & 0 deletions tests/test_validate_env_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ def _workspace(tmp_path, smoke: str, release: str, scripts: list[str]) -> Path:
return tmp_path


def _workspace_canonical(
tmp_path, smoke: str, release: str, scripts: list[str]
) -> Path:
"""Like ``_workspace`` but writes the canonical profile_*.yaml names
(the post-step-6 layout)."""
(tmp_path / "config" / "build").mkdir(parents=True)
(tmp_path / "config" / "build" / "profile_smoke.yaml").write_text(smoke)
(tmp_path / "config" / "build" / "profile_release.yaml").write_text(release)
for rel in scripts:
p = tmp_path / "scripts" / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("# script\n")
return tmp_path


GOOD_SMOKE = 'defaults:\n PYAUTO_TEST_MODE: "2"\noverrides: []\n'
GOOD_RELEASE = 'defaults:\n PYAUTO_TEST_MODE: "0"\noverrides: []\n'

Expand All @@ -37,6 +52,38 @@ def test_clean_workspace_passes(tmp_path):
assert errors == [] and warnings == []


def test_canonical_named_workspace_passes(tmp_path):
# The post-step-6 layout (profile_smoke.yaml + profile_release.yaml)
# validates exactly as the legacy layout does.
ws = _workspace_canonical(tmp_path, GOOD_SMOKE, GOOD_RELEASE, ["imaging/run.py"])
errors, warnings = validate_workspace(ws)
assert errors == [] and warnings == []


def test_both_names_for_one_kind_is_ambiguity_error(tmp_path):
# Canonical + legacy for the smoke kind: keeping both is ambiguous.
ws = _workspace_canonical(tmp_path, GOOD_SMOKE, GOOD_RELEASE, ["imaging/run.py"])
(ws / "config" / "build" / "env_vars.yaml").write_text(GOOD_SMOKE)
errors, _ = validate_workspace(ws)
assert any(
"profile_smoke.yaml AND legacy env_vars.yaml both exist" in e for e in errors
)


def test_missing_profile_names_canonical(tmp_path):
# Neither name present for a kind → the missing error names the canonical
# file and notes the legacy one is also absent.
(tmp_path / "config" / "build").mkdir(parents=True)
(tmp_path / "config" / "build" / "profile_smoke.yaml").write_text(GOOD_SMOKE)
(tmp_path / "scripts").mkdir()
(tmp_path / "scripts" / "run.py").write_text("# script\n")
errors, _ = validate_workspace(tmp_path)
assert any(
"profile_release.yaml: missing (legacy env_vars_release.yaml also absent)" in e
for e in errors
)


def test_unknown_top_key_is_an_error(tmp_path):
ws = _workspace(tmp_path, GOOD_SMOKE + "typo_key: 1\n", GOOD_RELEASE, ["a.py"])
errors, _ = validate_workspace(ws)
Expand Down