ducktools-pythonfinder 0.7.8__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/.gitignore +1 -0
  2. {ducktools_pythonfinder-0.7.8/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.8.0}/PKG-INFO +1 -1
  3. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__init__.py +4 -3
  4. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__main__.py +9 -1
  5. ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/_version.py +2 -0
  6. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/details_script.py +5 -3
  7. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/linux/__init__.py +26 -11
  8. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/linux/pyenv_search.py +49 -29
  9. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/shared.py +214 -60
  10. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/venv.py +10 -3
  11. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/win32/__init__.py +18 -10
  12. ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/win32/pyenv_search.py +106 -0
  13. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
  14. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +1 -0
  15. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/conftest.py +16 -7
  16. ducktools_pythonfinder-0.8.0/tests/test_cache.py +227 -0
  17. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_foldersearch.py +11 -9
  18. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_pyenv.py +19 -18
  19. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_uv_finder.py +12 -6
  20. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_venv_finder.py +2 -2
  21. ducktools_pythonfinder-0.7.8/src/ducktools/pythonfinder/_version.py +0 -2
  22. ducktools_pythonfinder-0.7.8/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -102
  23. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/LICENSE +0 -0
  24. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/MANIFEST.in +0 -0
  25. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/README.md +0 -0
  26. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/pyproject.toml +0 -0
  27. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/build_zipapp.py +0 -0
  28. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/detail_this_python.py +0 -0
  29. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/list_python_venvs.py +0 -0
  30. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/print_python_versions.py +0 -0
  31. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/setup.cfg +0 -0
  32. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
  33. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/package_list_script.py +0 -0
  34. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
  35. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/win32/registry_search.py +0 -0
  36. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  37. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  38. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
  39. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  40. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/sources/python_versions.txt +0 -0
  41. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/sources/release.json +0 -0
  42. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/sources/release_file.json +0 -0
  43. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_details_script.py +0 -0
  44. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_orgsearch.py +0 -0
  45. {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_shared.py +0 -0
@@ -117,6 +117,7 @@ celerybeat.pid
117
117
  # Environments
118
118
  .env
119
119
  .venv
120
+ .venv_*/
120
121
  env/
121
122
  venv/
122
123
  ENV/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.7.8
3
+ Version: 0.8.0
4
4
  Summary: Cross platform tool to find available python installations
5
5
  Author: David C Ellis
6
6
  Project-URL: Homepage, https://github.com/davidcellis/ducktools-pythonfinder
@@ -32,7 +32,7 @@ __all__ = [
32
32
 
33
33
  import sys
34
34
  from ._version import __version__
35
- from .shared import PythonInstall
35
+ from .shared import PythonInstall, DetailFinder
36
36
 
37
37
 
38
38
  if sys.platform == "win32":
@@ -43,9 +43,10 @@ else:
43
43
  from .linux import get_python_installs
44
44
 
45
45
 
46
- def list_python_installs(*, query_executables=True):
46
+ def list_python_installs(*, query_executables: bool = True, finder: "DetailFinder | None" = None):
47
+ finder = DetailFinder() if finder is None else finder
47
48
  return sorted(
48
- get_python_installs(query_executables=query_executables),
49
+ get_python_installs(query_executables=query_executables, finder=finder),
49
50
  reverse=True,
50
51
  key=lambda x: (x.version[3], *x.version[:3], x.version[4])
51
52
  )
@@ -28,6 +28,7 @@ import os
28
28
 
29
29
  from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
30
30
  from ducktools.pythonfinder import list_python_installs, __version__
31
+ from ducktools.pythonfinder.shared import purge_caches
31
32
 
32
33
  _laz = LazyImporter(
33
34
  [
@@ -122,6 +123,11 @@ def get_parser():
122
123
 
123
124
  subparsers = parser.add_subparsers(dest="command", required=False)
124
125
 
126
+ clear_cache = subparsers.add_parser(
127
+ "clear-cache",
128
+ help="Clear the cache of Python install details"
129
+ )
130
+
125
131
  online = subparsers.add_parser(
126
132
  "online",
127
133
  help="Get links to binaries from python.org"
@@ -305,7 +311,9 @@ def main():
305
311
  parser = get_parser()
306
312
  vals = parser.parse_args(sys.argv[1:])
307
313
 
308
- if vals.command == "online":
314
+ if vals.command == "clear-cache":
315
+ purge_caches()
316
+ elif vals.command == "online":
309
317
  system = vals.system if vals.system else _laz.platform.system()
310
318
  machine = vals.machine if vals.machine else _laz.platform.machine()
311
319
  try:
@@ -0,0 +1,2 @@
1
+ __version__ = "0.8.0"
2
+ __version_tuple__ = (0, 8, 0)
@@ -29,11 +29,13 @@ import sys
29
29
  FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)-?(?P<releaselevel>a|b|c|rc)?(?P<serial>\d*)?"
30
30
 
31
31
 
32
- def version_str_to_tuple(version):
32
+ def version_str_to_tuple(version: str):
33
33
  # Needed to parse GraalPy versions only available as strings
34
34
  import re
35
35
 
36
36
  parsed_version = re.fullmatch(FULL_PY_VER_RE, version)
37
+ if parsed_version is None:
38
+ raise ValueError(f"'version' must be a valid Python version string, not {version!r}")
37
39
 
38
40
  major, minor, micro, releaselevel, serial = parsed_version.groups()
39
41
 
@@ -70,11 +72,11 @@ def get_details():
70
72
  # Special case GraalPy as it erroneously reports the CPython target
71
73
  # instead of the Graal versiion
72
74
  try:
73
- ver = __graalpython__.get_graalvm_version()
75
+ ver = __graalpython__.get_graalvm_version() # type: ignore
74
76
  metadata = {
75
77
  "graalpy_version": version_str_to_tuple(ver)
76
78
  }
77
- except NameError:
79
+ except (NameError, ValueError):
78
80
  metadata = {"{}_version".format(implementation): sys.implementation.version}
79
81
  elif implementation != "cpython": # pragma: no cover
80
82
  metadata = {"{}_version".format(implementation): sys.implementation.version}
@@ -27,11 +27,17 @@ import os.path
27
27
  import itertools
28
28
  from _collections_abc import Iterator
29
29
 
30
- from ..shared import PythonInstall, get_folder_pythons, get_uv_pythons, get_uv_python_path
30
+ from ..shared import (
31
+ DetailFinder,
32
+ PythonInstall,
33
+ get_folder_pythons,
34
+ get_uv_pythons,
35
+ get_uv_python_path
36
+ )
31
37
  from .pyenv_search import get_pyenv_pythons, get_pyenv_root
32
38
 
33
39
 
34
- def get_path_pythons() -> Iterator[PythonInstall]:
40
+ def get_path_pythons(*, finder: DetailFinder | None = None) -> Iterator[PythonInstall]:
35
41
  exe_names = set()
36
42
 
37
43
  path_folders = os.environ.get("PATH", "").split(":")
@@ -40,6 +46,8 @@ def get_path_pythons() -> Iterator[PythonInstall]:
40
46
 
41
47
  excluded_folders = [pyenv_root, uv_root]
42
48
 
49
+ finder = DetailFinder if finder is None else finder
50
+
43
51
  for fld in path_folders:
44
52
  # Don't retrieve pyenv installs
45
53
  skip_folder = False
@@ -54,7 +62,7 @@ def get_path_pythons() -> Iterator[PythonInstall]:
54
62
  if not os.path.exists(fld):
55
63
  continue
56
64
 
57
- for install in get_folder_pythons(fld):
65
+ for install in get_folder_pythons(fld, finder=finder):
58
66
  name = os.path.basename(install.executable)
59
67
  if name in exe_names:
60
68
  install.shadowed = True
@@ -63,17 +71,24 @@ def get_path_pythons() -> Iterator[PythonInstall]:
63
71
  yield install
64
72
 
65
73
 
66
- def get_python_installs(*, query_executables=True):
74
+ def get_python_installs(
75
+ *,
76
+ query_executables: bool = True,
77
+ finder: DetailFinder | None = None,
78
+ ) -> Iterator[PythonInstall]:
67
79
  listed_pythons = set()
68
80
 
81
+ finder = DetailFinder() if finder is None else finder
82
+
69
83
  chain_commands = [
70
- get_pyenv_pythons(query_executables=query_executables),
71
- get_uv_pythons(query_executables=query_executables),
84
+ get_pyenv_pythons(query_executables=query_executables, finder=finder),
85
+ get_uv_pythons(query_executables=query_executables, finder=finder),
72
86
  ]
73
87
  if query_executables:
74
- chain_commands.append(get_path_pythons())
88
+ chain_commands.append(get_path_pythons(finder=finder))
75
89
 
76
- for py in itertools.chain.from_iterable(chain_commands):
77
- if py.executable not in listed_pythons:
78
- yield py
79
- listed_pythons.add(py.executable)
90
+ with finder:
91
+ for py in itertools.chain.from_iterable(chain_commands):
92
+ if py.executable not in listed_pythons:
93
+ yield py
94
+ listed_pythons.add(py.executable)
@@ -22,7 +22,6 @@
22
22
  # SOFTWARE.
23
23
  from __future__ import annotations
24
24
 
25
-
26
25
  """
27
26
  Discover python installs that have been created with pyenv
28
27
  """
@@ -33,10 +32,11 @@ from _collections_abc import Iterator
33
32
 
34
33
  from ducktools.lazyimporter import LazyImporter, FromImport, ModuleImport
35
34
 
36
- from ..shared import PythonInstall, get_install_details, FULL_PY_VER_RE, version_str_to_tuple
35
+ from ..shared import PythonInstall, DetailFinder, FULL_PY_VER_RE, INSTALLER_CACHE_PATH, version_str_to_tuple
37
36
 
38
37
  _laz = LazyImporter(
39
38
  [
39
+ ModuleImport("json"),
40
40
  ModuleImport("re"),
41
41
  FromImport("subprocess", "run"),
42
42
  ]
@@ -52,11 +52,24 @@ def get_pyenv_root() -> str | None:
52
52
  pyenv_root = os.environ.get("PYENV_ROOT")
53
53
  if not pyenv_root:
54
54
  try:
55
- output = _laz.run(["pyenv", "root"], capture_output=True, text=True)
56
- except FileNotFoundError:
57
- return None
55
+ with open(INSTALLER_CACHE_PATH) as f:
56
+ installer_cache = _laz.json.load(f)
57
+ except (FileNotFoundError, _laz.json.JSONDecodeError):
58
+ installer_cache = {}
59
+
60
+ pyenv_root = installer_cache.get("pyenv")
61
+ if pyenv_root is None or not os.path.exists(pyenv_root):
62
+ try:
63
+ output = _laz.run(["pyenv", "root"], capture_output=True, text=True)
64
+ except FileNotFoundError:
65
+ return None
66
+
67
+ pyenv_root = output.stdout.strip()
58
68
 
59
- pyenv_root = output.stdout.strip()
69
+ installer_cache["pyenv"] = pyenv_root
70
+ os.makedirs(os.path.dirname(INSTALLER_CACHE_PATH), exist_ok=True)
71
+ with open(INSTALLER_CACHE_PATH, 'w') as f:
72
+ _laz.json.dump(installer_cache, f)
60
73
 
61
74
  return pyenv_root
62
75
 
@@ -65,6 +78,7 @@ def get_pyenv_pythons(
65
78
  versions_folder: str | os.PathLike | None = None,
66
79
  *,
67
80
  query_executables: bool = True,
81
+ finder: DetailFinder = None,
68
82
  ) -> Iterator[PythonInstall]:
69
83
  if versions_folder is None:
70
84
  if pyenv_root := get_pyenv_root():
@@ -77,26 +91,32 @@ def get_pyenv_pythons(
77
91
  # This can lead to much faster returns by potentially yielding
78
92
  # the required python version before checking pypy/graalpy/micropython
79
93
 
80
- for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
81
- executable = os.path.join(p.path, "bin/python")
82
-
83
- if os.path.exists(executable):
84
- if p.name.endswith("t"):
85
- freethreaded = True
86
- version = p.name[:-1]
87
- else:
88
- freethreaded = False
89
- version = p.name
90
- if _laz.re.fullmatch(FULL_PY_VER_RE, version):
91
- version_tuple = version_str_to_tuple(version)
92
- metadata = {}
93
- if version_tuple >= (3, 13):
94
- metadata["freethreaded"] = freethreaded
95
- yield PythonInstall(
96
- version=version_tuple,
97
- executable=executable,
98
- metadata=metadata,
99
- managed_by="pyenv",
100
- )
101
- elif query_executables and (install := get_install_details(executable, managed_by="pyenv")):
102
- yield install
94
+ finder = DetailFinder() if finder is None else finder
95
+
96
+ with finder:
97
+ for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
98
+ executable = os.path.join(p.path, "bin/python")
99
+
100
+ if os.path.exists(executable):
101
+ if p.name.endswith("t"):
102
+ freethreaded = True
103
+ version = p.name[:-1]
104
+ else:
105
+ freethreaded = False
106
+ version = p.name
107
+ if _laz.re.fullmatch(FULL_PY_VER_RE, version):
108
+ version_tuple = version_str_to_tuple(version)
109
+ metadata = {}
110
+ if version_tuple >= (3, 13):
111
+ metadata["freethreaded"] = freethreaded
112
+ yield PythonInstall(
113
+ version=version_tuple,
114
+ executable=executable,
115
+ metadata=metadata,
116
+ managed_by="pyenv",
117
+ )
118
+ elif (
119
+ query_executables
120
+ and (install := finder.get_install_details(executable, managed_by="pyenv"))
121
+ ):
122
+ yield install
@@ -28,7 +28,7 @@ import os.path
28
28
 
29
29
  from _collections_abc import Iterator
30
30
 
31
- from ducktools.classbuilder.prefab import Prefab, attribute
31
+ from ducktools.classbuilder.prefab import Prefab, attribute, as_dict
32
32
  from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
33
33
 
34
34
  from . import details_script
@@ -39,13 +39,13 @@ _laz = LazyImporter(
39
39
  ModuleImport("json"),
40
40
  ModuleImport("platform"),
41
41
  ModuleImport("re"),
42
+ ModuleImport("shutil"),
42
43
  ModuleImport("subprocess"),
43
44
  ModuleImport("tempfile"),
44
45
  ModuleImport("zipfile"),
45
46
  ]
46
47
  )
47
48
 
48
-
49
49
  FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)-?(?P<releaselevel>a|b|c|rc)?(?P<serial>\d*)?"
50
50
 
51
51
  UV_PYTHON_RE = (
@@ -56,6 +56,38 @@ UV_PYTHON_RE = (
56
56
  r"-.*"
57
57
  )
58
58
 
59
+ # Cache for runtime details
60
+ # Code to work out where to store data
61
+ # Store in LOCALAPPDATA for windows, User folder for other operating systems
62
+ if sys.platform == "win32":
63
+ # os.path.expandvars will actually import a whole bunch of other modules
64
+ # Try just using the environment.
65
+ if _local_app_folder := os.environ.get("LOCALAPPDATA"):
66
+ if not os.path.isdir(_local_app_folder):
67
+ raise FileNotFoundError(
68
+ f"Could not find local app data folder {_local_app_folder}"
69
+ )
70
+ else:
71
+ raise EnvironmentError(
72
+ "Environment variable %LOCALAPPDATA% "
73
+ "for local application data folder location "
74
+ "not found"
75
+ )
76
+ USER_FOLDER = _local_app_folder
77
+ CACHE_FOLDER = os.path.join(USER_FOLDER, "ducktools", "pythonfinder", "cache")
78
+ else:
79
+ USER_FOLDER = os.path.expanduser("~")
80
+ CACHE_FOLDER = os.path.join(USER_FOLDER, ".cache", "ducktools", "pythonfinder")
81
+
82
+
83
+ CACHE_VERSION = 1
84
+ DETAILS_CACHE_PATH = os.path.join(CACHE_FOLDER, f"runtime_cache_v{CACHE_VERSION}.json")
85
+ INSTALLER_CACHE_PATH = os.path.join(CACHE_FOLDER, "installer_details.json")
86
+
87
+
88
+ def purge_caches(cache_folder=CACHE_FOLDER):
89
+ _laz.shutil.rmtree(cache_folder, ignore_errors=True)
90
+
59
91
 
60
92
  def version_str_to_tuple(version):
61
93
  parsed_version = _laz.re.fullmatch(FULL_PY_VER_RE, version)
@@ -129,7 +161,141 @@ class DetailsScript(Prefab):
129
161
  return self._source_code
130
162
 
131
163
 
132
- details = DetailsScript()
164
+ class DetailFinder(Prefab):
165
+ cache_path: str = DETAILS_CACHE_PATH
166
+ details_script: DetailsScript = attribute(default_factory=DetailsScript)
167
+
168
+ # Stores the dict loaded from the JSON file without processing
169
+ _raw_cache: dict | None = attribute(default=None, private=True)
170
+
171
+ # Indicates if the cache is known to have changed
172
+ _dirty_cache: bool = attribute(default=False, private=True)
173
+
174
+ # Increased each re-entry to the context manager
175
+ # Decreased on exit
176
+ # Save should only occur when all contexts exit
177
+ _context_level: int = attribute(default=0, private=True)
178
+
179
+ def __enter__(self):
180
+ self._context_level += 1
181
+ return self
182
+
183
+ def __exit__(self, exc_type, exc_val, exc_tb):
184
+ self._context_level -= 1
185
+ if (
186
+ exc_type in {None, GeneratorExit}
187
+ and self._dirty_cache
188
+ and self._context_level == 0
189
+ ):
190
+ self.save()
191
+
192
+ @property
193
+ def raw_cache(self):
194
+ if self._raw_cache is None:
195
+ try:
196
+ with open(self.cache_path) as f:
197
+ self._raw_cache = _laz.json.load(f)
198
+ except (_laz.json.JSONDecodeError, FileNotFoundError):
199
+ self._raw_cache = {}
200
+ return self._raw_cache
201
+
202
+ def save(self):
203
+ os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
204
+ with open(self.cache_path, 'w') as f:
205
+ _laz.json.dump(self.raw_cache, f, indent=4)
206
+
207
+ self._dirty_cache = False
208
+
209
+ def clear_invalid_runtimes(self):
210
+ """
211
+ Remove cache entries where the python.exe no longer exists
212
+ """
213
+ removed_runtimes: set[str] = set()
214
+ for exe_path in self.raw_cache.copy().keys():
215
+ if not os.path.exists(exe_path):
216
+ self.raw_cache.pop(exe_path)
217
+ removed_runtimes.add(exe_path)
218
+ if removed_runtimes:
219
+ self._dirty_cache = True
220
+
221
+ def clear_cache(self):
222
+ """
223
+ Completely empty the cache
224
+ """
225
+ self._raw_cache = {}
226
+ self._dirty_cache = True
227
+
228
+ def query_install(self, exe_path: str, managed_by: str | None = None) -> PythonInstall | None:
229
+ """
230
+ Query the details of a Python install directly
231
+
232
+ :param exe_path: Path to the runtime .exe
233
+ :param managed_by: Which tool manages this install (if any)
234
+ :return: a PythonInstall if one exists at the exe Path
235
+ """
236
+ try:
237
+ source = self.details_script.get_source_code()
238
+ except FileNotFoundError:
239
+ return None
240
+
241
+ try:
242
+ detail_output = _laz.subprocess.run(
243
+ [exe_path, "-"],
244
+ input=source,
245
+ capture_output=True,
246
+ text=True,
247
+ check=True,
248
+ ).stdout
249
+ except OSError:
250
+ # Something else has gone wrong
251
+ return None
252
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
253
+ # Potentially this is micropython which does not support
254
+ # piping from stdin. Try using a file in a temporary folder.
255
+ # Python 3.12 has delete_on_close that would make TemporaryFile
256
+ # Usable on windows but for now use a directory
257
+ with _laz.tempfile.TemporaryDirectory() as tempdir:
258
+ temp_script = os.path.join(tempdir, "details_script.py")
259
+ with open(temp_script, "w") as f:
260
+ f.write(source)
261
+ try:
262
+ detail_output = _laz.subprocess.run(
263
+ [exe_path, temp_script],
264
+ capture_output=True,
265
+ text=True,
266
+ check=True,
267
+ ).stdout
268
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
269
+ return None
270
+
271
+ try:
272
+ output = _laz.json.loads(detail_output)
273
+ except _laz.json.JSONDecodeError:
274
+ return None
275
+
276
+ return PythonInstall.from_json(**output, managed_by=managed_by)
277
+
278
+ def get_install_details(self, exe_path: str, managed_by=None) -> PythonInstall | None:
279
+ exe_path = os.path.abspath(exe_path)
280
+ mtime = os.stat(exe_path).st_mtime
281
+
282
+ install = None
283
+ if cached_details := self.raw_cache.get(exe_path):
284
+ if cached_details["mtime"] == mtime:
285
+ install = PythonInstall.from_json(**cached_details["install"])
286
+ else:
287
+ self.raw_cache.pop(exe_path)
288
+
289
+ if install is None:
290
+ install = self.query_install(exe_path, managed_by)
291
+ if install:
292
+ self.raw_cache[exe_path] = {
293
+ "mtime": mtime,
294
+ "install": as_dict(install)
295
+ }
296
+ self._dirty_cache = True
297
+
298
+ return install
133
299
 
134
300
 
135
301
  class PythonInstall(Prefab):
@@ -139,7 +305,7 @@ class PythonInstall(Prefab):
139
305
  implementation: str = "cpython"
140
306
  managed_by: str | None = None
141
307
  metadata: dict = attribute(default_factory=dict)
142
- shadowed: bool = False
308
+ shadowed: bool = attribute(default=False, serialize=False)
143
309
  _implementation_version: tuple[int, int, int, str, int] | None = attribute(default=None, private=True)
144
310
 
145
311
  def __prefab_post_init__(
@@ -197,13 +363,21 @@ class PythonInstall(Prefab):
197
363
  )
198
364
 
199
365
  @classmethod
200
- def from_json(cls, version, executable, architecture, implementation, metadata, managed_by=None):
366
+ def from_json(
367
+ cls,
368
+ version,
369
+ executable,
370
+ architecture,
371
+ implementation,
372
+ metadata,
373
+ managed_by=None,
374
+ ):
201
375
  if arch_ver := metadata.get(f"{implementation}_version"):
202
376
  metadata[f"{implementation}_version"] = tuple(arch_ver)
203
377
 
204
378
  # noinspection PyArgumentList
205
379
  return cls(
206
- version=tuple(version),
380
+ version=tuple(version), # type: ignore
207
381
  executable=executable,
208
382
  architecture=architecture,
209
383
  implementation=implementation,
@@ -238,63 +412,22 @@ def _python_exe_regex(basename: str = "python"):
238
412
  return _laz.re.compile(rf"{basename}\d?\.?\d*")
239
413
 
240
414
 
241
- def get_install_details(executable: str, managed_by=None) -> PythonInstall | None:
242
- try:
243
- source = details.get_source_code()
244
- except FileNotFoundError:
245
- return None
246
-
247
- try:
248
- detail_output = _laz.subprocess.run(
249
- [executable, "-"],
250
- input=source,
251
- capture_output=True,
252
- text=True,
253
- check=True,
254
- ).stdout
255
- except OSError:
256
- # Something else has gone wrong
257
- return None
258
- except (_laz.subprocess.CalledProcessError, FileNotFoundError):
259
- # Potentially this is micropython which does not support
260
- # piping from stdin. Try using a file in a temporary folder.
261
- # Python 3.12 has delete_on_close that would make TemporaryFile
262
- # Usable on windows but for now use a directory
263
- with _laz.tempfile.TemporaryDirectory() as tempdir:
264
- temp_script = os.path.join(tempdir, "details_script.py")
265
- with open(temp_script, "w") as f:
266
- f.write(source)
267
- try:
268
- detail_output = _laz.subprocess.run(
269
- [executable, temp_script],
270
- capture_output=True,
271
- text=True,
272
- check=True,
273
- ).stdout
274
- except (_laz.subprocess.CalledProcessError, FileNotFoundError):
275
- return None
276
-
277
- try:
278
- output = _laz.json.loads(detail_output)
279
- except _laz.json.JSONDecodeError as e:
280
- return None
281
-
282
- return PythonInstall.from_json(**output, managed_by=managed_by)
283
-
284
-
285
415
  def get_folder_pythons(
286
416
  base_folder: str | os.PathLike,
287
- basenames: tuple[str] = ("python", "pypy")
417
+ basenames: tuple[str] = ("python", "pypy"),
418
+ finder: DetailFinder | None = None,
288
419
  ):
289
420
  regexes = [_python_exe_regex(name) for name in basenames]
290
421
 
291
- with os.scandir(base_folder) as fld:
422
+ finder = DetailFinder() if finder is None else finder
423
+
424
+ with finder, os.scandir(base_folder) as fld:
292
425
  for file_path in fld:
293
426
  try:
294
427
  is_file = file_path.is_file()
295
428
  except PermissionError:
296
429
  continue
297
-
430
+
298
431
  if (
299
432
  is_file
300
433
  and any(reg.fullmatch(file_path.name) for reg in regexes)
@@ -307,13 +440,25 @@ def get_folder_pythons(
307
440
  continue
308
441
  else:
309
442
  p = file_path.path
310
- install = get_install_details(p)
443
+ install = finder.get_install_details(p)
311
444
  if install:
312
445
  yield install
313
446
 
314
447
 
315
448
  # UV Specific finder
316
449
  def get_uv_python_path() -> str | None:
450
+ # Attempt to get cache
451
+ try:
452
+ with open(INSTALLER_CACHE_PATH) as f:
453
+ installer_cache = _laz.json.load(f)
454
+ except (FileNotFoundError, _laz.json.JSONDecodeError):
455
+ installer_cache = {}
456
+
457
+ uv_python_dir = installer_cache.get("uv")
458
+ if uv_python_dir and os.path.exists(uv_python_dir):
459
+ return uv_python_dir
460
+
461
+ # Cache failed
317
462
  try:
318
463
  uv_python_find = _laz.subprocess.run(
319
464
  ["uv", "python", "dir"],
@@ -327,17 +472,25 @@ def get_uv_python_path() -> str | None:
327
472
  # remove newline
328
473
  uv_python_dir = uv_python_find.stdout.strip()
329
474
 
475
+ # Fill cache and update the cache file
476
+ installer_cache["uv"] = uv_python_dir
477
+ os.makedirs(os.path.dirname(INSTALLER_CACHE_PATH), exist_ok=True)
478
+ with open(INSTALLER_CACHE_PATH, 'w') as f:
479
+ _laz.json.dump(installer_cache, f)
480
+
330
481
  return uv_python_dir
331
482
 
332
483
 
333
484
  def _implementation_from_uv_dir(
334
485
  direntry: os.DirEntry,
335
486
  query_executables: bool = True,
487
+ finder: DetailFinder | None = None,
336
488
  ) -> PythonInstall | None:
337
489
  python_exe = "python.exe" if sys.platform == "win32" else "bin/python"
338
490
  python_path = os.path.join(direntry, python_exe)
339
491
 
340
492
  install: PythonInstall | None = None
493
+ finder = DetailFinder() if finder is None else finder
341
494
 
342
495
  if os.path.exists(python_path):
343
496
  if match := _laz.re.fullmatch(UV_PYTHON_RE, direntry.name):
@@ -354,7 +507,7 @@ def _implementation_from_uv_dir(
354
507
  architecture="32bit" if arch in {"i686", "armv7"} else "64bit",
355
508
  implementation=implementation,
356
509
  metadata=metadata,
357
- managed_by="Astral UV",
510
+ managed_by="Astral",
358
511
  )
359
512
  except ValueError:
360
513
  pass
@@ -363,21 +516,22 @@ def _implementation_from_uv_dir(
363
516
  # Directory name format has changed or this is an alternate implementation
364
517
  # Slow backup - ask python itself
365
518
  if query_executables:
366
- install = get_install_details(python_path)
367
- install.managed_by = "Astral UV"
519
+ install = finder.get_install_details(python_path, managed_by="Astral")
368
520
 
369
521
  return install
370
522
 
371
523
 
372
- def get_uv_pythons(query_executables=True) -> Iterator[PythonInstall]:
524
+ def get_uv_pythons(query_executables=True, finder=None) -> Iterator[PythonInstall]:
373
525
  # This takes some shortcuts over the regular pythonfinder
374
526
  # As the UV folders give the python version and the implementation
375
527
  if uv_python_path := get_uv_python_path():
376
528
  if os.path.exists(uv_python_path):
377
- with os.scandir(uv_python_path) as fld:
529
+ finder = DetailFinder() if finder is None else finder
530
+
531
+ with finder, os.scandir(uv_python_path) as fld:
378
532
  for f in fld:
379
533
  if (
380
534
  f.is_dir()
381
- and (install := _implementation_from_uv_dir(f, query_executables))
535
+ and (install := _implementation_from_uv_dir(f, query_executables, finder=finder))
382
536
  ):
383
537
  yield install