ducktools-pythonfinder 0.7.8__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.
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/.gitignore +1 -0
- {ducktools_pythonfinder-0.7.8/src/ducktools_pythonfinder.egg-info → ducktools_pythonfinder-0.8.0}/PKG-INFO +1 -1
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__init__.py +4 -3
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__main__.py +9 -1
- ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/_version.py +2 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/details_script.py +5 -3
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/linux/__init__.py +26 -11
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/linux/pyenv_search.py +49 -29
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/shared.py +214 -60
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/venv.py +10 -3
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/win32/__init__.py +18 -10
- ducktools_pythonfinder-0.8.0/src/ducktools/pythonfinder/win32/pyenv_search.py +106 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0/src/ducktools_pythonfinder.egg-info}/PKG-INFO +1 -1
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/SOURCES.txt +1 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/conftest.py +16 -7
- ducktools_pythonfinder-0.8.0/tests/test_cache.py +227 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_foldersearch.py +11 -9
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_pyenv.py +19 -18
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_uv_finder.py +12 -6
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_venv_finder.py +2 -2
- ducktools_pythonfinder-0.7.8/src/ducktools/pythonfinder/_version.py +0 -2
- ducktools_pythonfinder-0.7.8/src/ducktools/pythonfinder/win32/pyenv_search.py +0 -102
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/LICENSE +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/MANIFEST.in +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/README.md +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/pyproject.toml +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/build_zipapp.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/detail_this_python.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/list_python_venvs.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/scripts/print_python_versions.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/setup.cfg +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/darwin/__init__.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/package_list_script.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/pythonorg_search.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/win32/registry_search.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/dependency_links.txt +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/entry_points.txt +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/requires.txt +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools_pythonfinder.egg-info/top_level.txt +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/sources/python_versions.txt +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/sources/release.json +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/sources/release_file.json +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_details_script.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_orgsearch.py +0 -0
- {ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/tests/test_shared.py +0 -0
{ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__init__.py
RENAMED
|
@@ -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
|
)
|
{ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/__main__.py
RENAMED
|
@@ -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 == "
|
|
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:
|
|
@@ -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,11 +27,17 @@ import os.path
|
|
|
27
27
|
import itertools
|
|
28
28
|
from _collections_abc import Iterator
|
|
29
29
|
|
|
30
|
-
from ..shared import
|
|
30
|
+
from ..shared import (
|
|
31
|
+
DetailFinder,
|
|
32
|
+
PythonInstall,
|
|
33
|
+
get_folder_pythons,
|
|
34
|
+
get_uv_pythons,
|
|
35
|
+
get_uv_python_path
|
|
36
|
+
)
|
|
31
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(":")
|
|
@@ -40,6 +46,8 @@ def get_path_pythons() -> Iterator[PythonInstall]:
|
|
|
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(
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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)
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
# SOFTWARE.
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
-
|
|
26
25
|
"""
|
|
27
26
|
Discover python installs that have been created with pyenv
|
|
28
27
|
"""
|
|
@@ -33,10 +32,11 @@ from _collections_abc import Iterator
|
|
|
33
32
|
|
|
34
33
|
from ducktools.lazyimporter import LazyImporter, FromImport, ModuleImport
|
|
35
34
|
|
|
36
|
-
from ..shared import PythonInstall,
|
|
35
|
+
from ..shared import PythonInstall, DetailFinder, FULL_PY_VER_RE, INSTALLER_CACHE_PATH, version_str_to_tuple
|
|
37
36
|
|
|
38
37
|
_laz = LazyImporter(
|
|
39
38
|
[
|
|
39
|
+
ModuleImport("json"),
|
|
40
40
|
ModuleImport("re"),
|
|
41
41
|
FromImport("subprocess", "run"),
|
|
42
42
|
]
|
|
@@ -52,11 +52,24 @@ def get_pyenv_root() -> str | None:
|
|
|
52
52
|
pyenv_root = os.environ.get("PYENV_ROOT")
|
|
53
53
|
if not pyenv_root:
|
|
54
54
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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()
|
|
58
68
|
|
|
59
|
-
|
|
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)
|
|
60
73
|
|
|
61
74
|
return pyenv_root
|
|
62
75
|
|
|
@@ -65,6 +78,7 @@ def get_pyenv_pythons(
|
|
|
65
78
|
versions_folder: str | os.PathLike | None = None,
|
|
66
79
|
*,
|
|
67
80
|
query_executables: bool = True,
|
|
81
|
+
finder: DetailFinder = None,
|
|
68
82
|
) -> Iterator[PythonInstall]:
|
|
69
83
|
if versions_folder is None:
|
|
70
84
|
if pyenv_root := get_pyenv_root():
|
|
@@ -77,26 +91,32 @@ def get_pyenv_pythons(
|
|
|
77
91
|
# This can lead to much faster returns by potentially yielding
|
|
78
92
|
# the required python version before checking pypy/graalpy/micropython
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
{ducktools_pythonfinder-0.7.8 → ducktools_pythonfinder-0.8.0}/src/ducktools/pythonfinder/shared.py
RENAMED
|
@@ -28,7 +28,7 @@ import os.path
|
|
|
28
28
|
|
|
29
29
|
from _collections_abc import Iterator
|
|
30
30
|
|
|
31
|
-
from ducktools.classbuilder.prefab import Prefab, attribute
|
|
31
|
+
from ducktools.classbuilder.prefab import Prefab, attribute, as_dict
|
|
32
32
|
from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
|
|
33
33
|
|
|
34
34
|
from . import details_script
|
|
@@ -39,13 +39,13 @@ _laz = LazyImporter(
|
|
|
39
39
|
ModuleImport("json"),
|
|
40
40
|
ModuleImport("platform"),
|
|
41
41
|
ModuleImport("re"),
|
|
42
|
+
ModuleImport("shutil"),
|
|
42
43
|
ModuleImport("subprocess"),
|
|
43
44
|
ModuleImport("tempfile"),
|
|
44
45
|
ModuleImport("zipfile"),
|
|
45
46
|
]
|
|
46
47
|
)
|
|
47
48
|
|
|
48
|
-
|
|
49
49
|
FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)-?(?P<releaselevel>a|b|c|rc)?(?P<serial>\d*)?"
|
|
50
50
|
|
|
51
51
|
UV_PYTHON_RE = (
|
|
@@ -56,6 +56,38 @@ UV_PYTHON_RE = (
|
|
|
56
56
|
r"-.*"
|
|
57
57
|
)
|
|
58
58
|
|
|
59
|
+
# Cache for runtime details
|
|
60
|
+
# Code to work out where to store data
|
|
61
|
+
# Store in LOCALAPPDATA for windows, User folder for other operating systems
|
|
62
|
+
if sys.platform == "win32":
|
|
63
|
+
# os.path.expandvars will actually import a whole bunch of other modules
|
|
64
|
+
# Try just using the environment.
|
|
65
|
+
if _local_app_folder := os.environ.get("LOCALAPPDATA"):
|
|
66
|
+
if not os.path.isdir(_local_app_folder):
|
|
67
|
+
raise FileNotFoundError(
|
|
68
|
+
f"Could not find local app data folder {_local_app_folder}"
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
raise EnvironmentError(
|
|
72
|
+
"Environment variable %LOCALAPPDATA% "
|
|
73
|
+
"for local application data folder location "
|
|
74
|
+
"not found"
|
|
75
|
+
)
|
|
76
|
+
USER_FOLDER = _local_app_folder
|
|
77
|
+
CACHE_FOLDER = os.path.join(USER_FOLDER, "ducktools", "pythonfinder", "cache")
|
|
78
|
+
else:
|
|
79
|
+
USER_FOLDER = os.path.expanduser("~")
|
|
80
|
+
CACHE_FOLDER = os.path.join(USER_FOLDER, ".cache", "ducktools", "pythonfinder")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
CACHE_VERSION = 1
|
|
84
|
+
DETAILS_CACHE_PATH = os.path.join(CACHE_FOLDER, f"runtime_cache_v{CACHE_VERSION}.json")
|
|
85
|
+
INSTALLER_CACHE_PATH = os.path.join(CACHE_FOLDER, "installer_details.json")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def purge_caches(cache_folder=CACHE_FOLDER):
|
|
89
|
+
_laz.shutil.rmtree(cache_folder, ignore_errors=True)
|
|
90
|
+
|
|
59
91
|
|
|
60
92
|
def version_str_to_tuple(version):
|
|
61
93
|
parsed_version = _laz.re.fullmatch(FULL_PY_VER_RE, version)
|
|
@@ -129,7 +161,141 @@ class DetailsScript(Prefab):
|
|
|
129
161
|
return self._source_code
|
|
130
162
|
|
|
131
163
|
|
|
132
|
-
|
|
164
|
+
class DetailFinder(Prefab):
|
|
165
|
+
cache_path: str = DETAILS_CACHE_PATH
|
|
166
|
+
details_script: DetailsScript = attribute(default_factory=DetailsScript)
|
|
167
|
+
|
|
168
|
+
# Stores the dict loaded from the JSON file without processing
|
|
169
|
+
_raw_cache: dict | None = attribute(default=None, private=True)
|
|
170
|
+
|
|
171
|
+
# Indicates if the cache is known to have changed
|
|
172
|
+
_dirty_cache: bool = attribute(default=False, private=True)
|
|
173
|
+
|
|
174
|
+
# Increased each re-entry to the context manager
|
|
175
|
+
# Decreased on exit
|
|
176
|
+
# Save should only occur when all contexts exit
|
|
177
|
+
_context_level: int = attribute(default=0, private=True)
|
|
178
|
+
|
|
179
|
+
def __enter__(self):
|
|
180
|
+
self._context_level += 1
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
184
|
+
self._context_level -= 1
|
|
185
|
+
if (
|
|
186
|
+
exc_type in {None, GeneratorExit}
|
|
187
|
+
and self._dirty_cache
|
|
188
|
+
and self._context_level == 0
|
|
189
|
+
):
|
|
190
|
+
self.save()
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def raw_cache(self):
|
|
194
|
+
if self._raw_cache is None:
|
|
195
|
+
try:
|
|
196
|
+
with open(self.cache_path) as f:
|
|
197
|
+
self._raw_cache = _laz.json.load(f)
|
|
198
|
+
except (_laz.json.JSONDecodeError, FileNotFoundError):
|
|
199
|
+
self._raw_cache = {}
|
|
200
|
+
return self._raw_cache
|
|
201
|
+
|
|
202
|
+
def save(self):
|
|
203
|
+
os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
|
|
204
|
+
with open(self.cache_path, 'w') as f:
|
|
205
|
+
_laz.json.dump(self.raw_cache, f, indent=4)
|
|
206
|
+
|
|
207
|
+
self._dirty_cache = False
|
|
208
|
+
|
|
209
|
+
def clear_invalid_runtimes(self):
|
|
210
|
+
"""
|
|
211
|
+
Remove cache entries where the python.exe no longer exists
|
|
212
|
+
"""
|
|
213
|
+
removed_runtimes: set[str] = set()
|
|
214
|
+
for exe_path in self.raw_cache.copy().keys():
|
|
215
|
+
if not os.path.exists(exe_path):
|
|
216
|
+
self.raw_cache.pop(exe_path)
|
|
217
|
+
removed_runtimes.add(exe_path)
|
|
218
|
+
if removed_runtimes:
|
|
219
|
+
self._dirty_cache = True
|
|
220
|
+
|
|
221
|
+
def clear_cache(self):
|
|
222
|
+
"""
|
|
223
|
+
Completely empty the cache
|
|
224
|
+
"""
|
|
225
|
+
self._raw_cache = {}
|
|
226
|
+
self._dirty_cache = True
|
|
227
|
+
|
|
228
|
+
def query_install(self, exe_path: str, managed_by: str | None = None) -> PythonInstall | None:
|
|
229
|
+
"""
|
|
230
|
+
Query the details of a Python install directly
|
|
231
|
+
|
|
232
|
+
:param exe_path: Path to the runtime .exe
|
|
233
|
+
:param managed_by: Which tool manages this install (if any)
|
|
234
|
+
:return: a PythonInstall if one exists at the exe Path
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
source = self.details_script.get_source_code()
|
|
238
|
+
except FileNotFoundError:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
detail_output = _laz.subprocess.run(
|
|
243
|
+
[exe_path, "-"],
|
|
244
|
+
input=source,
|
|
245
|
+
capture_output=True,
|
|
246
|
+
text=True,
|
|
247
|
+
check=True,
|
|
248
|
+
).stdout
|
|
249
|
+
except OSError:
|
|
250
|
+
# Something else has gone wrong
|
|
251
|
+
return None
|
|
252
|
+
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
|
|
253
|
+
# Potentially this is micropython which does not support
|
|
254
|
+
# piping from stdin. Try using a file in a temporary folder.
|
|
255
|
+
# Python 3.12 has delete_on_close that would make TemporaryFile
|
|
256
|
+
# Usable on windows but for now use a directory
|
|
257
|
+
with _laz.tempfile.TemporaryDirectory() as tempdir:
|
|
258
|
+
temp_script = os.path.join(tempdir, "details_script.py")
|
|
259
|
+
with open(temp_script, "w") as f:
|
|
260
|
+
f.write(source)
|
|
261
|
+
try:
|
|
262
|
+
detail_output = _laz.subprocess.run(
|
|
263
|
+
[exe_path, temp_script],
|
|
264
|
+
capture_output=True,
|
|
265
|
+
text=True,
|
|
266
|
+
check=True,
|
|
267
|
+
).stdout
|
|
268
|
+
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
output = _laz.json.loads(detail_output)
|
|
273
|
+
except _laz.json.JSONDecodeError:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
return PythonInstall.from_json(**output, managed_by=managed_by)
|
|
277
|
+
|
|
278
|
+
def get_install_details(self, exe_path: str, managed_by=None) -> PythonInstall | None:
|
|
279
|
+
exe_path = os.path.abspath(exe_path)
|
|
280
|
+
mtime = os.stat(exe_path).st_mtime
|
|
281
|
+
|
|
282
|
+
install = None
|
|
283
|
+
if cached_details := self.raw_cache.get(exe_path):
|
|
284
|
+
if cached_details["mtime"] == mtime:
|
|
285
|
+
install = PythonInstall.from_json(**cached_details["install"])
|
|
286
|
+
else:
|
|
287
|
+
self.raw_cache.pop(exe_path)
|
|
288
|
+
|
|
289
|
+
if install is None:
|
|
290
|
+
install = self.query_install(exe_path, managed_by)
|
|
291
|
+
if install:
|
|
292
|
+
self.raw_cache[exe_path] = {
|
|
293
|
+
"mtime": mtime,
|
|
294
|
+
"install": as_dict(install)
|
|
295
|
+
}
|
|
296
|
+
self._dirty_cache = True
|
|
297
|
+
|
|
298
|
+
return install
|
|
133
299
|
|
|
134
300
|
|
|
135
301
|
class PythonInstall(Prefab):
|
|
@@ -139,7 +305,7 @@ class PythonInstall(Prefab):
|
|
|
139
305
|
implementation: str = "cpython"
|
|
140
306
|
managed_by: str | None = None
|
|
141
307
|
metadata: dict = attribute(default_factory=dict)
|
|
142
|
-
shadowed: bool = False
|
|
308
|
+
shadowed: bool = attribute(default=False, serialize=False)
|
|
143
309
|
_implementation_version: tuple[int, int, int, str, int] | None = attribute(default=None, private=True)
|
|
144
310
|
|
|
145
311
|
def __prefab_post_init__(
|
|
@@ -197,13 +363,21 @@ class PythonInstall(Prefab):
|
|
|
197
363
|
)
|
|
198
364
|
|
|
199
365
|
@classmethod
|
|
200
|
-
def from_json(
|
|
366
|
+
def from_json(
|
|
367
|
+
cls,
|
|
368
|
+
version,
|
|
369
|
+
executable,
|
|
370
|
+
architecture,
|
|
371
|
+
implementation,
|
|
372
|
+
metadata,
|
|
373
|
+
managed_by=None,
|
|
374
|
+
):
|
|
201
375
|
if arch_ver := metadata.get(f"{implementation}_version"):
|
|
202
376
|
metadata[f"{implementation}_version"] = tuple(arch_ver)
|
|
203
377
|
|
|
204
378
|
# noinspection PyArgumentList
|
|
205
379
|
return cls(
|
|
206
|
-
version=tuple(version),
|
|
380
|
+
version=tuple(version), # type: ignore
|
|
207
381
|
executable=executable,
|
|
208
382
|
architecture=architecture,
|
|
209
383
|
implementation=implementation,
|
|
@@ -238,63 +412,22 @@ def _python_exe_regex(basename: str = "python"):
|
|
|
238
412
|
return _laz.re.compile(rf"{basename}\d?\.?\d*")
|
|
239
413
|
|
|
240
414
|
|
|
241
|
-
def get_install_details(executable: str, managed_by=None) -> PythonInstall | None:
|
|
242
|
-
try:
|
|
243
|
-
source = details.get_source_code()
|
|
244
|
-
except FileNotFoundError:
|
|
245
|
-
return None
|
|
246
|
-
|
|
247
|
-
try:
|
|
248
|
-
detail_output = _laz.subprocess.run(
|
|
249
|
-
[executable, "-"],
|
|
250
|
-
input=source,
|
|
251
|
-
capture_output=True,
|
|
252
|
-
text=True,
|
|
253
|
-
check=True,
|
|
254
|
-
).stdout
|
|
255
|
-
except OSError:
|
|
256
|
-
# Something else has gone wrong
|
|
257
|
-
return None
|
|
258
|
-
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
|
|
259
|
-
# Potentially this is micropython which does not support
|
|
260
|
-
# piping from stdin. Try using a file in a temporary folder.
|
|
261
|
-
# Python 3.12 has delete_on_close that would make TemporaryFile
|
|
262
|
-
# Usable on windows but for now use a directory
|
|
263
|
-
with _laz.tempfile.TemporaryDirectory() as tempdir:
|
|
264
|
-
temp_script = os.path.join(tempdir, "details_script.py")
|
|
265
|
-
with open(temp_script, "w") as f:
|
|
266
|
-
f.write(source)
|
|
267
|
-
try:
|
|
268
|
-
detail_output = _laz.subprocess.run(
|
|
269
|
-
[executable, temp_script],
|
|
270
|
-
capture_output=True,
|
|
271
|
-
text=True,
|
|
272
|
-
check=True,
|
|
273
|
-
).stdout
|
|
274
|
-
except (_laz.subprocess.CalledProcessError, FileNotFoundError):
|
|
275
|
-
return None
|
|
276
|
-
|
|
277
|
-
try:
|
|
278
|
-
output = _laz.json.loads(detail_output)
|
|
279
|
-
except _laz.json.JSONDecodeError as e:
|
|
280
|
-
return None
|
|
281
|
-
|
|
282
|
-
return PythonInstall.from_json(**output, managed_by=managed_by)
|
|
283
|
-
|
|
284
|
-
|
|
285
415
|
def get_folder_pythons(
|
|
286
416
|
base_folder: str | os.PathLike,
|
|
287
|
-
basenames: tuple[str] = ("python", "pypy")
|
|
417
|
+
basenames: tuple[str] = ("python", "pypy"),
|
|
418
|
+
finder: DetailFinder | None = None,
|
|
288
419
|
):
|
|
289
420
|
regexes = [_python_exe_regex(name) for name in basenames]
|
|
290
421
|
|
|
291
|
-
|
|
422
|
+
finder = DetailFinder() if finder is None else finder
|
|
423
|
+
|
|
424
|
+
with finder, os.scandir(base_folder) as fld:
|
|
292
425
|
for file_path in fld:
|
|
293
426
|
try:
|
|
294
427
|
is_file = file_path.is_file()
|
|
295
428
|
except PermissionError:
|
|
296
429
|
continue
|
|
297
|
-
|
|
430
|
+
|
|
298
431
|
if (
|
|
299
432
|
is_file
|
|
300
433
|
and any(reg.fullmatch(file_path.name) for reg in regexes)
|
|
@@ -307,13 +440,25 @@ def get_folder_pythons(
|
|
|
307
440
|
continue
|
|
308
441
|
else:
|
|
309
442
|
p = file_path.path
|
|
310
|
-
install = get_install_details(p)
|
|
443
|
+
install = finder.get_install_details(p)
|
|
311
444
|
if install:
|
|
312
445
|
yield install
|
|
313
446
|
|
|
314
447
|
|
|
315
448
|
# UV Specific finder
|
|
316
449
|
def get_uv_python_path() -> str | None:
|
|
450
|
+
# Attempt to get cache
|
|
451
|
+
try:
|
|
452
|
+
with open(INSTALLER_CACHE_PATH) as f:
|
|
453
|
+
installer_cache = _laz.json.load(f)
|
|
454
|
+
except (FileNotFoundError, _laz.json.JSONDecodeError):
|
|
455
|
+
installer_cache = {}
|
|
456
|
+
|
|
457
|
+
uv_python_dir = installer_cache.get("uv")
|
|
458
|
+
if uv_python_dir and os.path.exists(uv_python_dir):
|
|
459
|
+
return uv_python_dir
|
|
460
|
+
|
|
461
|
+
# Cache failed
|
|
317
462
|
try:
|
|
318
463
|
uv_python_find = _laz.subprocess.run(
|
|
319
464
|
["uv", "python", "dir"],
|
|
@@ -327,17 +472,25 @@ def get_uv_python_path() -> str | None:
|
|
|
327
472
|
# remove newline
|
|
328
473
|
uv_python_dir = uv_python_find.stdout.strip()
|
|
329
474
|
|
|
475
|
+
# Fill cache and update the cache file
|
|
476
|
+
installer_cache["uv"] = uv_python_dir
|
|
477
|
+
os.makedirs(os.path.dirname(INSTALLER_CACHE_PATH), exist_ok=True)
|
|
478
|
+
with open(INSTALLER_CACHE_PATH, 'w') as f:
|
|
479
|
+
_laz.json.dump(installer_cache, f)
|
|
480
|
+
|
|
330
481
|
return uv_python_dir
|
|
331
482
|
|
|
332
483
|
|
|
333
484
|
def _implementation_from_uv_dir(
|
|
334
485
|
direntry: os.DirEntry,
|
|
335
486
|
query_executables: bool = True,
|
|
487
|
+
finder: DetailFinder | None = None,
|
|
336
488
|
) -> PythonInstall | None:
|
|
337
489
|
python_exe = "python.exe" if sys.platform == "win32" else "bin/python"
|
|
338
490
|
python_path = os.path.join(direntry, python_exe)
|
|
339
491
|
|
|
340
492
|
install: PythonInstall | None = None
|
|
493
|
+
finder = DetailFinder() if finder is None else finder
|
|
341
494
|
|
|
342
495
|
if os.path.exists(python_path):
|
|
343
496
|
if match := _laz.re.fullmatch(UV_PYTHON_RE, direntry.name):
|
|
@@ -354,7 +507,7 @@ def _implementation_from_uv_dir(
|
|
|
354
507
|
architecture="32bit" if arch in {"i686", "armv7"} else "64bit",
|
|
355
508
|
implementation=implementation,
|
|
356
509
|
metadata=metadata,
|
|
357
|
-
managed_by="Astral
|
|
510
|
+
managed_by="Astral",
|
|
358
511
|
)
|
|
359
512
|
except ValueError:
|
|
360
513
|
pass
|
|
@@ -363,21 +516,22 @@ def _implementation_from_uv_dir(
|
|
|
363
516
|
# Directory name format has changed or this is an alternate implementation
|
|
364
517
|
# Slow backup - ask python itself
|
|
365
518
|
if query_executables:
|
|
366
|
-
install = get_install_details(python_path)
|
|
367
|
-
install.managed_by = "Astral UV"
|
|
519
|
+
install = finder.get_install_details(python_path, managed_by="Astral")
|
|
368
520
|
|
|
369
521
|
return install
|
|
370
522
|
|
|
371
523
|
|
|
372
|
-
def get_uv_pythons(query_executables=True) -> Iterator[PythonInstall]:
|
|
524
|
+
def get_uv_pythons(query_executables=True, finder=None) -> Iterator[PythonInstall]:
|
|
373
525
|
# This takes some shortcuts over the regular pythonfinder
|
|
374
526
|
# As the UV folders give the python version and the implementation
|
|
375
527
|
if uv_python_path := get_uv_python_path():
|
|
376
528
|
if os.path.exists(uv_python_path):
|
|
377
|
-
|
|
529
|
+
finder = DetailFinder() if finder is None else finder
|
|
530
|
+
|
|
531
|
+
with finder, os.scandir(uv_python_path) as fld:
|
|
378
532
|
for f in fld:
|
|
379
533
|
if (
|
|
380
534
|
f.is_dir()
|
|
381
|
-
and (install := _implementation_from_uv_dir(f, query_executables))
|
|
535
|
+
and (install := _implementation_from_uv_dir(f, query_executables, finder=finder))
|
|
382
536
|
):
|
|
383
537
|
yield install
|