ducktools-pythonfinder 0.5.4__tar.gz → 0.6.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 (39) hide show
  1. {ducktools_pythonfinder-0.5.4/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.6.0}/PKG-INFO +1 -1
  2. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/__main__.py +39 -17
  3. ducktools_pythonfinder-0.6.0/src/ducktools/pythonfinder/_version.py +2 -0
  4. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/details_script.py +40 -1
  5. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/shared.py +21 -5
  6. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
  7. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +1 -0
  8. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/conftest.py +23 -0
  9. ducktools_pythonfinder-0.6.0/tests/test_uv_finder.py +146 -0
  10. ducktools_pythonfinder-0.5.4/src/ducktools/pythonfinder/_version.py +0 -2
  11. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/.gitignore +0 -0
  12. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/LICENSE +0 -0
  13. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/MANIFEST.in +0 -0
  14. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/README.md +0 -0
  15. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/pyproject.toml +0 -0
  16. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/scripts/build_zipapp.py +0 -0
  17. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/scripts/detail_this_python.py +0 -0
  18. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/scripts/print_python_versions.py +0 -0
  19. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/setup.cfg +0 -0
  20. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/__init__.py +0 -0
  21. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
  22. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/linux/__init__.py +0 -0
  23. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/linux/pyenv_search.py +0 -0
  24. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
  25. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/win32/__init__.py +0 -0
  26. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -0
  27. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools/pythonfinder/win32/registry_search.py +0 -0
  28. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
  29. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
  30. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
  31. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
  32. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/sources/python_versions.txt +0 -0
  33. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/sources/release.json +0 -0
  34. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/sources/release_file.json +0 -0
  35. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/test_details_script.py +0 -0
  36. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/test_foldersearch.py +0 -0
  37. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/test_orgsearch.py +0 -0
  38. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/tests/test_pyenv.py +0 -0
  39. {ducktools_pythonfinder-0.5.4 → ducktools_pythonfinder-0.6.0}/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.4
3
+ Version: 0.6.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
@@ -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
@@ -140,23 +141,16 @@ def display_local_installs(
140
141
  compatible = tuple(int(i) for i in compatible.split("."))
141
142
 
142
143
  installs = list_python_installs(query_executables=query_executables)
143
- headings = ["Python Version", "Executable Location"]
144
- max_executable_len = max(
145
- len(headings[1]), max(len(inst.executable) for inst in installs)
146
- )
147
- headings_str = f"| {headings[0]} | {headings[1]:<{max_executable_len}s} |"
148
144
 
149
- print("Discoverable Python Installs")
150
- if sys.platform == "win32":
151
- print("+ - Listed in the Windows Registry ")
152
- print("^ - This is a 32-bit Python install")
153
- if sys.platform != "win32":
154
- print("[] - This Python install is shadowed by another on Path")
155
- print("* - This is the active Python executable used to call this module")
156
- print("** - This is the parent Python executable of the venv used to call this module")
157
- print()
158
- print(headings_str)
159
- 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
160
154
  for install in installs:
161
155
  if min_ver and install.version < min_ver:
162
156
  continue
@@ -190,7 +184,35 @@ def display_local_installs(
190
184
  if install.shadowed:
191
185
  version_str = f"[{version_str}]"
192
186
 
193
- 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} |")
194
216
 
195
217
 
196
218
  def display_remote_binaries(
@@ -0,0 +1,2 @@
1
+ __version__ = "0.6.0"
2
+ __version_tuple__ = (0, 6, 0)
@@ -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 = {}
@@ -46,7 +46,7 @@ _laz = LazyImporter(
46
46
  )
47
47
 
48
48
 
49
- 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*)"
50
50
 
51
51
 
52
52
  def version_str_to_tuple(version):
@@ -57,7 +57,7 @@ def version_str_to_tuple(version):
57
57
 
58
58
  major, minor, micro, releaselevel, serial = parsed_version.groups()
59
59
 
60
- if releaselevel == "a":
60
+ if releaselevel in {"a", "dev"}:
61
61
  releaselevel = "alpha"
62
62
  elif releaselevel == "b":
63
63
  releaselevel = "beta"
@@ -131,6 +131,7 @@ class PythonInstall(Prefab):
131
131
  implementation: str = "cpython"
132
132
  metadata: dict = attribute(default_factory=dict)
133
133
  shadowed: bool = False
134
+ _implementation_version: tuple[int, int, int, str, int] | None = attribute(default=None, private=True)
134
135
 
135
136
  def __prefab_post_init__(
136
137
  self,
@@ -147,6 +148,22 @@ class PythonInstall(Prefab):
147
148
  def version_str(self) -> str:
148
149
  return version_tuple_to_str(self.version)
149
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
+
150
167
  @classmethod
151
168
  def from_str(
152
169
  cls,
@@ -242,7 +259,6 @@ def get_install_details(executable: str) -> PythonInstall | None:
242
259
  try:
243
260
  output = _laz.json.loads(detail_output)
244
261
  except _laz.json.JSONDecodeError as e:
245
- print(e)
246
262
  return None
247
263
 
248
264
  return PythonInstall.from_json(**output)
@@ -275,7 +291,7 @@ def get_uv_python_path() -> str | None:
275
291
  text=True,
276
292
  capture_output=True
277
293
  )
278
- except _laz.subprocess.CalledProcessError:
294
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
279
295
  uv_python_dir = None
280
296
  else:
281
297
  # remove newline
@@ -302,7 +318,7 @@ def _implementation_from_uv_dir(
302
318
  if query_executables:
303
319
  install = get_install_details(python_path)
304
320
  else:
305
- if implementation in {"cpython", "pypy"}:
321
+ if implementation in {"cpython"}:
306
322
  install = PythonInstall.from_str(
307
323
  version=version,
308
324
  executable=python_path,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ducktools-pythonfinder
3
- Version: 0.5.4
3
+ Version: 0.6.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
@@ -30,6 +30,7 @@ tests/test_foldersearch.py
30
30
  tests/test_orgsearch.py
31
31
  tests/test_pyenv.py
32
32
  tests/test_shared.py
33
+ tests/test_uv_finder.py
33
34
  tests/sources/python_versions.txt
34
35
  tests/sources/release.json
35
36
  tests/sources/release_file.json
@@ -35,3 +35,26 @@ def sources_folder():
35
35
  @pytest.fixture
36
36
  def uses_details_script(fs):
37
37
  fs.add_real_file(details_script.__file__)
38
+
39
+
40
+ def pytest_addoption(parser):
41
+ parser.addoption(
42
+ "--run-uv-python",
43
+ action="store_true",
44
+ default=False,
45
+ help="Run tests that involve installing UV pythons",
46
+ )
47
+
48
+
49
+ def pytest_configure(config):
50
+ config.addinivalue_line(
51
+ "markers", "uv_python: only run test if --run-uv-python is specified"
52
+ )
53
+
54
+
55
+ def pytest_collection_modifyitems(config, items):
56
+ if not config.getoption("--run-uv-python"):
57
+ skipper = pytest.mark.skip(reason="Only run when --run-uv-python is given")
58
+ for item in items:
59
+ if "uv_python" in item.keywords:
60
+ item.add_marker(skipper)
@@ -0,0 +1,146 @@
1
+ # ducktools-pythonfinder
2
+ # MIT License
3
+ #
4
+ # Copyright (c) 2013-2014 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
+
24
+ import os
25
+ import subprocess
26
+
27
+ from tempfile import TemporaryDirectory
28
+
29
+ import unittest.mock as mock
30
+
31
+
32
+ import pytest
33
+
34
+ from ducktools.pythonfinder.shared import (
35
+ get_uv_python_path,
36
+ _implementation_from_uv_dir,
37
+ get_uv_pythons,
38
+ )
39
+
40
+ UV_REASON = "UV is not installed - skipping tests that run UV"
41
+
42
+
43
+ @pytest.fixture
44
+ def uv_pythondir():
45
+ # Set the UV python folder to a temporary folder and
46
+ # yield the temporary folder value
47
+ uv_python_envkey = "UV_PYTHON_INSTALL_DIR"
48
+ old_uv_python_dir = os.environ.get(uv_python_envkey)
49
+ try:
50
+ with TemporaryDirectory() as tempdir:
51
+ os.environ[uv_python_envkey] = tempdir
52
+ yield tempdir
53
+ finally:
54
+ if old_uv_python_dir is None:
55
+ del os.environ[uv_python_envkey]
56
+ else:
57
+ os.environ[uv_python_envkey] = old_uv_python_dir
58
+
59
+
60
+ class TestUVFakes:
61
+ def test_fake_get_uv_python_path_success(self, uv_pythondir):
62
+ # Test the subprocess is called correctly and returned correctly
63
+ with mock.patch("subprocess.run") as run_mock:
64
+ run_mock.return_value.stdout = f"{uv_pythondir}\n"
65
+
66
+ pydir = get_uv_python_path()
67
+
68
+ run_mock.assert_called_once_with(
69
+ ["uv", "python", "dir"],
70
+ check=True,
71
+ text=True,
72
+ capture_output=True,
73
+ )
74
+
75
+ assert pydir == uv_pythondir
76
+
77
+ def test_fake_get_uv_python_path_failure(self, uv_pythondir):
78
+ # Test the subprocess is called correctly and returned correctly
79
+ with mock.patch("subprocess.run") as run_mock:
80
+ run_mock.side_effect = subprocess.CalledProcessError(-1, "uv python dir")
81
+
82
+ pydir = get_uv_python_path()
83
+
84
+ run_mock.assert_called_once_with(
85
+ ["uv", "python", "dir"],
86
+ check=True,
87
+ text=True,
88
+ capture_output=True,
89
+ )
90
+
91
+ assert pydir is None
92
+
93
+ def test_fake_get_uv_python_path_notfound(self, uv_pythondir):
94
+ # Test the subprocess is called correctly and returned correctly
95
+ with mock.patch("subprocess.run") as run_mock:
96
+ run_mock.side_effect = FileNotFoundError("[Errno 2] No such file or directory: 'uv'")
97
+
98
+ pydir = get_uv_python_path()
99
+
100
+ run_mock.assert_called_once_with(
101
+ ["uv", "python", "dir"],
102
+ check=True,
103
+ text=True,
104
+ capture_output=True,
105
+ )
106
+
107
+ assert pydir is None
108
+
109
+
110
+ @pytest.mark.skipif(get_uv_python_path() is None, reason=UV_REASON)
111
+ class TestUVReal:
112
+ def test_real_get_uv_python_path(self, uv_pythondir):
113
+ # This checks the UV python path has actually been set
114
+ uv_path = get_uv_python_path()
115
+ assert uv_path == uv_pythondir
116
+
117
+ def test_tempdir_empty(self, uv_pythondir):
118
+ # Tempdir should not have any python installs initially
119
+ pythons = list(get_uv_pythons())
120
+ assert pythons == []
121
+
122
+ @pytest.mark.uv_python
123
+ def test_finds_installed_python(self, uv_pythondir):
124
+ # Install python 3.12.6 in the tempdir
125
+ subprocess.run(
126
+ ["uv", "python", "install", "3.12.6"],
127
+ check=True,
128
+ )
129
+
130
+ pythons = list(get_uv_pythons())
131
+ assert len(pythons) == 1
132
+ assert pythons[0].version_str == "3.12.6"
133
+ assert pythons[0].implementation == "cpython"
134
+
135
+ @pytest.mark.uv_python
136
+ def test_finds_installed_pypy(self, uv_pythondir):
137
+ subprocess.run(
138
+ ["uv", "python", "install", "pypy3.10"],
139
+ check=True,
140
+ )
141
+
142
+ pythons = list(get_uv_pythons())
143
+ assert len(pythons) == 1
144
+ assert pythons[0].version >= (3, 10, 14)
145
+ assert pythons[0].implementation == "pypy"
146
+ assert pythons[0].implementation_version >= (7, 3, 17)
@@ -1,2 +0,0 @@
1
- __version__ = "0.5.4"
2
- __version_tuple__ = (0, 5, 4)