276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""Fixtures shared by the shell-activation tests.
|
|
|
|
An isolated checkout holds the real activation scripts, the real PM environment
|
|
reader and fake installed artifacts. Setup is replaced at its process boundary:
|
|
the stub records each sync and publishes a selected environment. Nothing here
|
|
sources the working checkout or runs its installer against the developer's home.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
from pm.store import current_target
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
ACTIVATE = REPO_ROOT / "activate"
|
|
ACTIVATE_FISH = REPO_ROOT / "activate.fish"
|
|
ACTIVATE_PS1 = REPO_ROOT / "activate.ps1"
|
|
SETUP_HERMES_SH = REPO_ROOT / "setup-hermes.sh"
|
|
SETUP_HERMES_PS1 = REPO_ROOT / "setup-hermes.ps1"
|
|
CANARY = "HERMES_PM_ACTIVATE_CANARY"
|
|
|
|
|
|
def posix(path: Path) -> str:
|
|
return str(path).replace("\\", "/")
|
|
|
|
|
|
def bash() -> str:
|
|
"""A bash CreateProcess can start. conftest blanks SystemRoot/ComSpec
|
|
and the hermetic runner's PATH may resolve `bash` to the MSIX payload
|
|
copy under ``C:\\Program Files\\WindowsApps\\...`` (WinError 5 outside
|
|
its package context) or miss entirely — prefer a conventional install
|
|
(same pattern as the bootstrap version-stamp tests' _git_exe)."""
|
|
found = shutil.which("bash")
|
|
if found and "windowsapps" not in str(found).lower():
|
|
return found
|
|
if sys.platform == "win32":
|
|
pf = Path(os.environ.get("ProgramFiles", r"C:\Program Files"))
|
|
for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")):
|
|
cand = pf.joinpath(*rel)
|
|
if cand.exists():
|
|
return str(cand)
|
|
return found or "bash"
|
|
|
|
|
|
def child_env() -> dict:
|
|
env = os.environ.copy()
|
|
if sys.platform == "win32":
|
|
env.setdefault("SystemRoot", r"C:\Windows")
|
|
env.setdefault("ComSpec", r"C:\Windows\system32\cmd.exe")
|
|
# PowerShell 5.1 silently fails to launch children through `&`
|
|
# without PATHEXT (empty output, exit 0) — the hermetic runner
|
|
# drops it, so every powershell-invoking child env needs it back.
|
|
env.setdefault(
|
|
"PATHEXT",
|
|
".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC",
|
|
)
|
|
return env
|
|
|
|
|
|
def spawnable_python() -> Path:
|
|
"""An interpreter this process can actually CreateProcess. The hermetic
|
|
runner's venv python can be an emulated x64 binary on an arm64 host
|
|
(WinError 5 on every spawn), or a macOS venv shim that stops working
|
|
after activation sanitizes its parent environment. Prefer the venv's
|
|
base interpreter, then whichever python can run a trivial child; last
|
|
resort is sys.executable."""
|
|
candidates: list[Path] = []
|
|
for env_name in ("HERMES_TEST_PYTHON",):
|
|
val = os.environ.get(env_name)
|
|
if val:
|
|
candidates.append(Path(val))
|
|
exe = Path(sys.executable)
|
|
if "windowsapps" in str(exe).lower():
|
|
candidates.append(exe) # native packaged python — spawnable by path
|
|
else:
|
|
base = Path(getattr(sys, "_base_executable", "") or exe)
|
|
if base != exe:
|
|
candidates.append(base)
|
|
candidates.append(exe)
|
|
for cand in candidates:
|
|
if can_spawn(cand):
|
|
return cand
|
|
return exe
|
|
|
|
|
|
def can_spawn(python: Path) -> bool:
|
|
try:
|
|
r = subprocess.run(
|
|
[str(python), "-c", "print(1)"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
env=child_env(),
|
|
check=False,
|
|
)
|
|
return r.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def fake_store(tmp_path: Path) -> tuple[Path, Path]:
|
|
"""A store laid out like pm's: facts.json marking the python package
|
|
installed (identity matches pm/lock.json) with a canary env export, and
|
|
an entry whose interpreter is a wrapper that runs the real python."""
|
|
lock = json.loads(
|
|
(REPO_ROOT / "pm" / "lock.json").read_text(encoding="utf-8-sig")
|
|
)["packages"]
|
|
target = current_target()
|
|
python_pkg = lock["python"]
|
|
sha = python_pkg["artifacts"][target]["sha256"]
|
|
|
|
store = tmp_path / "store"
|
|
entry = store / f"python-{python_pkg['version']}-{target}"
|
|
entry.mkdir(parents=True)
|
|
|
|
# The store interpreter only needs to run `python -m pm.cli env` —
|
|
# delegate to a real, spawnable interpreter via a #!/bin/sh wrapper (a
|
|
# copied CPython would miss its DLLs/stdlib; the wrapper is the honest
|
|
# minimal fake). sys.executable can be an emulated x64 python on an
|
|
# arm64 host that cannot CreateProcess children at all — resolve a
|
|
# spawnable interpreter instead (see spawnable_python). The wrapper
|
|
# works even named python.exe because activate execs it through
|
|
# bash/MSYS, which honors #!-scripts regardless of suffix.
|
|
interpreter = entry / ("python.exe" if sys.platform.startswith("win") else "bin/python3")
|
|
interpreter.parent.mkdir(parents=True, exist_ok=True)
|
|
real = spawnable_python()
|
|
wrapper = f"#!/bin/sh\nexec '{posix(real)}' \"$@\"\n"
|
|
interpreter.write_text(wrapper, encoding="utf-8")
|
|
interpreter.chmod(0o755)
|
|
|
|
(store / "facts.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": 1,
|
|
"packages": {
|
|
"python": {
|
|
"entry": entry.name,
|
|
"version": python_pkg["version"],
|
|
"env": {CANARY: "env-ok"},
|
|
"target": target,
|
|
"artifacts": [sha],
|
|
}
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return store, entry
|
|
|
|
|
|
def isolated_checkout(tmp_path: Path) -> Path:
|
|
root = tmp_path / "checkout with spaces"
|
|
root.mkdir()
|
|
shutil.copytree(REPO_ROOT / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__"))
|
|
(root / "hermes_cli").mkdir()
|
|
(root / "scripts").mkdir()
|
|
for relative in ("activate", "activate.fish", "activate.ps1", "scripts/_activation.sh",
|
|
"hermes_constants.py", "hermes_cli/__init__.py",
|
|
"pm/environments.py", "hermes_cli/runtime_state.py"):
|
|
shutil.copy2(REPO_ROOT / relative, root / relative)
|
|
# Environment-only tests do not exercise provisioning; the runtime tests
|
|
# replace these stubs with a publisher that records and applies each sync.
|
|
(root / "setup-hermes.sh").write_text(
|
|
'test "$#" = 2 && test "$1" = --runtime-only && case "$2" in --test-environment*) ;; *) exit 2 ;; esac\n',
|
|
encoding="utf-8",
|
|
)
|
|
(root / "setup-hermes.ps1").write_text(
|
|
"param([switch]$RuntimeOnly)\nif (-not $RuntimeOnly) { exit 2 }\n", encoding="utf-8",
|
|
)
|
|
return root
|
|
|
|
|
|
def bash_env(store: Path) -> dict:
|
|
env = child_env()
|
|
home = store.parent / "home"
|
|
home.mkdir(exist_ok=True)
|
|
env.update(HOME=posix(home), USERPROFILE=str(home), HERMES_HOME=posix(home / "hermes"))
|
|
for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV", "BASH_ENV", "__HERMES_ACTIVATED"):
|
|
env.pop(key, None)
|
|
env["HERMES_RUNTIME_DIR"] = posix(store)
|
|
# Keep the real env out of the composed pm output so the canary export
|
|
# is the only thing activate adds beyond the ambient environment.
|
|
env.pop(CANARY, None)
|
|
return env
|
|
|
|
|
|
def powershell() -> str | None:
|
|
for name in ("pwsh", "powershell"):
|
|
found = shutil.which(name)
|
|
if found and "windowsapps" not in str(found).lower():
|
|
return found
|
|
# Prefer the conventional System32 host over an MSIX-packaged one
|
|
# (same WinError-5-outside-package-context class as bash()); also the
|
|
# fallback when the hermetic runner's PATH misses both names.
|
|
if sys.platform == "win32":
|
|
cand = (
|
|
Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
|
/ "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
|
)
|
|
if cand.exists():
|
|
return str(cand)
|
|
return None
|
|
|
|
|
|
def sync_checkout(tmp_path: Path):
|
|
root = isolated_checkout(tmp_path)
|
|
env = bash_env(tmp_path / "store")
|
|
python = spawnable_python()
|
|
# The setup seam publishes a bootstrap and a selected generation. PM's
|
|
# freshness algorithms have their own tests; here a warm setup call is a
|
|
# no-op and activation must still call it, rather than cache its own answer.
|
|
(root / "sync.py").write_text(textwrap.dedent('''\
|
|
import json, os, pathlib, shutil, sys
|
|
from pm.environments import runtime_facts_path, site_packages
|
|
root = pathlib.Path(__file__).parent
|
|
record = {"argv": sys.argv[1:], "python_env": {
|
|
key: os.environ.get(key) for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")}}
|
|
with (root / "calls.jsonl").open("a", encoding="utf-8") as stream:
|
|
stream.write(json.dumps(record) + "\\n")
|
|
assert all(value is None for value in record["python_env"].values()), record
|
|
assert sys.argv[1:] == ["runtime-only", "--trust-recorded", "--test-environment"], record
|
|
print("setup progress")
|
|
if (root / "fail").exists():
|
|
sys.exit(42)
|
|
bootstrap = root / ".venv"
|
|
if not bootstrap.exists():
|
|
shutil.copytree(root / "prepared-bootstrap", bootstrap)
|
|
# Windows PowerShell's Set-Content writes a BOM.
|
|
generation = (root / "input").read_text(encoding="utf-8-sig").strip()
|
|
facts = runtime_facts_path(root)
|
|
selected = facts.parent / "environments" / generation / "venv"
|
|
if not selected.exists():
|
|
site_packages(selected).mkdir(parents=True)
|
|
(selected / "pyvenv.cfg").write_text("home = fixture", encoding="utf-8")
|
|
facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8")
|
|
with (root / "builds").open("a", encoding="utf-8") as stream:
|
|
stream.write(generation + "\\n")
|
|
'''), encoding="utf-8")
|
|
(root / "input").write_text("first", encoding="utf-8")
|
|
if os.name == "nt":
|
|
subprocess.run(
|
|
[str(python), "-m", "venv", "--without-pip", str(root / "prepared-bootstrap")],
|
|
check=True, capture_output=True, env=env, timeout=60,
|
|
)
|
|
else:
|
|
binary = root / "prepared-bootstrap" / "bin" / "python"
|
|
binary.parent.mkdir(parents=True)
|
|
binary.write_text(f"#!/bin/sh\nexec {shlex.quote(str(python))} \"$@\"\n", encoding="utf-8")
|
|
binary.chmod(0o755)
|
|
# Activation's contract with setup is the runtime-only switch; setup
|
|
# itself maps that to PM's --trust-recorded install.
|
|
(root / "setup-hermes.sh").write_text(
|
|
'test "$#" = 2 && test "$1" = --runtime-only && test "$2" = --test-environment || exit 2\n'
|
|
f'cd {shlex.quote(str(root))} || exit 3\n'
|
|
f'exec {shlex.quote(str(python))} sync.py runtime-only --trust-recorded --test-environment\n', encoding="utf-8",
|
|
)
|
|
(root / "setup-hermes.ps1").write_text(
|
|
"param([switch]$RuntimeOnly)\n"
|
|
"if (-not $RuntimeOnly) { exit 2 }\n"
|
|
"$ErrorActionPreference = 'Stop'\n"
|
|
"$record = @{pid=$PID; executable=(Get-Process -Id $PID).Path; "
|
|
"argv=[Environment]::GetCommandLineArgs()}\n"
|
|
"$record | ConvertTo-Json -Compress | Add-Content -LiteralPath \"$PSScriptRoot\\ps-calls.jsonl\"\n"
|
|
"Set-Location -LiteralPath $PSScriptRoot\n"
|
|
f"& '{python}' sync.py runtime-only --trust-recorded --test-environment\nexit $LASTEXITCODE\n", encoding="utf-8",
|
|
)
|
|
return root, env
|