fix(windows): preserve active source launchers during backend startup
This commit is contained in:
@@ -196,19 +196,65 @@ def mint_launcher(
|
||||
def _get_script_text(self, entry):
|
||||
return script
|
||||
|
||||
maker = _PathedScriptMaker(None, str(out_dir), add_launchers=True)
|
||||
maker.executable = str(python_exe)
|
||||
maker.variants = {""}
|
||||
maker.clobber = True
|
||||
try:
|
||||
written = maker.make(f"{name} = {module}:{func}", {"interpreter_args": ["-I"]})
|
||||
except Exception:
|
||||
written = []
|
||||
for path in written:
|
||||
if Path(path).suffix.lower() == ".exe":
|
||||
return Path(path)
|
||||
import tempfile
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
def make(directory: Path) -> list[str]:
|
||||
maker = _PathedScriptMaker(None, str(directory), add_launchers=True)
|
||||
maker.executable = str(python_exe)
|
||||
maker.variants = {""}
|
||||
maker.clobber = True
|
||||
return maker.make(f"{name} = {module}:{func}", {"interpreter_args": ["-I"]})
|
||||
|
||||
# distlib's exe ZIP records the current time, so byte equality cannot
|
||||
# detect an unchanged launcher. Compare its loader + shebang and script;
|
||||
# leave an active executable alone when only the ZIP timestamp changed.
|
||||
with tempfile.TemporaryDirectory(prefix=f".{name}-", dir=out_dir) as staging:
|
||||
try:
|
||||
candidate = next((Path(p) for p in make(Path(staging))
|
||||
if Path(p).suffix.lower() == ".exe"), None)
|
||||
except Exception:
|
||||
candidate = None
|
||||
if candidate is not None:
|
||||
target = out_dir / candidate.name
|
||||
try:
|
||||
with ZipFile(target) as old, ZipFile(candidate) as new:
|
||||
if (old.namelist() == new.namelist() == ["__main__.py"]
|
||||
and target.read_bytes()[:old.infolist()[0].header_offset]
|
||||
== candidate.read_bytes()[:new.infolist()[0].header_offset]
|
||||
and old.read("__main__.py") == new.read("__main__.py")):
|
||||
return target
|
||||
except (OSError, BadZipFile, KeyError):
|
||||
pass
|
||||
# For changed launchers, distlib's .deleteme replacement can
|
||||
# move an executable Windows still has mapped in memory.
|
||||
try:
|
||||
written = make(out_dir)
|
||||
except Exception:
|
||||
written = []
|
||||
for path in written:
|
||||
if Path(path).suffix.lower() == ".exe":
|
||||
return Path(path)
|
||||
# distlib ran but produced no exe (unexpected) — fall through to cmd.
|
||||
|
||||
# A prepared app environment need not include distlib, even when the
|
||||
# installer used it to publish a native exe. Keep that executable if its
|
||||
# embedded interpreter and script still match; replacing it with a cmd
|
||||
# would require unlinking the currently running exe on Windows.
|
||||
existing = out_dir / f"{name}.exe"
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
try:
|
||||
with ZipFile(existing) as archive:
|
||||
if archive.namelist() == ["__main__.py"]:
|
||||
prefix = existing.read_bytes()[:archive.infolist()[0].header_offset]
|
||||
shebangs = (f"#!{python_exe} -I\n".encode("utf-8"),
|
||||
f'#!"{python_exe}" -I\n'.encode("utf-8"))
|
||||
if (any(prefix.endswith(shebang) for shebang in shebangs)
|
||||
and archive.read("__main__.py") == script.encode("utf-8")):
|
||||
return existing
|
||||
except (OSError, BadZipFile, KeyError):
|
||||
pass
|
||||
|
||||
# The script is data to Python, not interpolated shell source.
|
||||
import base64
|
||||
encoded = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
|
||||
@@ -333,6 +333,60 @@ def test_windows_commands_survive_generation_collection(tmp_path, monkeypatch, s
|
||||
_command_survives_generation_collection(tmp_path, monkeypatch, surface)
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
@pytest.mark.parametrize("launcher_form", ["native", "cmd", "native-with-maker"])
|
||||
def test_running_source_launcher_can_republish_itself(tmp_path, monkeypatch, launcher_form):
|
||||
repo, _home, interpreter = fixture_tree(tmp_path, monkeypatch)
|
||||
selected = select_generation(repo, "selected", "ready")
|
||||
if launcher_form == "native-with-maker":
|
||||
import distlib
|
||||
|
||||
shutil.copytree(Path(distlib.__file__).parent, site_packages(selected) / "distlib")
|
||||
entry = repo / "hermes_cli/main.py"
|
||||
entry.write_text(
|
||||
"from pathlib import Path\n"
|
||||
"from hermes_cli._launchers import ensure_install_launchers, ENTRY_POINTS\n"
|
||||
"def main():\n"
|
||||
" root = Path(__file__).resolve().parents[1]\n"
|
||||
" written = ensure_install_launchers(root, root / '.hermes/bin')\n"
|
||||
" print('published', len(written), len(ENTRY_POINTS), flush=True)\n"
|
||||
" return 0 if len(written) == len(ENTRY_POINTS) else 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
out = repo / ".hermes/bin"
|
||||
if launcher_form == "cmd":
|
||||
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
|
||||
launchers = _launchers.ensure_install_launchers(repo, out)
|
||||
assert len(launchers) == len(_launchers.ENTRY_POINTS)
|
||||
command = next(Path(p) for p in launchers if Path(p).stem == "hermes")
|
||||
assert command.suffix == (".cmd" if launcher_form == "cmd" else ".exe")
|
||||
result = subprocess.run([str(command)], cwd=tmp_path, capture_output=True,
|
||||
text=True, encoding="utf-8", timeout=30)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "published 2 2" in result.stdout
|
||||
assert _launchers.ensure_install_launchers(repo, out)
|
||||
selected_python = _launchers.resolve_store_python(repo)
|
||||
assert selected_python is not None and selected_python.samefile(interpreter)
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
def test_repin_without_distlib_retires_stale_native_launcher(tmp_path, monkeypatch):
|
||||
repo, home, _interpreter = fixture_tree(tmp_path, monkeypatch)
|
||||
local = repo / ".hermes/bin"
|
||||
original = Path(_launchers.ensure_install_launchers(repo, local)[0])
|
||||
assert original.suffix == ".exe"
|
||||
new_python = home / "tools" / "repinned" / "python.exe"
|
||||
new_python.parent.mkdir()
|
||||
new_python.touch()
|
||||
(home / "tools/facts.json").write_text(
|
||||
json.dumps({"packages": {"python": {"entry": "repinned"}}}), encoding="utf-8")
|
||||
monkeypatch.setattr(_launchers, "_load_script_maker", lambda: None)
|
||||
result = _launchers.stage_launcher("hermes", repo, local)
|
||||
assert result == local / "hermes.cmd"
|
||||
assert not original.exists() # cmd.exe must not run the old exe first
|
||||
assert result is not None and str(new_python) in result.read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
def test_windows_repair_upgrades_healthy_old_pm_external_launchers(tmp_path, monkeypatch):
|
||||
from hermes_cli._install_repair import ensure_windows_bin_launchers
|
||||
|
||||
Reference in New Issue
Block a user