ducktools-pythonfinder 0.7.7__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 (46) hide show
  1. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/.gitignore +1 -0
  2. {ducktools_pythonfinder-0.7.7/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.8.0}/PKG-INFO +1 -1
  3. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__init__.py +4 -3
  4. {ducktools_pythonfinder-0.7.7 → 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.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/details_script.py +5 -3
  7. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/linux/__init__.py +28 -13
  8. ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/linux/pyenv_search.py +122 -0
  9. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/shared.py +214 -60
  10. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/venv.py +10 -3
  11. {ducktools_pythonfinder-0.7.7 → 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.7 → ducktools_pythonfinder-0.8.0/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
  14. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +1 -0
  15. {ducktools_pythonfinder-0.7.7 → 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.7 → ducktools_pythonfinder-0.8.0}/tests/test_foldersearch.py +11 -9
  18. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/test_pyenv.py +48 -23
  19. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/test_uv_finder.py +12 -6
  20. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/test_venv_finder.py +2 -2
  21. ducktools_pythonfinder-0.7.7/src/ducktools/pythonfinder/_version.py +0 -2
  22. ducktools_pythonfinder-0.7.7/src/ducktools/pythonfinder/linux/pyenv_search.py +0 -84
  23. ducktools_pythonfinder-0.7.7/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -93
  24. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/LICENSE +0 -0
  25. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/MANIFEST.in +0 -0
  26. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/README.md +0 -0
  27. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/pyproject.toml +0 -0
  28. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/scripts/build_zipapp.py +0 -0
  29. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/scripts/detail_this_python.py +0 -0
  30. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/scripts/list_python_venvs.py +0 -0
  31. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/scripts/print_python_versions.py +0 -0
  32. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/setup.cfg +0 -0
  33. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
  34. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/package_list_script.py +0 -0
  35. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
  36. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/win32/registry_search.py +0 -0
  37. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  38. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  39. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
  40. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  41. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/sources/python_versions.txt +0 -0
  42. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/sources/release.json +0 -0
  43. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/sources/release_file.json +0 -0
  44. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/test_details_script.py +0 -0
  45. {ducktools_pythonfinder-0.7.7 → ducktools_pythonfinder-0.8.0}/tests/test_orgsearch.py +0 -0
  46. {ducktools_pythonfinder-0.7.7 → 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.7
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,19 +27,27 @@ 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
31
- from .pyenv_search import get_pyenv_pythons
30
+ from ..shared import (
31
+ DetailFinder,
32
+ PythonInstall,
33
+ get_folder_pythons,
34
+ get_uv_pythons,
35
+ get_uv_python_path
36
+ )
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(":")
38
- pyenv_root = os.environ.get("PYENV_ROOT")
44
+ pyenv_root = get_pyenv_root()
39
45
  uv_root = get_uv_python_path()
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)
@@ -0,0 +1,122 @@
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
+ Discover python installs that have been created with pyenv
27
+ """
28
+
29
+ import os
30
+ import os.path
31
+ from _collections_abc import Iterator
32
+
33
+ from ducktools.lazyimporter import LazyImporter, FromImport, ModuleImport
34
+
35
+ from ..shared import PythonInstall, DetailFinder, FULL_PY_VER_RE, INSTALLER_CACHE_PATH, version_str_to_tuple
36
+
37
+ _laz = LazyImporter(
38
+ [
39
+ ModuleImport("json"),
40
+ ModuleImport("re"),
41
+ FromImport("subprocess", "run"),
42
+ ]
43
+ )
44
+
45
+ # pyenv folder name for pypy
46
+ PYPY_VER_RE = r"^pypy(?P<pyversion>\d{1,2}\.\d+)-(?P<pypyversion>[\d\.]*)$"
47
+
48
+
49
+ def get_pyenv_root() -> str | None:
50
+ # Check if the environment variable exists, if so use that
51
+ # As a backup try to run pyenv to obtain the root folder
52
+ pyenv_root = os.environ.get("PYENV_ROOT")
53
+ if not pyenv_root:
54
+ try:
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()
68
+
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)
73
+
74
+ return pyenv_root
75
+
76
+
77
+ def get_pyenv_pythons(
78
+ versions_folder: str | os.PathLike | None = None,
79
+ *,
80
+ query_executables: bool = True,
81
+ finder: DetailFinder = None,
82
+ ) -> Iterator[PythonInstall]:
83
+ if versions_folder is None:
84
+ if pyenv_root := get_pyenv_root():
85
+ versions_folder = os.path.join(pyenv_root, "versions")
86
+
87
+ if versions_folder is None or not os.path.exists(versions_folder):
88
+ return
89
+
90
+ # Sorting puts standard python versions before alternate implementations
91
+ # This can lead to much faster returns by potentially yielding
92
+ # the required python version before checking pypy/graalpy/micropython
93
+
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