1111
1212from ._compat import fs_path_id
1313from ._py_info import PythonInfo
14- from ._py_spec import PythonSpec
14+ from ._py_spec import KNOWN_IMPLEMENTATIONS , PythonSpec
1515
1616if TYPE_CHECKING :
17- from collections .abc import Callable , Generator , Iterable , Mapping , Sequence
17+ from collections .abc import Callable , Generator , Iterable , Iterator , Mapping , Sequence
1818
1919 from ._cache import PyInfoCache
2020
@@ -51,6 +51,64 @@ def get_interpreter(
5151 return None
5252
5353
54+ def iter_interpreters (
55+ key : str | Sequence [str ] | None = None ,
56+ try_first_with : Iterable [str ] | None = None ,
57+ cache : PyInfoCache | None = None ,
58+ env : Mapping [str , str ] | None = None ,
59+ predicate : Callable [[PythonInfo ], bool ] | None = None ,
60+ ) -> Iterator [PythonInfo ]:
61+ """
62+ Yield every interpreter on the system that satisfies *key*.
63+
64+ Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH``
65+ (left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying
66+ system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter
67+ collapse to a single entry. Callers that want a different ordering should sort the result.
68+
69+ :param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python
70+ implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`).
71+ :param try_first_with: executables to probe before the normal discovery search.
72+ :param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for
73+ enumeration, which interrogates every candidate as a subprocess on a cold cache.
74+ :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
75+ :param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter.
76+ """
77+ env_map = os .environ if env is None else env
78+ try_first_tuple = tuple (try_first_with or ())
79+ if key is None :
80+ keys : tuple [str | None , ...] = (None ,)
81+ elif isinstance (key , str ):
82+ keys = (key ,)
83+ else :
84+ keys = tuple (key )
85+ seen_real_paths : set [str ] = set ()
86+ for spec_str in keys :
87+ if spec_str is None :
88+ spec = PythonSpec ("" , None , None , None , None , None , None )
89+ wide = True
90+ else :
91+ spec = PythonSpec .from_string_spec (spec_str )
92+ wide = False
93+ for interpreter , impl_must_match in propose_interpreters (
94+ spec , try_first_tuple , cache , env_map , all_implementations = wide
95+ ):
96+ if interpreter is None :
97+ continue
98+ anchor = interpreter .system_executable or interpreter .executable
99+ if anchor is None :
100+ continue
101+ real_path = os .path .realpath (anchor )
102+ if real_path in seen_real_paths :
103+ continue
104+ if not interpreter .satisfies (spec , impl_must_match = impl_must_match ):
105+ continue
106+ if predicate is not None and not predicate (interpreter ):
107+ continue
108+ seen_real_paths .add (real_path )
109+ yield interpreter
110+
111+
54112def _find_interpreter (
55113 key : str ,
56114 try_first_with : Iterable [str ],
@@ -106,6 +164,8 @@ def propose_interpreters(
106164 try_first_with : Iterable [str ],
107165 cache : PyInfoCache | None = None ,
108166 env : Mapping [str , str ] | None = None ,
167+ * ,
168+ all_implementations : bool = False ,
109169) -> Generator [tuple [PythonInfo | None , bool ], None , None ]:
110170 """
111171 Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
@@ -114,6 +174,8 @@ def propose_interpreters(
114174 :param try_first_with: executable paths to probe before the standard search.
115175 :param cache: interpreter metadata cache; when ``None`` results are not cached.
116176 :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
177+ :param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface
178+ non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs.
117179 """
118180 env = os .environ if env is None else env
119181 tested_exes : set [str ] = set ()
@@ -125,8 +187,8 @@ def propose_interpreters(
125187 yield from _propose_explicit (spec , try_first_with , cache , env , tested_exes )
126188 if spec .path is not None and spec .is_abs : # pragma: no cover # relative spec.path is never abs
127189 return
128- yield from _propose_from_path (spec , cache , env , tested_exes )
129- yield from _propose_from_uv (cache , env )
190+ yield from _propose_from_path (spec , cache , env , tested_exes , all_implementations = all_implementations )
191+ yield from _propose_from_uv (cache , env , all_implementations = all_implementations )
130192
131193
132194def _propose_explicit (
@@ -170,8 +232,10 @@ def _propose_from_path(
170232 cache : PyInfoCache | None ,
171233 env : Mapping [str , str ],
172234 tested_exes : set [str ],
235+ * ,
236+ all_implementations : bool = False ,
173237) -> Generator [tuple [PythonInfo | None , bool ], None , None ]:
174- find_candidates = path_exe_finder (spec )
238+ find_candidates = path_exe_finder (spec , all_implementations = all_implementations )
175239 for pos , path in enumerate (get_paths (env )):
176240 _LOGGER .debug (LazyPathDump (pos , path , env ))
177241 for exe , impl_must_match in find_candidates (path ):
@@ -189,6 +253,8 @@ def _propose_from_path(
189253def _propose_from_uv (
190254 cache : PyInfoCache | None ,
191255 env : Mapping [str , str ],
256+ * ,
257+ all_implementations : bool = False ,
192258) -> Generator [tuple [PythonInfo | None , bool ], None , None ]:
193259 if uv_python_dir := os .getenv ("UV_PYTHON_INSTALL_DIR" ):
194260 uv_python_path = Path (uv_python_dir ).expanduser ()
@@ -197,10 +263,19 @@ def _propose_from_uv(
197263 else :
198264 uv_python_path = user_data_path ("uv" ) / "python"
199265
200- for exe_path in uv_python_path .glob ("*/bin/python" ): # pragma: no branch
201- interpreter = PathPythonInfo .from_exe (str (exe_path ), cache , raise_on_error = False , env = env )
202- if interpreter is not None : # pragma: no branch
203- yield interpreter , True
266+ patterns : list [str ] = ["*/bin/python" ]
267+ if all_implementations :
268+ patterns .extend (f"*/bin/{ impl } " for impl in KNOWN_IMPLEMENTATIONS if impl != "python" )
269+ seen_uv_paths : set [str ] = set ()
270+ for pattern in patterns :
271+ for exe_path in uv_python_path .glob (pattern ):
272+ resolved = str (Path (exe_path ).resolve ())
273+ if resolved in seen_uv_paths :
274+ continue
275+ seen_uv_paths .add (resolved )
276+ interpreter = PathPythonInfo .from_exe (str (exe_path ), cache , raise_on_error = False , env = env )
277+ if interpreter is not None : # pragma: no branch
278+ yield interpreter , True
204279
205280
206281def get_paths (env : Mapping [str , str ]) -> Generator [Path , None , None ]:
@@ -244,9 +319,11 @@ def __repr__(self) -> str:
244319 return content
245320
246321
247- def path_exe_finder (spec : PythonSpec ) -> Callable [[Path ], Generator [tuple [Path , bool ], None , None ]]:
322+ def path_exe_finder (
323+ spec : PythonSpec , * , all_implementations : bool = False
324+ ) -> Callable [[Path ], Generator [tuple [Path , bool ], None , None ]]:
248325 """Given a spec, return a function that can be called on a path to find all matching files in it."""
249- pat = spec .generate_re (windows = sys .platform == "win32" )
326+ pat = spec .generate_re (windows = sys .platform == "win32" , all_implementations = all_implementations )
250327 direct = spec .str_spec
251328 if sys .platform == "win32" : # pragma: win32 cover
252329 direct = f"{ direct } .exe"
@@ -331,5 +408,6 @@ class PathPythonInfo(PythonInfo):
331408 "PathPythonInfo" ,
332409 "get_interpreter" ,
333410 "get_paths" ,
411+ "iter_interpreters" ,
334412 "propose_interpreters" ,
335413]
0 commit comments