antsibull-nox 0.0.1__py3-none-any.whl → 0.2.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.
- antsibull_nox/__init__.py +66 -3
- antsibull_nox/ansible.py +260 -0
- antsibull_nox/collection/__init__.py +56 -0
- antsibull_nox/collection/data.py +106 -0
- antsibull_nox/collection/extract.py +23 -0
- antsibull_nox/collection/install.py +523 -0
- antsibull_nox/collection/search.py +456 -0
- antsibull_nox/config.py +332 -0
- antsibull_nox/data/action-groups.py +199 -0
- antsibull_nox/data/antsibull_nox_data_util.py +91 -0
- antsibull_nox/data/license-check.py +144 -0
- antsibull_nox/data/license-check.py.license +3 -0
- antsibull_nox/data/no-unwanted-files.py +123 -0
- antsibull_nox/data/plugin-yamllint.py +244 -0
- antsibull_nox/data_util.py +38 -0
- antsibull_nox/interpret_config.py +235 -0
- antsibull_nox/paths.py +220 -0
- antsibull_nox/python.py +81 -0
- antsibull_nox/sessions.py +1389 -168
- antsibull_nox/utils.py +85 -0
- {antsibull_nox-0.0.1.dist-info → antsibull_nox-0.2.0.dist-info}/METADATA +14 -4
- antsibull_nox-0.2.0.dist-info/RECORD +25 -0
- antsibull_nox-0.0.1.dist-info/RECORD +0 -7
- {antsibull_nox-0.0.1.dist-info → antsibull_nox-0.2.0.dist-info}/WHEEL +0 -0
- {antsibull_nox-0.0.1.dist-info → antsibull_nox-0.2.0.dist-info}/licenses/LICENSES/GPL-3.0-or-later.txt +0 -0
@@ -0,0 +1,235 @@
|
|
1
|
+
# Author: Felix Fontein <felix@fontein.de>
|
2
|
+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
|
3
|
+
# https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
5
|
+
# SPDX-FileCopyrightText: 2025, Ansible Project
|
6
|
+
|
7
|
+
"""
|
8
|
+
Interpret config.
|
9
|
+
"""
|
10
|
+
|
11
|
+
from __future__ import annotations
|
12
|
+
|
13
|
+
import typing as t
|
14
|
+
|
15
|
+
from .ansible import AnsibleCoreVersion
|
16
|
+
from .collection import CollectionSource, setup_collection_sources
|
17
|
+
from .config import ActionGroup as ConfigActionGroup
|
18
|
+
from .config import (
|
19
|
+
Config,
|
20
|
+
DevelLikeBranch,
|
21
|
+
Sessions,
|
22
|
+
)
|
23
|
+
from .sessions import (
|
24
|
+
ActionGroup,
|
25
|
+
add_all_ansible_test_sanity_test_sessions,
|
26
|
+
add_all_ansible_test_unit_test_sessions,
|
27
|
+
add_ansible_lint,
|
28
|
+
add_ansible_test_integration_sessions_default_container,
|
29
|
+
add_build_import_check,
|
30
|
+
add_docs_check,
|
31
|
+
add_extra_checks,
|
32
|
+
add_license_check,
|
33
|
+
add_lint_sessions,
|
34
|
+
add_matrix_generator,
|
35
|
+
)
|
36
|
+
from .utils import Version
|
37
|
+
|
38
|
+
|
39
|
+
def _interpret_config(config: Config) -> None:
|
40
|
+
if config.collection_sources:
|
41
|
+
setup_collection_sources(
|
42
|
+
{
|
43
|
+
name: CollectionSource(name=name, source=source.source)
|
44
|
+
for name, source in config.collection_sources.items()
|
45
|
+
}
|
46
|
+
)
|
47
|
+
|
48
|
+
|
49
|
+
def _convert_action_groups(
|
50
|
+
action_groups: list[ConfigActionGroup] | None,
|
51
|
+
) -> list[ActionGroup] | None:
|
52
|
+
if action_groups is None:
|
53
|
+
return None
|
54
|
+
return [
|
55
|
+
ActionGroup(
|
56
|
+
name=action_group.name,
|
57
|
+
pattern=action_group.pattern,
|
58
|
+
doc_fragment=action_group.doc_fragment,
|
59
|
+
exclusions=action_group.exclusions,
|
60
|
+
)
|
61
|
+
for action_group in action_groups
|
62
|
+
]
|
63
|
+
|
64
|
+
|
65
|
+
def _convert_devel_like_branches(
|
66
|
+
devel_like_branches: list[DevelLikeBranch] | None,
|
67
|
+
) -> list[tuple[str | None, str]] | None:
|
68
|
+
if devel_like_branches is None:
|
69
|
+
return None
|
70
|
+
return [(branch.repository, branch.branch) for branch in devel_like_branches]
|
71
|
+
|
72
|
+
|
73
|
+
def _convert_except_versions(
|
74
|
+
except_versions: list[AnsibleCoreVersion] | None,
|
75
|
+
) -> list[AnsibleCoreVersion | str] | None:
|
76
|
+
return t.cast(t.Optional[list[AnsibleCoreVersion | str]], except_versions)
|
77
|
+
|
78
|
+
|
79
|
+
def _convert_core_python_versions(
|
80
|
+
core_python_versions: dict[AnsibleCoreVersion | str, list[Version]] | None,
|
81
|
+
) -> dict[str | AnsibleCoreVersion, list[str | Version]] | None:
|
82
|
+
return t.cast(
|
83
|
+
t.Optional[dict[str | AnsibleCoreVersion, list[str | Version]]],
|
84
|
+
core_python_versions,
|
85
|
+
)
|
86
|
+
|
87
|
+
|
88
|
+
def _add_sessions(sessions: Sessions) -> None:
|
89
|
+
if sessions.lint:
|
90
|
+
add_lint_sessions(
|
91
|
+
make_lint_default=sessions.lint.default,
|
92
|
+
extra_code_files=sessions.lint.extra_code_files,
|
93
|
+
run_isort=sessions.lint.run_isort,
|
94
|
+
isort_config=sessions.lint.isort_config,
|
95
|
+
isort_package=sessions.lint.isort_package,
|
96
|
+
run_black=sessions.lint.run_black,
|
97
|
+
run_black_modules=sessions.lint.run_black_modules,
|
98
|
+
black_config=sessions.lint.black_config,
|
99
|
+
black_package=sessions.lint.black_package,
|
100
|
+
run_flake8=sessions.lint.run_flake8,
|
101
|
+
flake8_config=sessions.lint.flake8_config,
|
102
|
+
flake8_package=sessions.lint.flake8_package,
|
103
|
+
run_pylint=sessions.lint.run_pylint,
|
104
|
+
pylint_rcfile=sessions.lint.pylint_rcfile,
|
105
|
+
pylint_modules_rcfile=sessions.lint.pylint_modules_rcfile,
|
106
|
+
pylint_package=sessions.lint.pylint_package,
|
107
|
+
pylint_ansible_core_package=sessions.lint.pylint_ansible_core_package,
|
108
|
+
pylint_extra_deps=sessions.lint.pylint_extra_deps,
|
109
|
+
run_yamllint=sessions.lint.run_yamllint,
|
110
|
+
yamllint_config=sessions.lint.yamllint_config,
|
111
|
+
yamllint_config_plugins=sessions.lint.yamllint_config_plugins,
|
112
|
+
yamllint_config_plugins_examples=sessions.lint.yamllint_config_plugins_examples,
|
113
|
+
yamllint_package=sessions.lint.yamllint_package,
|
114
|
+
run_mypy=sessions.lint.run_mypy,
|
115
|
+
mypy_config=sessions.lint.mypy_config,
|
116
|
+
mypy_package=sessions.lint.mypy_package,
|
117
|
+
mypy_ansible_core_package=sessions.lint.mypy_ansible_core_package,
|
118
|
+
mypy_extra_deps=sessions.lint.mypy_extra_deps,
|
119
|
+
)
|
120
|
+
if sessions.docs_check:
|
121
|
+
add_docs_check(
|
122
|
+
make_docs_check_default=sessions.docs_check.default,
|
123
|
+
antsibull_docs_package=sessions.docs_check.antsibull_docs_package,
|
124
|
+
ansible_core_package=sessions.docs_check.ansible_core_package,
|
125
|
+
validate_collection_refs=sessions.docs_check.validate_collection_refs,
|
126
|
+
extra_collections=sessions.docs_check.extra_collections,
|
127
|
+
)
|
128
|
+
if sessions.license_check:
|
129
|
+
add_license_check(
|
130
|
+
make_license_check_default=sessions.license_check.default,
|
131
|
+
run_reuse=sessions.license_check.run_reuse,
|
132
|
+
reuse_package=sessions.license_check.reuse_package,
|
133
|
+
run_license_check=sessions.license_check.run_license_check,
|
134
|
+
license_check_extra_ignore_paths=(
|
135
|
+
sessions.license_check.license_check_extra_ignore_paths
|
136
|
+
),
|
137
|
+
)
|
138
|
+
if sessions.extra_checks:
|
139
|
+
add_extra_checks(
|
140
|
+
make_extra_checks_default=sessions.extra_checks.default,
|
141
|
+
run_no_unwanted_files=sessions.extra_checks.run_no_unwanted_files,
|
142
|
+
no_unwanted_files_module_extensions=(
|
143
|
+
sessions.extra_checks.no_unwanted_files_module_extensions
|
144
|
+
),
|
145
|
+
no_unwanted_files_other_extensions=(
|
146
|
+
sessions.extra_checks.no_unwanted_files_other_extensions
|
147
|
+
),
|
148
|
+
no_unwanted_files_yaml_extensions=(
|
149
|
+
sessions.extra_checks.no_unwanted_files_yaml_extensions
|
150
|
+
),
|
151
|
+
no_unwanted_files_skip_paths=(
|
152
|
+
sessions.extra_checks.no_unwanted_files_skip_paths
|
153
|
+
),
|
154
|
+
no_unwanted_files_skip_directories=(
|
155
|
+
sessions.extra_checks.no_unwanted_files_skip_directories
|
156
|
+
),
|
157
|
+
no_unwanted_files_yaml_directories=(
|
158
|
+
sessions.extra_checks.no_unwanted_files_yaml_directories
|
159
|
+
),
|
160
|
+
no_unwanted_files_allow_symlinks=(
|
161
|
+
sessions.extra_checks.no_unwanted_files_allow_symlinks
|
162
|
+
),
|
163
|
+
run_action_groups=sessions.extra_checks.run_action_groups,
|
164
|
+
action_groups_config=_convert_action_groups(
|
165
|
+
sessions.extra_checks.action_groups_config
|
166
|
+
),
|
167
|
+
)
|
168
|
+
if sessions.build_import_check:
|
169
|
+
add_build_import_check(
|
170
|
+
make_build_import_check_default=sessions.build_import_check.default,
|
171
|
+
ansible_core_package=sessions.build_import_check.ansible_core_package,
|
172
|
+
run_galaxy_importer=sessions.build_import_check.run_galaxy_importer,
|
173
|
+
galaxy_importer_package=sessions.build_import_check.galaxy_importer_package,
|
174
|
+
galaxy_importer_config_path=sessions.build_import_check.galaxy_importer_config_path,
|
175
|
+
)
|
176
|
+
if sessions.ansible_test_sanity:
|
177
|
+
add_all_ansible_test_sanity_test_sessions(
|
178
|
+
default=sessions.ansible_test_sanity.default,
|
179
|
+
include_devel=sessions.ansible_test_sanity.include_devel,
|
180
|
+
include_milestone=sessions.ansible_test_sanity.include_milestone,
|
181
|
+
add_devel_like_branches=_convert_devel_like_branches(
|
182
|
+
sessions.ansible_test_sanity.add_devel_like_branches
|
183
|
+
),
|
184
|
+
min_version=sessions.ansible_test_sanity.min_version,
|
185
|
+
max_version=sessions.ansible_test_sanity.max_version,
|
186
|
+
except_versions=_convert_except_versions(
|
187
|
+
sessions.ansible_test_sanity.except_versions
|
188
|
+
),
|
189
|
+
)
|
190
|
+
if sessions.ansible_test_units:
|
191
|
+
add_all_ansible_test_unit_test_sessions(
|
192
|
+
default=sessions.ansible_test_units.default,
|
193
|
+
include_devel=sessions.ansible_test_units.include_devel,
|
194
|
+
include_milestone=sessions.ansible_test_units.include_milestone,
|
195
|
+
add_devel_like_branches=_convert_devel_like_branches(
|
196
|
+
sessions.ansible_test_units.add_devel_like_branches
|
197
|
+
),
|
198
|
+
min_version=sessions.ansible_test_units.min_version,
|
199
|
+
max_version=sessions.ansible_test_units.max_version,
|
200
|
+
except_versions=_convert_except_versions(
|
201
|
+
sessions.ansible_test_units.except_versions
|
202
|
+
),
|
203
|
+
)
|
204
|
+
if sessions.ansible_test_integration_w_default_container:
|
205
|
+
cfg = sessions.ansible_test_integration_w_default_container
|
206
|
+
add_ansible_test_integration_sessions_default_container(
|
207
|
+
default=cfg.default,
|
208
|
+
include_devel=cfg.include_devel,
|
209
|
+
include_milestone=(cfg.include_milestone),
|
210
|
+
add_devel_like_branches=_convert_devel_like_branches(
|
211
|
+
cfg.add_devel_like_branches
|
212
|
+
),
|
213
|
+
min_version=cfg.min_version,
|
214
|
+
max_version=cfg.max_version,
|
215
|
+
except_versions=_convert_except_versions(cfg.except_versions),
|
216
|
+
core_python_versions=_convert_core_python_versions(
|
217
|
+
cfg.core_python_versions
|
218
|
+
),
|
219
|
+
controller_python_versions_only=cfg.controller_python_versions_only,
|
220
|
+
)
|
221
|
+
if sessions.ansible_lint:
|
222
|
+
add_ansible_lint(
|
223
|
+
make_ansible_lint_default=sessions.ansible_lint.default,
|
224
|
+
ansible_lint_package=sessions.ansible_lint.ansible_lint_package,
|
225
|
+
strict=sessions.ansible_lint.strict,
|
226
|
+
)
|
227
|
+
add_matrix_generator()
|
228
|
+
|
229
|
+
|
230
|
+
def interpret_config(config: Config) -> None:
|
231
|
+
"""
|
232
|
+
Interpret the config file's contents.
|
233
|
+
"""
|
234
|
+
_interpret_config(config)
|
235
|
+
_add_sessions(config.sessions)
|
antsibull_nox/paths.py
ADDED
@@ -0,0 +1,220 @@
|
|
1
|
+
# Author: Felix Fontein <felix@fontein.de>
|
2
|
+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
|
3
|
+
# https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
5
|
+
# SPDX-FileCopyrightText: 2025, Ansible Project
|
6
|
+
|
7
|
+
"""
|
8
|
+
Path utilities.
|
9
|
+
"""
|
10
|
+
|
11
|
+
from __future__ import annotations
|
12
|
+
|
13
|
+
import atexit
|
14
|
+
import functools
|
15
|
+
import os
|
16
|
+
import shutil
|
17
|
+
import tempfile
|
18
|
+
from pathlib import Path
|
19
|
+
|
20
|
+
from antsibull_fileutils.copier import Copier, GitCopier
|
21
|
+
from antsibull_fileutils.vcs import detect_vcs, list_git_files
|
22
|
+
|
23
|
+
|
24
|
+
def find_data_directory() -> Path:
|
25
|
+
"""
|
26
|
+
Retrieve the directory for antsibull_nox.data on disk.
|
27
|
+
"""
|
28
|
+
return Path(__file__).parent / "data"
|
29
|
+
|
30
|
+
|
31
|
+
def match_path(path: str, is_file: bool, paths: list[str]) -> bool:
|
32
|
+
"""
|
33
|
+
Check whether a path (that is a file or not) matches a given list of paths.
|
34
|
+
"""
|
35
|
+
for check in paths:
|
36
|
+
if check == path:
|
37
|
+
return True
|
38
|
+
if not is_file:
|
39
|
+
if not check.endswith("/"):
|
40
|
+
check += "/"
|
41
|
+
if path.startswith(check):
|
42
|
+
return True
|
43
|
+
return False
|
44
|
+
|
45
|
+
|
46
|
+
def restrict_paths(paths: list[str], restrict: list[str]) -> list[str]:
|
47
|
+
"""
|
48
|
+
Restrict a list of paths with a given set of restrictions.
|
49
|
+
"""
|
50
|
+
result = []
|
51
|
+
for path in paths:
|
52
|
+
is_file = os.path.isfile(path)
|
53
|
+
if not is_file and not path.endswith("/"):
|
54
|
+
path += "/"
|
55
|
+
if not match_path(path, is_file, restrict):
|
56
|
+
if not is_file:
|
57
|
+
for check in restrict:
|
58
|
+
if check.startswith(path) and os.path.exists(check):
|
59
|
+
result.append(check)
|
60
|
+
continue
|
61
|
+
result.append(path)
|
62
|
+
return result
|
63
|
+
|
64
|
+
|
65
|
+
def _scan_remove_paths(
|
66
|
+
path: str, remove: list[str], extensions: list[str] | None
|
67
|
+
) -> list[str]:
|
68
|
+
result = []
|
69
|
+
for root, dirs, files in os.walk(path, topdown=True):
|
70
|
+
if not root.endswith("/"):
|
71
|
+
root += "/"
|
72
|
+
if match_path(root, False, remove):
|
73
|
+
continue
|
74
|
+
if all(not check.startswith(root) for check in remove):
|
75
|
+
dirs[:] = []
|
76
|
+
result.append(root)
|
77
|
+
continue
|
78
|
+
for file in files:
|
79
|
+
if extensions and os.path.splitext(file)[1] not in extensions:
|
80
|
+
continue
|
81
|
+
filepath = os.path.normpath(os.path.join(root, file))
|
82
|
+
if not match_path(filepath, True, remove):
|
83
|
+
result.append(filepath)
|
84
|
+
for directory in list(dirs):
|
85
|
+
if directory == "__pycache__":
|
86
|
+
continue
|
87
|
+
dirpath = os.path.normpath(os.path.join(root, directory))
|
88
|
+
if match_path(dirpath, False, remove):
|
89
|
+
dirs.remove(directory)
|
90
|
+
continue
|
91
|
+
return result
|
92
|
+
|
93
|
+
|
94
|
+
def remove_paths(
|
95
|
+
paths: list[str], remove: list[str], extensions: list[str] | None
|
96
|
+
) -> list[str]:
|
97
|
+
"""
|
98
|
+
Restrict a list of paths by removing paths.
|
99
|
+
|
100
|
+
If ``extensions`` is specified, only files matching this extension
|
101
|
+
will be considered when files need to be explicitly enumerated.
|
102
|
+
"""
|
103
|
+
result = []
|
104
|
+
for path in paths:
|
105
|
+
is_file = os.path.isfile(path)
|
106
|
+
if not is_file and not path.endswith("/"):
|
107
|
+
path += "/"
|
108
|
+
if match_path(path, is_file, remove):
|
109
|
+
continue
|
110
|
+
if not is_file and any(check.startswith(path) for check in remove):
|
111
|
+
result.extend(_scan_remove_paths(path, remove, extensions))
|
112
|
+
continue
|
113
|
+
result.append(path)
|
114
|
+
return result
|
115
|
+
|
116
|
+
|
117
|
+
def filter_paths(
|
118
|
+
paths: list[str],
|
119
|
+
/,
|
120
|
+
remove: list[str] | None = None,
|
121
|
+
restrict: list[str] | None = None,
|
122
|
+
extensions: list[str] | None = None,
|
123
|
+
) -> list[str]:
|
124
|
+
"""
|
125
|
+
Modifies a list of paths by restricting to and/or removing paths.
|
126
|
+
"""
|
127
|
+
if restrict:
|
128
|
+
paths = restrict_paths(paths, restrict)
|
129
|
+
if remove:
|
130
|
+
paths = remove_paths(paths, remove, extensions)
|
131
|
+
return [path for path in paths if os.path.exists(path)]
|
132
|
+
|
133
|
+
|
134
|
+
@functools.cache
|
135
|
+
def list_all_files() -> list[Path]:
|
136
|
+
"""
|
137
|
+
List all files of interest in the repository.
|
138
|
+
"""
|
139
|
+
directory = Path.cwd()
|
140
|
+
vcs = detect_vcs(directory)
|
141
|
+
if vcs == "git":
|
142
|
+
return [directory / path.decode("utf-8") for path in list_git_files(directory)]
|
143
|
+
result = []
|
144
|
+
for root, dirs, files in os.walk(directory, topdown=True):
|
145
|
+
root_path = Path(root)
|
146
|
+
for file in files:
|
147
|
+
result.append(root_path / file)
|
148
|
+
if root_path == directory and ".nox" in dirs:
|
149
|
+
dirs.remove(".nox")
|
150
|
+
return result
|
151
|
+
|
152
|
+
|
153
|
+
def remove_path(path: Path) -> None:
|
154
|
+
"""
|
155
|
+
Delete a path.
|
156
|
+
"""
|
157
|
+
if not path.is_symlink() and path.is_dir():
|
158
|
+
shutil.rmtree(path)
|
159
|
+
elif path.exists():
|
160
|
+
path.unlink()
|
161
|
+
|
162
|
+
|
163
|
+
def copy_collection(source: Path, destination: Path) -> None:
|
164
|
+
"""
|
165
|
+
Copy a collection from source to destination.
|
166
|
+
|
167
|
+
Automatically detect supported VCSs and use their information to avoid
|
168
|
+
copying ignored files.
|
169
|
+
"""
|
170
|
+
if destination.exists():
|
171
|
+
remove_path(destination)
|
172
|
+
vcs = detect_vcs(source)
|
173
|
+
copier = {
|
174
|
+
"none": Copier,
|
175
|
+
"git": GitCopier,
|
176
|
+
}.get(vcs, Copier)()
|
177
|
+
copier.copy(source, destination, exclude_root=[".nox", ".tox"])
|
178
|
+
|
179
|
+
|
180
|
+
def create_temp_directory(basename: str) -> Path:
|
181
|
+
"""
|
182
|
+
Create a temporary directory outside the nox tree.
|
183
|
+
"""
|
184
|
+
directory = tempfile.mkdtemp(prefix=basename)
|
185
|
+
path = Path(directory)
|
186
|
+
|
187
|
+
def cleanup() -> None:
|
188
|
+
remove_path(path)
|
189
|
+
|
190
|
+
atexit.register(cleanup)
|
191
|
+
return path
|
192
|
+
|
193
|
+
|
194
|
+
def copy_directory_tree_into(source: Path, destination: Path) -> None:
|
195
|
+
"""
|
196
|
+
Copy the directory tree from ``source`` into the tree at ``destination``.
|
197
|
+
|
198
|
+
If ``destination`` does not yet exist, it will be created first.
|
199
|
+
"""
|
200
|
+
if not source.is_dir():
|
201
|
+
return
|
202
|
+
destination.mkdir(parents=True, exist_ok=True)
|
203
|
+
for root, _, files in source.walk():
|
204
|
+
path = destination / root.relative_to(source)
|
205
|
+
path.mkdir(exist_ok=True)
|
206
|
+
for file in files:
|
207
|
+
dest = path / file
|
208
|
+
remove_path(dest)
|
209
|
+
shutil.copy2(root / file, dest, follow_symlinks=False)
|
210
|
+
|
211
|
+
|
212
|
+
__all__ = [
|
213
|
+
"copy_collection",
|
214
|
+
"copy_directory_tree_into",
|
215
|
+
"create_temp_directory",
|
216
|
+
"filter_paths",
|
217
|
+
"find_data_directory",
|
218
|
+
"list_all_files",
|
219
|
+
"remove_path",
|
220
|
+
]
|
antsibull_nox/python.py
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
# Author: Felix Fontein <felix@fontein.de>
|
2
|
+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
|
3
|
+
# https://www.gnu.org/licenses/gpl-3.0.txt)
|
4
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
5
|
+
# SPDX-FileCopyrightText: 2025, Ansible Project
|
6
|
+
|
7
|
+
"""
|
8
|
+
Python version utilities.
|
9
|
+
"""
|
10
|
+
|
11
|
+
from __future__ import annotations
|
12
|
+
|
13
|
+
import functools
|
14
|
+
import shutil
|
15
|
+
import subprocess
|
16
|
+
from pathlib import Path
|
17
|
+
|
18
|
+
from .utils import Version
|
19
|
+
|
20
|
+
# The following contains Python version candidates
|
21
|
+
_PYTHON_VERSIONS_TO_TRY: tuple[Version, ...] = tuple(
|
22
|
+
Version.parse(v)
|
23
|
+
for v in [
|
24
|
+
# Python 2:
|
25
|
+
"2.6",
|
26
|
+
"2.7",
|
27
|
+
# Python 3:
|
28
|
+
"3.5",
|
29
|
+
"3.6",
|
30
|
+
"3.7",
|
31
|
+
"3.8",
|
32
|
+
"3.9",
|
33
|
+
"3.10",
|
34
|
+
"3.11",
|
35
|
+
"3.12",
|
36
|
+
"3.13",
|
37
|
+
"3.14",
|
38
|
+
# "3.15",
|
39
|
+
# "3.16",
|
40
|
+
]
|
41
|
+
)
|
42
|
+
|
43
|
+
|
44
|
+
@functools.cache
|
45
|
+
def get_installed_python_versions() -> dict[Version, Path]:
|
46
|
+
"""
|
47
|
+
Return a map of supported Python versions for which interpreters exist and are on the path,
|
48
|
+
mapped to an executable.
|
49
|
+
"""
|
50
|
+
result = {}
|
51
|
+
|
52
|
+
# Look for pythonX.Y binaries
|
53
|
+
for candidate in _PYTHON_VERSIONS_TO_TRY:
|
54
|
+
if exe := shutil.which(f"python{candidate}"):
|
55
|
+
result[candidate] = Path(exe)
|
56
|
+
|
57
|
+
# Look for python, python2, python3 binaries and determine their version
|
58
|
+
for executable in ("python", "python2", "python3"):
|
59
|
+
exe = shutil.which(executable)
|
60
|
+
if exe:
|
61
|
+
script = "import platform; print('.'.join(platform.python_version().split('.')[:2]))"
|
62
|
+
exe_result = subprocess.run(
|
63
|
+
[exe, "-c", script],
|
64
|
+
check=False,
|
65
|
+
text=True,
|
66
|
+
capture_output=True,
|
67
|
+
)
|
68
|
+
if exe_result.returncode == 0 and exe_result.stdout:
|
69
|
+
try:
|
70
|
+
version = Version.parse(exe_result.stdout.strip())
|
71
|
+
except (AttributeError, ValueError):
|
72
|
+
continue
|
73
|
+
else:
|
74
|
+
result.setdefault(version, Path(exe))
|
75
|
+
|
76
|
+
return result
|
77
|
+
|
78
|
+
|
79
|
+
__all__ = [
|
80
|
+
"get_installed_python_versions",
|
81
|
+
]
|