Skip to content

Commit 20ba4bb

Browse files
authored
✨ feat(discovery): add predicate parameter to get_interpreter (#31)
1 parent 8254844 commit 20ba4bb

7 files changed

Lines changed: 147 additions & 11 deletions

File tree

src/python_discovery/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ._discovery import get_interpreter
99
from ._py_info import PythonInfo
1010
from ._py_spec import PythonSpec
11+
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
1112

1213
__version__ = version("python-discovery")
1314

@@ -17,6 +18,9 @@
1718
"PyInfoCache",
1819
"PythonInfo",
1920
"PythonSpec",
21+
"SimpleSpecifier",
22+
"SimpleSpecifierSet",
23+
"SimpleVersion",
2024
"__version__",
2125
"get_interpreter",
2226
]

src/python_discovery/_cache.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,47 @@
1919
class ContentStore(Protocol):
2020
"""A store for reading and writing cached content."""
2121

22-
def exists(self) -> bool: ...
22+
def exists(self) -> bool:
23+
"""Return whether the cached content exists."""
24+
...
2325

24-
def read(self) -> dict | None: ...
26+
def read(self) -> dict | None:
27+
"""Read the cached content, or ``None`` if unavailable or corrupt."""
28+
...
2529

26-
def write(self, content: dict) -> None: ...
30+
def write(self, content: dict) -> None:
31+
"""
32+
Persist *content* to the store.
2733
28-
def remove(self) -> None: ...
34+
:param content: interpreter metadata to cache.
35+
"""
36+
...
37+
38+
def remove(self) -> None:
39+
"""Delete the cached content."""
40+
...
2941

3042
@contextmanager
31-
def locked(self) -> Generator[None]: ...
43+
def locked(self) -> Generator[None]:
44+
"""Context manager that acquires an exclusive lock on this store."""
45+
...
3246

3347

3448
@runtime_checkable
3549
class PyInfoCache(Protocol):
3650
"""Cache interface for Python interpreter information."""
3751

38-
def py_info(self, path: Path) -> ContentStore: ...
52+
def py_info(self, path: Path) -> ContentStore:
53+
"""
54+
Return the content store for the interpreter at *path*.
55+
56+
:param path: absolute path to a Python executable.
57+
"""
58+
...
3959

40-
def py_info_clear(self) -> None: ...
60+
def py_info_clear(self) -> None:
61+
"""Remove all cached interpreter information."""
62+
...
4163

4264

4365
class DiskContentStore:
@@ -101,10 +123,16 @@ def _py_info_dir(self) -> Path:
101123
return self._root / "py_info" / "4"
102124

103125
def py_info(self, path: Path) -> DiskContentStore:
126+
"""
127+
Return the content store for the interpreter at *path*.
128+
129+
:param path: absolute path to a Python executable.
130+
"""
104131
key = sha256(str(path).encode("utf-8")).hexdigest()
105132
return DiskContentStore(self._py_info_dir, key)
106133

107134
def py_info_clear(self) -> None:
135+
"""Remove all cached interpreter information."""
108136
folder = self._py_info_dir
109137
if folder.exists():
110138
for entry in folder.iterdir():

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:

src/python_discovery/_py_info.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,9 +483,11 @@ def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
483483
return cls._current_system
484484

485485
def to_json(self) -> str:
486+
"""Serialize this interpreter information to a JSON string."""
486487
return json.dumps(self.to_dict(), indent=2)
487488

488489
def to_dict(self) -> dict[str, object]:
490+
"""Convert this interpreter information to a plain dictionary."""
489491
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
490492
version_info = data["version_info"]
491493
data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info
@@ -520,18 +522,34 @@ def from_exe( # noqa: PLR0913
520522

521523
@classmethod
522524
def from_json(cls, payload: str) -> PythonInfo:
525+
"""
526+
Deserialize interpreter information from a JSON string.
527+
528+
:param payload: JSON produced by :meth:`to_json`.
529+
"""
523530
raw = json.loads(payload)
524531
return cls.from_dict(raw.copy())
525532

526533
@classmethod
527534
def from_dict(cls, data: dict[str, object]) -> PythonInfo:
535+
"""
536+
Reconstruct a :class:`PythonInfo` from a plain dictionary.
537+
538+
:param data: dictionary produced by :meth:`to_dict`.
539+
"""
528540
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
529541
result = cls()
530542
result.__dict__ = data.copy()
531543
return result
532544

533545
@classmethod
534546
def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
547+
"""
548+
Walk virtualenv/venv prefix chains to find the underlying system interpreter.
549+
550+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
551+
:param target: the interpreter to resolve.
552+
"""
535553
start_executable = target.executable
536554
prefixes = OrderedDict()
537555
while target.system_executable is None:

src/python_discovery/_py_spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
153153

154154
@property
155155
def is_abs(self) -> bool:
156+
"""``True`` if the spec refers to an absolute filesystem path."""
156157
return self.path is not None and pathlib.Path(self.path).is_absolute()
157158

158159
def _check_version_specifier(self, spec: PythonSpec) -> bool:

src/python_discovery/_specifier.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ class SimpleVersion:
5252

5353
@classmethod
5454
def from_string(cls, version_str: str) -> SimpleVersion:
55+
"""
56+
Parse a PEP 440 version string (e.g. ``3.12.1``).
57+
58+
:param version_str: the version string to parse.
59+
"""
5560
stripped = version_str.strip()
5661
if not (match := _VERSION_RE.match(stripped)):
5762
msg = f"Invalid version: {version_str}"
@@ -123,6 +128,11 @@ class SimpleSpecifier:
123128

124129
@classmethod
125130
def from_string(cls, spec_str: str) -> SimpleSpecifier:
131+
"""
132+
Parse a single PEP 440 specifier (e.g. ``>=3.10``).
133+
134+
:param spec_str: the specifier string to parse.
135+
"""
126136
stripped = spec_str.strip()
127137
if not (match := _SPECIFIER_RE.match(stripped)):
128138
msg = f"Invalid specifier: {spec_str}"
@@ -148,7 +158,11 @@ def from_string(cls, spec_str: str) -> SimpleSpecifier:
148158
)
149159

150160
def contains(self, version_str: str) -> bool:
151-
"""Check if a version string satisfies this specifier."""
161+
"""
162+
Check if a version string satisfies this specifier.
163+
164+
:param version_str: the version string to test.
165+
"""
152166
try:
153167
candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
154168
except ValueError:
@@ -223,6 +237,11 @@ class SimpleSpecifierSet:
223237

224238
@classmethod
225239
def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
240+
"""
241+
Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
242+
243+
:param specifiers_str: the specifier string to parse.
244+
"""
226245
stripped = specifiers_str.strip()
227246
specs: list[SimpleSpecifier] = []
228247
if stripped:
@@ -234,7 +253,11 @@ def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
234253
return cls(specifiers_str=stripped, specifiers=tuple(specs))
235254

236255
def contains(self, version_str: str) -> bool:
237-
"""Check if a version satisfies all specifiers in the set."""
256+
"""
257+
Check if a version satisfies all specifiers in the set.
258+
259+
:param version_str: the version string to test.
260+
"""
238261
if not self.specifiers:
239262
return True
240263
return all(spec.contains(version_str) for spec in self.specifiers)

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)