The cold-runtime e2e and the uv dependency-leg test ship or digest the host interpreter as if it were a standalone Python. The earlier skips matched one file (sitecustomize.py) and one path (/usr), so /usr/local, Homebrew or a distro stdlib with other absolute links still failed. Skip instead when the shipped stdlib holds a link tarfile's data filter refuses, or when the prefix has unreadable files or is far larger than a single interpreter.
307 lines
14 KiB
Python
307 lines
14 KiB
Python
"""Cold CLI provisioning and repair, with real tools and no mocked child code.
|
|
|
|
Only uv/Python are offered by the fixture's loopback archive server. The copied
|
|
PM recipe is unchanged; its small locked runtime is fetched from PyPI. The app
|
|
recipe is deliberately tiny so this cannot install the production dependency
|
|
set. Run through scripts/run_tests.sh with uv/uvx available on PATH.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from functools import partial
|
|
import hashlib
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import threading
|
|
import tomllib
|
|
|
|
import pytest
|
|
|
|
# Spawns children with a home it builds itself; the parent's must stay real.
|
|
pytestmark = pytest.mark.real_machine_home
|
|
|
|
|
|
class _ArchiveHandler(SimpleHTTPRequestHandler):
|
|
def copyfile(self, source, outputfile):
|
|
# The downloader closes its size probe without consuming the archive.
|
|
try:
|
|
super().copyfile(source, outputfile)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
|
|
def _run(command, *, cwd, env, expected=0, timeout=240):
|
|
result = subprocess.run(command, cwd=cwd, env=env, capture_output=True,
|
|
text=True, timeout=timeout)
|
|
assert result.returncode == expected, (
|
|
f"{command!r}\nexit={result.returncode}\n{result.stdout}\n{result.stderr}"
|
|
)
|
|
return result
|
|
|
|
|
|
def _bare(python, repo, code, *, env, expected=0):
|
|
return _run(
|
|
[str(python), "-I", "-S", "-B", "-c",
|
|
f"import sys; sys.path.insert(0, {str(repo)!r});\n" + code],
|
|
cwd=repo.parent, env=env, expected=expected,
|
|
)
|
|
|
|
|
|
_UNSHIPPED = {"site-packages", "test", "__pycache__"}
|
|
|
|
|
|
def _foreign_link(stdlib: Path) -> Path | None:
|
|
"""First link in the shipped stdlib that the store's extractor refuses.
|
|
|
|
PM extracts archives with tarfile's data filter, which rejects absolute or
|
|
escaping links. Distro Pythons link stdlib files into /etc (Debian's
|
|
sitecustomize.py), so their stdlib cannot stand in for a standalone one.
|
|
"""
|
|
for directory, dirnames, filenames in os.walk(stdlib):
|
|
dirnames[:] = [name for name in dirnames if name not in _UNSHIPPED]
|
|
for name in (*dirnames, *filenames):
|
|
path = Path(directory) / name
|
|
if path.is_symlink() and (os.path.isabs(os.readlink(path))
|
|
or not path.resolve().is_relative_to(stdlib)):
|
|
return path
|
|
return None
|
|
|
|
|
|
@pytest.mark.platforms("linux")
|
|
@pytest.mark.parametrize("bootstrap_name", [None, "python3.11"])
|
|
def test_cold_cli_builds_own_runtime_discovers_plugins_and_repairs_app(tmp_path, bootstrap_name):
|
|
from pm.packages import Python, Uv
|
|
from pm.store import current_target, tree_digest
|
|
|
|
uv = shutil.which("uv")
|
|
if uv is None:
|
|
pytest.skip("real uv and uvx must be on PATH")
|
|
uv = Path(uv).resolve()
|
|
uvx = uv.with_name("uvx")
|
|
assert uvx.is_file(), "the real uv distribution must include uvx"
|
|
python = Path(sys._base_executable).resolve()
|
|
if sys.version_info[:2] != (3, 14):
|
|
pytest.skip("the checked-in PM runtime currently requires Python 3.14")
|
|
stdlib = next(d for d in (Path(sys.base_prefix) / "lib").glob("python3.*") if (d / "os.py").is_file())
|
|
foreign = _foreign_link(stdlib)
|
|
if foreign is not None:
|
|
pytest.skip(f"host Python is not self-contained: {foreign} links outside its stdlib")
|
|
bootstrap_python = shutil.which(bootstrap_name) if bootstrap_name else python
|
|
if bootstrap_python is None:
|
|
pytest.skip(f"{bootstrap_name} must be on PATH for the legacy bootstrap test")
|
|
|
|
source = Path(__file__).resolve().parents[2]
|
|
repo = tmp_path / "source"
|
|
repo.mkdir()
|
|
for name in ("pm", "hermes_cli"):
|
|
shutil.copytree(source / name, repo / name,
|
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
for name in ("utils.py", "hermes_constants.py", "hermes_yaml.py",
|
|
"hermes_bootstrap.py"):
|
|
shutil.copy2(source / name, repo / name)
|
|
# No production application lock or metadata enters this source snapshot.
|
|
recipe = tomllib.loads((repo / "pm" / "pyproject.toml").read_text())
|
|
yaml_requirement = next(dep for dep in recipe["project"]["dependencies"]
|
|
if dep.startswith("ruamel.yaml"))
|
|
(repo / "pyproject.toml").write_text(
|
|
'[project]\nname="cold-pm-app"\nversion="0.0.0"\n'
|
|
'requires-python=">=3.14,<3.15"\n'
|
|
f'dependencies=[{json.dumps(yaml_requirement)}]\n'
|
|
'[project.optional-dependencies]\nall=[]\n'
|
|
'[tool.uv]\npackage=false\n', encoding="utf-8",
|
|
)
|
|
home = tmp_path / "home"
|
|
hermes_home = home / ".hermes"
|
|
plugin = hermes_home / "plugins" / "cold-proof"
|
|
plugin.mkdir(parents=True)
|
|
config = hermes_home / "config.yaml"
|
|
config.write_text("plugins:\n enabled: [cold-proof]\n", encoding="utf-8")
|
|
(plugin / "plugin.yaml").write_text(
|
|
"name: cold-proof\nversion: 1.0.0\npip_dependencies: [idna==3.10]\n",
|
|
encoding="utf-8",
|
|
)
|
|
store = hermes_home / "tools"
|
|
scratch = tmp_path / "scratch"
|
|
scratch.mkdir()
|
|
env = {
|
|
"HOME": str(home), "HERMES_HOME": str(hermes_home),
|
|
"HERMES_RUNTIME_DIR": str(store), "PATH": os.defpath,
|
|
"TMPDIR": str(scratch), "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8",
|
|
"UV_PYTHON_DOWNLOADS": "never", "UV_NO_CONFIG": "1",
|
|
"UV_CACHE_DIR": str(tmp_path / "seed-cache"),
|
|
}
|
|
# Keep TLS functional on Nix without inheriting any application settings.
|
|
for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"):
|
|
if key in os.environ:
|
|
env[key] = os.environ[key]
|
|
_run([str(uv), "lock", "--project", str(repo), "--python", str(python)],
|
|
cwd=tmp_path, env=env)
|
|
|
|
archives = tmp_path / "archives"
|
|
archives.mkdir()
|
|
target = current_target()
|
|
rows = {}
|
|
for package, files in (
|
|
(Python(), [(python, "python/bin/python3")]),
|
|
(Uv(), [(uv, "uv-dist/uv"), (uvx, "uv-dist/uvx")]),
|
|
):
|
|
archive = archives / f"{package.name}.tar.gz"
|
|
with tarfile.open(archive, "w:gz", compresslevel=1) as tar:
|
|
for binary, name in files:
|
|
tar.add(binary, arcname=name)
|
|
if package.name == "python":
|
|
# A relocatable python-build-standalone finds its stdlib beside the binary, not at
|
|
# the host's prefix: ship the host's stdlib the way the real archive does.
|
|
tar.add(stdlib, arcname=f"python/lib/{stdlib.name}",
|
|
filter=lambda info: None if any(part in info.name.split("/") for part in ("site-packages", "test", "__pycache__")) else info)
|
|
version = _run([str(files[0][0]), "--version"], cwd=tmp_path,
|
|
env=env).stdout.split()[1]
|
|
rows[package.name] = {
|
|
"version": version,
|
|
"artifacts": {target: {"sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}},
|
|
}
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0),
|
|
partial(_ArchiveHandler, directory=str(archives)))
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
for name, row in rows.items():
|
|
row["artifacts"][target]["url"] = f"http://127.0.0.1:{server.server_port}/{name}.tar.gz"
|
|
(repo / "pm" / "lock.json").write_text(
|
|
json.dumps({"schema": 1, "packages": rows}), encoding="utf-8",
|
|
)
|
|
|
|
bootstrap = """
|
|
import importlib.util
|
|
assert importlib.util.find_spec('ruamel') is None
|
|
assert importlib.util.find_spec('yaml') is None
|
|
assert importlib.util.find_spec('packaging') is None
|
|
assert importlib.util.find_spec('idna') is None
|
|
"""
|
|
cli = "\nsys.argv = ['hermes', 'pm', {action!r}]; import hermes_cli.main\n"
|
|
try:
|
|
assert not store.exists()
|
|
assert not (hermes_home / "installs").exists()
|
|
result = _bare(bootstrap_python, repo, bootstrap + cli.format(action="install"), env=env)
|
|
assert "✓ venv" in result.stdout
|
|
assert "Preparing the isolated Hermes runtime" in result.stderr
|
|
if bootstrap_name:
|
|
# The installed launcher still starts on the old interpreter after
|
|
# a source swap. Completion must re-exec before importing the app.
|
|
(repo / ".git").mkdir()
|
|
(repo / "install-stamp.json").write_text(
|
|
json.dumps({"updateMechanism": "self"}), encoding="utf-8",
|
|
)
|
|
lock = repo / "uv.lock"
|
|
lock.write_bytes(lock.read_bytes() + b"\n# source update\n")
|
|
entry = repo / "launch_probe.py"
|
|
entry.write_text(
|
|
"import hermes_bootstrap\n"
|
|
"import idna, json, sys\n"
|
|
"print(json.dumps({'python': sys.executable, 'version': list(sys.version_info[:2]), "
|
|
"'idna': idna.__file__}))\n", encoding="utf-8",
|
|
)
|
|
launched = _run([str(bootstrap_python), "-B", str(entry)], cwd=repo, env=env)
|
|
launch_report = json.loads(launched.stdout)
|
|
assert Path(launch_report["python"]).is_relative_to(store)
|
|
assert launch_report["version"] == list(sys.version_info[:2])
|
|
assert Path(launch_report["idna"]).is_relative_to(hermes_home / "installs")
|
|
assert launched.stderr.count("completing source-update dependencies") == 1
|
|
# The launch finished the update's shared tail, whose maintenance migrates
|
|
# config.yaml; `pm repair` below must then leave that migrated file alone.
|
|
assert "_config_version" in config.read_text()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=10)
|
|
expected_config = config.read_text()
|
|
|
|
report_code = """
|
|
import json
|
|
from pathlib import Path
|
|
from pm.environments import install_state_dir, selected_venv, runtime_facts_path
|
|
root = Path(sys.path[0])
|
|
state = install_state_dir(root)
|
|
print(json.dumps({'state': str(state), 'app': str(selected_venv(root)),
|
|
'facts': json.loads(runtime_facts_path(root).read_text())}))
|
|
"""
|
|
report = json.loads(_bare(python, repo, report_code, env=env).stdout)
|
|
state, app = Path(report["state"]), Path(report["app"])
|
|
assert state.is_relative_to(hermes_home)
|
|
pm_root = state / "pm-runtime"
|
|
selection = (pm_root / "selected.json").read_bytes()
|
|
runtime = pm_root / json.loads(selection)["generation"]
|
|
pm_python = runtime / "bin/python"
|
|
assert runtime != app
|
|
facts = json.loads((store / "facts.json").read_text())["packages"]
|
|
assert set(facts) == {"python", "uv"}
|
|
for name, fact in facts.items():
|
|
assert fact["version"] == rows[name]["version"]
|
|
assert fact["artifacts"] == [rows[name]["artifacts"][target]["sha256"]]
|
|
assert tree_digest(store / fact["entry"]) == fact["digest"]
|
|
|
|
probe = _run([str(pm_python), "-I", "-B", "-c", f"""
|
|
import importlib.util, json, sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, {str(repo)!r})
|
|
from pm.workspace import enabled_member_dirs
|
|
from ruamel.yaml import YAML
|
|
import ruamel.yaml
|
|
assert enabled_member_dirs() == [Path({str(plugin)!r})]
|
|
assert importlib.util.find_spec('idna') is None
|
|
assert importlib.util.find_spec('openai') is None
|
|
print(json.dumps({{'yaml': ruamel.yaml.__file__, 'prefix': sys.prefix}}))
|
|
"""], cwd=tmp_path, env=env)
|
|
pm_report = json.loads(probe.stdout)
|
|
assert Path(pm_report["yaml"]).is_relative_to(runtime)
|
|
app_code = """
|
|
import json
|
|
from pathlib import Path
|
|
from pm.environments import activate_dependencies, selected_venv
|
|
root = Path(sys.path[0])
|
|
activate_dependencies(root)
|
|
import ruamel.yaml, idna
|
|
from ruamel.yaml import YAML
|
|
assert idna.encode('bücher.example') == b'xn--bcher-kva.example'
|
|
assert YAML(typ='safe').load('proof: true')['proof'] is True
|
|
print(json.dumps({'yaml': ruamel.yaml.__file__, 'idna': idna.__file__,
|
|
'app': str(selected_venv(root))}))
|
|
"""
|
|
app_report = json.loads(_bare(python, repo, app_code, env=env).stdout)
|
|
assert Path(app_report["yaml"]).is_relative_to(app)
|
|
assert Path(app_report["idna"]).is_relative_to(app)
|
|
initial_fact = report["facts"]["packages"]["venv"]
|
|
assert initial_fact["extras"] == ["all"]
|
|
lock_before = Path(initial_fact["resolved_lock"]).read_bytes()
|
|
assert b'idna' in lock_before
|
|
|
|
# Real damage: the app no longer imports, while PM retains its independent YAML.
|
|
shutil.rmtree(Path(app_report["yaml"]).parent)
|
|
broken = _bare(python, repo, app_code, env=env, expected=1)
|
|
assert "ModuleNotFoundError" in broken.stderr
|
|
assert "ruamel.yaml" in broken.stderr
|
|
repaired = _bare(python, repo, bootstrap + cli.format(action="repair"), env=env)
|
|
assert "Restart Hermes" in repaired.stdout
|
|
restored = json.loads(_bare(python, repo, app_code, env=env).stdout)
|
|
repaired_app = Path(restored["app"])
|
|
assert repaired_app != app
|
|
assert Path(restored["yaml"]).is_relative_to(repaired_app)
|
|
assert Path(restored["idna"]).is_relative_to(repaired_app)
|
|
assert (pm_root / "selected.json").read_bytes() == selection
|
|
after = json.loads(_bare(python, repo, report_code, env=env).stdout)
|
|
repaired_fact = after["facts"]["packages"]["venv"]
|
|
assert repaired_fact["extras"] == initial_fact["extras"]
|
|
assert repaired_fact["stamp"] == initial_fact["stamp"]
|
|
assert Path(repaired_fact["resolved_lock"]).read_bytes() == lock_before
|
|
assert config.read_text() == expected_config
|
|
if not bootstrap_name:
|
|
assert expected_config == "plugins:\n enabled: [cold-proof]\n"
|
|
assert not (state / ".repair-incomplete").exists()
|
|
assert not (repo / "venv").exists()
|
|
assert not (repo / ".venv").exists()
|
|
print(f"cold PM proof: runtime={runtime}; first_app={app}; repaired_app={repaired_app}")
|