Skip to content

Commit 57c4043

Browse files
committed
fix(env): parso-version-aware interpreter selection for the analysis venv (#107)
jedi parses the analysis environment's Python with parso, which ships one hardcoded grammar per minor version. On hosts whose default python3 is newer than the newest shipped grammar (e.g. 3.14 with parso <= 0.8.4), every file failed with 'Python version 3.14 is currently not supported' and the run still exited 0 with an empty symbol table. Provisioning now derives parso's ceiling at runtime from its shipped grammar files and swaps a too-new default for the newest supported interpreter on the host (versioned PATH names, then pyenv installs), falling back loudly only when none exists. An explicit SYSTEM_PYTHON is honored with a warning when unsupported. A run in which every discovered file fails now logs a prominent error instead of staying silent, and parso>=0.8.5 (first release with the 3.14 grammar) is a direct dependency.
1 parent 9716632 commit 57c4043

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)