ducktools-pythonfinder 0.5.3__tar.gz → 0.5.4__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.4}/PKG-INFO +1 -1
  2. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/__init__.py +3 -2
  3. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/__main__.py +25 -10
  4. ducktools_pythonfinder-0.5.4/src/ducktools/pythonfinder/_version.py +2 -0
  5. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/linux/__init__.py +9 -6
  6. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/linux/pyenv_search.py +6 -11
  7. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/shared.py +59 -33
  8. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/win32/__init__.py +3 -3
  9. ducktools_pythonfinder-0.5.4/src/ducktools/pythonfinder/win32/pyenv_search.py +87 -0
  10. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
  11. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/conftest.py +7 -0
  12. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/test_foldersearch.py +1 -1
  13. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/test_pyenv.py +13 -8
  14. ducktools_pythonfinder-0.5.3/src/ducktools/pythonfinder/_version.py +0 -2
  15. ducktools_pythonfinder-0.5.3/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -81
  16. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/.gitignore +0 -0
  17. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/LICENSE +0 -0
  18. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/MANIFEST.in +0 -0
  19. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/README.md +0 -0
  20. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/pyproject.toml +0 -0
  21. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/scripts/build_zipapp.py +0 -0
  22. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/scripts/detail_this_python.py +0 -0
  23. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/scripts/print_python_versions.py +0 -0
  24. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/setup.cfg +0 -0
  25. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
  26. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/details_script.py +0 -0
  27. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
  28. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools/pythonfinder/win32/registry_search.py +0 -0
  29. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +0 -0
  30. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  31. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  32. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
  33. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  34. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/sources/python_versions.txt +0 -0
  35. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/sources/release.json +0 -0
  36. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/sources/release_file.json +0 -0
  37. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/test_details_script.py +0 -0
  38. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/tests/test_orgsearch.py +0 -0
  39. {ducktools_pythonfinder-0.5.3 → ducktools_pythonfinder-0.5.4}/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.4
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
  )
@@ -79,6 +79,11 @@ def parse_args(args):
79
79
  description="Discover base Python installs",
80
80
  )
81
81
  parser.add_argument("-V", "--version", action="version", version=__version__)
82
+ parser.add_argument(
83
+ "--fast",
84
+ action="store_true",
85
+ help="Skip Python installs that need to be launched to obtain metadata"
86
+ )
82
87
 
83
88
  subparsers = parser.add_subparsers(dest="command", required=False)
84
89
 
@@ -121,7 +126,12 @@ def parse_args(args):
121
126
  return vals
122
127
 
123
128
 
124
- def display_local_installs(min_ver=None, max_ver=None, compatible=None):
129
+ def display_local_installs(
130
+ min_ver=None,
131
+ max_ver=None,
132
+ compatible=None,
133
+ query_executables=True,
134
+ ):
125
135
  if min_ver:
126
136
  min_ver = tuple(int(i) for i in min_ver.split("."))
127
137
  if max_ver:
@@ -129,7 +139,7 @@ def display_local_installs(min_ver=None, max_ver=None, compatible=None):
129
139
  if compatible:
130
140
  compatible = tuple(int(i) for i in compatible.split("."))
131
141
 
132
- installs = list_python_installs()
142
+ installs = list_python_installs(query_executables=query_executables)
133
143
  headings = ["Python Version", "Executable Location"]
134
144
  max_executable_len = max(
135
145
  len(headings[1]), max(len(inst.executable) for inst in installs)
@@ -184,13 +194,13 @@ def display_local_installs(min_ver=None, max_ver=None, compatible=None):
184
194
 
185
195
 
186
196
  def display_remote_binaries(
187
- min_ver,
188
- max_ver,
189
- compatible,
190
- all_binaries,
191
- system,
192
- machine,
193
- prerelease
197
+ min_ver,
198
+ max_ver,
199
+ compatible,
200
+ all_binaries,
201
+ system,
202
+ machine,
203
+ prerelease
194
204
  ):
195
205
  specs = []
196
206
  if min_ver:
@@ -252,7 +262,12 @@ def main():
252
262
  except _laz.URLError:
253
263
  print("Could not connect to python.org")
254
264
  else:
255
- display_local_installs(vals.min, vals.max, vals.compatible)
265
+ display_local_installs(
266
+ min_ver=vals.min,
267
+ max_ver=vals.max,
268
+ compatible=vals.compatible,
269
+ query_executables=not vals.fast,
270
+ )
256
271
  else:
257
272
  # No arguments to parse
258
273
  display_local_installs()
@@ -0,0 +1,2 @@
1
+ __version__ = "0.5.4"
2
+ __version_tuple__ = (0, 5, 4)
@@ -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,18 +28,19 @@ 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
  )
@@ -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,22 +124,24 @@ 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
+
135
+ def __prefab_post_init__(
136
+ self,
137
+ version: tuple[int, int, int] | tuple[int, int, int, str, int]
138
+ ):
139
+ if len(version) == 3:
140
+ # Micropython gives an invalid 3 part version here
141
+ # Add the extras to avoid breaking
142
+ self.version = tuple([*version, "final", 0]) # type: ignore
143
+ else:
144
+ self.version = version
147
145
 
148
146
  @property
149
147
  def version_str(self) -> str:
@@ -219,12 +217,32 @@ def get_install_details(executable: str) -> PythonInstall | None:
219
217
  text=True,
220
218
  check=True,
221
219
  ).stdout
222
- except (_laz.subprocess.CalledProcessError, FileNotFoundError):
220
+ except OSError:
221
+ # Something else has gone wrong
223
222
  return None
223
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
224
+ # Potentially this is micropython which does not support
225
+ # piping from stdin. Try using a file in a temporary folder.
226
+ # Python 3.12 has delete_on_close that would make TemporaryFile
227
+ # Usable on windows but for now use a directory
228
+ with _laz.tempfile.TemporaryDirectory() as tempdir:
229
+ temp_script = os.path.join(tempdir, "details_script.py")
230
+ with open(temp_script, "w") as f:
231
+ f.write(source)
232
+ try:
233
+ detail_output = _laz.subprocess.run(
234
+ [executable, temp_script],
235
+ capture_output=True,
236
+ text=True,
237
+ check=True,
238
+ ).stdout
239
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
240
+ return None
224
241
 
225
242
  try:
226
243
  output = _laz.json.loads(detail_output)
227
- except _laz.json.JSONDecodeError:
244
+ except _laz.json.JSONDecodeError as e:
245
+ print(e)
228
246
  return None
229
247
 
230
248
  return PythonInstall.from_json(**output)
@@ -266,7 +284,10 @@ def get_uv_python_path() -> str | None:
266
284
  return uv_python_dir
267
285
 
268
286
 
269
- def _implementation_from_uv_dir(direntry: os.DirEntry) -> PythonInstall | None:
287
+ def _implementation_from_uv_dir(
288
+ direntry: os.DirEntry,
289
+ query_executables: bool = True,
290
+ ) -> PythonInstall | None:
270
291
  python_exe = "python.exe" if sys.platform == "win32" else "bin/python"
271
292
  python_path = os.path.join(direntry, python_exe)
272
293
 
@@ -278,9 +299,10 @@ def _implementation_from_uv_dir(direntry: os.DirEntry) -> PythonInstall | None:
278
299
  except ValueError:
279
300
  # Directory name format has changed
280
301
  # Slow backup - ask python itself
281
- install = get_install_details(python_path)
302
+ if query_executables:
303
+ install = get_install_details(python_path)
282
304
  else:
283
- if implementation == "cpython":
305
+ if implementation in {"cpython", "pypy"}:
284
306
  install = PythonInstall.from_str(
285
307
  version=version,
286
308
  executable=python_path,
@@ -289,16 +311,20 @@ def _implementation_from_uv_dir(direntry: os.DirEntry) -> PythonInstall | None:
289
311
  )
290
312
  else:
291
313
  # Get additional alternate implementation details
292
- install = get_install_details(python_path)
314
+ if query_executables:
315
+ install = get_install_details(python_path)
293
316
 
294
317
  return install
295
318
 
296
319
 
297
- def get_uv_pythons() -> Iterator[PythonInstall]:
320
+ def get_uv_pythons(query_executables=True) -> Iterator[PythonInstall]:
298
321
  # This takes some shortcuts over the regular pythonfinder
299
322
  # As the UV folders give the python version and the implementation
300
323
  if uv_python_path := get_uv_python_path():
301
324
  with os.scandir(uv_python_path) as fld:
302
325
  for f in fld:
303
- if f.is_dir() and (install := _implementation_from_uv_dir(f)):
326
+ if (
327
+ f.is_dir()
328
+ and (install := _implementation_from_uv_dir(f, query_executables))
329
+ ):
304
330
  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.4
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