@@ -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 ,
0 commit comments