fix(lsp): select the analyzed project's Python first

Pyright selected the Hermes interpreter ahead of an explicit or local
project environment. Keep PM as the fallback rather than hiding the
project's dependencies.

The regression drives the real server registry and spawn options with
disposable venvs, then executes each selected interpreter. Correct the
neighboring Windows pip fixture to patch the installer seam it uses.
No language-server subprocess or user project was changed.
This commit is contained in:
ethernet
2026-09-09 20:48:13 -04:00
parent f0667b7048
commit b64fe7b366
3 changed files with 42 additions and 24 deletions

View File

@@ -165,13 +165,7 @@ def _spawn_pyright(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
def _pm_store_python() -> Optional[str]:
"""The pm-provisioned interpreter (facts + store layout), or None.
Under no-boot-through-venv the running process is the store python and
``VIRTUAL_ENV`` is unset, so the old ambient-env probe had nothing to
offer; pm's ``python`` fact names the store entry that owns the
runtime. It takes the precedence the ambient ``VIRTUAL_ENV`` probe used
to have; project-local candidates follow."""
"""The PM interpreter used when the analyzed project has no environment."""
try:
from pm import paths
from pm.lock import Facts
@@ -189,14 +183,10 @@ def _pm_store_python() -> Optional[str]:
def _detect_python(root: str) -> Optional[str]:
# pm's store interpreter resolves to a full exe path (its layout is not
# a venv), so it is checked directly rather than through the v/sub probe.
pm_python = _pm_store_python()
if pm_python:
return pm_python
# Pyright needs the project's dependencies, not Hermes's runtime packages.
venvs = [v for v in (os.environ.get("VIRTUAL_ENV"), os.path.join(root, ".venv"), os.path.join(root, "venv")) if v]
paths = (os.path.join(v, sub) for v in venvs for sub in ("bin/python", "bin/python3", "Scripts/python.exe"))
return next((p for p in paths if os.path.exists(p)), None)
return next((p for p in paths if os.path.exists(p)), None) or _pm_store_python()
_warned_once: set = set()

View File

@@ -81,15 +81,15 @@ def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
from agent.lsp import install as install_mod
def fake_run(cmd, **kwargs):
def fake_pip_install(args, **kwargs):
scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
launcher = scripts_dir / "fake-language-server.exe"
launcher.write_text("launcher\n")
launcher.write_text("launcher\n", encoding="utf-8")
launcher.chmod(0o755)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
monkeypatch.setattr("hermes_cli.tools_config._pip_install", fake_pip_install)
resolved = install_mod._install_pip("fake-lsp", "fake-language-server")

View File

@@ -1,14 +1,8 @@
"""LSP python discovery must resolve the pm store interpreter, not sniff
VIRTUAL_ENV.
Under no-boot-through-venv the process is the store python
(``sys.prefix == sys.base_prefix``) and ``VIRTUAL_ENV`` is unset in bundled
installs, so the old ambient-env probe silently degraded to project-dir
probing. pm's ``python`` fact (facts.json + store layout) is the authority.
"""
"""Pyright uses the project environment before the Hermes runtime fallback."""
import json
import os
import subprocess
import sys
from pathlib import Path
@@ -81,3 +75,37 @@ def test_missing_store_entry_is_not_an_answer(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
assert _pm_store_python() is None
def test_pyright_uses_the_project_interpreter_before_hermes(tmp_path, monkeypatch):
from agent.lsp import servers
project = tmp_path / "project"
project.mkdir()
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(servers, "_pm_store_python", lambda: sys.executable)
context = servers.ServerContext(
workspace_root=str(project), install_strategy="off",
binary_overrides={"pyright": [sys.executable]},
)
server = servers.find_server_for_file(str(project / "app.py"))
assert server is not None
spec = server.build_spawn(str(project), context)
assert spec.initialization_options["python"]["pythonPath"] == sys.executable
for environment in (project / ".venv", tmp_path / "explicit-environment"):
subprocess.run(
[sys.executable, "-m", "venv", "--without-pip", str(environment)],
check=True, capture_output=True, text=True, timeout=30,
)
python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
if environment.name == "explicit-environment":
monkeypatch.setenv("VIRTUAL_ENV", str(environment))
spec = server.build_spawn(str(project), context)
selected = spec.initialization_options["python"]["pythonPath"]
assert Path(selected) == python
child = subprocess.run(
[selected, "-I", "-c", "import sys; print(sys.prefix)"],
check=True, capture_output=True, text=True, timeout=10,
)
assert Path(child.stdout.strip()) == environment