ducktools-pythonfinder 0.5.3__tar.gz → 0.5.5__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 (39) hide show
  1. {ducktools_pythonfinder-0.5.3/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.5.5}/PKG-INFO +1 -1
  2. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/__init__.py +3 -2
  3. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/__main__.py +64 -27
  4. ducktools_pythonfinder-0.5.5/src/ducktools/pythonfinder/_version.py +2 -0
  5. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/details_script.py +40 -1
  6. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/linux/__init__.py +9 -6
  7. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/linux/pyenv_search.py +6 -11
  8. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/shared.py +77 -35
  9. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/win32/__init__.py +3 -3
  10. ducktools_pythonfinder-0.5.5/src/ducktools/pythonfinder/win32/pyenv_search.py +87 -0
  11. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
  12. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/conftest.py +7 -0
  13. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/test_foldersearch.py +1 -1
  14. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/test_pyenv.py +13 -8
  15. ducktools_pythonfinder-0.5.3/src/ducktools/pythonfinder/_version.py +0 -2
  16. ducktools_pythonfinder-0.5.3/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -81
  17. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/.gitignore +0 -0
  18. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/LICENSE +0 -0
  19. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/MANIFEST.in +0 -0
  20. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/README.md +0 -0
  21. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/pyproject.toml +0 -0
  22. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/scripts/build_zipapp.py +0 -0
  23. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/scripts/detail_this_python.py +0 -0
  24. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/scripts/print_python_versions.py +0 -0
  25. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/setup.cfg +0 -0
  26. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
  27. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
  28. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools/pythonfinder/win32/registry_search.py +0 -0
  29. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +0 -0
  30. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  31. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  32. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
  33. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  34. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/sources/python_versions.txt +0 -0
  35. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/sources/release.json +0 -0
  36. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/sources/release_file.json +0 -0
  37. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/test_details_script.py +0 -0
  38. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/test_orgsearch.py +0 -0
  39. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.5}/tests/test_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.5.3
3
+ Version: 0.5.5
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
@@ -24,6 +24,7 @@
24
24
  # Find platform python versions
25
25
 
26
26
  __all__ = [
27
+ "__version__",
27
28
  "get_python_installs",
28
29
  "list_python_installs",
29
30
  "PythonInstall",
@@ -42,9 +43,9 @@ else:
42
43
  from .linux import get_python_installs
43
44
 
44
45
 
45
- def list_python_installs():
46
+ def list_python_installs(*, query_executables=True):
46
47
  return sorted(
47
- get_python_installs(),
48
+ get_python_installs(query_executables=query_executables),
48
49
  reverse=True,
49
50
  key=lambda x: (x.version[3], *x.version[:3], x.version[4])
50
51
  )
@@ -20,6 +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
+ from __future__ import annotations
23
24
 
24
25
 
25
26
  import sys
@@ -79,6 +80,11 @@ def parse_args(args):
79
80
  description="Discover base Python installs",
80
81
  )
81
82
  parser.add_argument("-V", "--version", action="version", version=__version__)
83
+ parser.add_argument(
84
+ "--fast",
85
+ action="store_true",
86
+ help="Skip Python installs that need to be launched to obtain metadata"
87
+ )
82
88
 
83
89
  subparsers = parser.add_subparsers(dest="command", required=False)
84
90
 
@@ -121,7 +127,12 @@ def parse_args(args):
121
127
  return vals
122
128
 
123
129
 
124
- def display_local_installs(min_ver=None, max_ver=None, compatible=None):
130
+ def display_local_installs(
131
+ min_ver=None,
132
+ max_ver=None,
133
+ compatible=None,
134
+ query_executables=True,
135
+ ):
125
136
  if min_ver:
126
137
  min_ver = tuple(int(i) for i in min_ver.split("."))
127
138
  if max_ver:
@@ -129,24 +140,17 @@ def display_local_installs(min_ver=None, max_ver=None, compatible=None):
129
140
  if compatible:
130
141
  compatible = tuple(int(i) for i in compatible.split("."))
131
142
 
132
- installs = list_python_installs()
133
- headings = ["Python Version", "Executable Location"]
134
- max_executable_len = max(
135
- len(headings[1]), max(len(inst.executable) for inst in installs)
136
- )
137
- headings_str = f"| {headings[0]} | {headings[1]:<{max_executable_len}s} |"
143
+ installs = list_python_installs(query_executables=query_executables)
138
144
 
139
- print("Discoverable Python Installs")
140
- if sys.platform == "win32":
141
- print("+ - Listed in the Windows Registry ")
142
- print("^ - This is a 32-bit Python install")
143
- if sys.platform != "win32":
144
- print("[] - This Python install is shadowed by another on Path")
145
- print("* - This is the active Python executable used to call this module")
146
- print("** - This is the parent Python executable of the venv used to call this module")
147
- print()
148
- print(headings_str)
149
- print(f"| {'-' * len(headings[0])} | {'-' * max_executable_len} |")
145
+ headings = ["Version", "Executable Location"]
146
+
147
+ install_collection: list[tuple[str, str]] = []
148
+ max_version_len = len(headings[0])
149
+ max_executable_len = len(headings[1])
150
+
151
+ alternate_implementations = False
152
+
153
+ # First collect the strings
150
154
  for install in installs:
151
155
  if min_ver and install.version < min_ver:
152
156
  continue
@@ -180,17 +184,45 @@ def display_local_installs(min_ver=None, max_ver=None, compatible=None):
180
184
  if install.shadowed:
181
185
  version_str = f"[{version_str}]"
182
186
 
183
- print(f"| {version_str:>14s} | {install.executable:<{max_executable_len}s} |")
187
+ if install.implementation != "cpython":
188
+ alternate_implementations = True
189
+ version_str = f"({install.implementation_version_str}) {version_str}"
190
+
191
+ max_version_len = max(max_version_len, len(version_str))
192
+ max_executable_len = max(max_executable_len, len(install.executable))
193
+
194
+ install_collection.append((version_str, install.executable))
195
+
196
+ print("Discoverable Python Installs")
197
+ print()
198
+ if alternate_implementations:
199
+ print("Alternate implementation versions are listed in parentheses")
200
+
201
+ if sys.platform == "win32":
202
+ print("+ - Listed in the Windows Registry ")
203
+ print("^ - This is a 32-bit Python install")
204
+ if sys.platform != "win32":
205
+ print("[] - This Python install is shadowed by another on Path")
206
+ print("* - This is the active Python executable used to call this module")
207
+ print("** - This is the parent Python executable of the venv used to call this module")
208
+ print()
209
+
210
+ headings_str = f"| {headings[0]:<{max_version_len}s} | {headings[1]:<{max_executable_len}s} |"
211
+ print(headings_str)
212
+ print(f"| {'-' * max_version_len} | {'-' * max_executable_len} |")
213
+
214
+ for version_str, executable in install_collection:
215
+ print(f"| {version_str:>{max_version_len}s} | {executable:<{max_executable_len}s} |")
184
216
 
185
217
 
186
218
  def display_remote_binaries(
187
- min_ver,
188
- max_ver,
189
- compatible,
190
- all_binaries,
191
- system,
192
- machine,
193
- prerelease
219
+ min_ver,
220
+ max_ver,
221
+ compatible,
222
+ all_binaries,
223
+ system,
224
+ machine,
225
+ prerelease
194
226
  ):
195
227
  specs = []
196
228
  if min_ver:
@@ -252,7 +284,12 @@ def main():
252
284
  except _laz.URLError:
253
285
  print("Could not connect to python.org")
254
286
  else:
255
- display_local_installs(vals.min, vals.max, vals.compatible)
287
+ display_local_installs(
288
+ min_ver=vals.min,
289
+ max_ver=vals.max,
290
+ compatible=vals.compatible,
291
+ query_executables=not vals.fast,
292
+ )
256
293
  else:
257
294
  # No arguments to parse
258
295
  display_local_installs()
@@ -0,0 +1,2 @@
1
+ __version__ = "0.5.5"
2
+ __version_tuple__ = (0, 5, 5)
@@ -26,6 +26,35 @@ Get the details from a python install as JSON
26
26
  """
27
27
  import sys
28
28
 
29
+ FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)-?(?P<releaselevel>[a-zA-Z]*)(?P<serial>\d*)"
30
+
31
+
32
+ def version_str_to_tuple(version):
33
+ # Needed to parse GraalPy versions only available as strings
34
+ import re
35
+
36
+ parsed_version = re.fullmatch(FULL_PY_VER_RE, version)
37
+
38
+ major, minor, micro, releaselevel, serial = parsed_version.groups()
39
+
40
+ if releaselevel in {"a", "dev"}:
41
+ releaselevel = "alpha"
42
+ elif releaselevel == "b":
43
+ releaselevel = "beta"
44
+ elif releaselevel == "rc":
45
+ releaselevel = "candidate"
46
+ else:
47
+ releaselevel = "final"
48
+
49
+ version_tuple = (
50
+ int(major),
51
+ int(minor),
52
+ int(micro) if micro else 0,
53
+ releaselevel,
54
+ int(serial if serial != "" else 0),
55
+ )
56
+ return version_tuple
57
+
29
58
 
30
59
  def get_details():
31
60
  try:
@@ -37,7 +66,17 @@ def get_details():
37
66
  implementation = platform.python_implementation().lower()
38
67
  metadata = {}
39
68
  else:
40
- if implementation != "cpython": # pragma: no cover
69
+ if implementation == "graalpy":
70
+ # Special case GraalPy as it erroneously reports the CPython target
71
+ # instead of the Graal versiion
72
+ try:
73
+ ver = __graalpython__.get_graalvm_version()
74
+ metadata = {
75
+ "graalpy_version": version_str_to_tuple(ver)
76
+ }
77
+ except NameError:
78
+ metadata = {"{}_version".format(implementation): sys.implementation.version}
79
+ elif implementation != "cpython": # pragma: no cover
41
80
  metadata = {"{}_version".format(implementation): sys.implementation.version}
42
81
  else:
43
82
  metadata = {}
@@ -63,14 +63,17 @@ def get_path_pythons() -> Iterator[PythonInstall]:
63
63
  yield install
64
64
 
65
65
 
66
- def get_python_installs():
66
+ def get_python_installs(*, query_executables=True):
67
67
  listed_pythons = set()
68
68
 
69
- for py in itertools.chain(
70
- get_pyenv_pythons(),
71
- get_uv_pythons(),
72
- get_path_pythons(),
73
- ):
69
+ chain_commands = [
70
+ get_pyenv_pythons(query_executables=query_executables),
71
+ get_uv_pythons(query_executables=query_executables),
72
+ ]
73
+ if query_executables:
74
+ chain_commands.append(get_path_pythons())
75
+
76
+ for py in itertools.chain.from_iterable(chain_commands):
74
77
  if py.executable not in listed_pythons:
75
78
  yield py
76
79
  listed_pythons.add(py.executable)
@@ -45,24 +45,20 @@ _laz = LazyImporter(
45
45
  PYTHON_VER_RE = r"\d{1,2}\.\d{1,2}\.\d+[a-z]*\d*"
46
46
  PYPY_VER_RE = r"^pypy(?P<pyversion>\d{1,2}\.\d+)-(?P<pypyversion>[\d\.]*)$"
47
47
 
48
- # 'pypy -V' output matcher
49
- PYPY_V_OUTPUT = (
50
- r"(?is)python (?P<python_version>\d+\.\d+\.\d+[a-z]*\d*).*?"
51
- r"pypy (?P<pypy_version>\d+\.\d+\.\d+[a-z]*\d*).*"
52
- )
53
-
54
48
  PYENV_VERSIONS_FOLDER = os.path.join(os.environ.get("PYENV_ROOT", ""), "versions")
55
49
 
56
50
 
57
51
  def get_pyenv_pythons(
58
52
  versions_folder: str | os.PathLike = PYENV_VERSIONS_FOLDER,
53
+ *,
54
+ query_executables: bool = True,
59
55
  ) -> Iterator[PythonInstall]:
60
56
  if not os.path.exists(versions_folder):
61
57
  return
62
58
 
63
- # Sorting puts standard python versions before pypy
59
+ # Sorting puts standard python versions before alternate implementations
64
60
  # This can lead to much faster returns by potentially yielding
65
- # the required python version before checking pypy
61
+ # the required python version before checking pypy/graalpy/micropython
66
62
 
67
63
  for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
68
64
  executable = os.path.join(p.path, "bin/python")
@@ -70,6 +66,5 @@ def get_pyenv_pythons(
70
66
  if os.path.exists(executable):
71
67
  if _laz.re.fullmatch(PYTHON_VER_RE, p.name):
72
68
  yield PythonInstall.from_str(p.name, executable)
73
- elif _laz.re.fullmatch(PYPY_VER_RE, p.name):
74
- if install := get_install_details(executable):
75
- yield install
69
+ elif query_executables and (install := get_install_details(executable)):
70
+ yield install
@@ -28,24 +28,25 @@ import os.path
28
28
 
29
29
  from _collections_abc import Iterator
30
30
 
31
- from ducktools.classbuilder import slotclass, Field, SlotFields
31
+ from ducktools.classbuilder.prefab import Prefab, attribute
32
32
  from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
33
33
 
34
34
  from . import details_script
35
35
 
36
36
  _laz = LazyImporter(
37
37
  [
38
- ModuleImport("re"),
39
- ModuleImport("subprocess"),
40
- ModuleImport("platform"),
41
38
  FromImport("glob", "glob"),
42
39
  ModuleImport("json"),
40
+ ModuleImport("platform"),
41
+ ModuleImport("re"),
42
+ ModuleImport("subprocess"),
43
+ ModuleImport("tempfile"),
43
44
  ModuleImport("zipfile"),
44
45
  ]
45
46
  )
46
47
 
47
48
 
48
- FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)(?P<releaselevel>[a-zA-Z]*)(?P<serial>\d*)"
49
+ FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)-?(?P<releaselevel>[a-zA-Z]*)(?P<serial>\d*)"
49
50
 
50
51
 
51
52
  def version_str_to_tuple(version):
@@ -56,7 +57,7 @@ def version_str_to_tuple(version):
56
57
 
57
58
  major, minor, micro, releaselevel, serial = parsed_version.groups()
58
59
 
59
- if releaselevel == "a":
60
+ if releaselevel in {"a", "dev"}:
60
61
  releaselevel = "alpha"
61
62
  elif releaselevel == "b":
62
63
  releaselevel = "beta"
@@ -95,17 +96,12 @@ def version_tuple_to_str(version_tuple):
95
96
  return f"{major}.{minor}.{micro}{releaselevel}{serial}"
96
97
 
97
98
 
98
- @slotclass
99
- class DetailsScript:
99
+ class DetailsScript(Prefab):
100
100
  """
101
101
  Class to obtain and cache the source code of details_script.py
102
102
  to use on external Pythons.
103
103
  """
104
- __slots__ = SlotFields(
105
- _source_code=Field(default=None)
106
- )
107
-
108
- _source_code: str | None
104
+ _source_code: str | None = attribute(default=None, private=True)
109
105
 
110
106
  def get_source_code(self):
111
107
  if self._source_code is None:
@@ -128,27 +124,46 @@ class DetailsScript:
128
124
  details = DetailsScript()
129
125
 
130
126
 
131
- @slotclass
132
- class PythonInstall:
133
- __slots__ = SlotFields(
134
- version=Field(),
135
- executable=Field(),
136
- architecture="64bit",
137
- implementation="cpython",
138
- metadata=Field(default_factory=dict),
139
- shadowed=False,
140
- )
127
+ class PythonInstall(Prefab):
141
128
  version: tuple[int, int, int, str, int]
142
129
  executable: str
143
- architecture: str
144
- implementation: str
145
- metadata: dict
146
- shadowed: bool
130
+ architecture: str = "64bit"
131
+ implementation: str = "cpython"
132
+ metadata: dict = attribute(default_factory=dict)
133
+ shadowed: bool = False
134
+ _implementation_version: tuple[int, int, int, str, int] | None = attribute(default=None, private=True)
135
+
136
+ def __prefab_post_init__(
137
+ self,
138
+ version: tuple[int, int, int] | tuple[int, int, int, str, int]
139
+ ):
140
+ if len(version) == 3:
141
+ # Micropython gives an invalid 3 part version here
142
+ # Add the extras to avoid breaking
143
+ self.version = tuple([*version, "final", 0]) # type: ignore
144
+ else:
145
+ self.version = version
147
146
 
148
147
  @property
149
148
  def version_str(self) -> str:
150
149
  return version_tuple_to_str(self.version)
151
150
 
151
+ @property
152
+ def implementation_version(self) -> tuple[int, int, int, str, int] | None:
153
+ if self._implementation_version is None:
154
+ if self.implementation == "cpython":
155
+ self._implementation_version = self.version
156
+ elif implementation_ver := self.metadata.get(f"{self.implementation}_version"):
157
+ if len(implementation_ver) == 3:
158
+ self._implementation_version = tuple([*implementation_ver, "final", 0]) # type: ignore
159
+ else:
160
+ self._implementation_version = implementation_ver
161
+ return self._implementation_version
162
+
163
+ @property
164
+ def implementation_version_str(self) -> str:
165
+ return version_tuple_to_str(self.implementation_version)
166
+
152
167
  @classmethod
153
168
  def from_str(
154
169
  cls,
@@ -219,12 +234,31 @@ def get_install_details(executable: str) -> PythonInstall | None:
219
234
  text=True,
220
235
  check=True,
221
236
  ).stdout
222
- except (_laz.subprocess.CalledProcessError, FileNotFoundError):
237
+ except OSError:
238
+ # Something else has gone wrong
223
239
  return None
240
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
241
+ # Potentially this is micropython which does not support
242
+ # piping from stdin. Try using a file in a temporary folder.
243
+ # Python 3.12 has delete_on_close that would make TemporaryFile
244
+ # Usable on windows but for now use a directory
245
+ with _laz.tempfile.TemporaryDirectory() as tempdir:
246
+ temp_script = os.path.join(tempdir, "details_script.py")
247
+ with open(temp_script, "w") as f:
248
+ f.write(source)
249
+ try:
250
+ detail_output = _laz.subprocess.run(
251
+ [executable, temp_script],
252
+ capture_output=True,
253
+ text=True,
254
+ check=True,
255
+ ).stdout
256
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
257
+ return None
224
258
 
225
259
  try:
226
260
  output = _laz.json.loads(detail_output)
227
- except _laz.json.JSONDecodeError:
261
+ except _laz.json.JSONDecodeError as e:
228
262
  return None
229
263
 
230
264
  return PythonInstall.from_json(**output)
@@ -266,7 +300,10 @@ def get_uv_python_path() -> str | None:
266
300
  return uv_python_dir
267
301
 
268
302
 
269
- def _implementation_from_uv_dir(direntry: os.DirEntry) -> PythonInstall | None:
303
+ def _implementation_from_uv_dir(
304
+ direntry: os.DirEntry,
305
+ query_executables: bool = True,
306
+ ) -> PythonInstall | None:
270
307
  python_exe = "python.exe" if sys.platform == "win32" else "bin/python"
271
308
  python_path = os.path.join(direntry, python_exe)
272
309
 
@@ -278,9 +315,10 @@ def _implementation_from_uv_dir(direntry: os.DirEntry) -> PythonInstall | None:
278
315
  except ValueError:
279
316
  # Directory name format has changed
280
317
  # Slow backup - ask python itself
281
- install = get_install_details(python_path)
318
+ if query_executables:
319
+ install = get_install_details(python_path)
282
320
  else:
283
- if implementation == "cpython":
321
+ if implementation in {"cpython"}:
284
322
  install = PythonInstall.from_str(
285
323
  version=version,
286
324
  executable=python_path,
@@ -289,16 +327,20 @@ def _implementation_from_uv_dir(direntry: os.DirEntry) -> PythonInstall | None:
289
327
  )
290
328
  else:
291
329
  # Get additional alternate implementation details
292
- install = get_install_details(python_path)
330
+ if query_executables:
331
+ install = get_install_details(python_path)
293
332
 
294
333
  return install
295
334
 
296
335
 
297
- def get_uv_pythons() -> Iterator[PythonInstall]:
336
+ def get_uv_pythons(query_executables=True) -> Iterator[PythonInstall]:
298
337
  # This takes some shortcuts over the regular pythonfinder
299
338
  # As the UV folders give the python version and the implementation
300
339
  if uv_python_path := get_uv_python_path():
301
340
  with os.scandir(uv_python_path) as fld:
302
341
  for f in fld:
303
- if f.is_dir() and (install := _implementation_from_uv_dir(f)):
342
+ if (
343
+ f.is_dir()
344
+ and (install := _implementation_from_uv_dir(f, query_executables))
345
+ ):
304
346
  yield install
@@ -30,12 +30,12 @@ from .pyenv_search import get_pyenv_pythons
30
30
  from .registry_search import get_registered_pythons
31
31
 
32
32
 
33
- def get_python_installs() -> Iterator[PythonInstall]:
33
+ def get_python_installs(*, query_executables: bool = True) -> Iterator[PythonInstall]:
34
34
  listed_installs = set()
35
35
  for py in itertools.chain(
36
36
  get_registered_pythons(),
37
- get_pyenv_pythons(),
38
- get_uv_pythons(),
37
+ get_pyenv_pythons(query_executables=query_executables),
38
+ get_uv_pythons(query_executables=query_executables),
39
39
  ):
40
40
  if py.executable not in listed_installs:
41
41
  yield py
@@ -0,0 +1,87 @@
1
+ # ducktools-pythonfinder
2
+ # MIT License
3
+ #
4
+ # Copyright (c) 2023-2024 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, get_install_details
30
+
31
+
32
+ PYENV_VERSIONS_FOLDER = os.path.join(os.environ.get("PYENV_ROOT", ""), "versions")
33
+
34
+
35
+ def get_pyenv_pythons(
36
+ versions_folder: str | os.PathLike = PYENV_VERSIONS_FOLDER,
37
+ *,
38
+ query_executables: bool = True,
39
+ ) -> Iterator[PythonInstall]:
40
+ if not os.path.exists(versions_folder):
41
+ return
42
+
43
+ for p in os.scandir(versions_folder):
44
+ path_base = os.path.basename(p.path)
45
+
46
+ if query_executables:
47
+ # Check for pypy/graalpy
48
+ if path_base.startswith("pypy"):
49
+ executable = os.path.join(p.path, "pypy.exe")
50
+ if os.path.exists(executable):
51
+ yield get_install_details(executable)
52
+ continue
53
+ elif path_base.startswith("graalpy"):
54
+ # Graalpy exe in bin subfolder
55
+ executable = os.path.join(p.path, "bin", "graalpy.exe")
56
+ if os.path.exists(executable):
57
+ yield get_install_details(executable)
58
+ continue
59
+
60
+ # Regular CPython
61
+ executable = os.path.join(p.path, "python.exe")
62
+
63
+ if os.path.exists(executable):
64
+ split_version = p.name.split("-")
65
+
66
+ # If there are 1 or 2 arguments this is a recognised version
67
+ # Otherwise it is unrecognised
68
+ if len(split_version) == 2:
69
+ version, arch = split_version
70
+
71
+ # win32 in pyenv name means 32 bit python install
72
+ # 'arm' is the only alternative which will be 64bit
73
+ arch = "32bit" if arch == "win32" else "64bit"
74
+ try:
75
+ yield PythonInstall.from_str(
76
+ version, executable, architecture=arch
77
+ )
78
+ except ValueError:
79
+ pass
80
+ elif len(split_version) == 1:
81
+ version = split_version[0]
82
+ try:
83
+ yield PythonInstall.from_str(
84
+ version, executable, architecture="64bit"
85
+ )
86
+ except ValueError:
87
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.5.3
3
+ Version: 0.5.5
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
@@ -24,7 +24,14 @@ from pathlib import Path
24
24
 
25
25
  import pytest
26
26
 
27
+ from ducktools.pythonfinder import details_script
28
+
27
29
 
28
30
  @pytest.fixture(scope="session")
29
31
  def sources_folder():
30
32
  return Path(__file__).parent / "sources"
33
+
34
+
35
+ @pytest.fixture
36
+ def uses_details_script(fs):
37
+ fs.add_real_file(details_script.__file__)
@@ -89,7 +89,7 @@ def test_get_install_details_error():
89
89
  ) as run_mock:
90
90
  details = get_install_details(fake_details_out.executable)
91
91
 
92
- run_mock.assert_called_with(
92
+ run_mock.assert_any_call(
93
93
  [fake_details_out.executable, "-"],
94
94
  input=details_text,
95
95
  capture_output=True,
@@ -110,30 +110,32 @@ def test_32bit_version(fs):
110
110
 
111
111
 
112
112
  @pytest.mark.skipif(sys.platform != "win32", reason="Test for Windows only")
113
- def test_invalid_ver_win(fs):
113
+ def test_invalid_ver_win(fs, uses_details_script):
114
114
  # Ignore non-standard versions
115
115
 
116
- tmpdir = "c:\\fake_folder"
116
+ pyenv_fld = "C:\\Fake_Folder"
117
117
 
118
- py_folder = os.path.join(tmpdir, "external-python3.12.1")
118
+ py_folder = os.path.join(pyenv_fld, "external-python3.12.1")
119
119
  py_exe = os.path.join(py_folder, "python.exe")
120
120
 
121
121
  fs.create_dir(py_folder)
122
122
  fs.create_file(py_exe)
123
123
 
124
- py2_folder = os.path.join(tmpdir, "ext3.13.0")
124
+ py2_folder = os.path.join(pyenv_fld, "ext3.13.0")
125
125
  py2_exe = os.path.join(py2_folder, "python.exe")
126
126
 
127
127
  fs.create_dir(py2_folder)
128
128
  fs.create_file(py2_exe)
129
129
 
130
- py3_folder = os.path.join(tmpdir, "invalid-version-3.12.1")
130
+ py3_folder = os.path.join(pyenv_fld, "invalid-version-3.12.1")
131
131
  py3_exe = os.path.join(py3_folder, "python.exe")
132
132
 
133
133
  fs.create_dir(py3_folder)
134
134
  fs.create_file(py3_exe)
135
135
 
136
- versions = list(get_pyenv_pythons(tmpdir))
136
+ with patch("subprocess.run") as run_mock:
137
+ versions = list(get_pyenv_pythons(pyenv_fld))
138
+ run_mock.assert_not_called()
137
139
 
138
140
  assert versions == []
139
141
 
@@ -157,7 +159,7 @@ def test_fs_versions_nix(fs):
157
159
 
158
160
 
159
161
  @pytest.mark.skipif(sys.platform == "win32", reason="Test for non-Windows only")
160
- def test_invalid_ver_nix(fs):
162
+ def test_invalid_ver_nix(fs, uses_details_script):
161
163
  # Test folders in fake file system
162
164
 
163
165
  tmpdir = "~/.pyenv/versions"
@@ -183,7 +185,10 @@ def test_invalid_ver_nix(fs):
183
185
  fs.create_dir(os.path.join(py3_folder, "bin"))
184
186
  fs.create_file(py3_exe)
185
187
 
186
- versions = list(get_pyenv_pythons(tmpdir))
188
+ with patch("subprocess.run") as run_mock:
189
+ run_mock.side_effect = OSError("Failure")
190
+ versions = list(get_pyenv_pythons(tmpdir))
191
+ assert run_mock.call_count == 3
187
192
 
188
193
  assert versions == []
189
194
 
@@ -1,2 +0,0 @@
1
- __version__ = "0.5.3"
2
- __version_tuple__ = (0, 5, 3)
@@ -1,81 +0,0 @@
1
- # ducktools-pythonfinder
2
- # MIT License
3
- #
4
- # Copyright (c) 2023-2024 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, get_install_details
30
-
31
-
32
- PYENV_VERSIONS_FOLDER = os.path.join(os.environ.get("PYENV_ROOT", ""), "versions")
33
-
34
-
35
- def get_pyenv_pythons(
36
- versions_folder: str | os.PathLike = PYENV_VERSIONS_FOLDER,
37
- ) -> Iterator[PythonInstall]:
38
- if not os.path.exists(versions_folder):
39
- return
40
-
41
- for p in os.scandir(versions_folder):
42
- path_base = os.path.basename(p.path)
43
-
44
- if path_base.startswith("pypy"):
45
- executable = os.path.join(p.path, "pypy.exe")
46
- if os.path.exists(executable):
47
- yield get_install_details(executable)
48
- elif path_base.startswith("graalpy"):
49
- # Graalpy exe in bin subfolder
50
- executable = os.path.join(p.path, "bin", "graalpy.exe")
51
- if os.path.exists(executable):
52
- yield get_install_details(executable)
53
- else:
54
- # Regular CPython
55
- executable = os.path.join(p.path, "python.exe")
56
-
57
- if os.path.exists(executable):
58
- split_version = p.name.split("-")
59
-
60
- # If there are 1 or 2 arguments this is a recognised version
61
- # Otherwise it is unrecognised
62
- if len(split_version) == 2:
63
- version, arch = split_version
64
-
65
- # win32 in pyenv name means 32 bit python install
66
- # 'arm' is the only alternative which will be 64bit
67
- arch = "32bit" if arch == "win32" else "64bit"
68
- try:
69
- yield PythonInstall.from_str(
70
- version, executable, architecture=arch
71
- )
72
- except ValueError:
73
- pass
74
- elif len(split_version) == 1:
75
- version = split_version[0]
76
- try:
77
- yield PythonInstall.from_str(
78
- version, executable, architecture="64bit"
79
- )
80
- except ValueError:
81
- pass