ducktools-pythonfinder 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,40 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ # Find platform python versions
18
+
19
+ __version__ = "v0.1.0"
20
+
21
+ __all__ = [
22
+ "get_python_installs",
23
+ "list_python_installs",
24
+ "PythonInstall",
25
+ ]
26
+
27
+ import sys
28
+ from .shared import PythonInstall
29
+
30
+ match sys.platform: # pragma: no cover
31
+ case "win32":
32
+ from .win32 import get_python_installs
33
+ case "darwin":
34
+ from .darwin import get_python_installs
35
+ case _:
36
+ from .linux import get_python_installs
37
+
38
+
39
+ def list_python_installs():
40
+ return sorted(get_python_installs(), reverse=True, key=lambda x: x.version)
@@ -0,0 +1,106 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+
18
+ import sys
19
+
20
+ from ducktools.lazyimporter import LazyImporter, ModuleImport
21
+ from ducktools.pythonfinder import list_python_installs
22
+
23
+ _laz = LazyImporter([ModuleImport("argparse")])
24
+
25
+
26
+ def parse_args(args):
27
+ parser = _laz.argparse.ArgumentParser(
28
+ prog="ducktools-pythonfinder",
29
+ description="Discover base Python installs",
30
+ )
31
+ parser.add_argument("--min", help="Specify minimum Python version")
32
+ parser.add_argument("--max", help="Specify maximum Python version")
33
+ parser.add_argument("--exact", help="Specify exact Python version")
34
+
35
+ vals = parser.parse_args(args)
36
+
37
+ if vals.min:
38
+ min_ver = tuple(int(i) for i in vals.min.split("."))
39
+ else:
40
+ min_ver = None
41
+
42
+ if vals.max:
43
+ max_ver = tuple(int(i) for i in vals.max.split("."))
44
+ else:
45
+ max_ver = None
46
+
47
+ if vals.exact:
48
+ exact = tuple(int(i) for i in vals.exact.split("."))
49
+ else:
50
+ exact = None
51
+
52
+ return min_ver, max_ver, exact
53
+
54
+
55
+ def main():
56
+ if sys.argv[1:]:
57
+ min_ver, max_ver, exact = parse_args(sys.argv[1:])
58
+ else:
59
+ min_ver, max_ver, exact = None, None, None
60
+
61
+ installs = list_python_installs()
62
+ headings = ["Python Version", "Executable Location"]
63
+ max_executable_len = max(
64
+ len(headings[1]), max(len(inst.executable) for inst in installs)
65
+ )
66
+ headings_str = f"| {headings[0]} | {headings[1]:<{max_executable_len}s} |"
67
+
68
+ print("Discoverable Python Installs")
69
+ if sys.platform == "win32":
70
+ print("+ - Listed in the Windows Registry ")
71
+ print("* - This is the active python executable used to call this module")
72
+ print(
73
+ "** - This is the parent python executable of the venv used to call this module"
74
+ )
75
+ print()
76
+ print(headings_str)
77
+ print(f"| {'-' * len(headings[0])} | {'-' * max_executable_len} |")
78
+ for install in installs:
79
+ if min_ver and install.version < min_ver:
80
+ continue
81
+ elif max_ver and install.version > max_ver:
82
+ continue
83
+ elif exact:
84
+ mismatch = False
85
+ for i, val in enumerate(exact):
86
+ if val != install.version[i]:
87
+ mismatch = True
88
+ break
89
+ if mismatch:
90
+ continue
91
+
92
+ version_str = install.version_str
93
+ if install.executable == sys.executable:
94
+ version_str = f"*{version_str}"
95
+ elif sys.prefix != sys.base_prefix and install.executable.startswith(
96
+ sys.base_prefix
97
+ ):
98
+ version_str = f"**{version_str}"
99
+
100
+ if sys.platform == "win32" and install.metadata.get("InWindowsRegistry"):
101
+ version_str = f"+{version_str}"
102
+
103
+ print(f"| {version_str:>14s} | {install.executable:<{max_executable_len}s} |")
104
+
105
+
106
+ main()
@@ -0,0 +1,19 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Currently just copied from linux"""
18
+
19
+ from ..linux import get_python_installs, get_path_pythons, get_pyenv_pythons
@@ -0,0 +1,58 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """
18
+ Get the details from a python install as JSON
19
+ """
20
+ import sys
21
+
22
+
23
+ def get_details():
24
+ try:
25
+ implementation = sys.implementation.name
26
+ except AttributeError: # pragma: no cover
27
+ # Probably Python 2
28
+ import platform
29
+
30
+ implementation = platform.python_implementation().lower()
31
+ metadata = {}
32
+ else:
33
+ if implementation != "cpython": # pragma: no cover
34
+ metadata = {"{}_version".format(implementation): sys.implementation.version}
35
+ else:
36
+ metadata = {}
37
+
38
+ install = dict(
39
+ version=list(sys.version_info),
40
+ executable=sys.executable,
41
+ architecture="64bit" if (sys.maxsize > 2**32) else "32bit",
42
+ implementation=implementation,
43
+ metadata=metadata,
44
+ )
45
+
46
+ return install
47
+
48
+
49
+ def main():
50
+ import json
51
+
52
+ install = get_details()
53
+
54
+ sys.stdout.write(json.dumps(install))
55
+
56
+
57
+ if __name__ == "__main__": # pragma: no cover
58
+ main()
@@ -0,0 +1,53 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import os
18
+ import os.path
19
+ import itertools
20
+ from _collections_abc import Iterator
21
+
22
+ from ..shared import PythonInstall, get_folder_pythons
23
+ from .pyenv_search import get_pyenv_pythons
24
+
25
+
26
+ PATH_FOLDERS = os.environ.get("PATH").split(":")
27
+ _PYENV_ROOT = os.environ.get("PYENV_ROOT")
28
+
29
+
30
+ def get_path_pythons() -> Iterator[PythonInstall]:
31
+ exe_names = set()
32
+
33
+ for fld in PATH_FOLDERS:
34
+ # Don't retrieve pyenv installs
35
+ if _PYENV_ROOT and fld.startswith(_PYENV_ROOT):
36
+ continue
37
+ elif not os.path.exists(fld):
38
+ continue
39
+
40
+ for install in get_folder_pythons(fld):
41
+ name = os.path.basename(install.executable)
42
+ if name not in exe_names:
43
+ yield install
44
+ exe_names.add(name)
45
+
46
+
47
+ def get_python_installs():
48
+ listed_pythons = set()
49
+
50
+ for py in itertools.chain(get_pyenv_pythons(), get_path_pythons()):
51
+ if py.executable not in listed_pythons:
52
+ yield py
53
+ listed_pythons.add(py.executable)
@@ -0,0 +1,80 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """
18
+ Discover python installs that have been created with pyenv
19
+ """
20
+
21
+ import os
22
+ import os.path
23
+ from _collections_abc import Iterator
24
+
25
+ from ducktools.lazyimporter import LazyImporter, ModuleImport
26
+
27
+ from ..shared import PythonInstall
28
+ from .. import details_script
29
+
30
+ _laz = LazyImporter(
31
+ [
32
+ ModuleImport("re"),
33
+ ModuleImport("subprocess"),
34
+ ModuleImport("json"),
35
+ ]
36
+ )
37
+
38
+ # pyenv folder names
39
+ PYTHON_VER_RE = r"\d{1,2}\.\d{1,2}\.\d+[a-z]*\d*"
40
+ PYPY_VER_RE = r"^pypy(?P<pyversion>\d{1,2}\.\d+)-(?P<pypyversion>[\d\.]*)$"
41
+
42
+ # 'pypy -V' output matcher
43
+ PYPY_V_OUTPUT = (
44
+ r"(?is)python (?P<python_version>\d+\.\d+\.\d+[a-z]*\d*).*?"
45
+ r"pypy (?P<pypy_version>\d+\.\d+\.\d+[a-z]*\d*).*"
46
+ )
47
+
48
+ PYENV_VERSIONS_FOLDER = os.path.join(os.environ.get("PYENV_ROOT", ""), "versions")
49
+
50
+
51
+ def get_pyenv_pythons(
52
+ versions_folder: str | os.PathLike = PYENV_VERSIONS_FOLDER,
53
+ ) -> Iterator[PythonInstall]:
54
+ if not os.path.exists(versions_folder):
55
+ return
56
+
57
+ # Sorting puts standard python versions before pypy
58
+ # This can lead to much faster returns by potentially yielding
59
+ # the required python version before checking pypy
60
+
61
+ for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
62
+ executable = os.path.join(p.path, "bin/python")
63
+
64
+ if os.path.exists(executable):
65
+ if _laz.re.fullmatch(PYTHON_VER_RE, p.name):
66
+ yield PythonInstall.from_str(p.name, executable)
67
+ elif _laz.re.fullmatch(PYPY_VER_RE, p.name):
68
+ details_output = _laz.subprocess.run(
69
+ [executable, details_script.__file__],
70
+ capture_output=True,
71
+ text=True,
72
+ ).stdout
73
+
74
+ if details_output:
75
+ try:
76
+ details = _laz.json.loads(details_output)
77
+ except _laz.json.JSONDecodeError:
78
+ pass
79
+ else:
80
+ yield PythonInstall.from_json(**details)
@@ -0,0 +1,181 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import sys
18
+ import os
19
+ import os.path
20
+
21
+ from prefab_classes import prefab, attribute
22
+ from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
23
+
24
+ from . import details_script
25
+
26
+ _laz = LazyImporter(
27
+ [
28
+ ModuleImport("re"),
29
+ ModuleImport("subprocess"),
30
+ ModuleImport("platform"),
31
+ FromImport("glob", "glob"),
32
+ ModuleImport("json"),
33
+ ]
34
+ )
35
+
36
+
37
+ FULL_PY_VER_RE = r"(?P<major>\d+)\.(?P<minor>\d+)\.?(?P<micro>\d*)(?P<releaselevel>[a-zA-Z]*)(?P<serial>\d*)"
38
+
39
+
40
+ @prefab
41
+ class PythonInstall:
42
+ version: tuple[int, int, int, str, int]
43
+ executable: str
44
+ architecture: str = "64bit"
45
+ implementation: str = "cpython"
46
+ metadata: dict = attribute(default_factory=dict)
47
+
48
+ @property
49
+ def version_str(self) -> str:
50
+ major, minor, micro, releaselevel, serial = self.version
51
+
52
+ match releaselevel:
53
+ case "alpha":
54
+ releaselevel = "a"
55
+ case "beta":
56
+ releaselevel = "b"
57
+ case "candidate":
58
+ releaselevel = "rc"
59
+ case _:
60
+ releaselevel = ""
61
+
62
+ if serial == 0:
63
+ serial = ""
64
+ else:
65
+ serial = f"{serial}"
66
+
67
+ return f"{major}.{minor}.{micro}{releaselevel}{serial}"
68
+
69
+ @classmethod
70
+ def from_str(
71
+ cls,
72
+ version: str,
73
+ executable: str,
74
+ architecture: str = "64bit",
75
+ implementation: str = "cpython",
76
+ metadata: dict | None = None,
77
+ ):
78
+ parsed_version = _laz.re.fullmatch(FULL_PY_VER_RE, version)
79
+
80
+ if not parsed_version:
81
+ raise ValueError(f"{version!r} is not a recognised Python version string.")
82
+
83
+ major, minor, micro, releaselevel, serial = parsed_version.groups()
84
+
85
+ match releaselevel:
86
+ case "a":
87
+ releaselevel = "alpha"
88
+ case "b":
89
+ releaselevel = "beta"
90
+ case "rc":
91
+ releaselevel = "candidate"
92
+ case _:
93
+ releaselevel = "final"
94
+
95
+ version_tuple = (
96
+ int(major),
97
+ int(minor),
98
+ int(micro) if micro else 0,
99
+ releaselevel,
100
+ int(serial if serial != "" else 0),
101
+ )
102
+
103
+ # noinspection PyArgumentList
104
+ return cls(
105
+ version=version_tuple,
106
+ executable=executable,
107
+ architecture=architecture,
108
+ implementation=implementation,
109
+ metadata=metadata,
110
+ )
111
+
112
+ @classmethod
113
+ def from_json(cls, version, executable, architecture, implementation, metadata):
114
+ if arch_ver := metadata.get(f"{implementation}_version"):
115
+ metadata[f"{implementation}_version"] = tuple(arch_ver)
116
+
117
+ return cls(
118
+ tuple(version), executable, architecture, implementation, metadata # noqa
119
+ )
120
+
121
+ def get_pip_version(self) -> str | None:
122
+ """
123
+ Get the version of pip installed on a python install.
124
+
125
+ :return: None if pip is not found or the command fails
126
+ version number as string otherwise.
127
+ """
128
+ pip_call = _laz.subprocess.run(
129
+ [self.executable, "-c", "import pip; print(pip.__version__, end='')"],
130
+ text=True,
131
+ capture_output=True,
132
+ )
133
+
134
+ # Pip call failed
135
+ if pip_call.returncode != 0:
136
+ return None
137
+
138
+ return pip_call.stdout
139
+
140
+
141
+ def _python_exe_regex(basename: str = "python"):
142
+ if sys.platform == "win32":
143
+ return _laz.re.compile(rf"{basename}\d?\.?\d*\.exe")
144
+ else:
145
+ return _laz.re.compile(rf"{basename}\d?\.?\d*")
146
+
147
+
148
+ def get_install_details(executable: str) -> PythonInstall | None:
149
+ try:
150
+ detail_output = _laz.subprocess.run(
151
+ [executable, details_script.__file__],
152
+ capture_output=True,
153
+ text=True,
154
+ check=True,
155
+ ).stdout
156
+ except (_laz.subprocess.CalledProcessError, FileNotFoundError):
157
+ return None
158
+
159
+ try:
160
+ output = _laz.json.loads(detail_output)
161
+ except _laz.json.JSONDecodeError:
162
+ return None
163
+
164
+ return PythonInstall.from_json(**output)
165
+
166
+
167
+ def get_folder_pythons(
168
+ base_folder: str | os.PathLike, basenames: tuple[str] = ("python", "pypy")
169
+ ):
170
+ regexes = [_python_exe_regex(name) for name in basenames]
171
+
172
+ with os.scandir(base_folder) as fld:
173
+ for file_path in fld:
174
+ if (
175
+ not file_path.is_symlink()
176
+ and file_path.is_file()
177
+ and any(reg.fullmatch(file_path.name) for reg in regexes)
178
+ ):
179
+ install = get_install_details(file_path.path)
180
+ if install:
181
+ yield install
@@ -0,0 +1,14 @@
1
+ from _collections_abc import Iterator
2
+ import itertools
3
+
4
+ from ..shared import PythonInstall
5
+ from .pyenv_search import get_pyenv_pythons
6
+ from .registry_search import get_registered_pythons
7
+
8
+
9
+ def get_python_installs() -> Iterator[PythonInstall]:
10
+ listed_installs = set()
11
+ for py in itertools.chain(get_registered_pythons(), get_pyenv_pythons()):
12
+ if py.executable not in listed_installs:
13
+ yield py
14
+ listed_installs.add(py.executable)
@@ -0,0 +1,57 @@
1
+ # ducktools-pythonfinder
2
+ # Copyright (C) 2024 David C Ellis
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import os
18
+ import os.path
19
+ from _collections_abc import Iterator
20
+
21
+ from ..shared import PythonInstall
22
+
23
+
24
+ PYENV_VERSIONS_FOLDER = os.path.join(os.environ.get("PYENV_ROOT", ""), "versions")
25
+
26
+
27
+ def get_pyenv_pythons(
28
+ versions_folder: str | os.PathLike = PYENV_VERSIONS_FOLDER,
29
+ ) -> Iterator[PythonInstall]:
30
+ if not os.path.exists(versions_folder):
31
+ return
32
+
33
+ for p in os.scandir(versions_folder):
34
+ executable = os.path.join(p.path, "python.exe")
35
+
36
+ if os.path.exists(executable):
37
+ match p.name.split("-"):
38
+ case (version, arch):
39
+ # win32 in pyenv name means 32 bit python install
40
+ # 'arm' is the only alternative which will be 64bit
41
+ arch = "32bit" if arch == "win32" else "64bit"
42
+ try:
43
+ yield PythonInstall.from_str(
44
+ version, executable, architecture=arch
45
+ )
46
+ except ValueError:
47
+ pass
48
+ case (version,):
49
+ # If no arch given pyenv will be 64 bit
50
+ try:
51
+ yield PythonInstall.from_str(
52
+ version, executable, architecture="64bit"
53
+ )
54
+ except ValueError:
55
+ pass
56
+ case _:
57
+ pass # Skip unrecognised versions