ansible-core 2.14.8__py3-none-any.whl → 2.14.9__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.

Potentially problematic release.


This version of ansible-core might be problematic. Click here for more details.

Files changed (41) hide show
  1. ansible/cli/config.py +1 -0
  2. ansible/cli/galaxy.py +6 -2
  3. ansible/cli/inventory.py +1 -1
  4. ansible/module_utils/ansible_release.py +1 -1
  5. ansible/modules/getent.py +1 -1
  6. ansible/modules/git.py +1 -1
  7. ansible/modules/setup.py +1 -1
  8. ansible/modules/stat.py +3 -3
  9. ansible/modules/validate_argument_spec.py +1 -1
  10. ansible/plugins/action/__init__.py +1 -1
  11. ansible/plugins/filter/split.yml +1 -1
  12. ansible/release.py +1 -1
  13. ansible_core-2.14.9.dist-info/METADATA +128 -0
  14. {ansible_core-2.14.8.dist-info → ansible_core-2.14.9.dist-info}/RECORD +39 -38
  15. {ansible_core-2.14.8.dist-info → ansible_core-2.14.9.dist-info}/WHEEL +1 -1
  16. ansible_test/_data/requirements/sanity.ansible-doc.txt +2 -0
  17. ansible_test/_data/requirements/sanity.changelog.txt +2 -0
  18. ansible_test/_data/requirements/sanity.import.plugin.txt +2 -0
  19. ansible_test/_data/requirements/sanity.import.txt +2 -0
  20. ansible_test/_data/requirements/sanity.integration-aliases.txt +2 -0
  21. ansible_test/_data/requirements/sanity.pylint.txt +2 -0
  22. ansible_test/_data/requirements/sanity.runtime-metadata.txt +2 -0
  23. ansible_test/_data/requirements/sanity.validate-modules.txt +2 -0
  24. ansible_test/_data/requirements/sanity.yamllint.txt +2 -0
  25. ansible_test/_internal/ansible_util.py +1 -1
  26. ansible_test/_internal/classification/__init__.py +6 -13
  27. ansible_test/_internal/classification/python.py +0 -1
  28. ansible_test/_internal/commands/sanity/__init__.py +35 -1
  29. ansible_test/_internal/commands/sanity/bin_symlinks.py +102 -0
  30. ansible_test/_internal/commands/sanity/integration_aliases.py +430 -0
  31. ansible_test/_internal/provider/layout/ansible.py +1 -1
  32. ansible_test/_internal/provider/source/unversioned.py +0 -3
  33. ansible_test/_internal/python_requirements.py +7 -0
  34. ansible_test/_util/target/setup/ConfigureRemotingForAnsible.ps1 +1 -1
  35. ansible_test/_util/target/setup/requirements.py +63 -0
  36. ansible_core-2.14.8.dist-info/METADATA +0 -154
  37. ansible_test/_internal/commands/sanity/sanity_docs.py +0 -61
  38. {ansible_core-2.14.8.data → ansible_core-2.14.9.data}/scripts/ansible-test +0 -0
  39. {ansible_core-2.14.8.dist-info → ansible_core-2.14.9.dist-info}/COPYING +0 -0
  40. {ansible_core-2.14.8.dist-info → ansible_core-2.14.9.dist-info}/entry_points.txt +0 -0
  41. {ansible_core-2.14.8.dist-info → ansible_core-2.14.9.dist-info}/top_level.txt +0 -0
@@ -231,7 +231,7 @@ def generate_egg_info(path: str) -> None:
231
231
  # minimal PKG-INFO stub following the format defined in PEP 241
232
232
  # required for older setuptools versions to avoid a traceback when importing pkg_resources from packages like cryptography
233
233
  # newer setuptools versions are happy with an empty directory
234
- # including a stub here means we don't need to locate the existing file or have setup.py generate it when running from source
234
+ # including a stub here means we don't need to locate the existing file or run any tools to generate it when running from source
235
235
  pkg_info = '''
236
236
  Metadata-Version: 1.0
237
237
  Name: ansible
@@ -721,17 +721,6 @@ class PathMapper:
721
721
  if path.startswith('changelogs/'):
722
722
  return minimal
723
723
 
724
- if path.startswith('docs/'):
725
- return minimal
726
-
727
- if path.startswith('examples/'):
728
- if path == 'examples/scripts/ConfigureRemotingForAnsible.ps1':
729
- return {
730
- 'windows-integration': 'connection_winrm',
731
- }
732
-
733
- return minimal
734
-
735
724
  if path.startswith('hacking/'):
736
725
  return minimal
737
726
 
@@ -753,8 +742,12 @@ class PathMapper:
753
742
  return minimal
754
743
 
755
744
  if path.startswith('packaging/'):
756
- if path.startswith('packaging/pep517_backend/'):
757
- return packaging
745
+ packaging_target = f'packaging_{os.path.splitext(path.split(os.path.sep)[1])[0]}'
746
+
747
+ if packaging_target in self.integration_targets_by_name:
748
+ return {
749
+ 'integration': packaging_target,
750
+ }
758
751
 
759
752
  return minimal
760
753
 
@@ -256,7 +256,6 @@ class ModuleUtilFinder(ast.NodeVisitor):
256
256
  # The mapping is a tuple consisting of a path pattern to match and a replacement path.
257
257
  # During analysis, any relative imports not covered here will result in warnings, which can be fixed by adding the appropriate entry.
258
258
  path_map = (
259
- ('^hacking/build_library/build_ansible/', 'build_ansible/'),
260
259
  ('^lib/ansible/', 'ansible/'),
261
260
  ('^test/lib/ansible_test/_util/controller/sanity/validate-modules/', 'validate_modules/'),
262
261
  ('^test/units/', 'test/units/'),
@@ -71,6 +71,7 @@ from ...executor import (
71
71
  )
72
72
 
73
73
  from ...python_requirements import (
74
+ PipCommand,
74
75
  PipInstall,
75
76
  collect_requirements,
76
77
  run_pip,
@@ -1143,7 +1144,7 @@ def create_sanity_virtualenv(
1143
1144
  # The path to the virtual environment must be kept short to avoid the 127 character shebang length limit on Linux.
1144
1145
  # If the limit is exceeded, generated entry point scripts from pip installed packages will fail with syntax errors.
1145
1146
  virtualenv_install = json.dumps([command.serialize() for command in commands], indent=4)
1146
- virtualenv_hash = hashlib.sha256(to_bytes(virtualenv_install)).hexdigest()[:8]
1147
+ virtualenv_hash = hash_pip_commands(commands)
1147
1148
  virtualenv_cache = os.path.join(os.path.expanduser('~/.ansible/test/venv'))
1148
1149
  virtualenv_path = os.path.join(virtualenv_cache, label, f'{python.version}', virtualenv_hash)
1149
1150
  virtualenv_marker = os.path.join(virtualenv_path, 'marker.txt')
@@ -1183,6 +1184,39 @@ def create_sanity_virtualenv(
1183
1184
  return virtualenv_python
1184
1185
 
1185
1186
 
1187
+ def hash_pip_commands(commands: list[PipCommand]) -> str:
1188
+ """Return a short hash unique to the given list of pip commands, suitable for identifying the resulting sanity test environment."""
1189
+ serialized_commands = json.dumps([make_pip_command_hashable(command) for command in commands], indent=4)
1190
+
1191
+ return hashlib.sha256(to_bytes(serialized_commands)).hexdigest()[:8]
1192
+
1193
+
1194
+ def make_pip_command_hashable(command: PipCommand) -> tuple[str, dict[str, t.Any]]:
1195
+ """Return a serialized version of the given pip command that is suitable for hashing."""
1196
+ if isinstance(command, PipInstall):
1197
+ # The pre-build instructions for pip installs must be omitted, so they do not affect the hash.
1198
+ # This is allows the pre-build commands to be added without breaking sanity venv caching.
1199
+ # It is safe to omit these from the hash since they only affect packages used during builds, not what is installed in the venv.
1200
+ command = PipInstall(
1201
+ requirements=[omit_pre_build_from_requirement(*req) for req in command.requirements],
1202
+ constraints=list(command.constraints),
1203
+ packages=list(command.packages),
1204
+ )
1205
+
1206
+ return command.serialize()
1207
+
1208
+
1209
+ def omit_pre_build_from_requirement(path: str, requirements: str) -> tuple[str, str]:
1210
+ """Return the given requirements with pre-build instructions omitted."""
1211
+ lines = requirements.splitlines(keepends=True)
1212
+
1213
+ # CAUTION: This code must be kept in sync with the code which processes pre-build instructions in:
1214
+ # test/lib/ansible_test/_util/target/setup/requirements.py
1215
+ lines = [line for line in lines if not line.startswith('# pre-build ')]
1216
+
1217
+ return path, ''.join(lines)
1218
+
1219
+
1186
1220
  def check_sanity_virtualenv_yaml(python: VirtualPythonConfig) -> t.Optional[bool]:
1187
1221
  """Return True if PyYAML has libyaml support for the given sanity virtual environment, False if it does not and None if it was not found."""
1188
1222
  virtualenv_path = os.path.dirname(os.path.dirname(python.path))
@@ -0,0 +1,102 @@
1
+ """Sanity test for symlinks in the bin directory."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ from . import (
7
+ SanityVersionNeutral,
8
+ SanityMessage,
9
+ SanityFailure,
10
+ SanitySuccess,
11
+ SanityTargets,
12
+ )
13
+
14
+ from ...constants import (
15
+ __file__ as symlink_map_full_path,
16
+ )
17
+
18
+ from ...test import (
19
+ TestResult,
20
+ )
21
+
22
+ from ...config import (
23
+ SanityConfig,
24
+ )
25
+
26
+ from ...data import (
27
+ data_context,
28
+ )
29
+
30
+ from ...payload import (
31
+ ANSIBLE_BIN_SYMLINK_MAP,
32
+ )
33
+
34
+ from ...util import (
35
+ ANSIBLE_BIN_PATH,
36
+ )
37
+
38
+
39
+ class BinSymlinksTest(SanityVersionNeutral):
40
+ """Sanity test for symlinks in the bin directory."""
41
+
42
+ ansible_only = True
43
+
44
+ @property
45
+ def can_ignore(self) -> bool:
46
+ """True if the test supports ignore entries."""
47
+ return False
48
+
49
+ @property
50
+ def no_targets(self) -> bool:
51
+ """True if the test does not use test targets. Mutually exclusive with all_targets."""
52
+ return True
53
+
54
+ def test(self, args: SanityConfig, targets: SanityTargets) -> TestResult:
55
+ bin_root = ANSIBLE_BIN_PATH
56
+ bin_names = os.listdir(bin_root)
57
+ bin_paths = sorted(os.path.join(bin_root, path) for path in bin_names)
58
+
59
+ errors: list[tuple[str, str]] = []
60
+
61
+ symlink_map_path = os.path.relpath(symlink_map_full_path, data_context().content.root)
62
+
63
+ for bin_path in bin_paths:
64
+ if not os.path.islink(bin_path):
65
+ errors.append((bin_path, 'not a symbolic link'))
66
+ continue
67
+
68
+ dest = os.readlink(bin_path)
69
+
70
+ if not os.path.exists(bin_path):
71
+ errors.append((bin_path, 'points to non-existent path "%s"' % dest))
72
+ continue
73
+
74
+ if not os.path.isfile(bin_path):
75
+ errors.append((bin_path, 'points to non-file "%s"' % dest))
76
+ continue
77
+
78
+ map_dest = ANSIBLE_BIN_SYMLINK_MAP.get(os.path.basename(bin_path))
79
+
80
+ if not map_dest:
81
+ errors.append((bin_path, 'missing from ANSIBLE_BIN_SYMLINK_MAP in file "%s"' % symlink_map_path))
82
+ continue
83
+
84
+ if dest != map_dest:
85
+ errors.append((bin_path, 'points to "%s" instead of "%s" from ANSIBLE_BIN_SYMLINK_MAP in file "%s"' % (dest, map_dest, symlink_map_path)))
86
+ continue
87
+
88
+ if not os.access(bin_path, os.X_OK):
89
+ errors.append((bin_path, 'points to non-executable file "%s"' % dest))
90
+ continue
91
+
92
+ for bin_name, dest in ANSIBLE_BIN_SYMLINK_MAP.items():
93
+ if bin_name not in bin_names:
94
+ bin_path = os.path.join(bin_root, bin_name)
95
+ errors.append((bin_path, 'missing symlink to "%s" defined in ANSIBLE_BIN_SYMLINK_MAP in file "%s"' % (dest, symlink_map_path)))
96
+
97
+ messages = [SanityMessage(message=message, path=os.path.relpath(path, data_context().content.root), confidence=100) for path, message in errors]
98
+
99
+ if errors:
100
+ return SanityFailure(self.name, messages=messages)
101
+
102
+ return SanitySuccess(self.name)
@@ -0,0 +1,430 @@
1
+ """Sanity test to check integration test aliases."""
2
+ from __future__ import annotations
3
+
4
+ import dataclasses
5
+ import json
6
+ import textwrap
7
+ import os
8
+ import re
9
+ import typing as t
10
+
11
+ from . import (
12
+ SanitySingleVersion,
13
+ SanityMessage,
14
+ SanityFailure,
15
+ SanitySuccess,
16
+ SanityTargets,
17
+ SANITY_ROOT,
18
+ )
19
+
20
+ from ...test import (
21
+ TestResult,
22
+ )
23
+
24
+ from ...config import (
25
+ SanityConfig,
26
+ )
27
+
28
+ from ...target import (
29
+ filter_targets,
30
+ walk_posix_integration_targets,
31
+ walk_windows_integration_targets,
32
+ walk_integration_targets,
33
+ walk_module_targets,
34
+ CompletionTarget,
35
+ IntegrationTargetType,
36
+ )
37
+
38
+ from ..integration.cloud import (
39
+ get_cloud_platforms,
40
+ )
41
+
42
+ from ...io import (
43
+ read_text_file,
44
+ )
45
+
46
+ from ...util import (
47
+ display,
48
+ raw_command,
49
+ )
50
+
51
+ from ...util_common import (
52
+ get_docs_url,
53
+ write_json_test_results,
54
+ ResultType,
55
+ )
56
+
57
+ from ...host_configs import (
58
+ PythonConfig,
59
+ )
60
+
61
+
62
+ class IntegrationAliasesTest(SanitySingleVersion):
63
+ """Sanity test to evaluate integration test aliases."""
64
+
65
+ CI_YML = '.azure-pipelines/azure-pipelines.yml'
66
+ TEST_ALIAS_PREFIX = 'shippable' # this will be changed at some point in the future
67
+
68
+ DISABLED = 'disabled/'
69
+ UNSTABLE = 'unstable/'
70
+ UNSUPPORTED = 'unsupported/'
71
+
72
+ EXPLAIN_URL = get_docs_url('https://docs.ansible.com/ansible-core/devel/dev_guide/testing/sanity/integration-aliases.html')
73
+
74
+ TEMPLATE_DISABLED = """
75
+ The following integration tests are **disabled** [[explain]({explain_url}#disabled)]:
76
+
77
+ {tests}
78
+
79
+ Consider fixing the integration tests before or alongside changes.
80
+ """
81
+
82
+ TEMPLATE_UNSTABLE = """
83
+ The following integration tests are **unstable** [[explain]({explain_url}#unstable)]:
84
+
85
+ {tests}
86
+
87
+ Tests may need to be restarted due to failures unrelated to changes.
88
+ """
89
+
90
+ TEMPLATE_UNSUPPORTED = """
91
+ The following integration tests are **unsupported** [[explain]({explain_url}#unsupported)]:
92
+
93
+ {tests}
94
+
95
+ Consider running the tests manually or extending test infrastructure to add support.
96
+ """
97
+
98
+ TEMPLATE_UNTESTED = """
99
+ The following modules have **no integration tests** [[explain]({explain_url}#untested)]:
100
+
101
+ {tests}
102
+
103
+ Consider adding integration tests before or alongside changes.
104
+ """
105
+
106
+ ansible_only = True
107
+
108
+ def __init__(self) -> None:
109
+ super().__init__()
110
+
111
+ self._ci_config: dict[str, t.Any] = {}
112
+ self._ci_test_groups: dict[str, list[int]] = {}
113
+
114
+ @property
115
+ def can_ignore(self) -> bool:
116
+ """True if the test supports ignore entries."""
117
+ return False
118
+
119
+ @property
120
+ def no_targets(self) -> bool:
121
+ """True if the test does not use test targets. Mutually exclusive with all_targets."""
122
+ return True
123
+
124
+ def load_ci_config(self, python: PythonConfig) -> dict[str, t.Any]:
125
+ """Load and return the CI YAML configuration."""
126
+ if not self._ci_config:
127
+ self._ci_config = self.load_yaml(python, self.CI_YML)
128
+
129
+ return self._ci_config
130
+
131
+ @property
132
+ def ci_test_groups(self) -> dict[str, list[int]]:
133
+ """Return a dictionary of CI test names and their group(s)."""
134
+ if not self._ci_test_groups:
135
+ test_groups: dict[str, set[int]] = {}
136
+
137
+ for stage in self._ci_config['stages']:
138
+ for job in stage['jobs']:
139
+ if job.get('template') != 'templates/matrix.yml':
140
+ continue
141
+
142
+ parameters = job['parameters']
143
+
144
+ groups = parameters.get('groups', [])
145
+ test_format = parameters.get('testFormat', '{0}')
146
+ test_group_format = parameters.get('groupFormat', '{0}/{{1}}')
147
+
148
+ for target in parameters['targets']:
149
+ test = target.get('test') or target.get('name')
150
+
151
+ if groups:
152
+ tests_formatted = [test_group_format.format(test_format).format(test, group) for group in groups]
153
+ else:
154
+ tests_formatted = [test_format.format(test)]
155
+
156
+ for test_formatted in tests_formatted:
157
+ parts = test_formatted.split('/')
158
+ key = parts[0]
159
+
160
+ if key in ('sanity', 'units'):
161
+ continue
162
+
163
+ try:
164
+ group = int(parts[-1])
165
+ except ValueError:
166
+ continue
167
+
168
+ if group < 1 or group > 99:
169
+ continue
170
+
171
+ group_set = test_groups.setdefault(key, set())
172
+ group_set.add(group)
173
+
174
+ self._ci_test_groups = dict((key, sorted(value)) for key, value in test_groups.items())
175
+
176
+ return self._ci_test_groups
177
+
178
+ def format_test_group_alias(self, name: str, fallback: str = '') -> str:
179
+ """Return a test group alias using the given name and fallback."""
180
+ group_numbers = self.ci_test_groups.get(name, None)
181
+
182
+ if group_numbers:
183
+ if min(group_numbers) != 1:
184
+ display.warning('Min test group "%s" in %s is %d instead of 1.' % (name, self.CI_YML, min(group_numbers)), unique=True)
185
+
186
+ if max(group_numbers) != len(group_numbers):
187
+ display.warning('Max test group "%s" in %s is %d instead of %d.' % (name, self.CI_YML, max(group_numbers), len(group_numbers)), unique=True)
188
+
189
+ if max(group_numbers) > 9:
190
+ alias = '%s/%s/group(%s)/' % (self.TEST_ALIAS_PREFIX, name, '|'.join(str(i) for i in range(min(group_numbers), max(group_numbers) + 1)))
191
+ elif len(group_numbers) > 1:
192
+ alias = '%s/%s/group[%d-%d]/' % (self.TEST_ALIAS_PREFIX, name, min(group_numbers), max(group_numbers))
193
+ else:
194
+ alias = '%s/%s/group%d/' % (self.TEST_ALIAS_PREFIX, name, min(group_numbers))
195
+ elif fallback:
196
+ alias = '%s/%s/group%d/' % (self.TEST_ALIAS_PREFIX, fallback, 1)
197
+ else:
198
+ raise Exception('cannot find test group "%s" in %s' % (name, self.CI_YML))
199
+
200
+ return alias
201
+
202
+ def load_yaml(self, python: PythonConfig, path: str) -> dict[str, t.Any]:
203
+ """Load the specified YAML file and return the contents."""
204
+ yaml_to_json_path = os.path.join(SANITY_ROOT, self.name, 'yaml_to_json.py')
205
+ return json.loads(raw_command([python.path, yaml_to_json_path], data=read_text_file(path), capture=True)[0])
206
+
207
+ def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
208
+ if args.explain:
209
+ return SanitySuccess(self.name)
210
+
211
+ if not os.path.isfile(self.CI_YML):
212
+ return SanityFailure(self.name, messages=[SanityMessage(
213
+ message='file missing',
214
+ path=self.CI_YML,
215
+ )])
216
+
217
+ results = Results(
218
+ comments=[],
219
+ labels={},
220
+ )
221
+
222
+ self.load_ci_config(python)
223
+ self.check_changes(args, results)
224
+
225
+ write_json_test_results(ResultType.BOT, 'data-sanity-ci.json', results.__dict__)
226
+
227
+ messages = []
228
+
229
+ messages += self.check_posix_targets(args)
230
+ messages += self.check_windows_targets()
231
+
232
+ if messages:
233
+ return SanityFailure(self.name, messages=messages)
234
+
235
+ return SanitySuccess(self.name)
236
+
237
+ def check_posix_targets(self, args: SanityConfig) -> list[SanityMessage]:
238
+ """Check POSIX integration test targets and return messages with any issues found."""
239
+ posix_targets = tuple(walk_posix_integration_targets())
240
+
241
+ clouds = get_cloud_platforms(args, posix_targets)
242
+ cloud_targets = ['cloud/%s/' % cloud for cloud in clouds]
243
+
244
+ all_cloud_targets = tuple(filter_targets(posix_targets, ['cloud/'], errors=False))
245
+ invalid_cloud_targets = tuple(filter_targets(all_cloud_targets, cloud_targets, include=False, errors=False))
246
+
247
+ messages = []
248
+
249
+ for target in invalid_cloud_targets:
250
+ for alias in target.aliases:
251
+ if alias.startswith('cloud/') and alias != 'cloud/':
252
+ if any(alias.startswith(cloud_target) for cloud_target in cloud_targets):
253
+ continue
254
+
255
+ messages.append(SanityMessage('invalid alias `%s`' % alias, '%s/aliases' % target.path))
256
+
257
+ messages += self.check_ci_group(
258
+ targets=tuple(filter_targets(posix_targets, ['cloud/', '%s/generic/' % self.TEST_ALIAS_PREFIX], include=False, errors=False)),
259
+ find=self.format_test_group_alias('linux').replace('linux', 'posix'),
260
+ find_incidental=['%s/posix/incidental/' % self.TEST_ALIAS_PREFIX],
261
+ )
262
+
263
+ messages += self.check_ci_group(
264
+ targets=tuple(filter_targets(posix_targets, ['%s/generic/' % self.TEST_ALIAS_PREFIX], errors=False)),
265
+ find=self.format_test_group_alias('generic'),
266
+ )
267
+
268
+ for cloud in clouds:
269
+ if cloud == 'httptester':
270
+ find = self.format_test_group_alias('linux').replace('linux', 'posix')
271
+ find_incidental = ['%s/posix/incidental/' % self.TEST_ALIAS_PREFIX]
272
+ else:
273
+ find = self.format_test_group_alias(cloud, 'generic')
274
+ find_incidental = ['%s/%s/incidental/' % (self.TEST_ALIAS_PREFIX, cloud), '%s/cloud/incidental/' % self.TEST_ALIAS_PREFIX]
275
+
276
+ messages += self.check_ci_group(
277
+ targets=tuple(filter_targets(posix_targets, ['cloud/%s/' % cloud], errors=False)),
278
+ find=find,
279
+ find_incidental=find_incidental,
280
+ )
281
+
282
+ target_type_groups = {
283
+ IntegrationTargetType.TARGET: (1, 2),
284
+ IntegrationTargetType.CONTROLLER: (3, 4, 5),
285
+ IntegrationTargetType.CONFLICT: (),
286
+ IntegrationTargetType.UNKNOWN: (),
287
+ }
288
+
289
+ for target in posix_targets:
290
+ if target.name == 'ansible-test-container':
291
+ continue # special test target which uses group 6 -- nothing else should be in that group
292
+
293
+ if f'{self.TEST_ALIAS_PREFIX}/posix/' not in target.aliases:
294
+ continue
295
+
296
+ found_groups = [alias for alias in target.aliases if re.search(f'^{self.TEST_ALIAS_PREFIX}/posix/group[0-9]+/$', alias)]
297
+ expected_groups = [f'{self.TEST_ALIAS_PREFIX}/posix/group{group}/' for group in target_type_groups[target.target_type]]
298
+ valid_groups = [group for group in found_groups if group in expected_groups]
299
+ invalid_groups = [group for group in found_groups if not any(group.startswith(expected_group) for expected_group in expected_groups)]
300
+
301
+ if not valid_groups:
302
+ messages.append(SanityMessage(f'Target of type {target.target_type.name} must be in at least one of these groups: {", ".join(expected_groups)}',
303
+ f'{target.path}/aliases'))
304
+
305
+ if invalid_groups:
306
+ messages.append(SanityMessage(f'Target of type {target.target_type.name} cannot be in these groups: {", ".join(invalid_groups)}',
307
+ f'{target.path}/aliases'))
308
+
309
+ return messages
310
+
311
+ def check_windows_targets(self) -> list[SanityMessage]:
312
+ """Check Windows integration test targets and return messages with any issues found."""
313
+ windows_targets = tuple(walk_windows_integration_targets())
314
+
315
+ messages = []
316
+
317
+ messages += self.check_ci_group(
318
+ targets=windows_targets,
319
+ find=self.format_test_group_alias('windows'),
320
+ find_incidental=['%s/windows/incidental/' % self.TEST_ALIAS_PREFIX],
321
+ )
322
+
323
+ return messages
324
+
325
+ def check_ci_group(
326
+ self,
327
+ targets: tuple[CompletionTarget, ...],
328
+ find: str,
329
+ find_incidental: t.Optional[list[str]] = None,
330
+ ) -> list[SanityMessage]:
331
+ """Check the CI groups set in the provided targets and return a list of messages with any issues found."""
332
+ all_paths = set(target.path for target in targets)
333
+ supported_paths = set(target.path for target in filter_targets(targets, [find], errors=False))
334
+ unsupported_paths = set(target.path for target in filter_targets(targets, [self.UNSUPPORTED], errors=False))
335
+
336
+ if find_incidental:
337
+ incidental_paths = set(target.path for target in filter_targets(targets, find_incidental, errors=False))
338
+ else:
339
+ incidental_paths = set()
340
+
341
+ unassigned_paths = all_paths - supported_paths - unsupported_paths - incidental_paths
342
+ conflicting_paths = supported_paths & unsupported_paths
343
+
344
+ unassigned_message = 'missing alias `%s` or `%s`' % (find.strip('/'), self.UNSUPPORTED.strip('/'))
345
+ conflicting_message = 'conflicting alias `%s` and `%s`' % (find.strip('/'), self.UNSUPPORTED.strip('/'))
346
+
347
+ messages = []
348
+
349
+ for path in unassigned_paths:
350
+ if path == 'test/integration/targets/ansible-test-container':
351
+ continue # special test target which uses group 6 -- nothing else should be in that group
352
+
353
+ messages.append(SanityMessage(unassigned_message, '%s/aliases' % path))
354
+
355
+ for path in conflicting_paths:
356
+ messages.append(SanityMessage(conflicting_message, '%s/aliases' % path))
357
+
358
+ return messages
359
+
360
+ def check_changes(self, args: SanityConfig, results: Results) -> None:
361
+ """Check changes and store results in the provided result dictionary."""
362
+ integration_targets = list(walk_integration_targets())
363
+ module_targets = list(walk_module_targets())
364
+
365
+ integration_targets_by_name = dict((target.name, target) for target in integration_targets)
366
+ module_names_by_path = dict((target.path, target.module) for target in module_targets)
367
+
368
+ disabled_targets = []
369
+ unstable_targets = []
370
+ unsupported_targets = []
371
+
372
+ for command in [command for command in args.metadata.change_description.focused_command_targets if 'integration' in command]:
373
+ for target in args.metadata.change_description.focused_command_targets[command]:
374
+ if self.DISABLED in integration_targets_by_name[target].aliases:
375
+ disabled_targets.append(target)
376
+ elif self.UNSTABLE in integration_targets_by_name[target].aliases:
377
+ unstable_targets.append(target)
378
+ elif self.UNSUPPORTED in integration_targets_by_name[target].aliases:
379
+ unsupported_targets.append(target)
380
+
381
+ untested_modules = []
382
+
383
+ for path in args.metadata.change_description.no_integration_paths:
384
+ module = module_names_by_path.get(path)
385
+
386
+ if module:
387
+ untested_modules.append(module)
388
+
389
+ comments = [
390
+ self.format_comment(self.TEMPLATE_DISABLED, disabled_targets),
391
+ self.format_comment(self.TEMPLATE_UNSTABLE, unstable_targets),
392
+ self.format_comment(self.TEMPLATE_UNSUPPORTED, unsupported_targets),
393
+ self.format_comment(self.TEMPLATE_UNTESTED, untested_modules),
394
+ ]
395
+
396
+ comments = [comment for comment in comments if comment]
397
+
398
+ labels = dict(
399
+ needs_tests=bool(untested_modules),
400
+ disabled_tests=bool(disabled_targets),
401
+ unstable_tests=bool(unstable_targets),
402
+ unsupported_tests=bool(unsupported_targets),
403
+ )
404
+
405
+ results.comments += comments
406
+ results.labels.update(labels)
407
+
408
+ def format_comment(self, template: str, targets: list[str]) -> t.Optional[str]:
409
+ """Format and return a comment based on the given template and targets, or None if there are no targets."""
410
+ if not targets:
411
+ return None
412
+
413
+ tests = '\n'.join('- %s' % target for target in targets)
414
+
415
+ data = dict(
416
+ explain_url=self.EXPLAIN_URL,
417
+ tests=tests,
418
+ )
419
+
420
+ message = textwrap.dedent(template).strip().format(**data)
421
+
422
+ return message
423
+
424
+
425
+ @dataclasses.dataclass
426
+ class Results:
427
+ """Check results."""
428
+
429
+ comments: list[str]
430
+ labels: dict[str, bool]
@@ -20,7 +20,7 @@ class AnsibleLayout(LayoutProvider):
20
20
  @staticmethod
21
21
  def is_content_root(path: str) -> bool:
22
22
  """Return True if the given path is a content root for this provider."""
23
- return os.path.exists(os.path.join(path, 'setup.py')) and os.path.exists(os.path.join(path, 'bin/ansible-test'))
23
+ return os.path.isfile(os.path.join(path, 'pyproject.toml')) and os.path.isdir(os.path.join(path, 'test/lib/ansible_test'))
24
24
 
25
25
  def create(self, root: str, paths: list[str]) -> ContentLayout:
26
26
  """Create a Layout using the given root and paths."""
@@ -48,9 +48,6 @@ class UnversionedSource(SourceProvider):
48
48
  'tests': (
49
49
  'output',
50
50
  ),
51
- 'docs/docsite': (
52
- '_build',
53
- ),
54
51
  }
55
52
 
56
53
  kill_sub_file = {
@@ -251,6 +251,13 @@ def collect_requirements(
251
251
  # installed packages may have run-time dependencies on setuptools
252
252
  uninstall_packages.remove('setuptools')
253
253
 
254
+ # hack to allow the package-data sanity test to keep wheel in the venv
255
+ install_commands = [command for command in commands if isinstance(command, PipInstall)]
256
+ install_wheel = any(install.has_package('wheel') for install in install_commands)
257
+
258
+ if install_wheel:
259
+ uninstall_packages.remove('wheel')
260
+
254
261
  commands.extend(collect_uninstall(packages=uninstall_packages))
255
262
 
256
263
  return commands