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.
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/workflows/release.yaml +2 -2
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.pre-commit-config.yaml +2 -2
- {python_discovery-1.6.2 → python_discovery-1.6.3}/PKG-INFO +1 -1
- python_discovery-1.6.3/docs/changelog/147.bugfix.rst +2 -0
- python_discovery-1.6.3/docs/changelog/148.bugfix.rst +2 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_cached_py_info.py +33 -18
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_py_info.py +32 -1
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_py_spec.py +3 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_windows/_pep514.py +4 -1
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_windows/_propose.py +8 -2
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_cached_py_info.py +67 -2
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_discovery.py +3 -2
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_spec.py +2 -2
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/conftest.py +1 -1
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/test_windows_pep514.py +75 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/FUNDING.yaml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/SECURITY.md +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/dependabot.yaml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/release.yaml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/workflows/check.yaml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.github/workflows/prepare-release.yaml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.gitignore +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.readthedocs.yaml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/.readthedocs.yml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/CODE_OF_CONDUCT.md +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/LICENSE +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/README.md +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/_static/custom.css +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/_static/logo.svg +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/changelog/template.jinja2 +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/changelog.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/conf.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/explanation.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/how-to/standalone-usage.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/index.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/reference/api.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/reference/environment-variables.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/docs/tutorial/getting-started.rst +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/pyproject.toml +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/__init__.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_cache.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_compat.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_discovery.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_py_info_collect.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_specifier.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/_windows/__init__.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/src/python_discovery/py.typed +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tasks/old_target_check.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tasks/old_target_check.sh +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tasks/release.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/conftest.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/py_info/test_py_info.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/py_info/test_py_info_exe_based_of.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_cache.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_cache_recovery.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_discovery_extra.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_iter_interpreters.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_info_collect.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_info_extra.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_py_spec_extra.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_query_timeout.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/test_specifier.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/test_windows.py +0 -0
- {python_discovery-1.6.2 → python_discovery-1.6.3}/tests/windows/winreg_mock_values.py +0 -0
- {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@
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
81
|
-
if not ignore_cache and
|
|
82
|
-
result = _CACHE[
|
|
83
|
+
key = Path(exe), executable_args
|
|
84
|
+
if not ignore_cache and key in _CACHE:
|
|
85
|
+
result = _CACHE[key]
|
|
83
86
|
else:
|
|
84
|
-
|
|
85
|
-
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
17
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_discovery-1.6.2 → python_discovery-1.6.3}/tests/py_info/test_py_info_exe_based_of.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|