Skip to content

Commit ab1acbc

Browse files
authored
Merge pull request #108 from codellm-devkit/fix/issue-107-env-interpreter-selection
fix(env): parso-version-aware interpreter selection for the analysis venv
2 parents 9716632 + 57c4043 commit ab1acbc

4 files changed

Lines changed: 331 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Analysis env provisioning is parso-version-aware** (#107): on hosts whose default
12+
`python3` is newer than the newest grammar the installed parso ships (e.g. Python 3.14
13+
with parso ≤ 0.8.4), every file failed jedi parsing and the run completed "successfully"
14+
with an empty symbol table. Provisioning now derives parso's supported ceiling at runtime
15+
from its shipped grammar files and prefers the newest supported interpreter on the host
16+
(versioned PATH names, then pyenv installs), falling back loudly only when none exists;
17+
an explicit `SYSTEM_PYTHON` is still honored, with a warning when unsupported. A run in
18+
which every discovered file fails now logs a prominent error instead of staying silent,
19+
and `parso>=0.8.5` (the first release with the 3.14 grammar) is a direct dependency.
20+
1021
## [1.0.2] - 2026-07-16
1122

1223
### Fixed

codeanalyzer/core.py

Lines changed: 161 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,159 @@ def _cmd_exec_helper(
163163
stderr=None,
164164
)
165165

166+
@classmethod
167+
def _get_base_interpreter(cls) -> Path:
168+
"""The interpreter used to provision the analysis virtualenv.
169+
170+
jedi parses the *analysis environment's* Python version with parso,
171+
which ships one hardcoded grammar file per minor version — an
172+
environment newer than the newest shipped grammar makes every file
173+
fail with "Python version X.Y is currently not supported" while the
174+
run still exits 0 (#107). So the default choice is gated on the
175+
installed parso's ceiling: a too-new default is swapped for the
176+
newest supported interpreter found on the host, falling back to the
177+
default (loudly) only when none exists. An explicit ``SYSTEM_PYTHON``
178+
always wins, with a warning when parso cannot parse its version.
179+
"""
180+
# An explicit SYSTEM_PYTHON override wins (consulted only when running
181+
# inside a virtualenv, matching the historical behavior).
182+
if sys.prefix != sys.base_prefix:
183+
system_python = os.getenv("SYSTEM_PYTHON")
184+
if system_python:
185+
system_python_path = Path(system_python)
186+
if system_python_path.exists() and system_python_path.is_file():
187+
ceiling = cls._parso_supported_ceiling()
188+
version = cls._interpreter_version(system_python_path)
189+
if ceiling is not None and version is not None and version > ceiling:
190+
logger.warning(
191+
f"SYSTEM_PYTHON={system_python} is Python "
192+
f"{version[0]}.{version[1]}, newer than the newest grammar "
193+
f"the installed parso ships ({ceiling[0]}.{ceiling[1]}). "
194+
"jedi will likely reject every file in the analysis "
195+
"environment (#107); honoring the explicit override anyway."
196+
)
197+
return system_python_path
198+
199+
candidate = cls._default_base_interpreter()
200+
ceiling = cls._parso_supported_ceiling()
201+
if ceiling is None:
202+
return candidate
203+
version = cls._interpreter_version(candidate)
204+
if version is None or version <= ceiling:
205+
return candidate
206+
logger.warning(
207+
f"Default interpreter {candidate} is Python {version[0]}.{version[1]}, "
208+
f"newer than the newest grammar the installed parso ships "
209+
f"({ceiling[0]}.{ceiling[1]}) — looking for a supported interpreter "
210+
"for the analysis environment (#107)."
211+
)
212+
supported = cls._find_supported_interpreter(ceiling)
213+
if supported is not None:
214+
logger.info(f"Provisioning the analysis environment with {supported}.")
215+
return supported
216+
logger.warning(
217+
f"No interpreter <= {ceiling[0]}.{ceiling[1]} found on this host; "
218+
f"falling back to {candidate}. jedi/parso will likely reject every "
219+
"file — install a supported Python or upgrade parso."
220+
)
221+
return candidate
222+
223+
@staticmethod
224+
def _versions_from_grammar_stems(stems: List[str]) -> List[tuple]:
225+
"""``grammar313`` → ``(3, 13)``, sorted ascending; malformed stems dropped."""
226+
versions = []
227+
for stem in stems:
228+
digits = stem[len("grammar"):]
229+
if len(digits) >= 2 and digits.isdigit():
230+
versions.append((int(digits[0]), int(digits[1:])))
231+
return sorted(versions)
232+
233+
@classmethod
234+
def _parso_supported_ceiling(cls) -> Optional[tuple]:
235+
"""Newest ``(major, minor)`` the installed parso ships a grammar for,
236+
derived from its ``python/grammar*.txt`` files so the ceiling moves
237+
automatically when parso adds a version. ``None`` if undeterminable."""
238+
try:
239+
import parso
240+
241+
stems = [
242+
p.stem
243+
for p in (Path(parso.__file__).parent / "python").glob("grammar*.txt")
244+
]
245+
versions = cls._versions_from_grammar_stems(stems)
246+
return versions[-1] if versions else None
247+
except Exception:
248+
return None
249+
250+
@staticmethod
251+
def _interpreter_version(interpreter: Path) -> Optional[tuple]:
252+
"""``(major, minor)`` of an interpreter, or ``None`` if it can't run."""
253+
try:
254+
result = subprocess.run(
255+
[
256+
str(interpreter),
257+
"-c",
258+
"import sys; print('%d.%d' % sys.version_info[:2])",
259+
],
260+
capture_output=True,
261+
text=True,
262+
timeout=5,
263+
)
264+
if result.returncode == 0:
265+
major, minor = result.stdout.strip().split(".")
266+
return (int(major), int(minor))
267+
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError, ValueError):
268+
pass
269+
return None
270+
271+
@staticmethod
272+
def _pick_supported_interpreter(
273+
candidates: List[tuple], ceiling: tuple
274+
) -> Optional[Path]:
275+
"""Newest candidate whose version is within the ceiling.
276+
277+
``candidates`` is ``[(path, (major, minor) | None), ...]``."""
278+
supported = [
279+
(version, path)
280+
for path, version in candidates
281+
if version is not None and version <= ceiling
282+
]
283+
return max(supported)[1] if supported else None
284+
285+
@classmethod
286+
def _find_supported_interpreter(cls, ceiling: tuple) -> Optional[Path]:
287+
"""Search the host for the newest interpreter within the parso ceiling:
288+
versioned names on PATH (``python3.13``, ``python3.12``, ...) first,
289+
then pyenv installs."""
290+
paths: List[Path] = []
291+
for minor in range(ceiling[1], 7, -1):
292+
which = shutil.which(f"python{ceiling[0]}.{minor}")
293+
# Skip the current virtualenv's own interpreter (same rule as
294+
# _default_base_interpreter): the analysis env must come from a
295+
# base installation.
296+
if which and not which.startswith(sys.prefix):
297+
paths.append(Path(which))
298+
for pyenv_root in (os.getenv("PYENV_ROOT"), str(Path.home() / ".pyenv")):
299+
if not pyenv_root:
300+
continue
301+
versions_dir = Path(pyenv_root) / "versions"
302+
if versions_dir.is_dir():
303+
for install in sorted(versions_dir.iterdir(), reverse=True):
304+
exe = install / "bin" / "python3"
305+
if exe.exists():
306+
paths.append(exe)
307+
seen = set()
308+
candidates = []
309+
for path in paths:
310+
key = str(path)
311+
if key in seen:
312+
continue
313+
seen.add(key)
314+
candidates.append((path, cls._interpreter_version(path)))
315+
return cls._pick_supported_interpreter(candidates, ceiling)
316+
166317
@staticmethod
167-
def _get_base_interpreter() -> Path:
318+
def _default_base_interpreter() -> Path:
168319
"""Get the base Python interpreter path.
169320
170321
This method finds a suitable base Python interpreter that can be used
@@ -183,13 +334,6 @@ def _get_base_interpreter() -> Path:
183334

184335
# We're inside a virtual environment; need to find the base interpreter
185336

186-
# First, check if user explicitly set SYSTEM_PYTHON
187-
system_python = os.getenv("SYSTEM_PYTHON")
188-
if system_python:
189-
system_python_path = Path(system_python)
190-
if system_python_path.exists() and system_python_path.is_file():
191-
return system_python_path
192-
193337
# Try to get the base interpreter from sys.base_executable (Python 3.3+)
194338
if hasattr(sys, "base_executable") and sys.base_executable:
195339
base_exec = Path(sys.base_executable)
@@ -778,6 +922,15 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
778922
if files_from_cache > 0:
779923
logger.info(f"Reused {files_from_cache} files from cache, processed {files_processed} new/changed files")
780924

925+
if py_files and not symbol_table:
926+
logger.error(
927+
"Every one of the %d discovered Python files failed to process — "
928+
"the symbol table is empty. This usually means the analysis "
929+
"environment's interpreter is newer than the installed jedi/parso "
930+
"stack supports (#107); check the per-file errors above.",
931+
len(py_files),
932+
)
933+
781934
logger.info(
782935
"✅ Symbol table: %d modules in %.1fs",
783936
len(symbol_table), time.perf_counter() - t0_st,

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ dependencies = [
1212
# jedi
1313
"jedi>=0.18.0,<0.20.0; python_version < '3.11'",
1414
"jedi<=0.19.2; python_version >= '3.11'",
15+
# parso 0.8.5 is the first release shipping the Python 3.14 grammar; older
16+
# resolutions make jedi reject every file in a 3.14 analysis env (#107)
17+
"parso>=0.8.5",
1518
# msgpack
1619
"msgpack>=1.0.0,<1.0.7; python_version < '3.11'",
1720
"msgpack>=1.0.7,<2.0.0; python_version >= '3.11'",

test/test_env_interpreter.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Regression tests for #107: environment provisioning must prefer an
2+
interpreter the installed jedi/parso stack can actually parse, and a run
3+
where every module fails must not stay silent.
4+
5+
parso ships one hardcoded grammar file per Python minor (grammar313.txt,
6+
grammar314.txt, ...). If the provisioned analysis venv is newer than the
7+
newest shipped grammar, jedi rejects every file and the symbol table comes
8+
back empty while the process still exits 0.
9+
"""
10+
import logging
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
from codeanalyzer.core import Codeanalyzer
16+
17+
18+
# ----------------------------------------------------------------------------------------------
19+
# The parso ceiling: derived from the shipped grammar files at runtime, never hardcoded.
20+
# ----------------------------------------------------------------------------------------------
21+
22+
23+
def test_grammar_stems_parse_to_versions():
24+
got = Codeanalyzer._versions_from_grammar_stems(
25+
["grammar36", "grammar39", "grammar310", "grammar313", "grammar314"]
26+
)
27+
assert got == [(3, 6), (3, 9), (3, 10), (3, 13), (3, 14)]
28+
29+
30+
def test_malformed_grammar_stems_are_ignored():
31+
got = Codeanalyzer._versions_from_grammar_stems(
32+
["grammar", "grammarXY", "grammar3", "grammar312"]
33+
)
34+
assert got == [(3, 12)]
35+
36+
37+
def test_parso_ceiling_reflects_installed_parso():
38+
"""The ceiling must be the max of the grammars parso actually ships —
39+
on any env with parso >= 0.8.5 that is at least (3, 13)."""
40+
ceiling = Codeanalyzer._parso_supported_ceiling()
41+
assert ceiling is not None
42+
assert ceiling >= (3, 13)
43+
44+
45+
# ----------------------------------------------------------------------------------------------
46+
# Interpreter choice honors the ceiling.
47+
# ----------------------------------------------------------------------------------------------
48+
49+
50+
def test_pick_supported_interpreter_prefers_newest_within_ceiling():
51+
candidates = [
52+
(Path("/opt/py315"), (3, 15)),
53+
(Path("/opt/py313"), (3, 13)),
54+
(Path("/opt/py311"), (3, 11)),
55+
]
56+
assert Codeanalyzer._pick_supported_interpreter(candidates, (3, 14)) == Path(
57+
"/opt/py313"
58+
)
59+
assert Codeanalyzer._pick_supported_interpreter(candidates, (3, 10)) is None
60+
61+
62+
def test_base_interpreter_swaps_out_unsupported_default(monkeypatch):
63+
"""If the default interpreter is newer than parso's ceiling, provisioning
64+
must pick a supported one instead."""
65+
fake_default = Path("/opt/py399/bin/python3")
66+
fake_supported = Path("/opt/py313/bin/python3")
67+
68+
monkeypatch.setattr(
69+
Codeanalyzer, "_default_base_interpreter", staticmethod(lambda: fake_default)
70+
)
71+
monkeypatch.setattr(
72+
Codeanalyzer, "_parso_supported_ceiling", staticmethod(lambda: (3, 13))
73+
)
74+
monkeypatch.setattr(
75+
Codeanalyzer,
76+
"_interpreter_version",
77+
staticmethod(lambda p: (3, 99) if p == fake_default else (3, 13)),
78+
)
79+
monkeypatch.setattr(
80+
Codeanalyzer,
81+
"_find_supported_interpreter",
82+
staticmethod(lambda ceiling: fake_supported),
83+
)
84+
assert Codeanalyzer._get_base_interpreter() == fake_supported
85+
86+
87+
def test_base_interpreter_keeps_supported_default(monkeypatch):
88+
fake_default = Path("/opt/py312/bin/python3")
89+
monkeypatch.setattr(
90+
Codeanalyzer, "_default_base_interpreter", staticmethod(lambda: fake_default)
91+
)
92+
monkeypatch.setattr(
93+
Codeanalyzer, "_parso_supported_ceiling", staticmethod(lambda: (3, 13))
94+
)
95+
monkeypatch.setattr(
96+
Codeanalyzer, "_interpreter_version", staticmethod(lambda p: (3, 12))
97+
)
98+
assert Codeanalyzer._get_base_interpreter() == fake_default
99+
100+
101+
def test_base_interpreter_falls_back_loudly_when_nothing_supported(monkeypatch, caplog):
102+
fake_default = Path("/opt/py399/bin/python3")
103+
monkeypatch.setattr(
104+
Codeanalyzer, "_default_base_interpreter", staticmethod(lambda: fake_default)
105+
)
106+
monkeypatch.setattr(
107+
Codeanalyzer, "_parso_supported_ceiling", staticmethod(lambda: (3, 13))
108+
)
109+
monkeypatch.setattr(
110+
Codeanalyzer, "_interpreter_version", staticmethod(lambda p: (3, 99))
111+
)
112+
monkeypatch.setattr(
113+
Codeanalyzer, "_find_supported_interpreter", staticmethod(lambda ceiling: None)
114+
)
115+
# the codeanalyzer logger sets propagate=False (rich handler); caplog needs
116+
# propagation to observe records
117+
monkeypatch.setattr(logging.getLogger("codeanalyzer"), "propagate", True)
118+
with caplog.at_level(logging.WARNING, logger="codeanalyzer"):
119+
assert Codeanalyzer._get_base_interpreter() == fake_default
120+
assert any("parso" in r.getMessage() for r in caplog.records)
121+
122+
123+
# ----------------------------------------------------------------------------------------------
124+
# A run where every module failed must be loud, not silently empty.
125+
# ----------------------------------------------------------------------------------------------
126+
127+
128+
def test_all_files_failing_emits_an_error(tmp_path, monkeypatch, caplog):
129+
proj = tmp_path / "proj"
130+
proj.mkdir()
131+
(proj / "a.py").write_text("def f():\n return 1\n", encoding="utf-8")
132+
(proj / "b.py").write_text("def g():\n return 2\n", encoding="utf-8")
133+
134+
from codeanalyzer.options.options import AnalysisOptions
135+
from codeanalyzer.config import OutputFormat
136+
from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
137+
138+
def boom(self, py_file):
139+
raise RuntimeError("Python version 3.99 is currently not supported.")
140+
141+
monkeypatch.setattr(SymbolTableBuilder, "build_pymodule_from_file", boom)
142+
143+
opts = AnalysisOptions(
144+
input=proj, output=None, format=OutputFormat.JSON,
145+
skip_tests=True, no_venv=True, cache_dir=tmp_path / "cache",
146+
rebuild_analysis=True,
147+
)
148+
analyzer = Codeanalyzer(opts)
149+
monkeypatch.setattr(logging.getLogger("codeanalyzer"), "propagate", True)
150+
with caplog.at_level(logging.ERROR, logger="codeanalyzer"):
151+
table = analyzer._build_symbol_table(cached_symbol_table={})
152+
assert table == {}
153+
assert any(
154+
"every" in r.getMessage().lower() or "all " in r.getMessage().lower()
155+
for r in caplog.records
156+
), "an empty symbol table from total per-file failure must be reported loudly"

0 commit comments

Comments
 (0)