python-discovery 1.5.1__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.
@@ -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 = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
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
- if py_info_store.exists() and (data := py_info_store.read()) is not None:
96
- of_path, of_st_mtime = data.get("path"), data.get("st_mtime")
97
- of_content, of_hash = data.get("content"), data.get("hash")
98
- if (
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
- py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
153
- if py_info_script.is_file():
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__, "_py_info.py")
181
+ data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name)
157
182
  if data is None:
158
- msg = "cannot locate _py_info.py for subprocess interrogation"
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
- msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
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
@@ -1,4 +1,4 @@
1
- """Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only)."""
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
- def __init__(self) -> None:
56
- self._init_identity()
57
- self._init_prefixes()
58
- self._init_schemes()
59
- self._init_sysconfig()
60
-
61
- def _init_identity(self) -> None:
62
- self.platform = sys.platform
63
- self.implementation = platform.python_implementation()
64
- if self.implementation == "GraalVM":
65
- self.implementation = "GraalPy"
66
- if self.implementation == "PyPy":
67
- self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
68
-
69
- self.version_info = VersionInfo(*sys.version_info)
70
- # same as stdlib platform.architecture to account for pointer size != max int
71
- self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
72
- self.sysconfig_platform = sysconfig.get_platform()
73
- self.version_nodot = sysconfig.get_config_var("py_version_nodot")
74
- self.version = sys.version
75
- self.os = os.name
76
- self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
77
- self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG"))
78
-
79
- def _init_prefixes(self) -> None:
80
- def abs_path(value: str | None) -> str | None:
81
- return None if value is None else os.path.abspath(value)
82
-
83
- self.prefix = abs_path(getattr(sys, "prefix", None))
84
- self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
85
- self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
86
- self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
87
- self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
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
- # Try fallback for POSIX virtual environments
226
- return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
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, object]:
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, object]) -> PythonInfo:
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
- def _main() -> None: # pragma: no cover
855
- argv = sys.argv[1:]
856
-
857
- if len(argv) >= 1:
858
- start_cookie = argv[0]
859
- argv = argv[1:]
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()
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-discovery
3
- Version: 1.5.1
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
@@ -68,6 +68,9 @@ the right one for you.
68
68
  Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
69
69
  and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
70
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
+
71
74
  ## Usage
72
75
 
73
76
  ```python
@@ -1,16 +1,17 @@
1
1
  python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
2
2
  python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
3
- python_discovery/_cached_py_info.py,sha256=l9Y9UJGjKjp4M7iR9HfXYzW3hWdQ3KyNOD5IbSEphLw,8659
3
+ python_discovery/_cached_py_info.py,sha256=MKV2sjrrypq-7gcOdw9GpAooMJl9kYCTAlYeUwO3ndQ,10473
4
4
  python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
5
  python_discovery/_discovery.py,sha256=ABPD8MRweUO6B7cG3U3ZBfUIYs08rh6dYPA225EURyY,22032
6
- python_discovery/_py_info.py,sha256=sXsOX4epGDRhrLvEcpDThh3r1yKMf5KZVaF2ZegoSOA,37311
6
+ python_discovery/_py_info.py,sha256=XX4yM6TVhTMFfmahNMekT7Y3Zy12Zy0IAfmz_xFRceU,25284
7
+ python_discovery/_py_info_collect.py,sha256=NSh00CS1UHI6_fbXrXUi-vlvFAEOix2NnC7Is10Taeg,15439
7
8
  python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
8
9
  python_discovery/_specifier.py,sha256=o7MHb0z_mFSLz9gXqMwJoNkHYjgi2biq3Ibvd0_slvc,10457
9
10
  python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
11
  python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
11
12
  python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
12
13
  python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
13
- python_discovery-1.5.1.dist-info/METADATA,sha256=DWitu_0XNhVQusKTT4aIXioAlImTpg-XH4Yp5ZhHbRc,4980
14
- python_discovery-1.5.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
- python_discovery-1.5.1.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
16
- python_discovery-1.5.1.dist-info/RECORD,,
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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any