236 lines
9.2 KiB
Python
236 lines
9.2 KiB
Python
"""Unit tests for the bundled payload's win32 launcher wrapper.
|
|
|
|
scripts/build/launcher_wrapper.py is the zip overlay a distlib
|
|
ScriptMaker packs into every minted CLI launcher exe (the rust shim's
|
|
replacement). The wrapper is import-safe on purpose, so these tests drive
|
|
its real logic — path resolution, sys.path order, the pycache_prefix
|
|
default — plus a real subprocess dispatch through a rendered copy.
|
|
|
|
The mechanism itself (distlib minting a PE whose shebang carries the
|
|
<launcher_dir> placeholder) is exercised end-to-end by
|
|
test_mint_launchers.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO = Path(__file__).resolve().parents[2]
|
|
_WRAPPER = _REPO / "scripts" / "build" / "launcher_wrapper.py"
|
|
|
|
|
|
def _load(env=None):
|
|
"""Import the wrapper with its placeholders substituted, like the build
|
|
script does, and return its module namespace."""
|
|
from scripts.build.launchers import render_wrapper
|
|
|
|
text = render_wrapper("stubmod.entry:main", "../repo", "../venv/Lib/site-packages")
|
|
namespace: dict = {"__name__": "launcher_wrapper_under_test"}
|
|
exec(compile(text, str(_WRAPPER), "exec"), namespace) # noqa: S102 - test fixture
|
|
return namespace
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_launcher_dir_resolves_from_argv0(tmp_path):
|
|
ns = _load()
|
|
argv0 = tmp_path / "bin" / "hermes.exe"
|
|
assert ns["launcher_dir"](str(argv0)) == str(tmp_path / "bin")
|
|
|
|
|
|
def test_payload_sys_paths_order_repo_first(tmp_path):
|
|
ns = _load()
|
|
here = str(tmp_path / "bin")
|
|
entries = ns["payload_sys_paths"](here)
|
|
# Repo first — its hermes_cli wins over anything stale in the venv
|
|
# (the sealed payload has no working editable install). Same order the
|
|
# old rust shim composed PYTHONPATH in.
|
|
assert entries == [
|
|
os.path.join(here, "..", "repo"),
|
|
os.path.join(here, "..", "venv", "Lib", "site-packages"),
|
|
]
|
|
|
|
|
|
def test_payload_sys_paths_accepts_forward_slash_rels_on_any_platform(tmp_path):
|
|
"""The layout facts ride in the wrapper as forward-slash strings (the
|
|
payload manifest convention); splitting must not depend on os.sep."""
|
|
ns = _load()
|
|
here = str(tmp_path / "bin")
|
|
for entry in ns["payload_sys_paths"](here):
|
|
assert os.path.isabs(os.path.normpath(entry))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# configure(): env hygiene, sys.path, pycache_prefix default
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_configure_prepends_repo_then_site_packages(tmp_path):
|
|
ns = _load()
|
|
here = str(tmp_path / "bin")
|
|
original = list(sys.path)
|
|
try:
|
|
entries = ns["configure"](here, environ={})
|
|
assert sys.path[:2] == entries
|
|
assert sys.path[0] == os.path.join(here, "..", "repo")
|
|
assert sys.path[1] == os.path.join(here, "..", "venv", "Lib", "site-packages")
|
|
finally:
|
|
sys.path[:] = original
|
|
|
|
|
|
def test_configure_processes_site_pth_files(tmp_path):
|
|
"""site-packages goes through site.addsitedir(), not a raw append.
|
|
|
|
Only addsitedir() runs .pth files, and pywin32.pth is load-bearing on
|
|
Windows bundles: it puts win32\\lib on sys.path, which is what makes
|
|
`import pywintypes` resolve (portalocker's Win32Locker →
|
|
concurrent-log-handler → hermes_logging.py's file handlers). The old
|
|
raw sys.path[0:0] = entries skipped .pth processing entirely, silently
|
|
killing file logging on Windows bundles (and any future .pth-based
|
|
dependency on every platform).
|
|
"""
|
|
ns = _load()
|
|
here = str(tmp_path / "bin")
|
|
site = os.path.join(here, "..", "venv", "Lib", "site-packages")
|
|
os.makedirs(site)
|
|
added = tmp_path / "pth-added"
|
|
added.mkdir()
|
|
with open(os.path.join(site, "load-bearing.pth"), "w", encoding="utf-8") as f:
|
|
f.write(f"{added}\n")
|
|
original = list(sys.path)
|
|
try:
|
|
ns["configure"](here, environ={})
|
|
repo = os.path.join(here, "..", "repo")
|
|
# Repo first, site second, .pth-added dirs after both.
|
|
assert sys.path[:2] == [repo, site]
|
|
assert sys.path.index(str(added)) > 1
|
|
finally:
|
|
sys.path[:] = original
|
|
|
|
|
|
def test_configure_drops_inherited_pythonpath_and_pythonhome(tmp_path):
|
|
ns = _load()
|
|
original = list(sys.path)
|
|
environ = {"PYTHONPATH": "/foreign/install", "PYTHONHOME": "/foreign/python"}
|
|
try:
|
|
ns["configure"](str(tmp_path), environ=environ)
|
|
assert "PYTHONPATH" not in environ
|
|
assert "PYTHONHOME" not in environ
|
|
finally:
|
|
sys.path[:] = original
|
|
|
|
|
|
@pytest.mark.platforms("windows")
|
|
def test_configure_defaults_pycache_prefix_to_localappdata(tmp_path, monkeypatch):
|
|
ns = _load()
|
|
environ = {"LOCALAPPDATA": str(tmp_path / "lad")}
|
|
original = sys.pycache_prefix
|
|
try:
|
|
ns["configure"](str(tmp_path), environ=environ)
|
|
expected = os.path.join(str(tmp_path / "lad"), "hermes", "pycache")
|
|
assert environ["PYTHONPYCACHEPREFIX"] == expected
|
|
# The env var alone cannot retro-activate; sys.pycache_prefix is
|
|
# the live switch (the rust shim set it on the CHILD's environment).
|
|
assert str(sys.pycache_prefix) == expected
|
|
finally:
|
|
sys.pycache_prefix = original
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_configure_defaults_pycache_prefix_to_home_cache(tmp_path, monkeypatch):
|
|
"""POSIX counterpart of the LOCALAPPDATA default: ~/.cache/hermes-pycache
|
|
(same configure() contract, per the wrapper's own platform split)."""
|
|
ns = _load()
|
|
environ = {"HOME": str(tmp_path / "home")}
|
|
original = sys.pycache_prefix
|
|
try:
|
|
ns["configure"](str(tmp_path), environ=environ)
|
|
expected = os.path.join(str(tmp_path / "home"), ".cache", "hermes-pycache")
|
|
assert environ["PYTHONPYCACHEPREFIX"] == expected
|
|
assert str(sys.pycache_prefix) == expected
|
|
finally:
|
|
sys.pycache_prefix = original
|
|
|
|
|
|
def test_configure_keeps_a_user_set_pycache_prefix(tmp_path, monkeypatch):
|
|
ns = _load()
|
|
environ = {"LOCALAPPDATA": str(tmp_path / "lad"), "PYTHONPYCACHEPREFIX": str(tmp_path / "mine")}
|
|
original = sys.pycache_prefix
|
|
try:
|
|
ns["configure"](str(tmp_path), environ=environ)
|
|
assert environ["PYTHONPYCACHEPREFIX"] == str(tmp_path / "mine")
|
|
assert sys.pycache_prefix == original # untouched
|
|
finally:
|
|
sys.pycache_prefix = original
|
|
|
|
|
|
def test_configure_without_localappdata_leaves_pycache_alone(tmp_path):
|
|
ns = _load()
|
|
environ: dict = {}
|
|
original = sys.pycache_prefix
|
|
try:
|
|
ns["configure"](str(tmp_path), environ=environ)
|
|
assert "PYTHONPYCACHEPREFIX" not in environ
|
|
assert sys.pycache_prefix == original
|
|
finally:
|
|
sys.pycache_prefix = original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Real dispatch: run a rendered copy as a subprocess against a stub module
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _render(tmp_path: Path) -> Path:
|
|
text = _WRAPPER.read_text(encoding="utf-8")
|
|
for placeholder, value in {
|
|
"__HERMES_ENTRY_MODULE__": "hermes_cli.main",
|
|
"__HERMES_ENTRY_FUNC__": "main",
|
|
"__HERMES_REPO_REL__": "../repo",
|
|
"__HERMES_SITE_REL__": "../venv/Lib/site-packages",
|
|
}.items():
|
|
text = text.replace(placeholder, value)
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
out = bin_dir / "hermes.exe"
|
|
out.write_text(text, encoding="utf-8")
|
|
return out
|
|
|
|
|
|
@pytest.mark.platforms("windows")
|
|
def test_rendered_wrapper_dispatches_to_the_entry_module(tmp_path):
|
|
"""The full wrapper contract without distlib: a rendered copy placed in
|
|
bin/ next to a stub repo + venv resolves its own dir from sys.argv[0],
|
|
imports hermes_cli.main off the payload paths, and returns main()'s
|
|
exit code."""
|
|
bin_dir = tmp_path / "bin"
|
|
(bin_dir / ".." / "repo" / "hermes_cli").mkdir(parents=True, exist_ok=True)
|
|
(bin_dir / ".." / "venv" / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True)
|
|
(bin_dir / ".." / "repo" / "hermes_cli" / "__init__.py").write_text("")
|
|
(bin_dir / ".." / "repo" / "hermes_bootstrap.py").write_text("READY = True\n")
|
|
(bin_dir / ".." / "repo" / "hermes_cli" / "main.py").write_text(
|
|
"import sys\n"
|
|
"assert sys.modules['hermes_bootstrap'].READY\n"
|
|
"def main():\n"
|
|
" assert sys.argv[0].endswith('hermes.exe'), sys.argv[0]\n"
|
|
" assert sys.argv[1:] == ['--version']\n"
|
|
" import stubdep # importable only via the venv site-packages entry\n"
|
|
" return 7\n"
|
|
)
|
|
(bin_dir / ".." / "venv" / "Lib" / "site-packages" / "stubdep.py").write_text("X = 1\n")
|
|
|
|
wrapper = _render(tmp_path)
|
|
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME", "PYTHONPYCACHEPREFIX")}
|
|
env["LOCALAPPDATA"] = str(tmp_path / "lad")
|
|
python = Path(sys.base_prefix) / "python.exe" if sys.platform == "win32" else sys.executable
|
|
proc = subprocess.run([str(python), str(wrapper), "--version"], capture_output=True, text=True, env=env)
|
|
assert proc.returncode == 7, proc.stderr
|