ducktools-pythonfinder 0.8.0__tar.gz → 0.8.2__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 (46) hide show
  1. {ducktools_pythonfinder-0.8.0/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.8.2}/PKG-INFO +1 -1
  2. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/__init__.py +3 -3
  3. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/__main__.py +1 -8
  4. ducktools_pythonfinder-0.8.2/src/ducktools/pythonfinder/_version.py +2 -0
  5. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/details_script.py +5 -3
  6. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/linux/__init__.py +3 -6
  7. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/linux/pyenv_search.py +3 -27
  8. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/shared.py +25 -32
  9. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/win32/__init__.py +2 -3
  10. ducktools_pythonfinder-0.8.2/src/ducktools/pythonfinder/win32/pyenv_search.py +68 -0
  11. ducktools_pythonfinder-0.8.2/src/ducktools/pythonfinder/win32/registry_search.py +124 -0
  12. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
  13. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_cache.py +1 -1
  14. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_foldersearch.py +3 -3
  15. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_pyenv.py +23 -56
  16. ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/_version.py +0 -2
  17. ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -106
  18. ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/win32/registry_search.py +0 -136
  19. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/.gitignore +0 -0
  20. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/LICENSE +0 -0
  21. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/MANIFEST.in +0 -0
  22. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/README.md +0 -0
  23. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/pyproject.toml +0 -0
  24. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/scripts/build_zipapp.py +0 -0
  25. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/scripts/detail_this_python.py +0 -0
  26. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/scripts/list_python_venvs.py +0 -0
  27. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/scripts/print_python_versions.py +0 -0
  28. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/setup.cfg +0 -0
  29. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
  30. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/package_list_script.py +0 -0
  31. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
  32. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools/pythonfinder/venv.py +0 -0
  33. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +0 -0
  34. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  35. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  36. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
  37. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  38. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/conftest.py +0 -0
  39. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/sources/python_versions.txt +0 -0
  40. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/sources/release.json +0 -0
  41. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/sources/release_file.json +0 -0
  42. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_details_script.py +0 -0
  43. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_orgsearch.py +0 -0
  44. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_shared.py +0 -0
  45. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_uv_finder.py +0 -0
  46. {ducktools_pythonfinder-0.8.0 → ducktools_pythonfinder-0.8.2}/tests/test_venv_finder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -20,7 +20,7 @@
20
20
  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
21
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
22
  # SOFTWARE.
23
-
23
+ from __future__ import annotations
24
24
  # Find platform python versions
25
25
 
26
26
  __all__ = [
@@ -43,10 +43,10 @@ else:
43
43
  from .linux import get_python_installs
44
44
 
45
45
 
46
- def list_python_installs(*, query_executables: bool = True, finder: "DetailFinder | None" = None):
46
+ def list_python_installs(*, finder: DetailFinder | None = None):
47
47
  finder = DetailFinder() if finder is None else finder
48
48
  return sorted(
49
- get_python_installs(query_executables=query_executables, finder=finder),
49
+ get_python_installs(finder=finder),
50
50
  reverse=True,
51
51
  key=lambda x: (x.version[3], *x.version[:3], x.version[4])
52
52
  )
@@ -115,11 +115,6 @@ def get_parser():
115
115
  description="Discover base Python installs",
116
116
  )
117
117
  parser.add_argument("-V", "--version", action="version", version=__version__)
118
- parser.add_argument(
119
- "--fast",
120
- action="store_true",
121
- help="Skip Python installs that need to be launched to obtain metadata"
122
- )
123
118
 
124
119
  subparsers = parser.add_subparsers(dest="command", required=False)
125
120
 
@@ -169,7 +164,6 @@ def display_local_installs(
169
164
  min_ver=None,
170
165
  max_ver=None,
171
166
  compatible=None,
172
- query_executables=True,
173
167
  ):
174
168
  if min_ver:
175
169
  min_ver = tuple(int(i) for i in min_ver.split("."))
@@ -178,7 +172,7 @@ def display_local_installs(
178
172
  if compatible:
179
173
  compatible = tuple(int(i) for i in compatible.split("."))
180
174
 
181
- installs = list_python_installs(query_executables=query_executables)
175
+ installs = list_python_installs()
182
176
 
183
177
  headings = ["Version", "Executable Location"]
184
178
 
@@ -333,7 +327,6 @@ def main():
333
327
  min_ver=vals.min,
334
328
  max_ver=vals.max,
335
329
  compatible=vals.compatible,
336
- query_executables=not vals.fast,
337
330
  )
338
331
  else:
339
332
  # No arguments to parse
@@ -0,0 +1,2 @@
1
+ __version__ = "0.8.2"
2
+ __version_tuple__ = (0, 8, 2)
@@ -29,13 +29,16 @@ 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: str):
32
+ def version_str_to_tuple(version):
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
37
  if parsed_version is None:
38
- raise ValueError(f"'version' must be a valid Python version string, not {version!r}")
38
+ raise ValueError(
39
+ "'version' must be a valid Python version string, "
40
+ + "not {version!r}".format(version=version)
41
+ )
39
42
 
40
43
  major, minor, micro, releaselevel, serial = parsed_version.groups()
41
44
 
@@ -87,7 +90,6 @@ def get_details():
87
90
  freethreaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
88
91
  metadata["freethreaded"] = freethreaded
89
92
 
90
-
91
93
  install = dict(
92
94
  version=list(sys.version_info),
93
95
  executable=sys.executable,
@@ -73,7 +73,6 @@ def get_path_pythons(*, finder: DetailFinder | None = None) -> Iterator[PythonIn
73
73
 
74
74
  def get_python_installs(
75
75
  *,
76
- query_executables: bool = True,
77
76
  finder: DetailFinder | None = None,
78
77
  ) -> Iterator[PythonInstall]:
79
78
  listed_pythons = set()
@@ -81,12 +80,10 @@ def get_python_installs(
81
80
  finder = DetailFinder() if finder is None else finder
82
81
 
83
82
  chain_commands = [
84
- get_pyenv_pythons(query_executables=query_executables, finder=finder),
85
- get_uv_pythons(query_executables=query_executables, finder=finder),
83
+ get_pyenv_pythons(finder=finder),
84
+ get_uv_pythons(finder=finder),
85
+ get_path_pythons(finder=finder),
86
86
  ]
87
- if query_executables:
88
- chain_commands.append(get_path_pythons(finder=finder))
89
-
90
87
  with finder:
91
88
  for py in itertools.chain.from_iterable(chain_commands):
92
89
  if py.executable not in listed_pythons:
@@ -32,7 +32,7 @@ from _collections_abc import Iterator
32
32
 
33
33
  from ducktools.lazyimporter import LazyImporter, FromImport, ModuleImport
34
34
 
35
- from ..shared import PythonInstall, DetailFinder, FULL_PY_VER_RE, INSTALLER_CACHE_PATH, version_str_to_tuple
35
+ from ..shared import PythonInstall, DetailFinder, INSTALLER_CACHE_PATH
36
36
 
37
37
  _laz = LazyImporter(
38
38
  [
@@ -42,9 +42,6 @@ _laz = LazyImporter(
42
42
  ]
43
43
  )
44
44
 
45
- # pyenv folder name for pypy
46
- PYPY_VER_RE = r"^pypy(?P<pyversion>\d{1,2}\.\d+)-(?P<pypyversion>[\d\.]*)$"
47
-
48
45
 
49
46
  def get_pyenv_root() -> str | None:
50
47
  # Check if the environment variable exists, if so use that
@@ -77,7 +74,6 @@ def get_pyenv_root() -> str | None:
77
74
  def get_pyenv_pythons(
78
75
  versions_folder: str | os.PathLike | None = None,
79
76
  *,
80
- query_executables: bool = True,
81
77
  finder: DetailFinder = None,
82
78
  ) -> Iterator[PythonInstall]:
83
79
  if versions_folder is None:
@@ -96,27 +92,7 @@ def get_pyenv_pythons(
96
92
  with finder:
97
93
  for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
98
94
  executable = os.path.join(p.path, "bin/python")
99
-
100
95
  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
- ):
96
+ install = finder.get_install_details(executable, managed_by="pyenv")
97
+ if install:
122
98
  yield install
@@ -225,12 +225,18 @@ class DetailFinder(Prefab):
225
225
  self._raw_cache = {}
226
226
  self._dirty_cache = True
227
227
 
228
- def query_install(self, exe_path: str, managed_by: str | None = None) -> PythonInstall | None:
228
+ def query_install(
229
+ self,
230
+ exe_path: str,
231
+ managed_by: str | None = None,
232
+ metadata: dict | None = None,
233
+ ) -> PythonInstall | None:
229
234
  """
230
235
  Query the details of a Python install directly
231
236
 
232
237
  :param exe_path: Path to the runtime .exe
233
238
  :param managed_by: Which tool manages this install (if any)
239
+ :param metadata: Dictionary of install metadata
234
240
  :return: a PythonInstall if one exists at the exe Path
235
241
  """
236
242
  try:
@@ -273,9 +279,19 @@ class DetailFinder(Prefab):
273
279
  except _laz.json.JSONDecodeError:
274
280
  return None
275
281
 
276
- return PythonInstall.from_json(**output, managed_by=managed_by)
282
+ if metadata:
283
+ output["metadata"].update(metadata)
284
+
285
+ install = PythonInstall.from_json(**output, managed_by=managed_by)
277
286
 
278
- def get_install_details(self, exe_path: str, managed_by=None) -> PythonInstall | None:
287
+ return install
288
+
289
+ def get_install_details(
290
+ self,
291
+ exe_path: str,
292
+ managed_by: str | None = None,
293
+ metadata: dict | None = None,
294
+ ) -> PythonInstall | None:
279
295
  exe_path = os.path.abspath(exe_path)
280
296
  mtime = os.stat(exe_path).st_mtime
281
297
 
@@ -287,7 +303,7 @@ class DetailFinder(Prefab):
287
303
  self.raw_cache.pop(exe_path)
288
304
 
289
305
  if install is None:
290
- install = self.query_install(exe_path, managed_by)
306
+ install = self.query_install(exe_path, managed_by, metadata)
291
307
  if install:
292
308
  self.raw_cache[exe_path] = {
293
309
  "mtime": mtime,
@@ -416,6 +432,7 @@ def get_folder_pythons(
416
432
  base_folder: str | os.PathLike,
417
433
  basenames: tuple[str] = ("python", "pypy"),
418
434
  finder: DetailFinder | None = None,
435
+ managed_by: str | None = None,
419
436
  ):
420
437
  regexes = [_python_exe_regex(name) for name in basenames]
421
438
 
@@ -440,7 +457,7 @@ def get_folder_pythons(
440
457
  continue
441
458
  else:
442
459
  p = file_path.path
443
- install = finder.get_install_details(p)
460
+ install = finder.get_install_details(p, managed_by=managed_by)
444
461
  if install:
445
462
  yield install
446
463
 
@@ -483,7 +500,6 @@ def get_uv_python_path() -> str | None:
483
500
 
484
501
  def _implementation_from_uv_dir(
485
502
  direntry: os.DirEntry,
486
- query_executables: bool = True,
487
503
  finder: DetailFinder | None = None,
488
504
  ) -> PythonInstall | None:
489
505
  python_exe = "python.exe" if sys.platform == "win32" else "bin/python"
@@ -493,35 +509,12 @@ def _implementation_from_uv_dir(
493
509
  finder = DetailFinder() if finder is None else finder
494
510
 
495
511
  if os.path.exists(python_path):
496
- if match := _laz.re.fullmatch(UV_PYTHON_RE, direntry.name):
497
- implementation, version, extra, platform, arch = match.groups()
498
- metadata = {
499
- "freethreaded": "freethreaded" in extra,
500
- }
501
-
502
- try:
503
- if implementation in {"cpython"}:
504
- install = PythonInstall.from_str(
505
- version=version,
506
- executable=python_path,
507
- architecture="32bit" if arch in {"i686", "armv7"} else "64bit",
508
- implementation=implementation,
509
- metadata=metadata,
510
- managed_by="Astral",
511
- )
512
- except ValueError:
513
- pass
514
-
515
- if install is None:
516
- # Directory name format has changed or this is an alternate implementation
517
- # Slow backup - ask python itself
518
- if query_executables:
519
- install = finder.get_install_details(python_path, managed_by="Astral")
512
+ install = finder.get_install_details(python_path, managed_by="Astral")
520
513
 
521
514
  return install
522
515
 
523
516
 
524
- def get_uv_pythons(query_executables=True, finder=None) -> Iterator[PythonInstall]:
517
+ def get_uv_pythons(finder=None) -> Iterator[PythonInstall]:
525
518
  # This takes some shortcuts over the regular pythonfinder
526
519
  # As the UV folders give the python version and the implementation
527
520
  if uv_python_path := get_uv_python_path():
@@ -532,6 +525,6 @@ def get_uv_pythons(query_executables=True, finder=None) -> Iterator[PythonInstal
532
525
  for f in fld:
533
526
  if (
534
527
  f.is_dir()
535
- and (install := _implementation_from_uv_dir(f, query_executables, finder=finder))
528
+ and (install := _implementation_from_uv_dir(f, finder=finder))
536
529
  ):
537
530
  yield install
@@ -32,7 +32,6 @@ from .registry_search import get_registered_pythons
32
32
 
33
33
  def get_python_installs(
34
34
  *,
35
- query_executables: bool = True,
36
35
  finder: DetailFinder | None = None
37
36
  ) -> Iterator[PythonInstall]:
38
37
  listed_installs = set()
@@ -42,8 +41,8 @@ def get_python_installs(
42
41
  with finder:
43
42
  for py in itertools.chain(
44
43
  get_registered_pythons(),
45
- get_pyenv_pythons(query_executables=query_executables, finder=finder),
46
- get_uv_pythons(query_executables=query_executables, finder=finder),
44
+ get_pyenv_pythons(finder=finder),
45
+ get_uv_pythons(finder=finder),
47
46
  ):
48
47
  if py.executable not in listed_installs:
49
48
  yield py
@@ -0,0 +1,68 @@
1
+ # ducktools-pythonfinder
2
+ # MIT License
3
+ #
4
+ # Copyright (c) 2023-2025 David C Ellis
5
+ #
6
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ # of this software and associated documentation files (the "Software"), to deal
8
+ # in the Software without restriction, including without limitation the rights
9
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the Software is
11
+ # furnished to do so, subject to the following conditions:
12
+ #
13
+ # The above copyright notice and this permission notice shall be included in all
14
+ # copies or substantial portions of the Software.
15
+ #
16
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ # SOFTWARE.
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import os.path
27
+ from _collections_abc import Iterator
28
+
29
+ from ..shared import PythonInstall, DetailFinder
30
+
31
+
32
+ def get_pyenv_root() -> str | None:
33
+ # Check if the environment variable exists, if so use that
34
+ # Windows PYENV does not have the `pyenv root` command to use as a backup.
35
+ pyenv_root = os.environ.get("PYENV_ROOT")
36
+ return pyenv_root
37
+
38
+
39
+ def get_pyenv_pythons(
40
+ versions_folder: str | os.PathLike | None = None,
41
+ *,
42
+ finder: DetailFinder | None = None,
43
+ ) -> Iterator[PythonInstall]:
44
+
45
+ if versions_folder is None:
46
+ if pyenv_root := get_pyenv_root():
47
+ versions_folder = os.path.join(pyenv_root, "versions")
48
+
49
+ if versions_folder is None or not os.path.exists(versions_folder):
50
+ return
51
+
52
+ finder = DetailFinder() if finder is None else finder
53
+
54
+ with finder:
55
+ for p in os.scandir(versions_folder):
56
+ path_base = os.path.basename(p.path)
57
+
58
+ if path_base.startswith("pypy"):
59
+ executable = os.path.join(p.path, "pypy.exe")
60
+ elif path_base.startswith("graalpy"):
61
+ # Graalpy exe in bin subfolder
62
+ executable = os.path.join(p.path, "bin", "graalpy.exe")
63
+ else:
64
+ # Try python.exe
65
+ executable = os.path.join(p.path, "python.exe")
66
+
67
+ if os.path.exists(executable):
68
+ yield finder.get_install_details(executable, managed_by="pyenv")
@@ -0,0 +1,124 @@
1
+ # ducktools-pythonfinder
2
+ # MIT License
3
+ #
4
+ # Copyright (c) 2023-2025 David C Ellis
5
+ #
6
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ # of this software and associated documentation files (the "Software"), to deal
8
+ # in the Software without restriction, including without limitation the rights
9
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the Software is
11
+ # furnished to do so, subject to the following conditions:
12
+ #
13
+ # The above copyright notice and this permission notice shall be included in all
14
+ # copies or substantial portions of the Software.
15
+ #
16
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ # SOFTWARE.
23
+ from __future__ import annotations
24
+
25
+ """
26
+ Search the Windows registry to find python installs
27
+
28
+ Based on PEP 514 registry entries.
29
+ """
30
+
31
+ import os.path
32
+ import winreg # noqa # pycharm seems to think winreg doesn't exist in python3.12
33
+ from _collections_abc import Iterator
34
+
35
+ from ..shared import DetailFinder, PythonInstall, version_str_to_tuple
36
+
37
+ exclude_companies = {
38
+ "PyLauncher", # pylauncher is special cased to be ignored
39
+ }
40
+
41
+
42
+ check_pairs = [
43
+ # Keys defined in PEP 514
44
+ (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Python", 0),
45
+ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Python", winreg.KEY_WOW64_64KEY),
46
+ # For system wide 32 bit python installs
47
+ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Python", winreg.KEY_WOW64_32KEY),
48
+ ]
49
+
50
+
51
+ def enum_keys(key):
52
+ subkey_count, _, _ = winreg.QueryInfoKey(key)
53
+ for i in range(subkey_count):
54
+ yield winreg.EnumKey(key, i)
55
+
56
+
57
+ def enum_values(key):
58
+ _, value_count, _ = winreg.QueryInfoKey(key)
59
+ for i in range(value_count):
60
+ yield winreg.EnumValue(key, i)
61
+
62
+
63
+ def get_registered_pythons(finder: DetailFinder | None = None) -> Iterator[PythonInstall]:
64
+ finder = DetailFinder() if finder is None else finder
65
+
66
+ with finder:
67
+ for base, py_folder, flags in check_pairs:
68
+ base_key = None
69
+ try:
70
+ base_key = winreg.OpenKeyEx(base, py_folder, access=winreg.KEY_READ | flags)
71
+ except FileNotFoundError:
72
+ continue
73
+ else:
74
+ # Query the base folder eg: HKEY_LOCAL_MACHINE\SOFTWARE\Python
75
+ # The values here should be "companies" as defined in the PEP
76
+ for company in enum_keys(base_key):
77
+ if company in exclude_companies:
78
+ continue
79
+
80
+ with winreg.OpenKey(base_key, company) as company_key:
81
+ comp_metadata = {
82
+ "Company": company
83
+ }
84
+
85
+ for name, data, _ in enum_values(company_key):
86
+ comp_metadata[f"Company{name}"] = data
87
+
88
+ for py_keyname in enum_keys(company_key):
89
+ metadata = {
90
+ **comp_metadata,
91
+ "Tag": py_keyname,
92
+ }
93
+
94
+ with winreg.OpenKey(company_key, py_keyname) as py_key:
95
+ for name, data, _ in enum_values(py_key):
96
+ metadata[name] = data
97
+
98
+ install_key = None
99
+ try:
100
+ install_key = winreg.OpenKey(py_key, "InstallPath")
101
+ python_path, _ = winreg.QueryValueEx(
102
+ install_key,
103
+ "ExecutablePath",
104
+ )
105
+ except FileNotFoundError:
106
+ python_path = None
107
+ finally:
108
+ if install_key:
109
+ winreg.CloseKey(install_key)
110
+
111
+ metadata["InWindowsRegistry"] = True
112
+
113
+ if python_path:
114
+ # Pyenv puts architecture information in the Version value for some reason
115
+ if os.path.isfile(python_path):
116
+ yield finder.get_install_details(
117
+ python_path,
118
+ managed_by=metadata["Company"],
119
+ metadata=metadata,
120
+ )
121
+
122
+ finally:
123
+ if base_key:
124
+ winreg.CloseKey(base_key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -210,7 +210,7 @@ def test_changed_stat_invalidates(run_mock, temp_finder):
210
210
 
211
211
  assert temp_finder.raw_cache[fake_abspath]["mtime"] == 1739886571
212
212
 
213
- querymock.assert_called_with(fake_abspath, None)
213
+ querymock.assert_called_with(fake_abspath, None, None)
214
214
  querymock.reset_mock()
215
215
 
216
216
  with temp_finder:
@@ -119,7 +119,7 @@ def test_get_folder_pythons(fs, temp_finder):
119
119
  fs.create_file(pypy_exe)
120
120
  fs.create_file(non_python_file)
121
121
 
122
- def mock_func(pth):
122
+ def mock_func(pth, managed_by=None, metadata=None):
123
123
  return pth
124
124
 
125
125
  with patch.object(
@@ -130,8 +130,8 @@ def test_get_folder_pythons(fs, temp_finder):
130
130
 
131
131
  get_dets.assert_has_calls(
132
132
  [
133
- call(python_exe),
134
- call(pypy_exe),
133
+ call(python_exe, managed_by=None),
134
+ call(pypy_exe, managed_by=None),
135
135
  ],
136
136
  any_order=True,
137
137
  )
@@ -31,7 +31,7 @@ from pathlib import Path
31
31
  import pytest
32
32
  from unittest.mock import patch, Mock
33
33
 
34
- from ducktools.pythonfinder.shared import PythonInstall
34
+ from ducktools.pythonfinder.shared import PythonInstall, DetailFinder
35
35
  from ducktools.pythonfinder import details_script
36
36
 
37
37
  if sys.platform == "win32":
@@ -90,13 +90,20 @@ def test_mock_versions_folder(temp_finder):
90
90
  mock_dir_entry.name = out_ver
91
91
  mock_dir_entry.path = os.path.join(versions_folder, out_ver)
92
92
 
93
- with patch("os.path.exists") as exists_mock, patch("os.scandir") as scandir_mock:
93
+ with patch("os.path.exists") as exists_mock, \
94
+ patch("os.scandir") as scandir_mock, \
95
+ patch.object(DetailFinder, "get_install_details") as details_mock:
96
+
97
+ return_val = PythonInstall.from_str(version=out_ver, executable=out_executable, managed_by="pyenv")
98
+ details_mock.return_value = return_val
94
99
  exists_mock.return_value = True
95
100
  scandir_mock.return_value = iter([mock_dir_entry])
96
101
 
97
- python_versions = list(get_pyenv_pythons(versions_folder="~/fake/versions", finder=temp_finder))
102
+ python_versions = list(get_pyenv_pythons(versions_folder=versions_folder, finder=temp_finder))
103
+
104
+ details_mock.assert_called_once_with(out_executable, managed_by="pyenv")
98
105
 
99
- assert python_versions == [PythonInstall.from_str(version=out_ver, executable=out_executable, managed_by="pyenv")]
106
+ assert python_versions == [return_val]
100
107
 
101
108
 
102
109
  @pytest.mark.skipif(sys.platform != "win32", reason="Test for Windows only")
@@ -111,59 +118,14 @@ def test_fs_versions_win(fs, temp_finder):
111
118
  fs.create_dir(py_folder)
112
119
  fs.create_file(py_exe)
113
120
 
114
- versions = list(get_pyenv_pythons(tmpdir, finder=temp_finder))
115
-
116
- assert versions == [PythonInstall.from_str(version="3.12.1", executable=py_exe, managed_by="pyenv")]
117
-
118
-
119
- @pytest.mark.skipif(sys.platform != "win32", reason="Test for Windows only")
120
- def test_32bit_version(fs, temp_finder):
121
- # Test with folders in fake file system
122
-
123
- tmpdir = "c:\\fake_folder"
124
-
125
- py_folder = os.path.join(tmpdir, "3.12.1-win32")
126
- py_exe = os.path.join(py_folder, "python.exe")
127
-
128
- fs.create_dir(py_folder)
129
- fs.create_file(py_exe)
130
-
131
- versions = list(get_pyenv_pythons(tmpdir, finder=temp_finder))
132
-
133
- assert versions == [
134
- PythonInstall.from_str(version="3.12.1", executable=py_exe, architecture="32bit", managed_by="pyenv")
135
- ]
136
-
137
-
138
- @pytest.mark.skipif(sys.platform != "win32", reason="Test for Windows only")
139
- def test_invalid_ver_win(fs, uses_details_script, temp_finder):
140
- # Ignore non-standard versions
141
-
142
- pyenv_fld = "C:\\Fake_Folder"
143
-
144
- py_folder = os.path.join(pyenv_fld, "external-python3.12.1")
145
- py_exe = os.path.join(py_folder, "python.exe")
146
-
147
- fs.create_dir(py_folder)
148
- fs.create_file(py_exe)
149
-
150
- py2_folder = os.path.join(pyenv_fld, "ext3.13.0")
151
- py2_exe = os.path.join(py2_folder, "python.exe")
152
-
153
- fs.create_dir(py2_folder)
154
- fs.create_file(py2_exe)
155
-
156
- py3_folder = os.path.join(pyenv_fld, "invalid-version-3.12.1")
157
- py3_exe = os.path.join(py3_folder, "python.exe")
158
-
159
- fs.create_dir(py3_folder)
160
- fs.create_file(py3_exe)
121
+ with patch.object(DetailFinder, "get_install_details") as details_mock:
122
+ return_val = PythonInstall.from_str(version="3.12.1", executable=py_exe, managed_by="pyenv")
123
+ details_mock.return_value = return_val
124
+ versions = list(get_pyenv_pythons(tmpdir, finder=temp_finder))
161
125
 
162
- with patch("subprocess.run") as run_mock:
163
- versions = list(get_pyenv_pythons(pyenv_fld, finder=temp_finder))
164
- run_mock.assert_not_called()
126
+ details_mock.assert_called_once_with(py_exe, managed_by="pyenv")
165
127
 
166
- assert versions == []
128
+ assert versions == [PythonInstall.from_str(version="3.12.1", executable=py_exe, managed_by="pyenv")]
167
129
 
168
130
 
169
131
  @pytest.mark.skipif(sys.platform == "win32", reason="Test for non-Windows only")
@@ -179,7 +141,12 @@ def test_fs_versions_nix(fs, temp_finder):
179
141
  fs.create_dir(os.path.join(py_folder, "bin"))
180
142
  fs.create_file(py_exe)
181
143
 
182
- versions = list(get_pyenv_pythons(tmpdir, finder=temp_finder))
144
+ with patch.object(DetailFinder, "get_install_details") as details_mock:
145
+ return_val = PythonInstall.from_str(version="3.12.1", executable=py_exe, managed_by="pyenv")
146
+ details_mock.return_value = return_val
147
+
148
+ versions = list(get_pyenv_pythons(tmpdir, finder=temp_finder))
149
+ details_mock.assert_called_once_with(py_exe, managed_by="pyenv")
183
150
 
184
151
  assert versions == [PythonInstall.from_str(version="3.12.1", executable=py_exe, managed_by="pyenv")]
185
152
 
@@ -1,2 +0,0 @@
1
- __version__ = "0.8.0"
2
- __version_tuple__ = (0, 8, 0)
@@ -1,106 +0,0 @@
1
- # ducktools-pythonfinder
2
- # MIT License
3
- #
4
- # Copyright (c) 2023-2025 David C Ellis
5
- #
6
- # Permission is hereby granted, free of charge, to any person obtaining a copy
7
- # of this software and associated documentation files (the "Software"), to deal
8
- # in the Software without restriction, including without limitation the rights
9
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- # copies of the Software, and to permit persons to whom the Software is
11
- # furnished to do so, subject to the following conditions:
12
- #
13
- # The above copyright notice and this permission notice shall be included in all
14
- # copies or substantial portions of the Software.
15
- #
16
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- # SOFTWARE.
23
- from __future__ import annotations
24
-
25
- import os
26
- import os.path
27
- from _collections_abc import Iterator
28
-
29
- from ..shared import PythonInstall, DetailFinder
30
-
31
-
32
- def get_pyenv_root() -> str | None:
33
- # Check if the environment variable exists, if so use that
34
- # Windows PYENV does not have the `pyenv root` command to use as a backup.
35
- pyenv_root = os.environ.get("PYENV_ROOT")
36
- return pyenv_root
37
-
38
-
39
- def get_pyenv_pythons(
40
- versions_folder: str | os.PathLike | None = None,
41
- *,
42
- query_executables: bool = True,
43
- finder: DetailFinder | None = None,
44
- ) -> Iterator[PythonInstall]:
45
-
46
- if versions_folder is None:
47
- if pyenv_root := get_pyenv_root():
48
- versions_folder = os.path.join(pyenv_root, "versions")
49
-
50
- if versions_folder is None or not os.path.exists(versions_folder):
51
- return
52
-
53
- finder = DetailFinder() if finder is None else finder
54
-
55
- with finder:
56
- for p in os.scandir(versions_folder):
57
- path_base = os.path.basename(p.path)
58
-
59
- if query_executables:
60
- # Check for pypy/graalpy
61
- if path_base.startswith("pypy"):
62
- executable = os.path.join(p.path, "pypy.exe")
63
- if os.path.exists(executable):
64
- yield finder.get_install_details(executable, managed_by="pyenv")
65
- continue
66
- elif path_base.startswith("graalpy"):
67
- # Graalpy exe in bin subfolder
68
- executable = os.path.join(p.path, "bin", "graalpy.exe")
69
- if os.path.exists(executable):
70
- yield finder.get_install_details(executable, managed_by="pyenv")
71
- continue
72
-
73
- # Regular CPython
74
- executable = os.path.join(p.path, "python.exe")
75
-
76
- if os.path.exists(executable):
77
- split_version = p.name.split("-")
78
-
79
- # If there are 1 or 2 arguments this is a recognised version
80
- # Otherwise it is unrecognised
81
- if len(split_version) == 2:
82
- version, arch = split_version
83
-
84
- # win32 in pyenv name means 32 bit python install
85
- # 'arm' is the only alternative which will be 64bit
86
- arch = "32bit" if arch == "win32" else "64bit"
87
- try:
88
- yield PythonInstall.from_str(
89
- version=version,
90
- executable=executable,
91
- architecture=arch,
92
- managed_by="pyenv",
93
- )
94
- except ValueError:
95
- pass
96
- elif len(split_version) == 1:
97
- version = split_version[0]
98
- try:
99
- yield PythonInstall.from_str(
100
- version=version,
101
- executable=executable,
102
- architecture="64bit",
103
- managed_by="pyenv",
104
- )
105
- except ValueError:
106
- pass
@@ -1,136 +0,0 @@
1
- # ducktools-pythonfinder
2
- # MIT License
3
- #
4
- # Copyright (c) 2023-2025 David C Ellis
5
- #
6
- # Permission is hereby granted, free of charge, to any person obtaining a copy
7
- # of this software and associated documentation files (the "Software"), to deal
8
- # in the Software without restriction, including without limitation the rights
9
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- # copies of the Software, and to permit persons to whom the Software is
11
- # furnished to do so, subject to the following conditions:
12
- #
13
- # The above copyright notice and this permission notice shall be included in all
14
- # copies or substantial portions of the Software.
15
- #
16
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- # SOFTWARE.
23
- from __future__ import annotations
24
-
25
- """
26
- Search the Windows registry to find python installs
27
-
28
- Based on PEP 514 registry entries.
29
- """
30
-
31
- import winreg # noqa # pycharm seems to think winreg doesn't exist in python3.12
32
- from _collections_abc import Iterator
33
-
34
- from ..shared import PythonInstall, version_str_to_tuple
35
-
36
- exclude_companies = {
37
- "PyLauncher", # pylauncher is special cased to be ignored
38
- }
39
-
40
-
41
- check_pairs = [
42
- # Keys defined in PEP 514
43
- (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Python", 0),
44
- (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Python", winreg.KEY_WOW64_64KEY),
45
- # For system wide 32 bit python installs
46
- (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Python", winreg.KEY_WOW64_32KEY),
47
- ]
48
-
49
-
50
- def enum_keys(key):
51
- subkey_count, _, _ = winreg.QueryInfoKey(key)
52
- for i in range(subkey_count):
53
- yield winreg.EnumKey(key, i)
54
-
55
-
56
- def enum_values(key):
57
- _, value_count, _ = winreg.QueryInfoKey(key)
58
- for i in range(value_count):
59
- yield winreg.EnumValue(key, i)
60
-
61
-
62
- def get_registered_pythons() -> Iterator[PythonInstall]:
63
- for base, py_folder, flags in check_pairs:
64
- base_key = None
65
- try:
66
- base_key = winreg.OpenKeyEx(base, py_folder, access=winreg.KEY_READ | flags)
67
- except FileNotFoundError:
68
- continue
69
- else:
70
- # Query the base folder eg: HKEY_LOCAL_MACHINE\SOFTWARE\Python
71
- # The values here should be "companies" as defined in the PEP
72
- for company in enum_keys(base_key):
73
- if company in exclude_companies:
74
- continue
75
-
76
- with winreg.OpenKey(base_key, company) as company_key:
77
- comp_metadata = {
78
- "Company": company
79
- }
80
-
81
- for name, data, _ in enum_values(company_key):
82
- comp_metadata[f"Company{name}"] = data
83
-
84
- for py_keyname in enum_keys(company_key):
85
- metadata = {
86
- **comp_metadata,
87
- "Tag": py_keyname,
88
- }
89
-
90
- with winreg.OpenKey(company_key, py_keyname) as py_key:
91
- for name, data, _ in enum_values(py_key):
92
- metadata[name] = data
93
-
94
- install_key = None
95
- try:
96
- install_key = winreg.OpenKey(py_key, "InstallPath")
97
- python_path, _ = winreg.QueryValueEx(
98
- install_key,
99
- "ExecutablePath",
100
- )
101
- except FileNotFoundError:
102
- python_path = None
103
- finally:
104
- if install_key:
105
- winreg.CloseKey(install_key)
106
-
107
- python_version: str | None = metadata.get("Version")
108
-
109
- architecture = metadata.get("SysArchitecture")
110
-
111
- metadata["InWindowsRegistry"] = True
112
-
113
- if python_path and python_version:
114
- # Pyenv puts architecture information in the Version value for some reason
115
- python_version = python_version.split("-")[0]
116
- version_tuple = version_str_to_tuple(python_version)
117
-
118
- if (
119
- version_tuple >= (3, 13)
120
- and "freethreaded" in metadata.get("DisplayName", "")
121
- ):
122
- metadata["freethreaded"] = True
123
- try:
124
- yield PythonInstall(
125
- version=version_tuple,
126
- executable=python_path,
127
- architecture=architecture,
128
- metadata=metadata,
129
- managed_by=metadata["Company"],
130
- )
131
- except ValueError:
132
- pass
133
-
134
- finally:
135
- if base_key:
136
- winreg.CloseKey(base_key)