python-discovery 1.6.2__tar.gz → 1.6.3__tar.gz

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.
Files changed (65) hide show
  1. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/workflows/release.yaml +2 -2
  2. {python_discovery-1.6.2 → python_discovery-1.6.3}/.pre-commit-config.yaml +2 -2
  3. {python_discovery-1.6.2 → python_discovery-1.6.3}/PKG-INFO +1 -1
  4. python_discovery-1.6.3/docs/changelog/147.bugfix.rst +2 -0
  5. python_discovery-1.6.3/docs/changelog/148.bugfix.rst +2 -0
  6. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_cached_py_info.py +33 -18
  7. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_py_info.py +32 -1
  8. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_py_spec.py +3 -0
  9. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_windows/_pep514.py +4 -1
  10. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_windows/_propose.py +8 -2
  11. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_cached_py_info.py +67 -2
  12. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_discovery.py +3 -2
  13. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_spec.py +2 -2
  14. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/conftest.py +1 -1
  15. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/test_windows_pep514.py +75 -0
  16. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/FUNDING.yaml +0 -0
  17. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/SECURITY.md +0 -0
  18. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/dependabot.yaml +0 -0
  19. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/release.yaml +0 -0
  20. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/workflows/check.yaml +0 -0
  21. {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/workflows/prepare-release.yaml +0 -0
  22. {python_discovery-1.6.2 → python_discovery-1.6.3}/.gitignore +0 -0
  23. {python_discovery-1.6.2 → python_discovery-1.6.3}/.readthedocs.yaml +0 -0
  24. {python_discovery-1.6.2 → python_discovery-1.6.3}/.readthedocs.yml +0 -0
  25. {python_discovery-1.6.2 → python_discovery-1.6.3}/CODE_OF_CONDUCT.md +0 -0
  26. {python_discovery-1.6.2 → python_discovery-1.6.3}/LICENSE +0 -0
  27. {python_discovery-1.6.2 → python_discovery-1.6.3}/README.md +0 -0
  28. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/_static/custom.css +0 -0
  29. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/_static/logo.svg +0 -0
  30. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/changelog/template.jinja2 +0 -0
  31. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/changelog.rst +0 -0
  32. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/conf.py +0 -0
  33. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/explanation.rst +0 -0
  34. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/how-to/standalone-usage.rst +0 -0
  35. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/index.rst +0 -0
  36. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/reference/api.rst +0 -0
  37. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/reference/environment-variables.rst +0 -0
  38. {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/tutorial/getting-started.rst +0 -0
  39. {python_discovery-1.6.2 → python_discovery-1.6.3}/pyproject.toml +0 -0
  40. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/__init__.py +0 -0
  41. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_cache.py +0 -0
  42. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_compat.py +0 -0
  43. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_discovery.py +0 -0
  44. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_py_info_collect.py +0 -0
  45. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_specifier.py +0 -0
  46. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_windows/__init__.py +0 -0
  47. {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/py.typed +0 -0
  48. {python_discovery-1.6.2 → python_discovery-1.6.3}/tasks/old_target_check.py +0 -0
  49. {python_discovery-1.6.2 → python_discovery-1.6.3}/tasks/old_target_check.sh +0 -0
  50. {python_discovery-1.6.2 → python_discovery-1.6.3}/tasks/release.py +0 -0
  51. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/conftest.py +0 -0
  52. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/py_info/test_py_info.py +0 -0
  53. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  54. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_cache.py +0 -0
  55. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_cache_recovery.py +0 -0
  56. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_discovery_extra.py +0 -0
  57. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_iter_interpreters.py +0 -0
  58. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_info_collect.py +0 -0
  59. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_info_extra.py +0 -0
  60. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_spec_extra.py +0 -0
  61. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_query_timeout.py +0 -0
  62. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_specifier.py +0 -0
  63. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/test_windows.py +0 -0
  64. {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/winreg_mock_values.py +0 -0
  65. {python_discovery-1.6.2 → python_discovery-1.6.3}/tox.toml +0 -0
@@ -24,7 +24,7 @@ jobs:
24
24
  - name: Build package
25
25
  run: uv build --python 3.15 --python-preference only-managed --sdist --wheel . --out-dir dist
26
26
  - name: Store the distribution packages
27
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
27
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
28
28
  with:
29
29
  name: ${{ env.dists-artifact-name }}
30
30
  path: dist/*
@@ -39,7 +39,7 @@ jobs:
39
39
  id-token: write
40
40
  steps:
41
41
  - name: Download all the dists
42
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
42
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
43
43
  with:
44
44
  name: ${{ env.dists-artifact-name }}
45
45
  path: dist/
@@ -16,11 +16,11 @@ repos:
16
16
  hooks:
17
17
  - id: codespell
18
18
  - repo: https://github.com/tox-dev/pyproject-fmt
19
- rev: "v2.30.1"
19
+ rev: "v2.30.3"
20
20
  hooks:
21
21
  - id: pyproject-fmt
22
22
  - repo: https://github.com/astral-sh/ruff-pre-commit
23
- rev: "v0.16.10"
23
+ rev: "v0.17.0"
24
24
  hooks:
25
25
  - id: ruff-format
26
26
  - id: ruff
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-discovery
3
- Version: 1.6.2
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
@@ -0,0 +1,2 @@
1
+ Treat specs with dot-only version components, such as ``python.``, as literal executable paths instead of raising
2
+ ``IndexError`` - by :user:`harbinresearcher`.
@@ -0,0 +1,2 @@
1
+ Pass PEP 514 ``ExecutableArguments`` to registered Windows interpreters when querying them, so a registration that
2
+ points at a launcher such as ``py.exe -3.11`` reports the interpreter it selects - by :user:`blouirts123`.
@@ -30,15 +30,16 @@ 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"
44
45
  _DEFAULT_QUERY_TIMEOUT: Final[float] = 15.0
@@ -58,9 +59,10 @@ def from_exe( # ruff:ignore[too-many-arguments]
58
59
  *,
59
60
  raise_on_error: bool = True,
60
61
  ignore_cache: bool = False,
62
+ executable_args: str | None = None,
61
63
  ) -> PythonInfo | None:
62
64
  env = os.environ if env is None else env
63
- 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)
64
66
  if isinstance(result, Exception):
65
67
  if raise_on_error:
66
68
  raise result
@@ -69,31 +71,34 @@ def from_exe( # ruff:ignore[too-many-arguments]
69
71
  return result
70
72
 
71
73
 
72
- def _get_from_cache(
74
+ def _get_from_cache( # ruff:ignore[too-many-arguments]
73
75
  cls: type[PythonInfo],
74
76
  cache: PyInfoCache | None,
75
77
  exe: str,
76
78
  env: Mapping[str, str],
77
79
  *,
78
80
  ignore_cache: bool = True,
81
+ executable_args: str | None = None,
79
82
  ) -> PythonInfo | Exception:
80
- exe_path = Path(exe)
81
- if not ignore_cache and exe_path in _CACHE:
82
- result = _CACHE[exe_path]
83
+ key = Path(exe), executable_args
84
+ if not ignore_cache and key in _CACHE:
85
+ result = _CACHE[key]
83
86
  else:
84
- py_info = _get_via_file_cache(cls, cache, exe_path, exe, env)
85
- result = _CACHE[exe_path] = py_info
86
- 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:
87
90
  result.executable = exe
88
91
  return result
89
92
 
90
93
 
91
- def _get_via_file_cache(
94
+ def _get_via_file_cache( # ruff:ignore[too-many-arguments]
92
95
  cls: type[PythonInfo],
93
96
  cache: PyInfoCache | None,
94
97
  path: Path,
95
98
  exe: str,
96
99
  env: Mapping[str, str],
100
+ *,
101
+ executable_args: str | None = None,
97
102
  ) -> PythonInfo | Exception:
98
103
  path_text = str(path)
99
104
  try:
@@ -107,17 +112,22 @@ def _get_via_file_cache(
107
112
 
108
113
  resolved_cache = cache if cache is not None else NoOpCache()
109
114
  py_info_store = resolved_cache.py_info(path)
110
- 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
+ }
111
121
  with py_info_store.locked():
112
122
  cached = _read_cache_entry(cls, py_info_store, entry_meta)
113
123
  if isinstance(cached, _UnsupportedInterpreterError):
114
124
  return cached
115
125
  py_info = cached
116
126
  if py_info is None:
117
- failure, py_info = _run_subprocess(cls, exe, env)
127
+ failure, py_info = _run_subprocess(cls, exe, env, executable_args)
118
128
  if failure is not None and not isinstance(failure, _UnsupportedInterpreterError):
119
129
  _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
120
- failure, py_info = _run_subprocess(cls, exe, env)
130
+ failure, py_info = _run_subprocess(cls, exe, env, executable_args)
121
131
  if isinstance(failure, _UnsupportedInterpreterError):
122
132
  _LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it
123
133
  py_info_store.write({**entry_meta, "unsupported": str(failure)})
@@ -214,16 +224,20 @@ def _run_subprocess(
214
224
  cls: type[PythonInfo],
215
225
  exe: str,
216
226
  env: Mapping[str, str],
227
+ executable_args: str | None = None,
217
228
  ) -> tuple[Exception | None, PythonInfo | None]:
218
229
  start_cookie = gen_cookie()
219
230
  end_cookie = gen_cookie()
220
231
  timeout = _query_timeout(env.get("PY_DISCOVERY_TIMEOUT"))
221
232
  with _resolve_py_info_script() as py_info_script:
222
- 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:])}"
223
237
  env = dict(env)
224
238
  env.pop("__PYVENV_LAUNCHER__", None)
225
239
  env["PYTHONUTF8"] = "1"
226
- _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))
227
241
  try:
228
242
  process = Popen( # ruff:ignore[subprocess-without-shell-equals-true]
229
243
  cmd,
@@ -248,7 +262,8 @@ def _run_subprocess(
248
262
  out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
249
263
  try:
250
264
  result = cls.from_json(out)
251
- result.executable = exe
265
+ if executable_args is None:
266
+ result.executable = exe
252
267
  except json.JSONDecodeError as exc:
253
268
  _LOGGER.warning(
254
269
  "subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
@@ -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:
@@ -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
 
@@ -5,8 +5,8 @@ import logging
5
5
  import os
6
6
  import sys
7
7
  from pathlib import Path
8
- from subprocess import TimeoutExpired
9
- from typing import TYPE_CHECKING
8
+ from subprocess import TimeoutExpired, list2cmdline
9
+ from typing import TYPE_CHECKING, Final
10
10
  from unittest.mock import MagicMock, patch
11
11
 
12
12
  import pytest
@@ -14,6 +14,7 @@ import pytest
14
14
  from python_discovery import DiskCache, PythonInfo
15
15
  from python_discovery._cached_py_info import (
16
16
  LogCmd,
17
+ _get_from_cache,
17
18
  _get_via_file_cache,
18
19
  _load_cached_py_info,
19
20
  _resolve_py_info_script,
@@ -323,3 +324,67 @@ def test_from_exe_cache_entry_with_bad_content(tmp_path: Path) -> None:
323
324
  required = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
324
325
  assert isinstance(required, PythonInfo)
325
326
  assert isinstance(store.read(), dict)
327
+
328
+
329
+ _LAUNCHER: Final[str] = r"C:\Program Files\Python Launcher\py.exe"
330
+ _ARGS: Final[str] = '-3.11 --config "settings folder\\python.json"'
331
+ _SELECTED: Final[str] = r"C:\Python311\python.exe"
332
+
333
+
334
+ @pytest.fixture
335
+ def popen(mocker: MockerFixture) -> MagicMock:
336
+ start, end = "a" * 32, "b" * 32
337
+ mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end])
338
+ info = PythonInfo()
339
+ info.executable = _SELECTED
340
+ out = f"{start[::-1]}{info.to_json()}{end[::-1]}"
341
+ process = MagicMock(**{"communicate.return_value": (out, ""), "returncode": 0})
342
+ return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process)
343
+
344
+
345
+ def test_executable_args_precede_query_script(popen: MagicMock) -> None:
346
+ _run_subprocess(PythonInfo, _LAUNCHER, dict(os.environ), _ARGS)
347
+
348
+ cmd = popen.call_args.args[0]
349
+ assert cmd.startswith(f"{list2cmdline([_LAUNCHER])} {_ARGS} ")
350
+ assert cmd.endswith(f" {'a' * 32} {'b' * 32}")
351
+
352
+
353
+ @pytest.mark.usefixtures("popen")
354
+ def test_executable_args_report_the_selected_interpreter() -> None:
355
+ failure, result = _run_subprocess(PythonInfo, _LAUNCHER, dict(os.environ), _ARGS)
356
+
357
+ assert failure is None
358
+ assert result is not None
359
+ assert result.executable == _SELECTED
360
+
361
+
362
+ def test_executable_args_disk_entry_not_reused_without_args(tmp_path: Path, mocker: MockerFixture) -> None:
363
+ with_args, without_args = PythonInfo(), PythonInfo()
364
+ with_args.executable = "selected"
365
+ query = mocker.patch(
366
+ "python_discovery._cached_py_info._run_subprocess",
367
+ side_effect=[(None, with_args), (None, without_args)],
368
+ )
369
+ cache, path, env = DiskCache(tmp_path), Path(sys.executable), dict(os.environ)
370
+ _get_via_file_cache(PythonInfo, cache, path, sys.executable, env, executable_args=_ARGS)
371
+
372
+ result = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
373
+
374
+ assert result is without_args
375
+ assert query.call_count == 2
376
+
377
+
378
+ def test_executable_args_memory_cache_keyed_by_args(tmp_path: Path, mocker: MockerFixture) -> None:
379
+ mocker.patch.dict("python_discovery._cached_py_info._CACHE", clear=True)
380
+ first, second = PythonInfo(), PythonInfo()
381
+ query = mocker.patch("python_discovery._cached_py_info._get_via_file_cache", side_effect=[first, second])
382
+ cache, env = DiskCache(tmp_path), dict(os.environ)
383
+
384
+ results = [
385
+ _get_from_cache(PythonInfo, cache, _LAUNCHER, env, ignore_cache=False, executable_args=args)
386
+ for args in ("-3.11", "-3.12", "-3.11")
387
+ ]
388
+
389
+ assert results == [first, second, first]
390
+ assert query.call_count == 2
@@ -100,9 +100,10 @@ def test_discovery_fallback_fail(session_cache: DiskCache, caplog: pytest.LogCap
100
100
  assert "accepted" not in caplog.text
101
101
 
102
102
 
103
- def test_discovery_fallback_ok(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
103
+ @pytest.mark.parametrize("missing", ["magic-one", "python."])
104
+ def test_discovery_fallback_ok(missing: str, session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
104
105
  caplog.set_level(logging.DEBUG)
105
- result = get_interpreter(["magic-one", sys.executable], cache=session_cache)
106
+ result = get_interpreter([missing, sys.executable], cache=session_cache)
106
107
  assert result is not None, caplog.text
107
108
  assert result.executable == sys.executable, caplog.text
108
109
  assert "accepted" in caplog.text
@@ -13,8 +13,8 @@ if TYPE_CHECKING:
13
13
  from pathlib import Path
14
14
 
15
15
 
16
- def test_bad_py_spec() -> None:
17
- text = "python2.3.4.5"
16
+ @pytest.mark.parametrize("text", ["python2.3.4.5", ".", "..", "...", "python.", "cpython..", "pypy..."])
17
+ def test_bad_py_spec(text: str) -> None:
18
18
  spec = PythonSpec.from_string_spec(text)
19
19
  assert text in repr(spec)
20
20
  assert spec.str_spec == text
@@ -172,4 +172,4 @@ def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None:
172
172
  ]
173
173
  for _, major, minor, arch, threaded, exe in interpreters:
174
174
  info = _mock_pyinfo(major, minor, arch, exe, threaded)
175
- monkeypatch.setitem(_CACHE, Path(info.executable), info)
175
+ monkeypatch.setitem(_CACHE, (Path(info.executable), None), info)
@@ -1,10 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
3
7
  import sys
4
8
  import textwrap
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+ from unittest.mock import MagicMock
5
12
 
6
13
  import pytest
7
14
 
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ from pytest_mock import MockerFixture
19
+
8
20
 
9
21
  @pytest.mark.usefixtures("_mock_registry")
10
22
  def test_pep514_discovers_interpreters() -> None:
@@ -154,3 +166,66 @@ def test_pep514_run(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptur
154
166
  f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X",
155
167
  ]
156
168
  assert caplog.messages == expected_logs
169
+
170
+
171
+ @pytest.fixture
172
+ def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, str | None] | None]:
173
+ from python_discovery._windows import _pep514 as pep514
174
+
175
+ mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock())
176
+ mocker.patch.object(pep514, "_resolve_exe", return_value=r"C:\py.exe")
177
+ mocker.patch.object(pep514.os.path, "exists", return_value=True)
178
+
179
+ def load(registered: object) -> tuple[str, str | None] | None:
180
+ mocker.patch.object(pep514, "get_value", return_value=registered)
181
+ return pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11")
182
+
183
+ return load
184
+
185
+
186
+ @pytest.mark.usefixtures("_mock_registry")
187
+ @pytest.mark.parametrize(
188
+ ("registered", "expected"),
189
+ [
190
+ pytest.param('-3.11 --config "a b"', '-3.11 --config "a b"', id="kept"),
191
+ pytest.param(None, None, id="missing"),
192
+ pytest.param("", None, id="empty"),
193
+ pytest.param(" ", None, id="blank"),
194
+ ],
195
+ )
196
+ def test_pep514_loads_executable_arguments(
197
+ load_exe_with_args: Callable[[object], tuple[str, str | None] | None], registered: str | None, expected: str | None
198
+ ) -> None:
199
+ assert load_exe_with_args(registered) == (r"C:\py.exe", expected)
200
+
201
+
202
+ @pytest.mark.usefixtures("_mock_registry")
203
+ def test_pep514_rejects_non_string_executable_arguments(
204
+ load_exe_with_args: Callable[[object], tuple[str, str | None] | None],
205
+ ) -> None:
206
+ assert load_exe_with_args(42) is None
207
+
208
+
209
+ @pytest.mark.skipif(sys.platform != "win32" or shutil.which("py") is None, reason="requires the Windows launcher")
210
+ def test_pep514_launcher_arguments_select_interpreter(mocker: MockerFixture, tmp_path: Path) -> None:
211
+ from python_discovery import DiskCache, PythonSpec
212
+ from python_discovery._windows import _propose
213
+
214
+ launcher = shutil.which("py")
215
+ assert launcher is not None
216
+ script = (
217
+ "import json, struct, sys; print(json.dumps([sys.executable, *sys.version_info[:2], struct.calcsize('P') * 8]))"
218
+ )
219
+ probe = subprocess.run([launcher, "-3.11", "-c", script], capture_output=True, text=True, check=False)
220
+ if probe.returncode:
221
+ pytest.skip("the launcher cannot select 3.11") # ty: ignore[too-many-positional-arguments] # ty ships a stale pytest stub for 3.9
222
+ selected, major, minor, arch = json.loads(probe.stdout)
223
+ mocker.patch.object(
224
+ _propose, "discover_pythons", return_value=[("PythonCore", major, minor, arch, False, launcher, "-3.11")]
225
+ )
226
+
227
+ info = next(
228
+ _propose.propose_interpreters(PythonSpec.from_string_spec("python3.11"), DiskCache(tmp_path), os.environ)
229
+ )
230
+
231
+ assert Path(info.executable).samefile(selected)