Skip to content

Commit e9d020e

Browse files
committed
✨ feat(discovery): add predicate param to get_interpreter
Hatch monkeypatches `propose_interpreters` to filter out incompatible interpreters, which broke when virtualenv moved discovery to this package. Adding a `predicate` callback gives downstream tools a public API to filter interpreters without monkeypatching internals. Named `predicate` instead of `filter` to avoid shadowing the Python builtin (ruff A002). Closes #30
1 parent 8254844 commit e9d020e

2 files changed

Lines changed: 64 additions & 2 deletions

File tree

src/python_discovery/_discovery.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,26 @@ def get_interpreter(
2727
try_first_with: Iterable[str] | None = None,
2828
cache: PyInfoCache | None = None,
2929
env: Mapping[str, str] | None = None,
30+
predicate: Callable[[PythonInfo], bool] | None = None,
3031
) -> PythonInfo | None:
32+
"""
33+
Find a Python interpreter matching *key*.
34+
35+
Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes
36+
the optional *predicate*.
37+
38+
:param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix
39+
(``cpython3.12``), or a PEP 440 specifier (``>=3.10``). When a sequence is given each entry is tried in order.
40+
:param try_first_with: executables to probe before the normal discovery search.
41+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
42+
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
43+
:param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
44+
interpreter, ``False`` to skip it and continue searching.
45+
:return: the first matching interpreter, or ``None`` if no match is found.
46+
"""
3147
specs = [key] if isinstance(key, str) else key
3248
for spec_str in specs:
33-
if result := _find_interpreter(spec_str, try_first_with or (), cache, env):
49+
if result := _find_interpreter(spec_str, try_first_with or (), cache, env, predicate):
3450
return result
3551
return None
3652

@@ -40,6 +56,7 @@ def _find_interpreter(
4056
try_first_with: Iterable[str],
4157
cache: PyInfoCache | None = None,
4258
env: Mapping[str, str] | None = None,
59+
predicate: Callable[[PythonInfo], bool] | None = None,
4360
) -> PythonInfo | None:
4461
spec = PythonSpec.from_string_spec(key)
4562
_LOGGER.info("find interpreter for spec %r", spec)
@@ -52,7 +69,9 @@ def _find_interpreter(
5269
if proposed_key in proposed_paths:
5370
continue
5471
_LOGGER.info("proposed %s", interpreter)
55-
if interpreter.satisfies(spec, impl_must_match=impl_must_match):
72+
if interpreter.satisfies(spec, impl_must_match=impl_must_match) and (
73+
predicate is None or predicate(interpreter)
74+
):
5675
_LOGGER.debug("accepted %s", interpreter)
5776
return interpreter
5877
proposed_paths.add(proposed_key)
@@ -88,6 +107,14 @@ def propose_interpreters(
88107
cache: PyInfoCache | None = None,
89108
env: Mapping[str, str] | None = None,
90109
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
110+
"""
111+
Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
112+
113+
:param spec: the parsed interpreter specification to match against.
114+
:param try_first_with: executable paths to probe before the standard search.
115+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
116+
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
117+
"""
91118
env = os.environ if env is None else env
92119
tested_exes: set[str] = set()
93120
if spec.is_abs and spec.path is not None:

tests/test_discovery.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,38 @@ def test_shim_colon_separated_pyenv_version_picks_first_match(
409409
mock_from_exe.return_value = None
410410
get_interpreter("python2.7", [])
411411
assert mock_from_exe.call_args_list[0][0][0] == str(second_binary)
412+
413+
414+
def test_predicate_filters_interpreters(session_cache: DiskCache) -> None:
415+
result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: False)
416+
assert result is None
417+
418+
419+
def test_predicate_accepts_interpreter(session_cache: DiskCache) -> None:
420+
result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: True)
421+
assert result is not None
422+
assert result.executable == sys.executable
423+
424+
425+
def test_predicate_none_is_noop(session_cache: DiskCache) -> None:
426+
result = get_interpreter(sys.executable, [], session_cache, predicate=None)
427+
assert result is not None
428+
assert result.executable == sys.executable
429+
430+
431+
def test_predicate_with_fallback_specs(session_cache: DiskCache) -> None:
432+
current = PythonInfo.current_system(session_cache)
433+
major, minor = current.version_info.major, current.version_info.minor
434+
accepted_exe: str | None = None
435+
436+
def reject_first(info: PythonInfo) -> bool:
437+
nonlocal accepted_exe
438+
if accepted_exe is None:
439+
accepted_exe = str(info.executable)
440+
return False
441+
return True
442+
443+
result = get_interpreter([f"{major}.{minor}", sys.executable], [], session_cache, predicate=reject_first)
444+
assert accepted_exe is not None
445+
assert result is not None
446+
assert str(result.executable) != accepted_exe

0 commit comments

Comments
 (0)