python-discovery 1.6.1__py3-none-any.whl → 1.6.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,17 +30,21 @@ if TYPE_CHECKING:
30
30
  from ._cache import ContentStore, PyInfoCache
31
31
 
32
32
  class CacheEntryMeta(TypedDict):
33
- """Identity of a cached interrogation result: the queried binary and the script that produced it."""
33
+ """Identity of a cached interrogation result: the queried binary, its arguments and the script."""
34
34
 
35
35
  st_mtime: float
36
36
  path: str
37
37
  hash: str | None
38
+ arguments: str | None
38
39
 
39
40
 
40
- _CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
41
- _CACHE[Path(sys.executable)] = PythonInfo()
41
+ _CACHE: OrderedDict[tuple[Path, str | None], PythonInfo | Exception] = OrderedDict()
42
+ _CACHE[Path(sys.executable), None] = PythonInfo()
42
43
  _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
43
44
  _PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py"
45
+ _DEFAULT_QUERY_TIMEOUT: Final[float] = 15.0
46
+ # poll(2) limits milliseconds to a signed C int.
47
+ _MAX_QUERY_TIMEOUT: Final[float] = (2**31 - 1) / 1000
44
48
 
45
49
 
46
50
  class _UnsupportedInterpreterError(RuntimeError):
@@ -55,9 +59,10 @@ def from_exe( # ruff:ignore[too-many-arguments]
55
59
  *,
56
60
  raise_on_error: bool = True,
57
61
  ignore_cache: bool = False,
62
+ executable_args: str | None = None,
58
63
  ) -> PythonInfo | None:
59
64
  env = os.environ if env is None else env
60
- result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache)
65
+ result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache, executable_args=executable_args)
61
66
  if isinstance(result, Exception):
62
67
  if raise_on_error:
63
68
  raise result
@@ -66,31 +71,34 @@ def from_exe( # ruff:ignore[too-many-arguments]
66
71
  return result
67
72
 
68
73
 
69
- def _get_from_cache(
74
+ def _get_from_cache( # ruff:ignore[too-many-arguments]
70
75
  cls: type[PythonInfo],
71
76
  cache: PyInfoCache | None,
72
77
  exe: str,
73
78
  env: Mapping[str, str],
74
79
  *,
75
80
  ignore_cache: bool = True,
81
+ executable_args: str | None = None,
76
82
  ) -> PythonInfo | Exception:
77
- exe_path = Path(exe)
78
- if not ignore_cache and exe_path in _CACHE:
79
- result = _CACHE[exe_path]
83
+ key = Path(exe), executable_args
84
+ if not ignore_cache and key in _CACHE:
85
+ result = _CACHE[key]
80
86
  else:
81
- py_info = _get_via_file_cache(cls, cache, exe_path, exe, env)
82
- result = _CACHE[exe_path] = py_info
83
- if isinstance(result, PythonInfo):
87
+ result = _CACHE[key] = _get_via_file_cache(cls, cache, key[0], exe, env, executable_args=executable_args)
88
+ # with launcher arguments the queried binary is not the interpreter, so keep the one the query reported
89
+ if isinstance(result, PythonInfo) and executable_args is None:
84
90
  result.executable = exe
85
91
  return result
86
92
 
87
93
 
88
- def _get_via_file_cache(
94
+ def _get_via_file_cache( # ruff:ignore[too-many-arguments]
89
95
  cls: type[PythonInfo],
90
96
  cache: PyInfoCache | None,
91
97
  path: Path,
92
98
  exe: str,
93
99
  env: Mapping[str, str],
100
+ *,
101
+ executable_args: str | None = None,
94
102
  ) -> PythonInfo | Exception:
95
103
  path_text = str(path)
96
104
  try:
@@ -104,17 +112,22 @@ def _get_via_file_cache(
104
112
 
105
113
  resolved_cache = cache if cache is not None else NoOpCache()
106
114
  py_info_store = resolved_cache.py_info(path)
107
- entry_meta: CacheEntryMeta = {"st_mtime": path_modified, "path": path_text, "hash": py_info_hash}
115
+ entry_meta: CacheEntryMeta = {
116
+ "st_mtime": path_modified,
117
+ "path": path_text,
118
+ "hash": py_info_hash,
119
+ "arguments": executable_args,
120
+ }
108
121
  with py_info_store.locked():
109
122
  cached = _read_cache_entry(cls, py_info_store, entry_meta)
110
123
  if isinstance(cached, _UnsupportedInterpreterError):
111
124
  return cached
112
125
  py_info = cached
113
126
  if py_info is None:
114
- failure, py_info = _run_subprocess(cls, exe, env)
127
+ failure, py_info = _run_subprocess(cls, exe, env, executable_args)
115
128
  if failure is not None and not isinstance(failure, _UnsupportedInterpreterError):
116
129
  _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
117
- failure, py_info = _run_subprocess(cls, exe, env)
130
+ failure, py_info = _run_subprocess(cls, exe, env, executable_args)
118
131
  if isinstance(failure, _UnsupportedInterpreterError):
119
132
  _LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it
120
133
  py_info_store.write({**entry_meta, "unsupported": str(failure)})
@@ -136,7 +149,7 @@ def _read_cache_entry(
136
149
  ) -> PythonInfo | _UnsupportedInterpreterError | None:
137
150
  if not py_info_store.exists() or (data := py_info_store.read()) is None:
138
151
  return None
139
- if all(data.get(key) == value for key, value in entry_meta.items()):
152
+ if isinstance(data, dict) and all(data.get(key) == value for key, value in entry_meta.items()):
140
153
  if isinstance(unsupported := data.get("unsupported"), str):
141
154
  return _UnsupportedInterpreterError(unsupported)
142
155
  if isinstance(content := data.get("content"), dict):
@@ -152,10 +165,10 @@ def _load_cached_py_info(
152
165
  ) -> PythonInfo | None:
153
166
  try:
154
167
  py_info = cls.from_dict(content.copy())
155
- except (KeyError, TypeError):
156
- py_info_store.remove()
157
- return None
158
- if (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).exists():
168
+ stale = (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).is_file()
169
+ except (AttributeError, KeyError, OSError, TypeError):
170
+ stale = True
171
+ if stale:
159
172
  py_info_store.remove()
160
173
  return None
161
174
  return py_info
@@ -211,16 +224,20 @@ def _run_subprocess(
211
224
  cls: type[PythonInfo],
212
225
  exe: str,
213
226
  env: Mapping[str, str],
227
+ executable_args: str | None = None,
214
228
  ) -> tuple[Exception | None, PythonInfo | None]:
215
229
  start_cookie = gen_cookie()
216
230
  end_cookie = gen_cookie()
217
- timeout = float(env.get("PY_DISCOVERY_TIMEOUT", "15"))
231
+ timeout = _query_timeout(env.get("PY_DISCOVERY_TIMEOUT"))
218
232
  with _resolve_py_info_script() as py_info_script:
219
- cmd = [exe, str(py_info_script), start_cookie, end_cookie]
233
+ cmd: list[str] | str = [exe, str(py_info_script), start_cookie, end_cookie]
234
+ if executable_args is not None:
235
+ # PEP 514 ExecutableArguments is raw command line text, placed before the script per the PEP
236
+ cmd = f"{subprocess.list2cmdline(cmd[:1])} {executable_args} {subprocess.list2cmdline(cmd[1:])}"
220
237
  env = dict(env)
221
238
  env.pop("__PYVENV_LAUNCHER__", None)
222
239
  env["PYTHONUTF8"] = "1"
223
- _LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
240
+ _LOGGER.debug("get interpreter info via cmd: %s", cmd if isinstance(cmd, str) else LogCmd(cmd))
224
241
  try:
225
242
  process = Popen( # ruff:ignore[subprocess-without-shell-equals-true]
226
243
  cmd,
@@ -245,7 +262,8 @@ def _run_subprocess(
245
262
  out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
246
263
  try:
247
264
  result = cls.from_json(out)
248
- result.executable = exe
265
+ if executable_args is None:
266
+ result.executable = exe
249
267
  except json.JSONDecodeError as exc:
250
268
  _LOGGER.warning(
251
269
  "subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
@@ -264,6 +282,22 @@ def _run_subprocess(
264
282
  return None, result
265
283
 
266
284
 
285
+ @lru_cache(maxsize=128)
286
+ def _query_timeout(raw: str | None) -> float | None:
287
+ if raw is None:
288
+ return _DEFAULT_QUERY_TIMEOUT
289
+ try:
290
+ timeout = float(raw)
291
+ except ValueError:
292
+ timeout = 0.0
293
+ if timeout > 0:
294
+ return timeout if timeout <= _MAX_QUERY_TIMEOUT else None
295
+ _LOGGER.warning(
296
+ "ignoring PY_DISCOVERY_TIMEOUT=%r, not a positive number of seconds; using %s", raw, _DEFAULT_QUERY_TIMEOUT
297
+ )
298
+ return _DEFAULT_QUERY_TIMEOUT
299
+
300
+
267
301
  def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError:
268
302
  # the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a
269
303
  # failed exec or a shim that echoes the marker phrase, but only the gate produces the combination
@@ -378,10 +378,41 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
378
378
  :param resolve_to_host: resolve through virtualenv layers to the system interpreter.
379
379
  :param env: environment mapping; defaults to :data:`os.environ`.
380
380
  """
381
+ return cls._from_exe_with_args(
382
+ exe,
383
+ cache,
384
+ executable_args=None,
385
+ raise_on_error=raise_on_error,
386
+ ignore_cache=ignore_cache,
387
+ resolve_to_host=resolve_to_host,
388
+ env=env,
389
+ )
390
+
391
+ @classmethod
392
+ def _from_exe_with_args( # ruff:ignore[too-many-arguments]
393
+ cls,
394
+ exe: str,
395
+ cache: PyInfoCache | None = None,
396
+ *,
397
+ executable_args: str | None,
398
+ raise_on_error: bool = True,
399
+ ignore_cache: bool = False,
400
+ resolve_to_host: bool = True,
401
+ env: Mapping[str, str] | None = None,
402
+ ) -> PythonInfo | None:
403
+ """Interrogate an executable with optional platform-specific leading arguments."""
381
404
  from ._cached_py_info import from_exe # ruff:ignore[import-outside-top-level]
382
405
 
383
406
  env = os.environ if env is None else env
384
- proposed = from_exe(cls, cache, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache)
407
+ proposed = from_exe(
408
+ cls,
409
+ cache,
410
+ exe,
411
+ env=env,
412
+ raise_on_error=raise_on_error,
413
+ ignore_cache=ignore_cache,
414
+ executable_args=executable_args,
415
+ )
385
416
 
386
417
  if isinstance(proposed, PythonInfo) and resolve_to_host:
387
418
  try:
@@ -321,7 +321,7 @@ class PythonInfoCollector:
321
321
  "script_args": "--no-user-cfg",
322
322
  }) # conf files not parsed so they do not hijack paths
323
323
  if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
324
- sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
324
+ sys._framework = None # ty: ignore[invalid-assignment] # ruff:ignore[private-member-access] # disable macOS static paths for framework
325
325
 
326
326
  with warnings.catch_warnings(): # disable warning for PEP-632
327
327
  warnings.simplefilter("ignore")
@@ -50,6 +50,9 @@ def _int_or_none(val: str | None) -> int | None:
50
50
 
51
51
  def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
52
52
  versions = tuple(int(i) for i in version.split(".") if i)
53
+ if not versions:
54
+ msg = "version must contain a numeric component"
55
+ raise ValueError(msg)
53
56
  if len(versions) > _MAX_VERSION_PARTS:
54
57
  msg = "too many version parts"
55
58
  raise ValueError(msg)
@@ -125,7 +125,10 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[
125
125
  try:
126
126
  with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute]
127
127
  if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe):
128
- return exe, get_value(ip_key, "ExecutableArguments")
128
+ if (args := get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str):
129
+ return exe, args if args and args.strip() else None
130
+ msg(f"{key_path}/ExecutableArguments", f"expected a string, got {type(args).__name__}")
131
+ return None
129
132
  msg(key_path, f"could not load exe with value {exe}")
130
133
  except OSError:
131
134
  msg(f"{key_path}/InstallPath", "missing")
@@ -36,13 +36,19 @@ def propose_interpreters(
36
36
  reverse=True,
37
37
  )
38
38
 
39
- for name, major, minor, arch, threaded, exe, _ in existing:
39
+ for name, major, minor, arch, threaded, exe, executable_args in existing:
40
40
  implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
41
41
 
42
42
  skip_pre_filter = implementation.lower() != "cpython"
43
43
  registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded)
44
44
  if skip_pre_filter or registry_spec.satisfies(spec):
45
- interpreter = Pep514PythonInfo.from_exe(exe, cache, env=env, raise_on_error=False)
45
+ interpreter = Pep514PythonInfo._from_exe_with_args( # ruff:ignore[private-member-access]
46
+ exe,
47
+ cache,
48
+ env=env,
49
+ executable_args=executable_args,
50
+ raise_on_error=False,
51
+ )
46
52
  if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
47
53
  yield interpreter
48
54
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-discovery
3
- Version: 1.6.1
3
+ Version: 1.6.3
4
4
  Summary: Python interpreter discovery
5
5
  Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases
6
6
  Project-URL: Documentation, https://python-discovery.readthedocs.io
@@ -35,7 +35,6 @@ Classifier: Operating System :: MacOS :: MacOS X
35
35
  Classifier: Operating System :: Microsoft :: Windows
36
36
  Classifier: Operating System :: POSIX
37
37
  Classifier: Programming Language :: Python :: 3 :: Only
38
- Classifier: Programming Language :: Python :: 3.8
39
38
  Classifier: Programming Language :: Python :: 3.9
40
39
  Classifier: Programming Language :: Python :: 3.10
41
40
  Classifier: Programming Language :: Python :: 3.11
@@ -46,8 +45,8 @@ Classifier: Programming Language :: Python :: 3.15
46
45
  Classifier: Programming Language :: Python :: Implementation :: CPython
47
46
  Classifier: Topic :: Software Development :: Libraries
48
47
  Classifier: Topic :: Utilities
49
- Requires-Python: >=3.8
50
- Requires-Dist: filelock>=3.15.4
48
+ Requires-Python: >=3.9
49
+ Requires-Dist: filelock>=3.16.1
51
50
  Description-Content-Type: text/markdown
52
51
 
53
52
  # [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
@@ -68,7 +67,7 @@ the right one for you.
68
67
  Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
69
68
  and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
70
69
 
71
- The library runs on Python 3.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
70
+ The library runs on Python 3.9+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
72
71
  warning naming the version it found.
73
72
 
74
73
  ## Usage
@@ -0,0 +1,17 @@
1
+ python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
2
+ python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
3
+ python_discovery/_cached_py_info.py,sha256=15P5_TODxkZCkNfD2Uk5dtPxGf0Nea_aWm2N7uY9zbY,12059
4
+ python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
+ python_discovery/_discovery.py,sha256=BDHC1XEx0XNStEIbXjujpHHm0nkBpe26bl26YVHSWhM,22488
6
+ python_discovery/_py_info.py,sha256=rRyixfRnqyrmudr7SSSKLxKfsCjdfSezH30YE6n773U,26672
7
+ python_discovery/_py_info_collect.py,sha256=3PNlySbxNSHDjBw8KJ_olVpOXww1qhGdY28YoLfF0Z8,16160
8
+ python_discovery/_py_spec.py,sha256=1L7uVqpLdTVvHX6ZcXiUICFAfnOcJaThfdCGIntp7l4,11073
9
+ python_discovery/_specifier.py,sha256=o7MHb0z_mFSLz9gXqMwJoNkHYjgi2biq3Ibvd0_slvc,10457
10
+ python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
12
+ python_discovery/_windows/_pep514.py,sha256=Vhxe-3hETFIXQrekLqOHmzG5eq5moeR8b0dCS80zbBg,8026
13
+ python_discovery/_windows/_propose.py,sha256=EE-N-BiNgrFU9mQ34SkoF5gRaNjtspghssQxT9uRQi0,1756
14
+ python_discovery-1.6.3.dist-info/METADATA,sha256=WsZZH1Wr81WH9g-ea1Ftj0PuktCQbt6fsIK3TQqzUU8,5082
15
+ python_discovery-1.6.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
16
+ python_discovery-1.6.3.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
17
+ python_discovery-1.6.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.3
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,17 +0,0 @@
1
- python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
2
- python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
3
- python_discovery/_cached_py_info.py,sha256=MKV2sjrrypq-7gcOdw9GpAooMJl9kYCTAlYeUwO3ndQ,10473
4
- python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
- python_discovery/_discovery.py,sha256=BDHC1XEx0XNStEIbXjujpHHm0nkBpe26bl26YVHSWhM,22488
6
- python_discovery/_py_info.py,sha256=dov0UFDbcbKlNW765puRgLQuAvbc_ze0Mpsdb7Tixgs,25797
7
- python_discovery/_py_info_collect.py,sha256=YhF5U-kKcwMXZMfRNDlZK6kCPJJI9tHIvd5SjQqMJqY,16126
8
- python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
9
- python_discovery/_specifier.py,sha256=o7MHb0z_mFSLz9gXqMwJoNkHYjgi2biq3Ibvd0_slvc,10457
10
- python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
12
- python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
13
- python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
14
- python_discovery-1.6.1.dist-info/METADATA,sha256=_U6UJYiWySf9blNUmIx7MHoPFraiWr1dC2Rvz1zJ_qg,5132
15
- python_discovery-1.6.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
16
- python_discovery-1.6.1.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
17
- python_discovery-1.6.1.dist-info/RECORD,,