The _hermes-python prologue compared uv.lock / pyproject.toml / pm/lock.json against facts.json with `-nt`. facts.json is only rewritten on a real sync, so a checkout that rewrites an input without changing it left the input newer forever: every run from an activated shell re-sourced activate (~370ms instead of ~13ms). pm now records, after every successful full-closure install (no-op syncs included), a stamp per input under installs/<key>/inputs carrying the exact mtime the install was verified against, snapshotted before installing so an input edited mid-install still reads as stale. The prologue re-activates when any input's mtime differs from its stamp in either direction (branch switches can move it backwards), when an input is missing, or when there are no stamps. It globs the stamp dir, so pm owns the only input list. Also read pyvenv.cfg as utf-8-sig (footgun lane, same file).
144 lines
5.6 KiB
Python
144 lines
5.6 KiB
Python
"""The self-activating shebang prologue: when does it re-activate?
|
|
|
|
``scripts/_hermes-python`` is the POSIX shebang target. Its one decision worth
|
|
pinning is staleness: pm stamps each dependency input's mtime beside the
|
|
installed-state file ``__HERMES_ACTIVATED`` names, and any input whose mtime
|
|
differs from its stamp means the inherited environment may not match its
|
|
inputs. Invert that either way and the cost is invisible — a re-sync on every
|
|
run, or a stale environment that looks fine.
|
|
|
|
The stamps come from the real ``pm.environments`` writer, so these tests pin
|
|
the contract between what pm records and what the prologue reads.
|
|
|
|
A ``python3`` shim goes on PATH because the prologue execs ``python3`` by name,
|
|
which does not exist on stock Windows; 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
|
|
|
|
from pm.environments import ACTIVATION_INPUTS, activation_input_mtimes, record_activation_inputs
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
PROLOGUE = REPO_ROOT / "scripts" / "_hermes-python"
|
|
|
|
EARLIER = "2018-01-01 00:00:00"
|
|
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"
|
|
|
|
|
|
def _set_mtime(path: Path, stamp: str) -> None:
|
|
when = datetime.datetime.strptime(stamp, "%Y-%m-%d %H:%M:%S").timestamp()
|
|
os.utime(path, (when, when))
|
|
|
|
|
|
@pytest.fixture
|
|
def checkout(tmp_path: Path) -> Path:
|
|
"""A repo-shaped tree whose stub activate announces each sourcing; its
|
|
installed state lives in ``state/`` (facts.json beside the input stamps)."""
|
|
root = tmp_path / "checkout"
|
|
(root / "scripts").mkdir(parents=True)
|
|
(root / "shim").mkdir()
|
|
(root / "state").mkdir()
|
|
shutil.copy2(PROLOGUE, root / "scripts" / PROLOGUE.name)
|
|
for name in ACTIVATION_INPUTS:
|
|
(root / name).parent.mkdir(parents=True, exist_ok=True)
|
|
(root / name).touch()
|
|
_set_mtime(root / name, LONG_AGO)
|
|
(root / "state" / "facts.json").touch()
|
|
_set_mtime(root / "state" / "facts.json", STAMP_TIME)
|
|
|
|
# Quote in posix form: a /bin/sh script treats backslashes in an unquoted
|
|
# word as escapes (same pattern as tests/pm/test_activate_scripts.py).
|
|
shim = root / "shim" / "python3"
|
|
shim.write_text("#!/bin/sh\nexec '%s' \"$@\"\n" % _posix(Path(sys.executable)), encoding="utf-8")
|
|
shim.chmod(0o755)
|
|
|
|
(root / "scripts" / "probe.py").write_text(
|
|
"import os, sys\n"
|
|
"print('target ran; sentinel =', os.environ.get('__HERMES_ACTIVATED'))\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "activate").write_text(
|
|
"echo 'ACTIVATED' >&2\n"
|
|
"export __HERMES_ACTIVATED=\"%s/state/facts.json\"\n"
|
|
"export PATH=\"%s/shim:$PATH\"\n" % (_posix(root), _posix(root)),
|
|
encoding="utf-8",
|
|
)
|
|
return root
|
|
|
|
|
|
def _record(root: Path) -> None:
|
|
"""What a successful ``pm install`` leaves behind."""
|
|
record_activation_inputs(root / "state" / "inputs", activation_input_mtimes(root))
|
|
|
|
|
|
def _run(root: Path, sentinel: str | None = "{root}/state/facts.json") -> str:
|
|
"""Drive the prologue as the kernel would; return stderr, assert it ran."""
|
|
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(root / "scripts" / PROLOGUE.name), _posix(root / "scripts" / "probe.py")],
|
|
capture_output=True, text=True, cwd=_posix(root), env=env, timeout=60,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "target ran" in result.stdout, result.stderr
|
|
return result.stderr
|
|
|
|
|
|
def test_recorded_inputs_are_left_alone(checkout: Path):
|
|
"""A checkout rewrote every input after facts.json was last written, then a
|
|
no-op sync recorded them: the environment is current. Re-syncing on every
|
|
run is the cost of getting this wrong."""
|
|
for name in ACTIVATION_INPUTS:
|
|
_set_mtime(checkout / name, JUST_AFTER)
|
|
_record(checkout)
|
|
assert "ACTIVATED" not in _run(checkout)
|
|
|
|
|
|
@pytest.mark.parametrize("moved_to", [JUST_AFTER, EARLIER], ids=["newer", "older"])
|
|
@pytest.mark.parametrize("input_name", ACTIVATION_INPUTS)
|
|
def test_input_mtime_differing_from_its_stamp_reactivates(checkout: Path, input_name: str, moved_to: str):
|
|
"""A branch switch can move an input's mtime either way; both mean the
|
|
recorded install was not verified against what is on disk now."""
|
|
_record(checkout)
|
|
_set_mtime(checkout / input_name, moved_to)
|
|
assert "ACTIVATED" in _run(checkout)
|
|
|
|
|
|
@pytest.mark.parametrize("sentinel", [None, "{root}/gone", "1", "{root}/state/facts.json"],
|
|
ids=["cold", "dangling", "legacy-1", "no-stamps"])
|
|
def test_unusable_sentinel_activates(checkout: Path, sentinel: str | None):
|
|
"""Cold, dangling stamp, the bare ``1`` an older activate exported, or an
|
|
install from before stamps existed — each must activate rather than read
|
|
as current."""
|
|
assert "ACTIVATED" in _run(checkout, sentinel)
|