diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index a813702a25d46..c8b54c8661af0 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -78,14 +78,34 @@ def resolve_exec_command() -> str: # third-party import (#90292) — silently, since Terminal=false. # sys.executable is the interpreter actually running Hermes (the # venv one), so prefix it explicitly. - argv = [str(Path(sys.executable).resolve()), str(resolved), "desktop"] + argv = [_launcher_python(), str(resolved), "desktop"] else: argv = [str(resolved), "desktop"] else: - argv = [str(Path(sys.executable).resolve()), "-m", "hermes_cli.main", "desktop"] + argv = [_launcher_python(), "-m", "hermes_cli.main", "desktop"] return " ".join(_quote_exec_arg(a) for a in argv) +def _launcher_python() -> str: + """The interpreter to write into ``Exec=``: absolute, but NOT symlink-resolved. + + ``sys.executable`` is already absolute, so ``.resolve()`` buys nothing — + and on a uv-created venv it is actively wrong. ``venv/bin/python`` is a + symlink to the uv-managed CPython under + ``~/.local/share/uv/python/cpython--.../bin/python3.11``. Resolving + it hands the launcher the *base* interpreter, whose ``sys.prefix`` is the + uv install, not the venv — so none of Hermes' dependencies are importable + and the app dies on ``import yaml`` before it can draw a window. With + ``Terminal=false`` the traceback goes nowhere and the icon looks inert. + + Keeping the symlink intact preserves venv detection (PEP 405 uses the + unresolved argv[0]/executable path to find ``pyvenv.cfg``), and it also + survives a uv patch upgrade: the version-pinned resolved path disappears + when uv swaps 3.11.16 for 3.11.17, while ``venv/bin/python`` keeps working. + """ + return os.path.abspath(sys.executable) + + def _needs_interpreter(bin_path: Path) -> bool: """Whether ``bin_path`` is a Python script that must run under ``sys.executable`` to see Hermes' venv (rather than its own shebang).""" @@ -106,8 +126,15 @@ def _needs_interpreter(bin_path: Path) -> bool: # A python shebang pointing INSIDE the running interpreter's environment # already resolves correctly; anything else (``/usr/bin/env python3``, # a system path) would escape the venv when spawned by the DE. - exe_dir = str(Path(sys.executable).resolve().parent) - return exe_dir not in shebang + # Compare against the UNRESOLVED venv bin dir: a uv venv's shebang reads + # ``#!/…/venv/bin/python3`` while the resolved interpreter lives under + # ``~/.local/share/uv/python/…``. Resolving here would never match, so a + # perfectly self-sufficient venv console script gets needlessly prefixed. + candidates = { + str(Path(sys.executable).parent), + str(Path(sys.executable).resolve().parent), + } + return not any(c.lower() in shebang for c in candidates) def _quote_exec_arg(arg: str) -> str: diff --git a/tests/hermes_cli/test_linux_desktop_entry.py b/tests/hermes_cli/test_linux_desktop_entry.py index 5bc73fb5b262e..65e912dd2aa45 100644 --- a/tests/hermes_cli/test_linux_desktop_entry.py +++ b/tests/hermes_cli/test_linux_desktop_entry.py @@ -94,6 +94,7 @@ def test_exec_falls_back_to_interpreter_module(tmp_path, xdg_home, monkeypatch): # silent (Terminal=false). The Exec line must prefix sys.executable for any # resolved bin that is a python script escaping the running venv. def test_exec_prefixes_interpreter_for_env_shebang_python_script(tmp_path, xdg_home, monkeypatch): + import os import sys root = _make_project(tmp_path) @@ -107,8 +108,11 @@ def test_exec_prefixes_interpreter_for_env_shebang_python_script(tmp_path, xdg_h entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] - interpreter = str(Path(sys.executable).resolve()) + interpreter = os.path.abspath(sys.executable) assert exec_line.split(" ")[0].strip('"') == interpreter + # The prefix must stay INSIDE the venv: resolving the symlink would yield + # the uv base interpreter, which cannot import Hermes' dependencies. + assert Path(exec_line.split(" ")[0].strip('"')).is_absolute() assert str(hermes_bin) in exec_line assert exec_line.endswith("desktop") @@ -149,6 +153,78 @@ def test_exec_leaves_venv_shebang_scripts_alone(tmp_path, xdg_home, monkeypatch) assert exec_line == f"{hermes_bin} desktop" +# Regression: on a uv-created venv, ``venv/bin/python`` is a SYMLINK into +# ``~/.local/share/uv/python/cpython--.../bin/``. Resolving it wrote the +# base interpreter into Exec=, which has no access to the venv's site-packages +# -> the app died on ``import yaml`` with Terminal=false, so clicking the +# launcher icon did nothing. It also pinned a patch version that vanished on +# the next uv upgrade. Exec must keep the unresolved venv path. +def test_exec_keeps_uv_venv_symlink_unresolved(tmp_path, xdg_home, monkeypatch): + import os + + root = _make_project(tmp_path) + + # Mimic uv's layout: a base interpreter + a venv whose python is a symlink. + base_bin = tmp_path / "uv" / "cpython-3.11.16" / "bin" + base_bin.mkdir(parents=True) + base_python = base_bin / "python3.11" + base_python.write_text("", encoding="utf-8") + base_python.chmod(0o755) + + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + try: + venv_python.symlink_to(base_python) + except (OSError, NotImplementedError): + pytest.skip("symlinks unavailable on this platform") + + monkeypatch.setattr(lde.sys, "executable", str(venv_python)) + + # A python script with an /usr/bin/env shebang forces the interpreter prefix. + hermes_bin = tmp_path / "bin" / "hermes" + hermes_bin.parent.mkdir() + hermes_bin.write_text("#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8") + hermes_bin.chmod(0o755) + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin)) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + written = exec_line.split(" ")[0].strip('"') + + assert written == os.path.abspath(str(venv_python)) + # The version-pinned base interpreter must NOT appear: it breaks imports + # and disappears when uv upgrades the patch release. + assert str(base_python) not in exec_line + assert "cpython-3.11.16" not in exec_line + + +# A venv console script whose shebang names the venv (unresolved) is +# self-sufficient: _needs_interpreter must not demand a prefix just because +# sys.executable resolves elsewhere. +def test_venv_shebang_matches_unresolved_executable(tmp_path, monkeypatch): + base_bin = tmp_path / "uv" / "cpython-3.11.16" / "bin" + base_bin.mkdir(parents=True) + base_python = base_bin / "python3.11" + base_python.write_text("", encoding="utf-8") + + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + try: + venv_python.symlink_to(base_python) + except (OSError, NotImplementedError): + pytest.skip("symlinks unavailable on this platform") + + script = venv_bin / "hermes" + script.write_text(f"#!{venv_bin / 'python3'}\nimport hermes_cli\n", encoding="utf-8") + script.chmod(0o755) + + monkeypatch.setattr(lde.sys, "executable", str(venv_python)) + assert lde._needs_interpreter(script) is False + + def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes")