Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions hermes_cli/linux_desktop_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<X.Y.Z>-.../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)."""
Expand All @@ -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:
Expand Down
78 changes: 77 additions & 1 deletion tests/hermes_cli/test_linux_desktop_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")

Expand Down Expand Up @@ -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-<X.Y.Z>-.../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")
Expand Down