Windows PowerShell's Set-Content writes a BOM, which the fake sync read
into the environment name ('\ufeffsecond').
222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""Activation syncs through setup before selecting or changing the caller's env."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shlex
|
|
import subprocess
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from tests.pm.test_activate_scripts import (
|
|
_bash, _bash_env, _isolated_checkout, _posix, _powershell, _spawnable_python,
|
|
)
|
|
|
|
# Spawns children with a home it builds itself; the parent's must stay real.
|
|
pytestmark = pytest.mark.real_machine_home
|
|
|
|
|
|
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
|
|
|
|
|
|
def _assert_syncs(root: Path):
|
|
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
|
assert calls == [{"argv": ["runtime-only", "--trust-recorded", "--test-environment"], "python_env": {
|
|
"PYTHONHOME": None, "PYTHONPATH": None, "VIRTUAL_ENV": None,
|
|
}}] * 3
|
|
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("canary", [None, "caller-canary"])
|
|
def test_bash_cold_sync_changed_input_and_warm_noop(tmp_path, canary):
|
|
root, env = _sync_checkout(tmp_path)
|
|
assert not (root / ".venv").exists()
|
|
if canary is not None:
|
|
env["HERMES_PM_ACTIVATE_CANARY"] = canary
|
|
from tests.pm.test_activate_scripts import _fake_store
|
|
_fake_store(tmp_path)
|
|
script = f'''
|
|
set -e
|
|
export PYTHONPATH=caller-original VIRTUAL_ENV=caller-venv
|
|
original_path="$PATH"
|
|
source "{_posix(root / 'activate')}"
|
|
printf '%s\\n' "$PYTHONPATH"
|
|
test "$HERMES_PM_ACTIVATE_CANARY" = env-ok
|
|
printf second > "{_posix(root / 'input')}"
|
|
source "{_posix(root / 'activate')}"
|
|
printf '%s\\n' "$PYTHONPATH"
|
|
source "{_posix(root / 'activate')}"
|
|
printf '%s\\n' "$PYTHONPATH"
|
|
test "$PWD" = "{_posix(tmp_path)}"
|
|
deactivate
|
|
test "$PATH" = "$original_path"
|
|
test "$PYTHONPATH" = caller-original
|
|
test "$VIRTUAL_ENV" = caller-venv
|
|
{('test "$HERMES_PM_ACTIVATE_CANARY" = caller-canary' if canary else 'test -z "${HERMES_PM_ACTIVATE_CANARY+set}"')}
|
|
test -z "${{__HERMES_ACTIVATED+set}}"
|
|
! declare -F deactivate >/dev/null
|
|
'''
|
|
run = subprocess.run([_bash(), "-c", script], cwd=tmp_path, env=env,
|
|
capture_output=True, text=True, timeout=40)
|
|
assert run.returncode == 0, run.stdout + run.stderr
|
|
first, second, warm = run.stdout.splitlines()
|
|
assert first.startswith(str(root) + os.pathsep) and "/first/venv/" in first
|
|
assert second.startswith(str(root) + os.pathsep) and "/second/venv/" in second
|
|
assert warm == second
|
|
assert run.stderr.count("setup progress") == 3
|
|
_assert_syncs(root)
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("already_active", [False, True])
|
|
def test_bash_setup_failure_preserves_caller(tmp_path, already_active):
|
|
root, env = _sync_checkout(tmp_path)
|
|
activate = shlex.quote(str(root / "activate"))
|
|
script = f'''
|
|
set -e
|
|
{f'source {activate}' if already_active else ':'}
|
|
export PYTHONHOME=caller-home PYTHONPATH=caller-path VIRTUAL_ENV=caller-venv
|
|
before_env=$(export -p)
|
|
before_function=$(declare -f deactivate || :)
|
|
before_active=${{__HERMES_ACTIVATED-unset}}
|
|
before_cwd="$PWD"
|
|
touch {shlex.quote(str(root / 'fail'))}
|
|
if source {activate}; then exit 9; fi
|
|
test "$(export -p)" = "$before_env"
|
|
test "$(declare -f deactivate || :)" = "$before_function"
|
|
test "${{__HERMES_ACTIVATED-unset}}" = "$before_active"
|
|
test "$PWD" = "$before_cwd"
|
|
{"deactivate" if already_active else ':'}
|
|
printf preserved
|
|
'''
|
|
run = subprocess.run([_bash(), "-c", script], cwd=tmp_path, env=env,
|
|
capture_output=True, text=True, timeout=40)
|
|
assert run.returncode == 0, run.stdout + run.stderr
|
|
assert run.stdout == "preserved"
|
|
calls = (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()
|
|
assert len(calls) == (2 if already_active else 1)
|
|
assert json.loads(calls[-1])["python_env"] == dict.fromkeys(("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"))
|
|
|
|
|
|
@pytest.mark.platforms("windows")
|
|
def test_powershell_cold_sync_changed_input_warm_and_failure(tmp_path):
|
|
# Native child-process and venv semantics cannot be reproduced by faking win32.
|
|
root, env = _sync_checkout(tmp_path)
|
|
ps = _powershell()
|
|
assert ps, "native Windows test requires PowerShell"
|
|
script = tmp_path / "run.ps1"
|
|
script.write_text(f'''
|
|
$ErrorActionPreference = 'Stop'
|
|
$env:PYTHONPATH = 'caller-original'
|
|
$env:PYTHONHOME = 'caller-home'
|
|
$env:VIRTUAL_ENV = 'caller-venv'
|
|
$originalPath = $env:PATH
|
|
$originalCwd = (Get-Location).Path
|
|
. '{root / 'activate.ps1'}'
|
|
Write-Output ('selection=' + $env:PYTHONPATH)
|
|
Set-Content -LiteralPath '{root / 'input'}' -Value second
|
|
. '{root / 'activate.ps1'}'
|
|
Write-Output ('selection=' + $env:PYTHONPATH)
|
|
. '{root / 'activate.ps1'}'
|
|
Write-Output ('selection=' + $env:PYTHONPATH)
|
|
Write-Output ('parent=' + $PID)
|
|
$beforeEnv = Get-ChildItem env: | Sort-Object Name | ConvertTo-Json -Compress
|
|
$beforeFunction = (Get-Item function:deactivate).Definition
|
|
Set-Content -LiteralPath '{root / 'fail'}' -Value fail
|
|
$failed = $false
|
|
try {{ . '{root / 'activate.ps1'}' }} catch {{ $failed = $true }}
|
|
if (-not $failed) {{ throw 'setup failure accepted' }}
|
|
if ((Get-ChildItem env: | Sort-Object Name | ConvertTo-Json -Compress) -ne $beforeEnv) {{ throw 'env changed on failure' }}
|
|
if ((Get-Item function:deactivate).Definition -ne $beforeFunction) {{ throw 'deactivation lost' }}
|
|
if ((Get-Location).Path -ne $originalCwd) {{ throw 'cwd changed' }}
|
|
deactivate
|
|
if ($env:PATH -ne $originalPath -or $env:PYTHONPATH -ne 'caller-original' -or
|
|
$env:PYTHONHOME -ne 'caller-home' -or $env:VIRTUAL_ENV -ne 'caller-venv') {{ throw 'restore failed' }}
|
|
''', encoding="utf-8")
|
|
run = subprocess.run([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
|
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90)
|
|
assert run.returncode == 0, run.stdout + run.stderr
|
|
selected = [line.removeprefix("selection=") for line in run.stdout.splitlines() if line.startswith("selection=")]
|
|
assert len(selected) == 3
|
|
assert "\\first\\venv\\" in selected[0]
|
|
assert "\\second\\venv\\" in selected[1]
|
|
assert selected[1] == selected[2]
|
|
assert all(value.startswith(str(root) + os.pathsep) for value in selected)
|
|
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
|
assert len(calls) == 4
|
|
assert all(call["python_env"] == dict.fromkeys(("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")) for call in calls)
|
|
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]
|
|
parent = next(line.removeprefix("parent=") for line in run.stdout.splitlines() if line.startswith("parent="))
|
|
ps_calls = [json.loads(line) for line in (root / "ps-calls.jsonl").read_text(encoding="utf-8-sig").splitlines()]
|
|
assert len(ps_calls) == 4
|
|
for call in ps_calls:
|
|
assert str(call["pid"]) != parent
|
|
assert Path(call["executable"]).samefile(ps)
|
|
assert [arg.lower() for arg in call["argv"][1:]] == [
|
|
"-noprofile", "-noninteractive", "-executionpolicy", "bypass", "-file",
|
|
str(root / "setup-hermes.ps1").lower(), "-runtimeonly",
|
|
]
|