python-discovery 1.5.0__py3-none-any.whl → 1.5.2__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.
- python_discovery/_cached_py_info.py +65 -29
- python_discovery/_discovery.py +147 -20
- python_discovery/_py_info.py +51 -298
- python_discovery/_py_info_collect.py +345 -0
- python_discovery/_specifier.py +7 -4
- {python_discovery-1.5.0.dist-info → python_discovery-1.5.2.dist-info}/METADATA +5 -3
- python_discovery-1.5.2.dist-info/RECORD +17 -0
- {python_discovery-1.5.0.dist-info → python_discovery-1.5.2.dist-info}/WHEEL +1 -1
- python_discovery-1.5.0.dist-info/RECORD +0 -16
- {python_discovery-1.5.0.dist-info → python_discovery-1.5.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import tempfile
|
|
14
14
|
from collections import OrderedDict
|
|
15
15
|
from contextlib import contextmanager
|
|
16
|
+
from functools import lru_cache
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from shlex import quote
|
|
18
19
|
from subprocess import Popen, TimeoutExpired # ruff:ignore[suspicious-subprocess-import]
|
|
@@ -20,16 +21,30 @@ from typing import TYPE_CHECKING, Final
|
|
|
20
21
|
|
|
21
22
|
from ._cache import NoOpCache
|
|
22
23
|
from ._py_info import PythonInfo
|
|
24
|
+
from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER
|
|
23
25
|
|
|
24
26
|
if TYPE_CHECKING:
|
|
25
27
|
from collections.abc import Generator, Mapping
|
|
28
|
+
from typing import TypedDict
|
|
26
29
|
|
|
27
30
|
from ._cache import ContentStore, PyInfoCache
|
|
28
31
|
|
|
32
|
+
class CacheEntryMeta(TypedDict):
|
|
33
|
+
"""Identity of a cached interrogation result: the queried binary and the script that produced it."""
|
|
34
|
+
|
|
35
|
+
st_mtime: float
|
|
36
|
+
path: str
|
|
37
|
+
hash: str | None
|
|
38
|
+
|
|
29
39
|
|
|
30
40
|
_CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
|
|
31
41
|
_CACHE[Path(sys.executable)] = PythonInfo()
|
|
32
42
|
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
43
|
+
_PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _UnsupportedInterpreterError(RuntimeError):
|
|
47
|
+
"""The interpreter is older than the interrogation script supports; retrying cannot help."""
|
|
33
48
|
|
|
34
49
|
|
|
35
50
|
def from_exe( # ruff:ignore[too-many-arguments]
|
|
@@ -82,48 +97,54 @@ def _get_via_file_cache(
|
|
|
82
97
|
path_modified = path.stat().st_mtime
|
|
83
98
|
except OSError:
|
|
84
99
|
path_modified = -1
|
|
85
|
-
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
|
|
86
100
|
try:
|
|
87
|
-
py_info_hash: str | None =
|
|
101
|
+
py_info_hash: str | None = _script_hash()
|
|
88
102
|
except OSError:
|
|
89
103
|
py_info_hash = None
|
|
90
104
|
|
|
91
105
|
resolved_cache = cache if cache is not None else NoOpCache()
|
|
92
|
-
py_info: PythonInfo | None = None
|
|
93
106
|
py_info_store = resolved_cache.py_info(path)
|
|
107
|
+
entry_meta: CacheEntryMeta = {"st_mtime": path_modified, "path": path_text, "hash": py_info_hash}
|
|
94
108
|
with py_info_store.locked():
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
of_path == path_text
|
|
100
|
-
and of_st_mtime == path_modified
|
|
101
|
-
and of_hash == py_info_hash
|
|
102
|
-
and isinstance(of_content, dict)
|
|
103
|
-
):
|
|
104
|
-
py_info = _load_cached_py_info(cls, py_info_store, of_content)
|
|
105
|
-
else:
|
|
106
|
-
py_info_store.remove()
|
|
109
|
+
cached = _read_cache_entry(cls, py_info_store, entry_meta)
|
|
110
|
+
if isinstance(cached, _UnsupportedInterpreterError):
|
|
111
|
+
return cached
|
|
112
|
+
py_info = cached
|
|
107
113
|
if py_info is None:
|
|
108
114
|
failure, py_info = _run_subprocess(cls, exe, env)
|
|
109
|
-
if failure is not None:
|
|
115
|
+
if failure is not None and not isinstance(failure, _UnsupportedInterpreterError):
|
|
110
116
|
_LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
|
|
111
117
|
failure, py_info = _run_subprocess(cls, exe, env)
|
|
118
|
+
if isinstance(failure, _UnsupportedInterpreterError):
|
|
119
|
+
_LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it
|
|
120
|
+
py_info_store.write({**entry_meta, "unsupported": str(failure)})
|
|
121
|
+
return failure
|
|
112
122
|
if failure is not None:
|
|
113
123
|
return failure
|
|
114
124
|
if py_info is not None:
|
|
115
|
-
py_info_store.write({
|
|
116
|
-
"st_mtime": path_modified,
|
|
117
|
-
"path": path_text,
|
|
118
|
-
"content": py_info.to_dict(),
|
|
119
|
-
"hash": py_info_hash,
|
|
120
|
-
})
|
|
125
|
+
py_info_store.write({**entry_meta, "content": py_info.to_dict()})
|
|
121
126
|
if py_info is None:
|
|
122
127
|
msg = f"{exe} failed to produce interpreter info"
|
|
123
128
|
return RuntimeError(msg)
|
|
124
129
|
return py_info
|
|
125
130
|
|
|
126
131
|
|
|
132
|
+
def _read_cache_entry(
|
|
133
|
+
cls: type[PythonInfo],
|
|
134
|
+
py_info_store: ContentStore,
|
|
135
|
+
entry_meta: CacheEntryMeta,
|
|
136
|
+
) -> PythonInfo | _UnsupportedInterpreterError | None:
|
|
137
|
+
if not py_info_store.exists() or (data := py_info_store.read()) is None:
|
|
138
|
+
return None
|
|
139
|
+
if all(data.get(key) == value for key, value in entry_meta.items()):
|
|
140
|
+
if isinstance(unsupported := data.get("unsupported"), str):
|
|
141
|
+
return _UnsupportedInterpreterError(unsupported)
|
|
142
|
+
if isinstance(content := data.get("content"), dict):
|
|
143
|
+
return _load_cached_py_info(cls, py_info_store, content)
|
|
144
|
+
py_info_store.remove()
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
127
148
|
def _load_cached_py_info(
|
|
128
149
|
cls: type[PythonInfo],
|
|
129
150
|
py_info_store: ContentStore,
|
|
@@ -140,6 +161,11 @@ def _load_cached_py_info(
|
|
|
140
161
|
return py_info
|
|
141
162
|
|
|
142
163
|
|
|
164
|
+
@lru_cache(maxsize=1)
|
|
165
|
+
def _script_hash() -> str:
|
|
166
|
+
return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest()
|
|
167
|
+
|
|
168
|
+
|
|
143
169
|
COOKIE_LENGTH: Final[int] = 32
|
|
144
170
|
|
|
145
171
|
|
|
@@ -149,13 +175,12 @@ def gen_cookie() -> str:
|
|
|
149
175
|
|
|
150
176
|
@contextmanager
|
|
151
177
|
def _resolve_py_info_script() -> Generator[Path]:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
yield py_info_script
|
|
178
|
+
if _PY_INFO_SCRIPT.is_file():
|
|
179
|
+
yield _PY_INFO_SCRIPT
|
|
155
180
|
else:
|
|
156
|
-
data = pkgutil.get_data(__package__ or __name__,
|
|
181
|
+
data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name)
|
|
157
182
|
if data is None:
|
|
158
|
-
msg = "cannot locate
|
|
183
|
+
msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation"
|
|
159
184
|
raise FileNotFoundError(msg)
|
|
160
185
|
fd, tmp = tempfile.mkstemp(suffix=".py")
|
|
161
186
|
try:
|
|
@@ -216,8 +241,7 @@ def _run_subprocess(
|
|
|
216
241
|
except OSError as os_error:
|
|
217
242
|
out, err, code = "", os_error.strerror, os_error.errno
|
|
218
243
|
if code != 0:
|
|
219
|
-
|
|
220
|
-
return RuntimeError(f"failed to query {msg}"), None
|
|
244
|
+
return _query_failure(exe, out, err, code), None
|
|
221
245
|
out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
|
|
222
246
|
try:
|
|
223
247
|
result = cls.from_json(out)
|
|
@@ -240,6 +264,18 @@ def _run_subprocess(
|
|
|
240
264
|
return None, result
|
|
241
265
|
|
|
242
266
|
|
|
267
|
+
def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError:
|
|
268
|
+
# the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a
|
|
269
|
+
# failed exec or a shim that echoes the marker phrase, but only the gate produces the combination
|
|
270
|
+
if code == UNSUPPORTED_EXIT_CODE and err is not None and (at := err.find(UNSUPPORTED_MARKER)) != -1:
|
|
271
|
+
version = err[at + len(UNSUPPORTED_MARKER) :].split() or ["unknown"]
|
|
272
|
+
floor = ".".join(str(i) for i in MIN_INTERROGATE_VERSION)
|
|
273
|
+
msg = f"{exe} is Python {version[0]}, older than the minimum {floor} python-discovery can query"
|
|
274
|
+
return _UnsupportedInterpreterError(msg)
|
|
275
|
+
msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
|
|
276
|
+
return RuntimeError(f"failed to query {msg}")
|
|
277
|
+
|
|
278
|
+
|
|
243
279
|
class LogCmd:
|
|
244
280
|
def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None:
|
|
245
281
|
self.cmd = cmd
|
python_discovery/_discovery.py
CHANGED
|
@@ -4,14 +4,14 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
6
|
from contextlib import suppress
|
|
7
|
+
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import TYPE_CHECKING, Final
|
|
9
10
|
|
|
10
|
-
from platformdirs import user_data_path
|
|
11
|
-
|
|
12
11
|
from ._compat import fs_path_id
|
|
13
12
|
from ._py_info import PythonInfo
|
|
14
13
|
from ._py_spec import PythonSpec
|
|
14
|
+
from ._specifier import DC_KW, SimpleVersion
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
17
|
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
|
|
@@ -199,7 +199,7 @@ def propose_interpreters(
|
|
|
199
199
|
if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
|
|
200
200
|
return
|
|
201
201
|
yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
|
|
202
|
-
yield from _propose_from_uv(cache, env, all_implementations=all_implementations)
|
|
202
|
+
yield from _propose_from_uv(spec, cache, env, all_implementations=all_implementations)
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
def _propose_explicit(
|
|
@@ -262,30 +262,157 @@ def _propose_from_path(
|
|
|
262
262
|
|
|
263
263
|
|
|
264
264
|
def _propose_from_uv(
|
|
265
|
+
spec: PythonSpec,
|
|
265
266
|
cache: PyInfoCache | None,
|
|
266
267
|
env: Mapping[str, str],
|
|
267
268
|
*,
|
|
268
269
|
all_implementations: bool = False,
|
|
269
270
|
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
seen_uv_paths: set[str] = set()
|
|
272
|
+
for root in _uv_python_roots(env):
|
|
273
|
+
for install in _uv_installs(root, spec, all_implementations=all_implementations):
|
|
274
|
+
for exe_path in _uv_executables(install):
|
|
275
|
+
if (resolved := str(exe_path.resolve())) in seen_uv_paths:
|
|
276
|
+
continue
|
|
277
|
+
seen_uv_paths.add(resolved)
|
|
278
|
+
if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
|
|
279
|
+
yield interpreter, True
|
|
280
|
+
break # one install directory holds one interpreter
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]:
|
|
284
|
+
"""Yield uv's store roots, legacy first; searching both keeps a stale legacy directory from hiding the live one."""
|
|
285
|
+
if install_dir := env.get("UV_PYTHON_INSTALL_DIR"):
|
|
286
|
+
yield Path(install_dir).expanduser()
|
|
287
|
+
return
|
|
288
|
+
for state_dir in _uv_state_dirs(env):
|
|
289
|
+
yield state_dir / "python"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]:
|
|
293
|
+
"""Yield uv's legacy state directory then its current one, the order uv prefers them in."""
|
|
294
|
+
windows = sys.platform == "win32"
|
|
295
|
+
home = Path(env.get("USERPROFILE" if windows else "HOME") or "~").expanduser()
|
|
296
|
+
if windows:
|
|
297
|
+
app_data = Path(env["APPDATA"]) if "APPDATA" in env else home / "AppData" / "Roaming"
|
|
298
|
+
yield app_data / "uv" / "data"
|
|
299
|
+
yield app_data / "uv"
|
|
300
|
+
return
|
|
301
|
+
if sys.platform == "darwin":
|
|
302
|
+
yield home / "Library" / "Application Support" / "uv"
|
|
303
|
+
# uv ignores a relative XDG_DATA_HOME, as the XDG specification requires
|
|
304
|
+
if (xdg_data_home := env.get("XDG_DATA_HOME")) and (xdg := Path(xdg_data_home)).is_absolute():
|
|
305
|
+
yield xdg / "uv"
|
|
274
306
|
else:
|
|
275
|
-
|
|
307
|
+
yield home / ".local" / "share" / "uv"
|
|
276
308
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
309
|
+
|
|
310
|
+
def _uv_installs(root: Path, spec: PythonSpec, *, all_implementations: bool) -> list[_UvInstall]:
|
|
311
|
+
try:
|
|
312
|
+
matching = [
|
|
313
|
+
install
|
|
314
|
+
for entry in sorted(root.iterdir())
|
|
315
|
+
if not entry.name.startswith(".")
|
|
316
|
+
and entry.is_dir()
|
|
317
|
+
and _uv_install_matches(install := _parse_uv_install(entry), spec, all_implementations=all_implementations)
|
|
318
|
+
]
|
|
319
|
+
except OSError:
|
|
320
|
+
return []
|
|
321
|
+
matching.sort(key=_uv_sort_key) # stable, so equally ranked installs stay in directory name order
|
|
322
|
+
return matching
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _parse_uv_install(path: Path) -> _UvInstall:
|
|
326
|
+
implementation, _, remainder = path.name.partition("-")
|
|
327
|
+
version_str, _, variant = remainder.partition("-")[0].partition("+")
|
|
328
|
+
try:
|
|
329
|
+
version = SimpleVersion.from_string(version_str)
|
|
330
|
+
except ValueError: # not a uv key, but the directory may still hold an interpreter worth probing
|
|
331
|
+
return _UvInstall(path=path, key=None)
|
|
332
|
+
variants = variant.split("+")
|
|
333
|
+
key = _UvKey(
|
|
334
|
+
implementation=implementation.lower(),
|
|
335
|
+
version=version,
|
|
336
|
+
free_threaded="freethreaded" in variants,
|
|
337
|
+
debug="debug" in variants,
|
|
338
|
+
)
|
|
339
|
+
return _UvInstall(path=path, key=key)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(**DC_KW)
|
|
343
|
+
class _UvKey:
|
|
344
|
+
"""The ``<implementation>-<version>[+<variant>]-<os>-<arch>-<libc>`` name uv gives an install directory."""
|
|
345
|
+
|
|
346
|
+
implementation: str
|
|
347
|
+
version: SimpleVersion
|
|
348
|
+
free_threaded: bool
|
|
349
|
+
debug: bool
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(**DC_KW)
|
|
353
|
+
class _UvInstall:
|
|
354
|
+
path: Path
|
|
355
|
+
key: _UvKey | None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _uv_install_matches(install: _UvInstall, spec: PythonSpec, *, all_implementations: bool) -> bool:
|
|
359
|
+
if (key := install.key) is None:
|
|
360
|
+
return True
|
|
361
|
+
if spec.implementation is not None:
|
|
362
|
+
if spec.implementation.lower() != key.implementation:
|
|
363
|
+
return False
|
|
364
|
+
elif not all_implementations and key.implementation != "cpython":
|
|
365
|
+
# uv resolves a bare `3.8` to `cpython-3.8`; on PATH the equivalent probe finds CPython too
|
|
366
|
+
return False
|
|
367
|
+
if spec.free_threaded is not None and spec.free_threaded != key.free_threaded:
|
|
368
|
+
return False
|
|
369
|
+
if spec.debug is not None and spec.debug != key.debug:
|
|
370
|
+
return False
|
|
371
|
+
return _uv_version_matches(key, spec)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool:
|
|
375
|
+
# GraalPy keys carry the GraalVM version in older uv releases, so only CPython and PyPy state a Python version
|
|
376
|
+
if key.implementation not in {"cpython", "pypy"}:
|
|
377
|
+
return True
|
|
378
|
+
if spec.version_specifier is not None and not spec.version_specifier.contains(str(key.version)):
|
|
379
|
+
return False
|
|
380
|
+
return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro)))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _uv_sort_key(install: _UvInstall) -> tuple[int, ...]:
|
|
384
|
+
"""Rank like uv does: CPython first, newest version first, default build ahead of free-threaded and debug."""
|
|
385
|
+
impl_order: Final[tuple[str, ...]] = ("cpython", "pypy", "graalpy", "pyodide")
|
|
386
|
+
if (key := install.key) is None:
|
|
387
|
+
return (len(impl_order) + 1,)
|
|
388
|
+
version = key.version
|
|
389
|
+
return (
|
|
390
|
+
impl_order.index(key.implementation) if key.implementation in impl_order else len(impl_order),
|
|
391
|
+
# uv's minor version links point at a concrete install that moves on upgrade, so report the target instead
|
|
392
|
+
int(install.path.is_symlink()),
|
|
393
|
+
-version.major,
|
|
394
|
+
-version.minor,
|
|
395
|
+
-version.micro,
|
|
396
|
+
-{"a": 0, "b": 1, "rc": 2, None: 3}[version.pre_type],
|
|
397
|
+
-(version.pre_num or 0),
|
|
398
|
+
int(key.free_threaded),
|
|
399
|
+
int(key.debug),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _uv_executables(install: _UvInstall) -> Generator[Path, None, None]:
|
|
404
|
+
# some distributions unpack into an `install` subdirectory, which uv resolves the same way
|
|
405
|
+
base = nested if (nested := install.path / "install").is_dir() else install.path
|
|
406
|
+
for pattern in (
|
|
407
|
+
"bin/python",
|
|
408
|
+
"python.exe",
|
|
409
|
+
"bin/pypy*",
|
|
410
|
+
"pypy*.exe",
|
|
411
|
+
"bin/graalpy",
|
|
412
|
+
"bin/graalpy.exe",
|
|
413
|
+
"python*t.exe", # free-threaded Windows builds that ship no python.exe (astral-sh/uv#8298)
|
|
414
|
+
):
|
|
415
|
+
yield from sorted(base.glob(pattern))
|
|
289
416
|
|
|
290
417
|
|
|
291
418
|
def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
|
python_discovery/_py_info.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Concrete Python interpreter information,
|
|
1
|
+
"""Concrete Python interpreter information, collected in-process or via subprocess interrogation."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -6,23 +6,23 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
import platform
|
|
9
|
-
import re
|
|
10
|
-
import struct
|
|
11
9
|
import sys
|
|
12
|
-
import sysconfig
|
|
13
|
-
import warnings
|
|
14
10
|
from collections import OrderedDict
|
|
15
11
|
from itertools import product
|
|
16
12
|
from string import digits
|
|
17
13
|
from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
|
|
18
14
|
|
|
15
|
+
from ._py_info_collect import PythonInfoCollector
|
|
16
|
+
|
|
19
17
|
if TYPE_CHECKING:
|
|
20
|
-
import tkinter as tk
|
|
21
18
|
from collections.abc import Generator, Mapping
|
|
19
|
+
from typing import Union
|
|
22
20
|
|
|
23
21
|
from ._cache import PyInfoCache
|
|
24
22
|
from ._py_spec import PythonSpec
|
|
25
23
|
|
|
24
|
+
InfoValue = Union[str, int, bool, None, "tuple[int | str, ...]", "list[str]", "dict[str, str | int | None]"]
|
|
25
|
+
|
|
26
26
|
|
|
27
27
|
class VersionInfo(NamedTuple):
|
|
28
28
|
major: int
|
|
@@ -40,246 +40,47 @@ def _get_path_extensions() -> list[str]:
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
EXTENSIONS: Final[list[str]] = _get_path_extensions()
|
|
43
|
-
_32BIT_POINTER_SIZE: Final[int] = 4
|
|
44
|
-
_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
|
|
45
|
-
r"""
|
|
46
|
-
\{ \w+ } # sysconfig variable placeholder like {base}
|
|
47
|
-
""",
|
|
48
|
-
re.VERBOSE,
|
|
49
|
-
)
|
|
50
43
|
|
|
51
44
|
|
|
52
45
|
class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
53
46
|
"""Contains information for a Python interpreter."""
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
self.executable = abs_path(sys.executable)
|
|
90
|
-
self.original_executable = abs_path(self.executable)
|
|
91
|
-
self.system_executable = self._fast_get_system_executable()
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
__import__("venv")
|
|
95
|
-
has = True
|
|
96
|
-
except ImportError: # pragma: no cover # venv is always available in standard CPython
|
|
97
|
-
has = False
|
|
98
|
-
self.has_venv = has
|
|
99
|
-
self.path = sys.path
|
|
100
|
-
self.file_system_encoding = sys.getfilesystemencoding()
|
|
101
|
-
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
|
|
102
|
-
|
|
103
|
-
def _init_schemes(self) -> None:
|
|
104
|
-
scheme_names = sysconfig.get_scheme_names()
|
|
105
|
-
|
|
106
|
-
if "venv" in scheme_names: # pragma: >=3.11 cover
|
|
107
|
-
self.sysconfig_scheme = "venv"
|
|
108
|
-
self.sysconfig_paths = {
|
|
109
|
-
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
110
|
-
}
|
|
111
|
-
self.distutils_install = {}
|
|
112
|
-
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
|
|
113
|
-
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
|
|
114
|
-
self.sysconfig_scheme = "posix_prefix"
|
|
115
|
-
self.sysconfig_paths = {
|
|
116
|
-
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
117
|
-
}
|
|
118
|
-
self.distutils_install = {}
|
|
119
|
-
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
|
|
120
|
-
self.sysconfig_scheme = None
|
|
121
|
-
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
|
|
122
|
-
self.distutils_install = self._distutils_install().copy()
|
|
123
|
-
|
|
124
|
-
def _init_sysconfig(self) -> None:
|
|
125
|
-
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
|
|
126
|
-
self.sysconfig = {
|
|
127
|
-
k: v
|
|
128
|
-
for k, v in [
|
|
129
|
-
("makefile_filename", makefile() if makefile is not None else None),
|
|
130
|
-
]
|
|
131
|
-
if k is not None
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
config_var_keys = set()
|
|
135
|
-
for element in self.sysconfig_paths.values():
|
|
136
|
-
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
|
|
137
|
-
config_var_keys.add("PYTHONFRAMEWORK")
|
|
138
|
-
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
|
|
139
|
-
|
|
140
|
-
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
|
|
141
|
-
|
|
142
|
-
if "TCL_LIBRARY" in os.environ:
|
|
143
|
-
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
|
|
144
|
-
else:
|
|
145
|
-
self.tcl_lib, self.tk_lib = None, None
|
|
146
|
-
|
|
147
|
-
confs = {
|
|
148
|
-
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
|
|
149
|
-
for k, v in self.sysconfig_vars.items()
|
|
150
|
-
}
|
|
151
|
-
self.system_stdlib = self.sysconfig_path("stdlib", confs)
|
|
152
|
-
self.system_stdlib_platform = self.sysconfig_path("platstdlib", confs)
|
|
153
|
-
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
|
|
154
|
-
self._creators = None # virtualenv-specific, set via monkey-patch
|
|
155
|
-
|
|
156
|
-
@staticmethod
|
|
157
|
-
def _get_tcl_tk_libs() -> tuple[
|
|
158
|
-
str | None,
|
|
159
|
-
str | None,
|
|
160
|
-
]: # pragma: no cover # tkinter availability varies; tested indirectly via __init__
|
|
161
|
-
"""Detect the tcl and tk libraries using tkinter."""
|
|
162
|
-
tcl_lib, tk_lib = None, None
|
|
163
|
-
try:
|
|
164
|
-
import tkinter as tk # ruff:ignore[import-outside-top-level]
|
|
165
|
-
except ImportError:
|
|
166
|
-
pass
|
|
167
|
-
else:
|
|
168
|
-
try:
|
|
169
|
-
tcl = tk.Tcl()
|
|
170
|
-
tcl_lib = tcl.eval("info library")
|
|
171
|
-
tk_lib = PythonInfo._resolve_tk_lib(tcl, tcl_lib)
|
|
172
|
-
except tk.TclError:
|
|
173
|
-
pass
|
|
174
|
-
|
|
175
|
-
return tcl_lib, tk_lib
|
|
176
|
-
|
|
177
|
-
@staticmethod
|
|
178
|
-
def _query_tk_library(tcl: tk.Tk) -> str | None: # pragma: no cover
|
|
179
|
-
"""Try to get the TK library path directly from Tcl."""
|
|
180
|
-
import tkinter as tk # ruff:ignore[import-outside-top-level]
|
|
181
|
-
|
|
182
|
-
try:
|
|
183
|
-
if (tk_lib := tcl.eval("set tk_library")) and os.path.isdir(tk_lib):
|
|
184
|
-
return tk_lib
|
|
185
|
-
except tk.TclError:
|
|
186
|
-
pass
|
|
187
|
-
return None
|
|
188
|
-
|
|
189
|
-
@staticmethod
|
|
190
|
-
def _resolve_tk_lib(tcl: tk.Tk, tcl_lib: str) -> str | None: # pragma: no cover
|
|
191
|
-
"""Resolve the TK library path by direct query or path construction."""
|
|
192
|
-
if (tk_lib := PythonInfo._query_tk_library(tcl)) is not None:
|
|
193
|
-
return tk_lib
|
|
194
|
-
tk_version = tcl.eval("package require Tk")
|
|
195
|
-
tcl_parent = os.path.dirname(tcl_lib)
|
|
196
|
-
for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
|
|
197
|
-
tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
|
|
198
|
-
if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
|
|
199
|
-
return tk_lib_path
|
|
200
|
-
return None
|
|
201
|
-
|
|
202
|
-
def _fast_get_system_executable(self) -> str | None:
|
|
203
|
-
"""Try to get the system executable by just looking at properties."""
|
|
204
|
-
# if we're not in a virtual environment, this is already a system python, so return the original executable
|
|
205
|
-
# note we must choose the original and not the pure executable as shim scripts might throw us off
|
|
206
|
-
if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
|
|
207
|
-
return self._resolve_executable_symlink(self.original_executable)
|
|
208
|
-
|
|
209
|
-
# if this is NOT a virtual environment, can't determine easily, bail out
|
|
210
|
-
if self.real_prefix is not None:
|
|
211
|
-
return None
|
|
212
|
-
|
|
213
|
-
base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
|
|
214
|
-
if base_executable is None: # use the saved system executable if present
|
|
215
|
-
return None
|
|
216
|
-
|
|
217
|
-
# we know we're in a virtual environment, can not be us
|
|
218
|
-
if sys.executable == base_executable:
|
|
219
|
-
return None
|
|
220
|
-
|
|
221
|
-
# We're not in a venv and base_executable exists; use it directly
|
|
222
|
-
if os.path.exists(base_executable): # pragma: >=3.11 cover
|
|
223
|
-
return self._resolve_executable_symlink(base_executable)
|
|
48
|
+
platform: str
|
|
49
|
+
implementation: str
|
|
50
|
+
pypy_version_info: tuple[int, ...]
|
|
51
|
+
version_info: VersionInfo
|
|
52
|
+
architecture: int
|
|
53
|
+
sysconfig_platform: str | None
|
|
54
|
+
version_nodot: str | None
|
|
55
|
+
version: str
|
|
56
|
+
os: str
|
|
57
|
+
free_threaded: bool
|
|
58
|
+
debug_build: bool
|
|
59
|
+
prefix: str | None
|
|
60
|
+
base_prefix: str | None
|
|
61
|
+
real_prefix: str | None
|
|
62
|
+
base_exec_prefix: str | None
|
|
63
|
+
exec_prefix: str | None
|
|
64
|
+
executable: str
|
|
65
|
+
original_executable: str
|
|
66
|
+
system_executable: str | None
|
|
67
|
+
has_venv: bool
|
|
68
|
+
path: list[str]
|
|
69
|
+
file_system_encoding: str
|
|
70
|
+
stdout_encoding: str | None
|
|
71
|
+
sysconfig_scheme: str | None
|
|
72
|
+
sysconfig_paths: dict[str, str]
|
|
73
|
+
distutils_install: dict[str, str]
|
|
74
|
+
sysconfig: dict[str, str | None]
|
|
75
|
+
sysconfig_vars: dict[str, str | int | None]
|
|
76
|
+
tcl_lib: str | None
|
|
77
|
+
tk_lib: str | None
|
|
78
|
+
system_stdlib: str
|
|
79
|
+
system_stdlib_platform: str
|
|
80
|
+
max_size: int
|
|
224
81
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _resolve_executable_symlink(self, path: str, *, framework: bool | None = None) -> str:
|
|
229
|
-
"""
|
|
230
|
-
Resolve symlinks of the executable itself, but never of its parent directories.
|
|
231
|
-
|
|
232
|
-
Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
|
|
233
|
-
resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
|
|
234
|
-
kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
|
|
235
|
-
directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
|
|
236
|
-
"""
|
|
237
|
-
result = os.path.abspath(path)
|
|
238
|
-
if self.os != "posix": # CPython only does this where HAVE_READLINK
|
|
239
|
-
return result
|
|
240
|
-
if framework is None:
|
|
241
|
-
framework = bool(sysconfig.get_config_var("PYTHONFRAMEWORK"))
|
|
242
|
-
if framework: # macOS framework builds self-locate via dyld from the real binary; e.g. for Homebrew
|
|
243
|
-
return result # resolving would pin the versioned Cellar path into the recorded home
|
|
244
|
-
real_path = os.path.realpath(result)
|
|
245
|
-
if not os.path.exists(real_path): # symlink loop or broken symlink
|
|
246
|
-
return result
|
|
247
|
-
while os.path.islink(result):
|
|
248
|
-
if self._stdlib_landmark_exists(os.path.dirname(result)):
|
|
249
|
-
return result
|
|
250
|
-
link = os.readlink(result)
|
|
251
|
-
candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
|
|
252
|
-
# normpath through a symlinked directory may point at a different file - stop resolving there
|
|
253
|
-
if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
|
|
254
|
-
return result
|
|
255
|
-
result = candidate
|
|
256
|
-
return result
|
|
257
|
-
|
|
258
|
-
@staticmethod
|
|
259
|
-
def _stdlib_landmark_exists(dir_path: str) -> bool:
|
|
260
|
-
lib_name = os.path.basename(os.path.dirname(os.__file__))
|
|
261
|
-
return any(
|
|
262
|
-
os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
|
|
266
|
-
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
|
|
267
|
-
major, minor = self.version_info.major, self.version_info.minor
|
|
268
|
-
if self.os != "posix" or (major, minor) < (3, 11):
|
|
269
|
-
return None
|
|
270
|
-
|
|
271
|
-
# search relative to the directory of sys._base_executable
|
|
272
|
-
base_dir = os.path.dirname(base_executable)
|
|
273
|
-
candidates = [f"python{major}", f"python{major}.{minor}"]
|
|
274
|
-
if self.implementation == "PyPy":
|
|
275
|
-
candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"])
|
|
276
|
-
|
|
277
|
-
for candidate in candidates:
|
|
278
|
-
full_path = os.path.join(base_dir, candidate)
|
|
279
|
-
if os.path.exists(full_path):
|
|
280
|
-
return full_path
|
|
281
|
-
|
|
282
|
-
return None # in this case we just can't tell easily without poking around FS and calling them, bail
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
self.__dict__.update(vars(self.from_dict(PythonInfoCollector().to_dict())))
|
|
283
84
|
|
|
284
85
|
def install_path(self, key: str) -> str:
|
|
285
86
|
"""
|
|
@@ -295,36 +96,6 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
295
96
|
result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep)
|
|
296
97
|
return result
|
|
297
98
|
|
|
298
|
-
@staticmethod
|
|
299
|
-
def _distutils_install() -> dict[str, str]:
|
|
300
|
-
# use distutils primarily because that's what pip does
|
|
301
|
-
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
|
|
302
|
-
# note here we don't import Distribution directly to allow setuptools to patch it
|
|
303
|
-
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
304
|
-
warnings.simplefilter("ignore")
|
|
305
|
-
try:
|
|
306
|
-
# ruff:ignore[import-outside-top-level]
|
|
307
|
-
from distutils import dist # ty: ignore[unresolved-import]
|
|
308
|
-
|
|
309
|
-
# ruff:ignore[import-outside-top-level]
|
|
310
|
-
from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import]
|
|
311
|
-
except ImportError: # pragma: no cover # if removed or not installed ignore
|
|
312
|
-
return {}
|
|
313
|
-
|
|
314
|
-
distribution = dist.Distribution({
|
|
315
|
-
"script_args": "--no-user-cfg",
|
|
316
|
-
}) # conf files not parsed so they do not hijack paths
|
|
317
|
-
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
|
|
318
|
-
sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
|
|
319
|
-
|
|
320
|
-
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
321
|
-
warnings.simplefilter("ignore")
|
|
322
|
-
install = distribution.get_command_obj("install", create=True)
|
|
323
|
-
|
|
324
|
-
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
|
|
325
|
-
install.finalize_options()
|
|
326
|
-
return {key: (getattr(install, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
|
|
327
|
-
|
|
328
99
|
@property
|
|
329
100
|
def version_str(self) -> str:
|
|
330
101
|
"""The full version as ``major.minor.micro`` string (e.g. ``3.13.2``)."""
|
|
@@ -351,7 +122,7 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
351
122
|
"""``True`` if this interpreter runs inside a PEP 405 venv (has ``base_prefix``)."""
|
|
352
123
|
return self.base_prefix is not None
|
|
353
124
|
|
|
354
|
-
def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
|
|
125
|
+
def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | None = None, sep: str = os.sep) -> str:
|
|
355
126
|
"""
|
|
356
127
|
Return the sysconfig install path for a scheme *key*, optionally substituting config variables.
|
|
357
128
|
|
|
@@ -568,7 +339,7 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
568
339
|
"""Serialize this interpreter information to a JSON string."""
|
|
569
340
|
return json.dumps(self.to_dict(), indent=2)
|
|
570
341
|
|
|
571
|
-
def to_dict(self) -> dict[str,
|
|
342
|
+
def to_dict(self) -> dict[str, InfoValue]:
|
|
572
343
|
"""Convert this interpreter information to a plain dictionary."""
|
|
573
344
|
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
|
|
574
345
|
version_info = data["version_info"]
|
|
@@ -622,14 +393,14 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
622
393
|
return cls.from_dict(raw.copy())
|
|
623
394
|
|
|
624
395
|
@classmethod
|
|
625
|
-
def from_dict(cls, data: dict[str,
|
|
396
|
+
def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo:
|
|
626
397
|
"""
|
|
627
398
|
Reconstruct a :class:`PythonInfo` from a plain dictionary.
|
|
628
399
|
|
|
629
400
|
:param data: dictionary produced by :meth:`to_dict`.
|
|
630
401
|
"""
|
|
631
402
|
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
|
|
632
|
-
result = cls()
|
|
403
|
+
result = cls.__new__(cls) # skip __init__, data replaces the full state
|
|
633
404
|
result.__dict__ = data.copy()
|
|
634
405
|
return result
|
|
635
406
|
|
|
@@ -851,27 +622,9 @@ def normalize_isa(isa: str) -> str:
|
|
|
851
622
|
}.get(low, low)
|
|
852
623
|
|
|
853
624
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
else:
|
|
861
|
-
start_cookie = ""
|
|
862
|
-
|
|
863
|
-
if len(argv) >= 1:
|
|
864
|
-
end_cookie = argv[0]
|
|
865
|
-
argv = argv[1:]
|
|
866
|
-
else:
|
|
867
|
-
end_cookie = ""
|
|
868
|
-
|
|
869
|
-
sys.argv = sys.argv[:1] + argv
|
|
870
|
-
|
|
871
|
-
result = PythonInfo().to_json()
|
|
872
|
-
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
|
|
873
|
-
sys.stdout.flush()
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if __name__ == "__main__":
|
|
877
|
-
_main()
|
|
625
|
+
__all__ = [
|
|
626
|
+
"KNOWN_ARCHITECTURES",
|
|
627
|
+
"PythonInfo",
|
|
628
|
+
"VersionInfo",
|
|
629
|
+
"normalize_isa",
|
|
630
|
+
]
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collect interpreter information, also run as the subprocess interrogation script (stdlib only).
|
|
3
|
+
|
|
4
|
+
Executed by the interpreter being probed. Collection supports Python 3.6+, but the file must parse on 2.7 so the
|
|
5
|
+
version gate below can report older interpreters instead of dying with a ``SyntaxError``: no f-strings, no walrus
|
|
6
|
+
operator, no annotations, no typing imports, no imports from this package. The gate sits above every other import
|
|
7
|
+
so that 3.0 and 3.1, which lack ``sysconfig``, still reach it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
MIN_INTERROGATE_VERSION = (3, 6)
|
|
13
|
+
UNSUPPORTED_EXIT_CODE = 79 # first value past the sysexits.h range, so no stdlib or shell meaning
|
|
14
|
+
UNSUPPORTED_MARKER = "unsupported Python version "
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__" and sys.version_info[:2] < MIN_INTERROGATE_VERSION: # pragma: no cover # needs <3.6
|
|
17
|
+
sys.stderr.write(UNSUPPORTED_MARKER + ".".join(str(i) for i in sys.version_info[:3]))
|
|
18
|
+
raise SystemExit(UNSUPPORTED_EXIT_CODE)
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import re
|
|
24
|
+
import struct
|
|
25
|
+
import sysconfig
|
|
26
|
+
import warnings
|
|
27
|
+
from collections import namedtuple
|
|
28
|
+
|
|
29
|
+
# ruff:ignore[collections-named-tuple] # typing.NamedTuple class syntax needs annotations, unavailable on 3.6
|
|
30
|
+
VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"])
|
|
31
|
+
|
|
32
|
+
_32BIT_POINTER_SIZE = 4
|
|
33
|
+
_CONF_VAR_RE = re.compile(
|
|
34
|
+
r"""
|
|
35
|
+
\{ \w+ } # sysconfig variable placeholder like {base}
|
|
36
|
+
""",
|
|
37
|
+
re.VERBOSE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PythonInfoCollector:
|
|
42
|
+
"""Collects information about the currently running Python interpreter."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._init_identity()
|
|
46
|
+
self._init_prefixes()
|
|
47
|
+
self._init_schemes()
|
|
48
|
+
self._init_sysconfig()
|
|
49
|
+
|
|
50
|
+
def _init_identity(self):
|
|
51
|
+
self.platform = sys.platform
|
|
52
|
+
self.implementation = platform.python_implementation()
|
|
53
|
+
if self.implementation == "GraalVM":
|
|
54
|
+
self.implementation = "GraalPy"
|
|
55
|
+
if self.implementation == "PyPy":
|
|
56
|
+
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
|
|
57
|
+
|
|
58
|
+
self.version_info = VersionInfo(*sys.version_info)
|
|
59
|
+
# same as stdlib platform.architecture to account for pointer size != max int
|
|
60
|
+
self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
|
|
61
|
+
self.sysconfig_platform = sysconfig.get_platform()
|
|
62
|
+
self.version_nodot = sysconfig.get_config_var("py_version_nodot")
|
|
63
|
+
self.version = sys.version
|
|
64
|
+
self.os = os.name
|
|
65
|
+
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
|
|
66
|
+
self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG"))
|
|
67
|
+
|
|
68
|
+
def _init_prefixes(self):
|
|
69
|
+
def abs_path(value):
|
|
70
|
+
return None if value is None else os.path.abspath(value)
|
|
71
|
+
|
|
72
|
+
self.prefix = abs_path(getattr(sys, "prefix", None))
|
|
73
|
+
self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
|
|
74
|
+
self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
|
|
75
|
+
self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
|
|
76
|
+
self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
|
|
77
|
+
|
|
78
|
+
self.executable = abs_path(sys.executable)
|
|
79
|
+
self.original_executable = abs_path(self.executable)
|
|
80
|
+
self.system_executable = self._fast_get_system_executable()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
__import__("venv")
|
|
84
|
+
has = True
|
|
85
|
+
except ImportError: # pragma: no cover # venv is always available in standard CPython
|
|
86
|
+
has = False
|
|
87
|
+
self.has_venv = has
|
|
88
|
+
self.path = sys.path
|
|
89
|
+
self.file_system_encoding = sys.getfilesystemencoding()
|
|
90
|
+
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
|
|
91
|
+
|
|
92
|
+
def _init_schemes(self):
|
|
93
|
+
scheme_names = sysconfig.get_scheme_names()
|
|
94
|
+
|
|
95
|
+
if "venv" in scheme_names: # pragma: >=3.11 cover
|
|
96
|
+
self.sysconfig_scheme = "venv"
|
|
97
|
+
self.sysconfig_paths = {
|
|
98
|
+
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
99
|
+
}
|
|
100
|
+
self.distutils_install = {}
|
|
101
|
+
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
|
|
102
|
+
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
|
|
103
|
+
self.sysconfig_scheme = "posix_prefix"
|
|
104
|
+
self.sysconfig_paths = {
|
|
105
|
+
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
106
|
+
}
|
|
107
|
+
self.distutils_install = {}
|
|
108
|
+
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
|
|
109
|
+
self.sysconfig_scheme = None
|
|
110
|
+
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
|
|
111
|
+
self.distutils_install = self._distutils_install().copy()
|
|
112
|
+
|
|
113
|
+
def _init_sysconfig(self):
|
|
114
|
+
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
|
|
115
|
+
self.sysconfig = {
|
|
116
|
+
k: v
|
|
117
|
+
for k, v in [
|
|
118
|
+
("makefile_filename", makefile() if makefile is not None else None),
|
|
119
|
+
]
|
|
120
|
+
if k is not None
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
config_var_keys = set()
|
|
124
|
+
for element in self.sysconfig_paths.values():
|
|
125
|
+
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
|
|
126
|
+
config_var_keys.add("PYTHONFRAMEWORK")
|
|
127
|
+
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
|
|
128
|
+
|
|
129
|
+
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
|
|
130
|
+
|
|
131
|
+
if "TCL_LIBRARY" in os.environ:
|
|
132
|
+
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
|
|
133
|
+
else:
|
|
134
|
+
self.tcl_lib, self.tk_lib = None, None
|
|
135
|
+
|
|
136
|
+
system_prefix = self.real_prefix or self.base_prefix or self.prefix
|
|
137
|
+
confs = {
|
|
138
|
+
k: (system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
|
|
139
|
+
for k, v in self.sysconfig_vars.items()
|
|
140
|
+
}
|
|
141
|
+
self.system_stdlib = self._sysconfig_path("stdlib", confs)
|
|
142
|
+
self.system_stdlib_platform = self._sysconfig_path("platstdlib", confs)
|
|
143
|
+
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
|
|
144
|
+
self._creators = None # virtualenv-specific, set via monkey-patch
|
|
145
|
+
|
|
146
|
+
def _sysconfig_path(self, key, config_var):
|
|
147
|
+
pattern = self.sysconfig_paths.get(key)
|
|
148
|
+
if pattern is None: # custom builds may ship schemes without stdlib / platstdlib
|
|
149
|
+
return ""
|
|
150
|
+
base = self.sysconfig_vars.copy()
|
|
151
|
+
base.update(config_var)
|
|
152
|
+
return pattern.format(**base).replace("/", os.sep)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _get_tcl_tk_libs(): # pragma: no cover # tkinter availability varies; tested indirectly via __init__
|
|
156
|
+
"""Detect the tcl and tk libraries using tkinter."""
|
|
157
|
+
tcl_lib, tk_lib = None, None
|
|
158
|
+
try:
|
|
159
|
+
import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate
|
|
160
|
+
except ImportError:
|
|
161
|
+
pass
|
|
162
|
+
else:
|
|
163
|
+
try:
|
|
164
|
+
tcl = tk.Tcl()
|
|
165
|
+
tcl_lib = tcl.eval("info library")
|
|
166
|
+
tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib)
|
|
167
|
+
except tk.TclError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
return tcl_lib, tk_lib
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _query_tk_library(tcl): # pragma: no cover
|
|
174
|
+
"""Try to get the TK library path directly from Tcl."""
|
|
175
|
+
import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
tk_lib = tcl.eval("set tk_library")
|
|
179
|
+
if tk_lib and os.path.isdir(tk_lib):
|
|
180
|
+
return tk_lib
|
|
181
|
+
except tk.TclError:
|
|
182
|
+
pass
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover
|
|
187
|
+
"""Resolve the TK library path by direct query or path construction."""
|
|
188
|
+
tk_lib = PythonInfoCollector._query_tk_library(tcl)
|
|
189
|
+
if tk_lib is not None:
|
|
190
|
+
return tk_lib
|
|
191
|
+
tk_version = tcl.eval("package require Tk")
|
|
192
|
+
tcl_parent = os.path.dirname(tcl_lib)
|
|
193
|
+
for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
|
|
194
|
+
tk_lib_path = os.path.join(tcl_parent, "tk{}".format(version))
|
|
195
|
+
if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
|
|
196
|
+
return tk_lib_path
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _fast_get_system_executable(self):
|
|
200
|
+
"""Try to get the system executable by just looking at properties."""
|
|
201
|
+
# if we're not in a virtual environment, this is already a system python, so return the original executable
|
|
202
|
+
# note we must choose the original and not the pure executable as shim scripts might throw us off
|
|
203
|
+
if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
|
|
204
|
+
return self._resolve_executable_symlink(self.original_executable)
|
|
205
|
+
|
|
206
|
+
# if this is NOT a virtual environment, can't determine easily, bail out
|
|
207
|
+
if self.real_prefix is not None:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
|
|
211
|
+
if base_executable is None: # use the saved system executable if present
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
# we know we're in a virtual environment, can not be us
|
|
215
|
+
if sys.executable == base_executable:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# We're not in a venv and base_executable exists; use it directly
|
|
219
|
+
if os.path.exists(base_executable): # pragma: >=3.11 cover
|
|
220
|
+
return self._resolve_executable_symlink(base_executable)
|
|
221
|
+
|
|
222
|
+
# Try fallback for POSIX virtual environments
|
|
223
|
+
return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
|
|
224
|
+
|
|
225
|
+
def _resolve_executable_symlink(self, path):
|
|
226
|
+
"""
|
|
227
|
+
Resolve symlinks of the executable itself, but never of its parent directories.
|
|
228
|
+
|
|
229
|
+
Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
|
|
230
|
+
resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
|
|
231
|
+
kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
|
|
232
|
+
directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
|
|
233
|
+
"""
|
|
234
|
+
result = os.path.abspath(path)
|
|
235
|
+
if self.os != "posix": # pragma: win32 cover # CPython only does this where HAVE_READLINK
|
|
236
|
+
return result
|
|
237
|
+
if sysconfig.get_config_var("PYTHONFRAMEWORK"): # macOS framework builds self-locate via dyld; e.g. Homebrew
|
|
238
|
+
return result # resolving would pin the versioned Cellar path into the recorded home
|
|
239
|
+
real_path = os.path.realpath(result)
|
|
240
|
+
if not os.path.exists(real_path): # symlink loop or broken symlink
|
|
241
|
+
return result
|
|
242
|
+
while os.path.islink(result):
|
|
243
|
+
if self._stdlib_landmark_exists(os.path.dirname(result)):
|
|
244
|
+
return result
|
|
245
|
+
link = os.readlink(result)
|
|
246
|
+
candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
|
|
247
|
+
# normpath through a symlinked directory may point at a different file - stop resolving there
|
|
248
|
+
if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
|
|
249
|
+
return result
|
|
250
|
+
result = candidate
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _stdlib_landmark_exists(dir_path):
|
|
255
|
+
lib_name = os.path.basename(os.path.dirname(os.__file__))
|
|
256
|
+
return any(
|
|
257
|
+
os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _try_posix_fallback_executable(self, base_executable):
|
|
261
|
+
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
|
|
262
|
+
major, minor = self.version_info.major, self.version_info.minor
|
|
263
|
+
if self.os != "posix" or (major, minor) < (3, 11):
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
# search relative to the directory of sys._base_executable
|
|
267
|
+
base_dir = os.path.dirname(base_executable)
|
|
268
|
+
candidates = ["python{}".format(major), "python{}.{}".format(major, minor)]
|
|
269
|
+
if self.implementation == "PyPy":
|
|
270
|
+
candidates.extend(["pypy", "pypy3", "pypy{}".format(major), "pypy{}.{}".format(major, minor)])
|
|
271
|
+
|
|
272
|
+
for candidate in candidates:
|
|
273
|
+
full_path = os.path.join(base_dir, candidate)
|
|
274
|
+
if os.path.exists(full_path):
|
|
275
|
+
return full_path
|
|
276
|
+
|
|
277
|
+
return None # in this case we just can't tell easily without poking around FS and calling them, bail
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme instead
|
|
281
|
+
# use distutils primarily because that's what pip does
|
|
282
|
+
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
|
|
283
|
+
# note here we don't import Distribution directly to allow setuptools to patch it
|
|
284
|
+
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
285
|
+
warnings.simplefilter("ignore")
|
|
286
|
+
try:
|
|
287
|
+
# ruff:ignore[import-outside-top-level]
|
|
288
|
+
from distutils import dist # ty: ignore[unresolved-import]
|
|
289
|
+
|
|
290
|
+
# ruff:ignore[import-outside-top-level]
|
|
291
|
+
from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import]
|
|
292
|
+
except ImportError: # pragma: no cover # if removed or not installed ignore
|
|
293
|
+
return {}
|
|
294
|
+
|
|
295
|
+
distribution = dist.Distribution({
|
|
296
|
+
"script_args": "--no-user-cfg",
|
|
297
|
+
}) # conf files not parsed so they do not hijack paths
|
|
298
|
+
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
|
|
299
|
+
sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
|
|
300
|
+
|
|
301
|
+
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
302
|
+
warnings.simplefilter("ignore")
|
|
303
|
+
install = distribution.get_command_obj("install", create=True)
|
|
304
|
+
|
|
305
|
+
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
|
|
306
|
+
install.finalize_options()
|
|
307
|
+
return {key: (getattr(install, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
|
|
308
|
+
|
|
309
|
+
def to_dict(self):
|
|
310
|
+
"""Convert the collected information to a plain dictionary."""
|
|
311
|
+
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
|
|
312
|
+
data["version_info"] = self.version_info._asdict()
|
|
313
|
+
return data
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _main(): # pragma: no cover # exercised via subprocess runs
|
|
317
|
+
argv = sys.argv[1:]
|
|
318
|
+
|
|
319
|
+
if len(argv) >= 1:
|
|
320
|
+
start_cookie = argv[0]
|
|
321
|
+
argv = argv[1:]
|
|
322
|
+
else:
|
|
323
|
+
start_cookie = ""
|
|
324
|
+
|
|
325
|
+
if len(argv) >= 1:
|
|
326
|
+
end_cookie = argv[0]
|
|
327
|
+
argv = argv[1:]
|
|
328
|
+
else:
|
|
329
|
+
end_cookie = ""
|
|
330
|
+
|
|
331
|
+
sys.argv = sys.argv[:1] + argv
|
|
332
|
+
|
|
333
|
+
result = json.dumps(PythonInfoCollector().to_dict(), indent=2)
|
|
334
|
+
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
|
|
335
|
+
sys.stdout.flush()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = [
|
|
339
|
+
"MIN_INTERROGATE_VERSION",
|
|
340
|
+
"UNSUPPORTED_MARKER",
|
|
341
|
+
"PythonInfoCollector",
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
_main()
|
python_discovery/_specifier.py
CHANGED
|
@@ -9,7 +9,9 @@ import sys
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from typing import TYPE_CHECKING, Final
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
DC_KW: Final[dict[str, bool]] = (
|
|
13
|
+
{"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
|
|
14
|
+
)
|
|
13
15
|
|
|
14
16
|
if TYPE_CHECKING:
|
|
15
17
|
from collections.abc import Iterator
|
|
@@ -38,7 +40,7 @@ _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
|
|
|
38
40
|
_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
@dataclass(**
|
|
43
|
+
@dataclass(**DC_KW)
|
|
42
44
|
class SimpleVersion:
|
|
43
45
|
"""
|
|
44
46
|
Simple PEP 440-like version parser using only standard library.
|
|
@@ -125,7 +127,7 @@ class SimpleVersion:
|
|
|
125
127
|
return f"SimpleVersion('{self.version_str}')"
|
|
126
128
|
|
|
127
129
|
|
|
128
|
-
@dataclass(**
|
|
130
|
+
@dataclass(**DC_KW)
|
|
129
131
|
class SimpleSpecifier:
|
|
130
132
|
"""
|
|
131
133
|
Simple PEP 440-like version specifier using only standard library.
|
|
@@ -247,7 +249,7 @@ class SimpleSpecifier:
|
|
|
247
249
|
return f"SimpleSpecifier('{self.spec_str}')"
|
|
248
250
|
|
|
249
251
|
|
|
250
|
-
@dataclass(**
|
|
252
|
+
@dataclass(**DC_KW)
|
|
251
253
|
class SimpleSpecifierSet:
|
|
252
254
|
"""
|
|
253
255
|
Simple PEP 440-like specifier set using only standard library.
|
|
@@ -305,6 +307,7 @@ class SimpleSpecifierSet:
|
|
|
305
307
|
|
|
306
308
|
|
|
307
309
|
__all__ = [
|
|
310
|
+
"DC_KW",
|
|
308
311
|
"SimpleSpecifier",
|
|
309
312
|
"SimpleSpecifierSet",
|
|
310
313
|
"SimpleVersion",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-discovery
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.2
|
|
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
|
|
@@ -48,7 +48,6 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
48
48
|
Classifier: Topic :: Utilities
|
|
49
49
|
Requires-Python: >=3.8
|
|
50
50
|
Requires-Dist: filelock>=3.15.4
|
|
51
|
-
Requires-Dist: platformdirs<5,>=4.3.6
|
|
52
51
|
Description-Content-Type: text/markdown
|
|
53
52
|
|
|
54
53
|
# [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
|
|
@@ -69,6 +68,9 @@ the right one for you.
|
|
|
69
68
|
Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
|
|
70
69
|
and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
|
|
71
70
|
|
|
71
|
+
The library runs on Python 3.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
|
|
72
|
+
warning naming the version it found.
|
|
73
|
+
|
|
72
74
|
## Usage
|
|
73
75
|
|
|
74
76
|
```python
|
|
@@ -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=MKV2sjrrypq-7gcOdw9GpAooMJl9kYCTAlYeUwO3ndQ,10473
|
|
4
|
+
python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
|
|
5
|
+
python_discovery/_discovery.py,sha256=ABPD8MRweUO6B7cG3U3ZBfUIYs08rh6dYPA225EURyY,22032
|
|
6
|
+
python_discovery/_py_info.py,sha256=XX4yM6TVhTMFfmahNMekT7Y3Zy12Zy0IAfmz_xFRceU,25284
|
|
7
|
+
python_discovery/_py_info_collect.py,sha256=NSh00CS1UHI6_fbXrXUi-vlvFAEOix2NnC7Is10Taeg,15439
|
|
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.5.2.dist-info/METADATA,sha256=4kPFZVceJ_w4hP8AKPig_59eT502_O4NEzjeTyjMECw,5132
|
|
15
|
+
python_discovery-1.5.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
16
|
+
python_discovery-1.5.2.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
|
|
17
|
+
python_discovery-1.5.2.dist-info/RECORD,,
|
|
@@ -1,16 +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=l9Y9UJGjKjp4M7iR9HfXYzW3hWdQ3KyNOD5IbSEphLw,8659
|
|
4
|
-
python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
|
|
5
|
-
python_discovery/_discovery.py,sha256=HF_TIuH6WLpvHU1KQiK9_yWfJ-np1eB9OSJwI1xRgdc,16894
|
|
6
|
-
python_discovery/_py_info.py,sha256=sXsOX4epGDRhrLvEcpDThh3r1yKMf5KZVaF2ZegoSOA,37311
|
|
7
|
-
python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
|
|
8
|
-
python_discovery/_specifier.py,sha256=Mu9Wp0tgY3WhXNffsG8Je8K3uc-vxqadEdvvQzWIanA,10416
|
|
9
|
-
python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
|
|
11
|
-
python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
|
|
12
|
-
python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
|
|
13
|
-
python_discovery-1.5.0.dist-info/METADATA,sha256=aDIMeMkRg8kO2fVDwL41lD5DgT_u9Iu8tokFsq-0UPY,5018
|
|
14
|
-
python_discovery-1.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
-
python_discovery-1.5.0.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
|
|
16
|
-
python_discovery-1.5.0.dist-info/RECORD,,
|
|
File without changes
|