From 53d757fe8573f5c85e0171034ad87bbd099c08c6 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 16 Sep 2026 19:32:07 -0400 Subject: [PATCH] feat(dev): self-activating scripts with a stale-aware activation sentinel Repo scripts assume the PM-activated environment, so running one without activation fails much later with a confusing ImportError. Add the two halves covering both invocation paths: - scripts/_activation.py: require_activation() exits immediately, naming the exact command for the caller's shell (source ./activate on POSIX, . .\activate.ps1 on a native Windows host), before any heavy import. - scripts/_hermes-python: the POSIX shebang target. `#!/usr/bin/env -S bash -c '...'` hands itself the target path through bash -c's $0, sources activate, then execs the interpreter on the same file -- so tracebacks and __file__ still point at the real script and ./scripts/foo.py works from any cwd with no manual source. __HERMES_ACTIVATED changes from a bare "1" to the installed-state file the environment was composed against, so one value carries activation, which checkout activated it, and a staleness stamp. The prologue compares that file against uv.lock / pyproject.toml / pm/lock.json with the `-nt` builtin -- no process spawn -- and re-activates once when the inherited environment predates its inputs. pm rewrites that file only on a real sync, so the check settles back to current rather than re-syncing on every run. A legacy "1" keeps working: require_activation() tests non-emptiness, and the prologue's [ -e ] fails on it, so it activates once and upgrades. --- activate | 4 +- activate.ps1 | 2 + hermes_cli/runtime_paths.py | 8 ++ scripts/_activation.py | 93 +++++++++++++ scripts/_activation_demo.py | 19 +++ scripts/_hermes-python | 50 +++++++ tests/pm/test_activate_scripts.py | 25 ++++ tests/scripts/test_activation_guard.py | 64 +++++++++ tests/scripts/test_hermes_python_prologue.py | 137 +++++++++++++++++++ 9 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 scripts/_activation.py create mode 100755 scripts/_activation_demo.py create mode 100755 scripts/_hermes-python create mode 100644 tests/scripts/test_activation_guard.py create mode 100644 tests/scripts/test_hermes_python_prologue.py diff --git a/activate b/activate index 0583576467..445757728e 100755 --- a/activate +++ b/activate @@ -62,7 +62,9 @@ _hermes_keys="$(printf '%s' "$_hermes_json" | "$_hermes_py" -c 'import json,sys; [ -n "$_hermes_keys" ] || _hermes_keys="PATH" # --- snapshot what we are about to change (deactivate restores this) --- -__HERMES_ACTIVATED=1 +# __HERMES_ACTIVATED is part of the composed env below (it carries the +# installed-state path this environment was built from), so it is snapshotted +# and exported with every other key — no separate assignment here. __HERMES_SAVED_PATH="$PATH" for _hermes_k in $_hermes_keys; do eval "__HERMES_SAVED_$_hermes_k=\"\${$_hermes_k+set}\"" diff --git a/activate.ps1 b/activate.ps1 index 7aaa9d9d9c..9256ba61c3 100755 --- a/activate.ps1 +++ b/activate.ps1 @@ -52,6 +52,8 @@ try { if ($null -eq $priorPath) { Remove-Item env:PYTHONPATH -ErrorAction SilentlyContinue } else { $env:PYTHONPATH = $priorPath } if ($null -eq $priorHome) { Remove-Item env:PYTHONHOME -ErrorAction SilentlyContinue } else { $env:PYTHONHOME = $priorHome } } +# __HERMES_ACTIVATED (the sentinel repo scripts and the shebang prologue read) +# is part of the composed env, so it is saved and restored with the rest. $global:_hermesKeys = @($composed.PSObject.Properties.Name) $global:_hermesSaved = @{} foreach ($key in $global:_hermesKeys) { diff --git a/hermes_cli/runtime_paths.py b/hermes_cli/runtime_paths.py index 6c0431cb77..63044d244e 100644 --- a/hermes_cli/runtime_paths.py +++ b/hermes_cli/runtime_paths.py @@ -170,6 +170,14 @@ def activation_environment(project_root: Path) -> dict[str, str]: env.pop("PYTHONHOME", None) env.pop("VIRTUAL_ENV", None) env["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) + # The child-process sentinel. Its VALUE is the installed-state file this + # environment was composed against, so a consumer gets three things for + # free: that it inherited an activated shell, which checkout/profile that + # shell came from, and a staleness stamp — uv.lock / pyproject.toml / + # pm/lock.json newer than this file means the shell's environment predates + # its inputs. pm rewrites it on every real sync and no-ops otherwise, so a + # `-nt` comparison settles back to "current" after one re-activation. + env["__HERMES_ACTIVATED"] = str(runtime_facts_path(project_root)) return env diff --git a/scripts/_activation.py b/scripts/_activation.py new file mode 100644 index 0000000000..689aa4f2d1 --- /dev/null +++ b/scripts/_activation.py @@ -0,0 +1,93 @@ +"""Activation guard for repository scripts. + +Every script in this checkout assumes it runs under the PM-activated +environment (``source ./activate`` on POSIX, ``. .\\activate.ps1`` on Windows), +which is what puts the pinned interpreter and its dependency tree on +``PATH``/``PYTHONPATH``. A script run with a bare system Python instead fails +much later with a confusing ``ModuleNotFoundError``. Call +:func:`require_activation` first and it fails immediately, naming the exact +command for the caller's shell. + +Import it by its bare name: ``scripts/`` is the running script's own directory, +so it is on ``sys.path`` whether or not the shell is activated:: + + from _activation import require_activation + + require_activation() + +This module must stay stdlib-only and side-effect free at import — it has to +load before the environment it checks for exists. + +Activating from the shebang +--------------------------- + +A POSIX script can activate for itself, so ``./scripts/foo.py`` works from any +cwd with no manual ``source``. The shebang points at ``scripts/_hermes-python``, +a real repo script that sources ``activate`` and execs the interpreter on the +same file:: + + #!/usr/bin/env -S bash -c 'exec "$BASH" "$(dirname "$0")/_hermes-python" "$0" "$@"' + +The kernel appends the invoking script as the last argument, and ``bash -c`` +binds it to ``$0`` — so the prologue finds the target relative to itself and +the shebang stays independent of the cwd. ``$BASH`` for the second hop keeps it +free of both the exec bit and a ``PATH`` lookup. ``scripts/_activation_demo.py`` +is a runnable example. + +Activation is not re-run when the inherited environment is still current: the +sentinel's value is the installed-state file the environment was composed +against (see ``hermes_cli.runtime_paths.activation_environment``), so the +prologue compares it against the inputs that decide the dependency set. Any of +``uv.lock``, ``pyproject.toml`` or ``pm/lock.json`` being newer than that file +means the inherited environment predates its inputs, and the prologue +re-activates once. ``[ -nt ]`` is a bash builtin, so the check costs no process +spawn; pm rewrites that file on every real sync and no-ops otherwise, so it +settles back to current rather than re-syncing on every run. + +Two constraints worth knowing: the line is 83 bytes (a shebang must stay under +127), and ``/usr/bin/env -S`` is GNU and newer-BSD — verify it if an older +macOS or BSD is a target. Windows keeps using ``python scripts\\foo.py`` with +the guard. +""" + +from __future__ import annotations + +import os +import sys + +ACTIVATION_ENV_VAR = "__HERMES_ACTIVATED" +POSIX_COMMAND = "source ./activate" +WINDOWS_COMMAND = ". .\\activate.ps1" + + +def activation_command() -> str: + """The exact command that activates this checkout in the caller's shell.""" + if os.name != "nt": + return POSIX_COMMAND + # A bash-family shell on Windows (Git Bash, MSYS, WSL interop) sources the + # POSIX script; only a native host gets the PowerShell one. + if os.environ.get("MSYSTEM") or os.path.basename(os.environ.get("SHELL", "")) in {"bash", "sh", "zsh"}: + return POSIX_COMMAND + return WINDOWS_COMMAND + + +def require_activation() -> None: + """Exit the process unless the launching shell sourced the activate script. + + Only tests the sentinel for non-emptiness: its value is the installed-state + path the environment was built from (and earlier revisions wrote a bare + ``1``), so any value means "an ancestor shell activated". + """ + if os.environ.get(ACTIVATION_ENV_VAR): + return + script = os.path.basename(sys.argv[0]) or "this script" + print( + f"{script}: the Hermes environment is not activated.\n" + "From the repository root, run:\n" + "\n" + f" {activation_command()}\n" + "\n" + "then re-run this script.", + file=sys.stderr, + ) + raise SystemExit(1) diff --git a/scripts/_activation_demo.py b/scripts/_activation_demo.py new file mode 100755 index 0000000000..99a34de02d --- /dev/null +++ b/scripts/_activation_demo.py @@ -0,0 +1,19 @@ +#!/usr/bin/env -S bash -c 'exec "$BASH" "$(dirname "$0")/_hermes-python" "$0" "$@"' +"""Demo: a POSIX script that activates the Hermes environment for itself. + +Run it from any cwd in any shell: the shebang hands the file to +``scripts/_hermes-python``, which sources ``activate`` and execs the +interpreter on this same file. Delete this file once you have copied the +header, or keep it as the template. +""" + +import os +import sys + +from _activation import require_activation + +require_activation() + +print("activated sentinel:", os.environ.get("__HERMES_ACTIVATED")) +print("interpreter :", sys.executable) +print("args :", sys.argv[1:]) diff --git a/scripts/_hermes-python b/scripts/_hermes-python new file mode 100755 index 0000000000..237e0278a6 --- /dev/null +++ b/scripts/_hermes-python @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Run a repo Python script under the activated Hermes environment. +# +# Shebang target for POSIX scripts: +# #!/usr/bin/env -S bash -c 'exec "$BASH" "$(dirname "$0")/_hermes-python" "$0" "$@"' +# +# The kernel appends the invoking script, so `$1` is the Python file to run +# and `$@` its arguments. Activates when there is no environment to inherit, or +# when the inherited one predates its inputs; then execs the interpreter on the +# same file, so tracebacks and __file__ point at the real script. +set -u + +target=${1:-} +if [ -z "$target" ]; then + printf 'usage: %s [args...]\n' "$0" >&2 + exit 2 +fi +shift + +_here="$(cd "$(dirname "$target")" && pwd)" +while [ "$_here" != / ] && [ ! -f "$_here/activate" ]; do _here="$(dirname "$_here")"; done +if [ ! -f "$_here/activate" ]; then + printf '%s: no activate script above %s\n' "$0" "$target" >&2 + exit 1 +fi + +# __HERMES_ACTIVATED holds the installed-state file the environment was built +# against, so `-nt` against the inputs that decide the dependency set answers +# "is the inherited environment stale". `[ -nt ]` is a bash builtin — no +# process spawn — and pm rewrites that file on every real sync while no-oping +# otherwise, so one re-activation settles this back to current rather than +# re-syncing on every run. A path that no longer exists (or a legacy literal +# "1") fails `-e` and activates. +_needs_activation=1 +if [ -n "${__HERMES_ACTIVATED:-}" ] && [ -e "${__HERMES_ACTIVATED}" ]; then + _needs_activation=0 + for _input in uv.lock pyproject.toml pm/lock.json; do + if [ "$_here/$_input" -nt "$__HERMES_ACTIVATED" ]; then + _needs_activation=1 + break + fi + done +fi + +if [ "$_needs_activation" = 1 ]; then + # shellcheck source=/dev/null + . "$_here/activate" || exit 1 +fi + +exec python3 "$target" "$@" diff --git a/tests/pm/test_activate_scripts.py b/tests/pm/test_activate_scripts.py index 88c45c282f..ba72b40e8b 100644 --- a/tests/pm/test_activate_scripts.py +++ b/tests/pm/test_activate_scripts.py @@ -209,6 +209,31 @@ def test_source_activate_exports_the_pm_env(tmp_path: Path): assert result.stdout == "env-ok" +@pytest.mark.platforms("windows") +def test_activate_exports_the_sentinel_to_child_processes(tmp_path: Path): + """Repo scripts read activation from the environment, so the sentinel must + survive into an exec'd child (a plain shell variable would not), and + deactivate must take it back out.""" + root = _isolated_checkout(tmp_path) + store, _ = _fake_store(tmp_path) + script = ( + f'source "{_posix(root / "activate")}" && ' + f'"$BASH" -c \'test -n "$__HERMES_ACTIVATED"\' && ' + f'deactivate && ' + f'! "$BASH" -c \'test -n "$__HERMES_ACTIVATED"\' && ' + f'echo exported-then-cleared' + ) + result = subprocess.run( + [_bash(), "-c", script], + capture_output=True, + text=True, + cwd=_posix(tmp_path), + env=_bash_env(store), + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "exported-then-cleared" + + @pytest.mark.platforms("windows") def test_deactivate_restores_the_prior_shell(tmp_path: Path): root = _isolated_checkout(tmp_path) diff --git a/tests/scripts/test_activation_guard.py b/tests/scripts/test_activation_guard.py new file mode 100644 index 0000000000..ab4c28faf7 --- /dev/null +++ b/tests/scripts/test_activation_guard.py @@ -0,0 +1,64 @@ +"""The guard repo scripts call to require an activated shell. + +``scripts/_activation.py`` is stdlib-only and importable before the environment +it checks for exists; these tests pin its contract: it passes under activation, +and it exits naming the exact command for the caller's shell otherwise. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts._activation import ACTIVATION_ENV_VAR, activation_command, require_activation + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_require_activation_passes_when_the_sentinel_is_set(monkeypatch): + monkeypatch.setenv(ACTIVATION_ENV_VAR, "1") + require_activation() # must return, not raise + + +def test_require_activation_exits_naming_the_activation_command(monkeypatch, capsys): + monkeypatch.delenv(ACTIVATION_ENV_VAR, raising=False) + with pytest.raises(SystemExit) as excinfo: + require_activation() + assert excinfo.value.code not in (0, None) + assert activation_command() in capsys.readouterr().err + + +@pytest.mark.platforms("posix") +def test_activation_command_is_the_posix_source_line(): + assert activation_command() == "source ./activate" + + +@pytest.mark.platforms("windows") +def test_activation_command_is_powershell_on_a_native_windows_shell(monkeypatch): + monkeypatch.delenv("MSYSTEM", raising=False) + monkeypatch.delenv("SHELL", raising=False) + assert activation_command() == ". .\\activate.ps1" + + +def test_a_script_refuses_to_run_without_activation(tmp_path): + """End-to-end: a real script importing the guard stops before its body.""" + script = tmp_path / "probe.py" + script.write_text( + "import sys\n" + f"sys.path.insert(0, {str(REPO_ROOT / 'scripts')!r})\n" + "from _activation import require_activation\n" + "require_activation()\n" + "print('ran')\n", + encoding="utf-8", + ) + env = {key: value for key, value in os.environ.items() if key != ACTIVATION_ENV_VAR} + result = subprocess.run( + [sys.executable, str(script)], capture_output=True, text=True, env=env + ) + assert result.returncode != 0 + assert "ran" not in result.stdout + assert activation_command() in result.stderr \ No newline at end of file diff --git a/tests/scripts/test_hermes_python_prologue.py b/tests/scripts/test_hermes_python_prologue.py new file mode 100644 index 0000000000..c34f0d1d66 --- /dev/null +++ b/tests/scripts/test_hermes_python_prologue.py @@ -0,0 +1,137 @@ +"""The self-activating shebang prologue: when does it re-activate? + +``scripts/_hermes-python`` is the POSIX shebang target. Its one interesting +decision is staleness: ``__HERMES_ACTIVATED`` holds the installed-state file the +environment was built against, so any of ``uv.lock`` / ``pyproject.toml`` / +``pm/lock.json`` being newer than that file means the inherited environment +predates its inputs. + +These drive the real prologue with controlled mtimes. A ``python3`` shim is put +on PATH because the prologue execs ``python3`` by name — on some hosts (stock +Windows) that name does not exist, and the subject here is the staleness +branch, not interpreter resolution. +""" + +from __future__ import annotations + +import datetime +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROLOGUE = REPO_ROOT / "scripts" / "_hermes-python" +DEMO = REPO_ROOT / "scripts" / "_activation_demo.py" +GUARD = REPO_ROOT / "scripts" / "_activation.py" + +LONG_AGO = "2019-01-01 00:00:00" +STAMP_TIME = "2020-06-01 00:00:00" +JUST_AFTER = "2021-01-01 00:00:00" + + +def _posix(path: Path) -> str: + return str(path).replace("\\", "/") + + +def _bash() -> str: + found = shutil.which("bash") + if found and "windowsapps" not in str(found).lower(): + return found + if sys.platform == "win32": + for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")): + cand = Path(os.environ.get("ProgramFiles", r"C:\Program Files")).joinpath(*rel) + if cand.exists(): + return str(cand) + return found or "bash" + + +@pytest.fixture +def checkout(tmp_path: Path) -> Path: + """A repo-shaped tree with a stub activate that announces each sourcing.""" + root = tmp_path / "checkout" + (root / "scripts").mkdir(parents=True) + (root / "pm").mkdir() + (root / "shim").mkdir() + for src in (PROLOGUE, DEMO, GUARD): + shutil.copy2(src, root / "scripts" / src.name) + for name in ("uv.lock", "pyproject.toml"): + (root / name).touch() + (root / "pm" / "lock.json").touch() + (root / "stamp").touch() + + shim = root / "shim" / "python3" + # Quote in posix form: the shim is a /bin/sh script, where backslashes in + # an unquoted word are escape characters (repo pattern: _fake_store). + shim.write_text("#!/bin/sh\nexec '%s' \"$@\"\n" % _posix(Path(sys.executable)), encoding="utf-8") + shim.chmod(0o755) + + # Stands in for the real activate: announces itself and exports the stamp + # path, exactly as activation_environment() now supplies it. + (root / "activate").write_text( + "echo 'ACTIVATED' >&2\n" + "export __HERMES_ACTIVATED=\"%s/stamp\"\n" + "export PATH=\"%s/shim:$PATH\"\n" % (_posix(root), _posix(root)), + encoding="utf-8", + ) + return root + + +def _touch_at(path: Path, stamp: str) -> None: + """Set a file's mtime without shelling out (the test runner scrubs PATH, + so `touch` is not reliably available).""" + when = datetime.datetime.strptime(stamp, "%Y-%m-%d %H:%M:%S").timestamp() + os.utime(path, (when, when)) + + +def run_prologue(root: Path, sentinel: str | None) -> tuple[int, str]: + env = {**os.environ, "PATH": f"{_posix(root / 'shim')}{os.pathsep}{os.environ.get('PATH', '')}"} + env.pop("__HERMES_ACTIVATED", None) + if sentinel is not None: + env["__HERMES_ACTIVATED"] = sentinel.replace("{root}", _posix(root)) + result = subprocess.run( + [_bash(), _posix(PROLOGUE), _posix(root / "scripts" / DEMO.name)], + capture_output=True, text=True, cwd=_posix(root), env=env, timeout=60, + ) + return result.returncode, result.stderr + + +def test_no_sentinel_activates(checkout: Path): + code, err = run_prologue(checkout, None) + assert code == 0, err + assert "ACTIVATED" in err + + +def test_current_environment_is_left_alone(checkout: Path): + """Inputs older than the stamp: the inherited env is current, so the + prologue must not pay for a re-sync on every run.""" + _touch_at(checkout / "stamp", STAMP_TIME) + for name in ("uv.lock", "pyproject.toml", "pm/lock.json"): + _touch_at(checkout / name, LONG_AGO) + code, err = run_prologue(checkout, "{root}/stamp") + assert code == 0, err + assert "ACTIVATED" not in err + + +@pytest.mark.parametrize("input_name", ["uv.lock", "pyproject.toml", "pm/lock.json"]) +def test_input_newer_than_stamp_reactivates(checkout: Path, input_name: str): + _touch_at(checkout / "stamp", STAMP_TIME) + for name in ("uv.lock", "pyproject.toml", "pm/lock.json"): + _touch_at(checkout / name, LONG_AGO) + _touch_at(checkout / input_name, JUST_AFTER) + code, err = run_prologue(checkout, "{root}/stamp") + assert code == 0, err + assert "ACTIVATED" in err, f"{input_name} newer than the stamp must re-activate" + + +@pytest.mark.parametrize("sentinel", ["{root}/gone", "1"]) +def test_unusable_sentinel_activates(checkout: Path, sentinel: str): + """A missing stamp path forces activation; the literal ``1`` written by an + older activate must keep working rather than silently reading as current.""" + _touch_at(checkout / "stamp", STAMP_TIME) + code, err = run_prologue(checkout, sentinel) + assert code == 0, err + assert "ACTIVATED" in err \ No newline at end of file