ducktools-pythonfinder 0.9.2__tar.gz → 0.10.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 (48) hide show
  1. {ducktools_pythonfinder-0.9.2/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.10.0}/PKG-INFO +5 -11
  2. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/pyproject.toml +12 -9
  3. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/__init__.py +1 -1
  4. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/__main__.py +38 -30
  5. ducktools_pythonfinder-0.10.0/src/ducktools/pythonfinder/_version.py +2 -0
  6. ducktools_pythonfinder-0.10.0/src/ducktools/pythonfinder/darwin/__init__.py +74 -0
  7. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/linux/__init__.py +16 -4
  8. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/linux/pyenv_search.py +9 -5
  9. ducktools_pythonfinder-0.10.0/src/ducktools/pythonfinder/py.typed +1 -0
  10. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/pythonorg_search.py +4 -1
  11. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/shared.py +19 -15
  12. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/venv.py +24 -13
  13. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/win32/__init__.py +5 -1
  14. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/win32/pyenv_search.py +6 -2
  15. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/win32/registry_search.py +6 -2
  16. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0/src/ducktools_pythonfinder.egg-info}/PKG-INFO +5 -11
  17. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +2 -0
  18. ducktools_pythonfinder-0.10.0/src/ducktools_pythonfinder.egg-info/requires.txt +3 -0
  19. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/conftest.py +7 -9
  20. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_orgsearch.py +4 -1
  21. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_venv_finder.py +20 -7
  22. ducktools_pythonfinder-0.10.0/uv.lock +325 -0
  23. ducktools_pythonfinder-0.9.2/src/ducktools/pythonfinder/_version.py +0 -2
  24. ducktools_pythonfinder-0.9.2/src/ducktools/pythonfinder/darwin/__init__.py +0 -26
  25. ducktools_pythonfinder-0.9.2/src/ducktools_pythonfinder.egg-info/requires.txt +0 -8
  26. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/.gitignore +0 -0
  27. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/LICENSE +0 -0
  28. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/MANIFEST.in +0 -0
  29. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/README.md +0 -0
  30. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/scripts/build_zipapp.py +0 -0
  31. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/scripts/detail_this_python.py +0 -0
  32. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/scripts/list_python_venvs.py +0 -0
  33. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/scripts/print_python_versions.py +0 -0
  34. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/setup.cfg +0 -0
  35. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/details_script.py +0 -0
  36. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools/pythonfinder/package_list_script.py +0 -0
  37. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  38. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  39. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  40. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/sources/python_versions.txt +0 -0
  41. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/sources/release.json +0 -0
  42. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/sources/release_file.json +0 -0
  43. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_cache.py +0 -0
  44. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_details_script.py +0 -0
  45. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_foldersearch.py +0 -0
  46. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_pyenv.py +0 -0
  47. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_shared.py +0 -0
  48. {ducktools_pythonfinder-0.9.2 → ducktools_pythonfinder-0.10.0}/tests/test_uv_finder.py +0 -0
@@ -1,12 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.9.2
3
+ Version: 0.10.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
7
7
  Classifier: Development Status :: 3 - Alpha
8
- Classifier: Programming Language :: Python :: 3.8
9
- Classifier: Programming Language :: Python :: 3.9
10
8
  Classifier: Programming Language :: Python :: 3.10
11
9
  Classifier: Programming Language :: Python :: 3.11
12
10
  Classifier: Programming Language :: Python :: 3.12
@@ -15,16 +13,12 @@ Classifier: License :: OSI Approved :: MIT License
15
13
  Classifier: Operating System :: MacOS
16
14
  Classifier: Operating System :: Microsoft :: Windows :: Windows 10
17
15
  Classifier: Operating System :: POSIX :: Linux
18
- Requires-Python: >=3.8
16
+ Requires-Python: >=3.10
19
17
  Description-Content-Type: text/markdown
20
18
  License-File: LICENSE
21
- Requires-Dist: ducktools-lazyimporter
22
- Requires-Dist: ducktools-classbuilder>=0.7.1
23
- Requires-Dist: packaging>=23.2
24
- Provides-Extra: testing
25
- Requires-Dist: pytest; extra == "testing"
26
- Requires-Dist: pytest-cov; extra == "testing"
27
- Requires-Dist: pyfakefs; extra == "testing"
19
+ Requires-Dist: ducktools-lazyimporter>=0.7.3
20
+ Requires-Dist: ducktools-classbuilder>=0.9.1
21
+ Requires-Dist: packaging>=24.2
28
22
  Dynamic: license-file
29
23
 
30
24
  # ducktools: pythonfinder #
@@ -12,16 +12,14 @@ authors = [
12
12
  { name = "David C Ellis" },
13
13
  ]
14
14
  readme = "README.md"
15
- requires-python = ">=3.8"
15
+ requires-python = ">=3.10"
16
16
  dependencies = [
17
- "ducktools-lazyimporter",
18
- "ducktools-classbuilder>=0.7.1",
19
- "packaging>=23.2",
17
+ "ducktools-lazyimporter>=0.7.3",
18
+ "ducktools-classbuilder>=0.9.1",
19
+ "packaging>=24.2",
20
20
  ]
21
21
  classifiers = [
22
22
  "Development Status :: 3 - Alpha",
23
- "Programming Language :: Python :: 3.8",
24
- "Programming Language :: Python :: 3.9",
25
23
  "Programming Language :: Python :: 3.10",
26
24
  "Programming Language :: Python :: 3.11",
27
25
  "Programming Language :: Python :: 3.12",
@@ -36,12 +34,17 @@ dynamic = ['version']
36
34
  [project.urls]
37
35
  "Homepage" = "https://github.com/davidcellis/ducktools-pythonfinder"
38
36
 
39
- [project.optional-dependencies]
40
- testing = ["pytest", "pytest-cov", "pyfakefs"]
41
-
42
37
  [project.scripts]
43
38
  "ducktools-pythonfinder" = "ducktools.pythonfinder.__main__:main"
44
39
 
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=8.4",
43
+ "pytest-cov>=6.1",
44
+ "pyfakefs>=5.8",
45
+ "mypy>=1.16",
46
+ ]
47
+
45
48
  [tool.setuptools.packages.find]
46
49
  where = ["src"]
47
50
 
@@ -43,7 +43,7 @@ else:
43
43
  from .linux import get_python_installs
44
44
 
45
45
 
46
- def list_python_installs(*, finder: DetailFinder | None = None):
46
+ def list_python_installs(*, finder: DetailFinder | None = None) -> list[PythonInstall]:
47
47
  finder = DetailFinder() if finder is None else finder
48
48
  return sorted(
49
49
  get_python_installs(finder=finder),
@@ -22,13 +22,19 @@
22
22
  # SOFTWARE.
23
23
  from __future__ import annotations
24
24
 
25
-
26
25
  import sys
27
26
  import os
28
27
 
29
28
  from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
30
- from ducktools.pythonfinder import list_python_installs, __version__
31
- from ducktools.pythonfinder.shared import purge_caches
29
+
30
+ from . import list_python_installs, __version__
31
+ from .shared import purge_caches
32
+
33
+
34
+ TYPE_CHECKING = False
35
+ if TYPE_CHECKING:
36
+ import argparse
37
+
32
38
 
33
39
  _laz = LazyImporter(
34
40
  [
@@ -49,7 +55,7 @@ class UnsupportedPythonError(Exception):
49
55
  pass
50
56
 
51
57
 
52
- def stop_autoclose():
58
+ def stop_autoclose() -> None:
53
59
  """
54
60
  Checks if it thinks windows will auto close the window after running
55
61
 
@@ -76,11 +82,11 @@ def stop_autoclose():
76
82
  _laz.subprocess.run("pause", shell=True)
77
83
 
78
84
 
79
- def _get_parser_class():
85
+ def _get_parser_class() -> type[argparse.ArgumentParser]:
80
86
  # This class is deferred to avoid the argparse import
81
87
  # if there are no arguments to parse
82
88
 
83
- class FixedArgumentParser(_laz.argparse.ArgumentParser):
89
+ class FixedArgumentParser(_laz.argparse.ArgumentParser): # type: ignore
84
90
  """
85
91
  The builtin argument parser uses shutil to figure out the terminal width
86
92
  to display help info. This one replaces the function that calls help info
@@ -108,7 +114,7 @@ def _get_parser_class():
108
114
  return FixedArgumentParser
109
115
 
110
116
 
111
- def get_parser():
117
+ def get_parser() -> argparse.ArgumentParser:
112
118
  FixedArgumentParser = _get_parser_class() # noqa
113
119
 
114
120
  parser = FixedArgumentParser(
@@ -162,16 +168,16 @@ def get_parser():
162
168
 
163
169
 
164
170
  def display_local_installs(
165
- min_ver=None,
166
- max_ver=None,
167
- compatible=None,
168
- ):
171
+ min_ver: str | None = None,
172
+ max_ver: str | None = None,
173
+ compatible: str | None = None,
174
+ ) -> None:
169
175
  if min_ver:
170
- min_ver = tuple(int(i) for i in min_ver.split("."))
176
+ min_ver_tuple = tuple(int(i) for i in min_ver.split("."))
171
177
  if max_ver:
172
- max_ver = tuple(int(i) for i in max_ver.split("."))
178
+ max_ver_tuple = tuple(int(i) for i in max_ver.split("."))
173
179
  if compatible:
174
- compatible = tuple(int(i) for i in compatible.split("."))
180
+ compatible_tuple = tuple(int(i) for i in compatible.split("."))
175
181
 
176
182
  installs = list_python_installs()
177
183
 
@@ -185,15 +191,15 @@ def display_local_installs(
185
191
 
186
192
  # First collect the strings
187
193
  for install in installs:
188
- if min_ver and install.version < min_ver:
194
+ if min_ver and install.version < min_ver_tuple:
189
195
  continue
190
- elif max_ver and install.version > max_ver:
196
+ elif max_ver and install.version > max_ver_tuple:
191
197
  continue
192
198
  elif compatible:
193
- if install.version < compatible:
199
+ if install.version < compatible_tuple:
194
200
  continue
195
201
  version_parts = len(compatible) - 1
196
- if install.version[:version_parts] > compatible[:-1]:
202
+ if install.version[:version_parts] > compatible_tuple[:-1]:
197
203
  continue
198
204
 
199
205
  version_str = install.version_str
@@ -253,14 +259,14 @@ def display_local_installs(
253
259
 
254
260
 
255
261
  def display_remote_binaries(
256
- min_ver,
257
- max_ver,
258
- compatible,
259
- all_binaries,
260
- system,
261
- machine,
262
- prerelease
263
- ):
262
+ min_ver: str,
263
+ max_ver: str,
264
+ compatible: str,
265
+ all_binaries: bool,
266
+ system: str,
267
+ machine: str,
268
+ prerelease: bool,
269
+ ) -> None:
264
270
  specs = []
265
271
  if min_ver:
266
272
  specs.append(f">={min_ver}")
@@ -294,12 +300,12 @@ def display_remote_binaries(
294
300
  print("No Python releases found matching specification")
295
301
 
296
302
 
297
- def main():
298
- if sys.version_info < (3, 8):
303
+ def main() -> int:
304
+ if sys.version_info < (3, 10):
299
305
  v = sys.version_info
300
306
  raise UnsupportedPythonError(
301
307
  f"Python {v.major}.{v.minor}.{v.micro} is not supported. "
302
- f"ducktools.pythonfinder requires Python 3.8 or later."
308
+ f"ducktools.pythonfinder requires Python 3.10 or later."
303
309
  )
304
310
 
305
311
  if sys.argv[1:]:
@@ -334,7 +340,9 @@ def main():
334
340
  display_local_installs()
335
341
 
336
342
  stop_autoclose()
343
+
344
+ return 0
337
345
 
338
346
 
339
347
  if __name__ == "__main__":
340
- main()
348
+ sys.exit(main())
@@ -0,0 +1,2 @@
1
+ __version__ = "0.10.0"
2
+ __version_tuple__ = (0, 10, 0)
@@ -0,0 +1,74 @@
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 itertools
26
+ try:
27
+ from _collections_abc import Iterator
28
+ except ImportError:
29
+ from collections.abc import Iterator
30
+
31
+ from .. import linux
32
+ from ..shared import get_uv_pythons, DetailFinder,PythonInstall
33
+
34
+
35
+ # This is the difference from the linux methods
36
+ KNOWN_MANAGED_PATHS = {
37
+ **linux.KNOWN_MANAGED_PATHS,
38
+ "/opt/homebrew": "Homebrew", # ARM Apple
39
+ "/usr/local/opt": "Homebrew", # x86_64 Apple
40
+ "/Applications/Xcode.app": "Xcode",
41
+ "/Library/Developer/CommandLineTools": "Xcode", # Xcode commandline tools
42
+ "/Library/Frameworks/Python.framework": "python.org",
43
+ }
44
+
45
+
46
+ def get_path_pythons(
47
+ *,
48
+ finder: DetailFinder | None = None,
49
+ known_paths: dict[str, str] | None = None,
50
+ ) -> Iterator[PythonInstall]:
51
+
52
+ known_paths = KNOWN_MANAGED_PATHS if known_paths is None else known_paths
53
+
54
+ return linux.get_path_pythons(finder=finder, known_paths=known_paths)
55
+
56
+
57
+ def get_python_installs(
58
+ *,
59
+ finder: DetailFinder | None = None,
60
+ ) -> Iterator[PythonInstall]:
61
+ listed_pythons = set()
62
+
63
+ finder = DetailFinder() if finder is None else finder
64
+
65
+ chain_commands = [
66
+ linux.get_pyenv_pythons(finder=finder),
67
+ get_uv_pythons(finder=finder),
68
+ get_path_pythons(finder=finder),
69
+ ]
70
+ with finder:
71
+ for py in itertools.chain.from_iterable(chain_commands):
72
+ if py.executable not in listed_pythons:
73
+ yield py
74
+ listed_pythons.add(py.executable)
@@ -25,7 +25,11 @@ from __future__ import annotations
25
25
  import os
26
26
  import os.path
27
27
  import itertools
28
- from _collections_abc import Iterator
28
+
29
+ try:
30
+ from _collections_abc import Iterator
31
+ except ImportError:
32
+ from collections.abc import Iterator
29
33
 
30
34
  from ..shared import (
31
35
  DetailFinder,
@@ -45,7 +49,12 @@ KNOWN_MANAGED_PATHS = {
45
49
  }
46
50
 
47
51
 
48
- def get_path_pythons(*, finder: DetailFinder | None = None) -> Iterator[PythonInstall]:
52
+ def get_path_pythons(
53
+ *,
54
+ finder: DetailFinder | None = None,
55
+ known_paths: dict[str, str] | None = None,
56
+ ) -> Iterator[PythonInstall]:
57
+
49
58
  exe_names = set()
50
59
 
51
60
  path_folders = os.environ.get("PATH", "").split(":")
@@ -55,6 +64,7 @@ def get_path_pythons(*, finder: DetailFinder | None = None) -> Iterator[PythonIn
55
64
  excluded_folders = [pyenv_root, uv_root]
56
65
 
57
66
  finder = DetailFinder() if finder is None else finder
67
+ known_paths = KNOWN_MANAGED_PATHS if known_paths is None else known_paths
58
68
 
59
69
  for fld in path_folders:
60
70
  # Don't retrieve pyenv installs
@@ -71,8 +81,10 @@ def get_path_pythons(*, finder: DetailFinder | None = None) -> Iterator[PythonIn
71
81
  continue
72
82
 
73
83
  for install in get_folder_pythons(fld, finder=finder):
74
- if manager := KNOWN_MANAGED_PATHS.get(os.path.dirname(install.executable)):
75
- install.managed_by = manager
84
+ for path, manager in known_paths.items():
85
+ if os.path.commonpath((path, install.executable)) == path:
86
+ install.managed_by = manager
87
+ break
76
88
 
77
89
  name = os.path.basename(install.executable)
78
90
  if name in exe_names:
@@ -20,15 +20,19 @@
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
- from __future__ import annotations
24
-
25
23
  """
26
24
  Discover python installs that have been created with pyenv
27
25
  """
28
26
 
27
+ from __future__ import annotations
28
+
29
29
  import os
30
30
  import os.path
31
- from _collections_abc import Iterator
31
+
32
+ try:
33
+ from _collections_abc import Iterator
34
+ except ImportError:
35
+ from collections.abc import Iterator
32
36
 
33
37
  from ducktools.lazyimporter import LazyImporter, FromImport, ModuleImport
34
38
 
@@ -74,7 +78,7 @@ def get_pyenv_root() -> str | None:
74
78
  def get_pyenv_pythons(
75
79
  versions_folder: str | os.PathLike | None = None,
76
80
  *,
77
- finder: DetailFinder = None,
81
+ finder: DetailFinder | None = None,
78
82
  ) -> Iterator[PythonInstall]:
79
83
  if versions_folder is None:
80
84
  if pyenv_root := get_pyenv_root():
@@ -90,7 +94,7 @@ def get_pyenv_pythons(
90
94
  finder = DetailFinder() if finder is None else finder
91
95
 
92
96
  with finder:
93
- for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
97
+ for p in sorted(os.scandir(str(versions_folder)), key=lambda x: x.path):
94
98
  # Don't include folders that are venvs
95
99
  venv_indicator = os.path.join(p.path, "pyvenv.cfg")
96
100
  if os.path.exists(venv_indicator):
@@ -39,7 +39,8 @@ from packaging.specifiers import SpecifierSet
39
39
  from packaging.version import Version
40
40
 
41
41
  from ducktools.classbuilder.prefab import Prefab, attribute, get_attributes
42
- from ducktools.pythonfinder.shared import version_str_to_tuple
42
+
43
+ from .shared import version_str_to_tuple
43
44
 
44
45
 
45
46
  RELEASE_PAGE = "https://www.python.org/api/v2/downloads/release/"
@@ -341,6 +342,7 @@ class PythonOrgSearch(Prefab):
341
342
  for download in self.matching_downloads(specifier, prereleases):
342
343
  if download.url.endswith(tag):
343
344
  return download
345
+ return None
344
346
 
345
347
  def latest_python_download(self, prereleases=False) -> PythonDownload | None:
346
348
  """
@@ -369,3 +371,4 @@ class PythonOrgSearch(Prefab):
369
371
  md5_sum=release_file.md5_sum,
370
372
  )
371
373
  return download
374
+ return None
@@ -26,7 +26,10 @@ import sys
26
26
  import os
27
27
  import os.path
28
28
 
29
- from _collections_abc import Iterator
29
+ try:
30
+ from _collections_abc import Iterator
31
+ except ImportError:
32
+ from collections.abc import Iterator
30
33
 
31
34
  from ducktools.classbuilder.prefab import Prefab, attribute, as_dict
32
35
  from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
@@ -190,7 +193,7 @@ class DetailFinder(Prefab):
190
193
  self.save()
191
194
 
192
195
  @property
193
- def raw_cache(self):
196
+ def raw_cache(self) -> dict:
194
197
  if self._raw_cache is None:
195
198
  try:
196
199
  with open(self.cache_path) as f:
@@ -199,14 +202,14 @@ class DetailFinder(Prefab):
199
202
  self._raw_cache = {}
200
203
  return self._raw_cache
201
204
 
202
- def save(self):
205
+ def save(self) -> None:
203
206
  os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
204
207
  with open(self.cache_path, 'w') as f:
205
208
  _laz.json.dump(self.raw_cache, f, indent=4)
206
209
 
207
210
  self._dirty_cache = False
208
211
 
209
- def clear_invalid_runtimes(self):
212
+ def clear_invalid_runtimes(self) -> None:
210
213
  """
211
214
  Remove cache entries where the python.exe no longer exists
212
215
  """
@@ -218,7 +221,7 @@ class DetailFinder(Prefab):
218
221
  if removed_runtimes:
219
222
  self._dirty_cache = True
220
223
 
221
- def clear_cache(self):
224
+ def clear_cache(self) -> None:
222
225
  """
223
226
  Completely empty the cache
224
227
  """
@@ -357,6 +360,8 @@ class PythonInstall(Prefab):
357
360
  def implementation_version_str(self) -> str:
358
361
  return version_tuple_to_str(self.implementation_version)
359
362
 
363
+ # Typing these classmethods would require an import
364
+ # This is not acceptable for performance reasons
360
365
  @classmethod
361
366
  def from_str(
362
367
  cls,
@@ -373,7 +378,6 @@ class PythonInstall(Prefab):
373
378
  metadata = {} if metadata is None else metadata
374
379
  paths = {} if paths is None else paths
375
380
 
376
- # noinspection PyArgumentList
377
381
  return cls(
378
382
  version=version_tuple,
379
383
  executable=executable,
@@ -387,20 +391,19 @@ class PythonInstall(Prefab):
387
391
  @classmethod
388
392
  def from_json(
389
393
  cls,
390
- version,
391
- executable,
392
- architecture,
393
- implementation,
394
- metadata,
395
- paths=None,
396
- managed_by=None,
394
+ version: list[int | str], # This is actually the list version of [int, int, int, str, int]
395
+ executable: str,
396
+ architecture: str,
397
+ implementation: str,
398
+ metadata: dict,
399
+ paths: dict | None = None,
400
+ managed_by: str | None = None,
397
401
  ):
398
402
  if arch_ver := metadata.get(f"{implementation}_version"):
399
403
  metadata[f"{implementation}_version"] = tuple(arch_ver)
400
404
 
401
405
  paths = {} if paths is None else paths
402
406
 
403
- # noinspection PyArgumentList
404
407
  return cls(
405
408
  version=tuple(version), # type: ignore
406
409
  executable=executable,
@@ -431,6 +434,7 @@ class PythonInstall(Prefab):
431
434
  return pip_call.stdout
432
435
 
433
436
 
437
+ # Return type missing due to import requirements
434
438
  def _python_exe_regex(basename: str = "python"):
435
439
  if sys.platform == "win32":
436
440
  return _laz.re.compile(rf"{basename}\d?\.?\d*\.exe")
@@ -443,7 +447,7 @@ def get_folder_pythons(
443
447
  basenames: tuple[str, ...] = ("python", "pypy", "micropython"),
444
448
  finder: DetailFinder | None = None,
445
449
  managed_by: str | None = None,
446
- ):
450
+ ) -> Iterator[PythonInstall]:
447
451
  regexes = [_python_exe_regex(name) for name in basenames]
448
452
 
449
453
  finder = DetailFinder() if finder is None else finder
@@ -1,18 +1,18 @@
1
1
  # ducktools-pythonfinder
2
2
  # MIT License
3
- #
3
+ #
4
4
  # Copyright (c) 2023-2025 David C Ellis
5
- #
5
+ #
6
6
  # Permission is hereby granted, free of charge, to any person obtaining a copy
7
7
  # of this software and associated documentation files (the "Software"), to deal
8
8
  # in the Software without restriction, including without limitation the rights
9
9
  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
10
  # copies of the Software, and to permit persons to whom the Software is
11
11
  # furnished to do so, subject to the following conditions:
12
- #
12
+ #
13
13
  # The above copyright notice and this permission notice shall be included in all
14
14
  # copies or substantial portions of the Software.
15
- #
15
+ #
16
16
  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
17
  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
18
  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -30,7 +30,6 @@ except ImportError:
30
30
  import os
31
31
  import sys
32
32
 
33
-
34
33
  from ducktools.classbuilder.prefab import Prefab, attribute
35
34
  from ducktools.lazyimporter import LazyImporter, FromImport, ModuleImport
36
35
 
@@ -84,17 +83,23 @@ class PythonVEnv(Prefab):
84
83
  return version_tuple_to_str(self.version)
85
84
 
86
85
  @property
87
- def parent_executable(self) -> str:
86
+ def parent_executable(self) -> str | None:
88
87
  if self._parent_executable is None:
89
88
  # Guess the parent executable file
90
89
  parent_exe = None
91
90
  if sys.platform == "win32":
92
91
  parent_exe = os.path.join(self.parent_path, "python.exe")
93
92
  else:
94
- # try with additional numbers in order eg: python313, python3, python
95
- for i in reversed(range(2)):
96
- version_part = "".join(str(v) for v in self.version[:i])
97
- parent_exe = os.path.join(self.parent_path, f"python{version_part}")
93
+ # try with additional numbers in order eg: python3.13, python313, python3, python
94
+ suffixes = [
95
+ f"{self.version[0]}.{self.version[1]}",
96
+ f"{self.version[0]}{self.version[1]}",
97
+ f"{self.version[0]}",
98
+ ""
99
+ ]
100
+
101
+ for suffix in suffixes:
102
+ parent_exe = os.path.join(self.parent_path, f"python{suffix}")
98
103
  if os.path.exists(parent_exe):
99
104
  break
100
105
 
@@ -122,7 +127,9 @@ class PythonVEnv(Prefab):
122
127
 
123
128
  @property
124
129
  def parent_exists(self) -> bool:
125
- return os.path.exists(self.parent_executable)
130
+ if self.parent_executable and os.path.exists(self.parent_executable):
131
+ return True
132
+ return False
126
133
 
127
134
  def get_parent_install(
128
135
  self,
@@ -135,6 +142,9 @@ class PythonVEnv(Prefab):
135
142
  finder = DetailFinder() if finder is None else finder
136
143
 
137
144
  if self.parent_exists:
145
+ # parent_exists forces this check
146
+ assert self.parent_executable is not None
147
+
138
148
  exe = self.parent_executable
139
149
 
140
150
  # Python installs may be cached, can skip querying exe.
@@ -253,6 +263,7 @@ def get_python_venvs(
253
263
  :param search_parent_folders: Also search parent folders
254
264
  :yield: PythonVEnv details.
255
265
  """
266
+ # This converts base_dir to a Path, but mypy doesn't know that
256
267
  base_dir = _laz.Path.cwd() if base_dir is None else _laz.Path(base_dir)
257
268
 
258
269
  cwd_pattern = pattern = f"*/{VENV_CONFIG_NAME}"
@@ -261,7 +272,7 @@ def get_python_venvs(
261
272
  # Only search cwd recursively, parents are searched non-recursively
262
273
  cwd_pattern = "*" + pattern
263
274
 
264
- for conf in base_dir.glob(cwd_pattern):
275
+ for conf in base_dir.glob(cwd_pattern): # type: ignore
265
276
  try:
266
277
  env = PythonVEnv.from_cfg(conf)
267
278
  except InvalidVEnvError:
@@ -270,7 +281,7 @@ def get_python_venvs(
270
281
 
271
282
  if search_parent_folders:
272
283
  # Search parent folders
273
- for fld in base_dir.parents:
284
+ for fld in base_dir.parents: # type: ignore
274
285
  try:
275
286
  for conf in fld.glob(pattern):
276
287
  try:
@@ -22,9 +22,13 @@
22
22
  # SOFTWARE.
23
23
  from __future__ import annotations
24
24
 
25
- from _collections_abc import Iterator
26
25
  import itertools
27
26
 
27
+ try:
28
+ from _collections_abc import Iterator
29
+ except ImportError:
30
+ from collections.abc import Iterator
31
+
28
32
  from ..shared import PythonInstall, get_uv_pythons, DetailFinder
29
33
  from .pyenv_search import get_pyenv_pythons
30
34
  from .registry_search import get_registered_pythons
@@ -24,7 +24,11 @@ from __future__ import annotations
24
24
 
25
25
  import os
26
26
  import os.path
27
- from _collections_abc import Iterator
27
+
28
+ try:
29
+ from _collections_abc import Iterator
30
+ except ImportError:
31
+ from collections.abc import Iterator
28
32
 
29
33
  from ..shared import PythonInstall, DetailFinder
30
34
 
@@ -52,7 +56,7 @@ def get_pyenv_pythons(
52
56
  finder = DetailFinder() if finder is None else finder
53
57
 
54
58
  with finder:
55
- for p in os.scandir(versions_folder):
59
+ for p in os.scandir(str(versions_folder)):
56
60
  # On windows, venv folders usually have the python.exe in \Scripts\
57
61
  # while runtimes have it in the base folder so venvs shouldn't be disovered
58
62
  # but exclude them early anyway