1"""Classify changes in Ansible code."""
2from __future__ import (absolute_import, division, print_function)
3__metaclass__ = type
4
5import collections
6import os
7import re
8import time
9
10from . import types as t
11
12from .target import (
13    walk_module_targets,
14    walk_integration_targets,
15    walk_units_targets,
16    walk_compile_targets,
17    walk_sanity_targets,
18    load_integration_prefixes,
19    analyze_integration_target_dependencies,
20)
21
22from .util import (
23    display,
24    is_subdir,
25)
26
27from .import_analysis import (
28    get_python_module_utils_imports,
29    get_python_module_utils_name,
30)
31
32from .csharp_import_analysis import (
33    get_csharp_module_utils_imports,
34    get_csharp_module_utils_name,
35)
36
37from .powershell_import_analysis import (
38    get_powershell_module_utils_imports,
39    get_powershell_module_utils_name,
40)
41
42from .config import (
43    TestConfig,
44    IntegrationConfig,
45)
46
47from .metadata import (
48    ChangeDescription,
49)
50
51from .data import (
52    data_context,
53)
54
55FOCUSED_TARGET = '__focused__'
56
57
58def categorize_changes(args, paths, verbose_command=None):
59    """
60    :type args: TestConfig
61    :type paths: list[str]
62    :type verbose_command: str
63    :rtype: ChangeDescription
64    """
65    mapper = PathMapper(args)
66
67    commands = {
68        'sanity': set(),
69        'units': set(),
70        'integration': set(),
71        'windows-integration': set(),
72        'network-integration': set(),
73    }
74
75    focused_commands = collections.defaultdict(set)
76
77    deleted_paths = set()
78    original_paths = set()
79    additional_paths = set()
80    no_integration_paths = set()
81
82    for path in paths:
83        if not os.path.exists(path):
84            deleted_paths.add(path)
85            continue
86
87        original_paths.add(path)
88
89        dependent_paths = mapper.get_dependent_paths(path)
90
91        if not dependent_paths:
92            continue
93
94        display.info('Expanded "%s" to %d dependent file(s):' % (path, len(dependent_paths)), verbosity=2)
95
96        for dependent_path in dependent_paths:
97            display.info(dependent_path, verbosity=2)
98            additional_paths.add(dependent_path)
99
100    additional_paths -= set(paths)  # don't count changed paths as additional paths
101
102    if additional_paths:
103        display.info('Expanded %d changed file(s) into %d additional dependent file(s).' % (len(paths), len(additional_paths)))
104        paths = sorted(set(paths) | additional_paths)
105
106    display.info('Mapping %d changed file(s) to tests.' % len(paths))
107
108    none_count = 0
109
110    for path in paths:
111        tests = mapper.classify(path)
112
113        if tests is None:
114            focused_target = False
115
116            display.info('%s -> all' % path, verbosity=1)
117            tests = all_tests(args)  # not categorized, run all tests
118            display.warning('Path not categorized: %s' % path)
119        else:
120            focused_target = tests.pop(FOCUSED_TARGET, False) and path in original_paths
121
122            tests = dict((key, value) for key, value in tests.items() if value)
123
124            if focused_target and not any('integration' in command for command in tests):
125                no_integration_paths.add(path)  # path triggers no integration tests
126
127            if verbose_command:
128                result = '%s: %s' % (verbose_command, tests.get(verbose_command) or 'none')
129
130                # identify targeted integration tests (those which only target a single integration command)
131                if 'integration' in verbose_command and tests.get(verbose_command):
132                    if not any('integration' in command for command in tests if command != verbose_command):
133                        if focused_target:
134                            result += ' (focused)'
135
136                        result += ' (targeted)'
137            else:
138                result = '%s' % tests
139
140            if not tests.get(verbose_command):
141                # minimize excessive output from potentially thousands of files which do not trigger tests
142                none_count += 1
143                verbosity = 2
144            else:
145                verbosity = 1
146
147            if args.verbosity >= verbosity:
148                display.info('%s -> %s' % (path, result), verbosity=1)
149
150        for command, target in tests.items():
151            commands[command].add(target)
152
153            if focused_target:
154                focused_commands[command].add(target)
155
156    if none_count > 0 and args.verbosity < 2:
157        display.notice('Omitted %d file(s) that triggered no tests.' % none_count)
158
159    for command in commands:
160        commands[command].discard('none')
161
162        if any(target == 'all' for target in commands[command]):
163            commands[command] = set(['all'])
164
165    commands = dict((c, sorted(commands[c])) for c in commands if commands[c])
166    focused_commands = dict((c, sorted(focused_commands[c])) for c in focused_commands)
167
168    for command in commands:
169        if commands[command] == ['all']:
170            commands[command] = []  # changes require testing all targets, do not filter targets
171
172    changes = ChangeDescription()
173    changes.command = verbose_command
174    changes.changed_paths = sorted(original_paths)
175    changes.deleted_paths = sorted(deleted_paths)
176    changes.regular_command_targets = commands
177    changes.focused_command_targets = focused_commands
178    changes.no_integration_paths = sorted(no_integration_paths)
179
180    return changes
181
182
183class PathMapper:
184    """Map file paths to test commands and targets."""
185    def __init__(self, args):
186        """
187        :type args: TestConfig
188        """
189        self.args = args
190        self.integration_all_target = get_integration_all_target(self.args)
191
192        self.integration_targets = list(walk_integration_targets())
193        self.module_targets = list(walk_module_targets())
194        self.compile_targets = list(walk_compile_targets())
195        self.units_targets = list(walk_units_targets())
196        self.sanity_targets = list(walk_sanity_targets())
197        self.powershell_targets = [target for target in self.sanity_targets if os.path.splitext(target.path)[1] in ('.ps1', '.psm1')]
198        self.csharp_targets = [target for target in self.sanity_targets if os.path.splitext(target.path)[1] == '.cs']
199
200        self.units_modules = set(target.module for target in self.units_targets if target.module)
201        self.units_paths = set(a for target in self.units_targets for a in target.aliases)
202        self.sanity_paths = set(target.path for target in self.sanity_targets)
203
204        self.module_names_by_path = dict((target.path, target.module) for target in self.module_targets)
205        self.integration_targets_by_name = dict((target.name, target) for target in self.integration_targets)
206        self.integration_targets_by_alias = dict((a, target) for target in self.integration_targets for a in target.aliases)
207
208        self.posix_integration_by_module = dict((m, target.name) for target in self.integration_targets
209                                                if 'posix/' in target.aliases for m in target.modules)
210        self.windows_integration_by_module = dict((m, target.name) for target in self.integration_targets
211                                                  if 'windows/' in target.aliases for m in target.modules)
212        self.network_integration_by_module = dict((m, target.name) for target in self.integration_targets
213                                                  if 'network/' in target.aliases for m in target.modules)
214
215        self.prefixes = load_integration_prefixes()
216        self.integration_dependencies = analyze_integration_target_dependencies(self.integration_targets)
217
218        self.python_module_utils_imports = {}  # populated on first use to reduce overhead when not needed
219        self.powershell_module_utils_imports = {}  # populated on first use to reduce overhead when not needed
220        self.csharp_module_utils_imports = {}  # populated on first use to reduce overhead when not needed
221
222        self.paths_to_dependent_targets = {}
223
224        for target in self.integration_targets:
225            for path in target.needs_file:
226                if path not in self.paths_to_dependent_targets:
227                    self.paths_to_dependent_targets[path] = set()
228
229                self.paths_to_dependent_targets[path].add(target)
230
231    def get_dependent_paths(self, path):
232        """
233        :type path: str
234        :rtype: list[str]
235        """
236        unprocessed_paths = set(self.get_dependent_paths_non_recursive(path))
237        paths = set()
238
239        while unprocessed_paths:
240            queued_paths = list(unprocessed_paths)
241            paths |= unprocessed_paths
242            unprocessed_paths = set()
243
244            for queued_path in queued_paths:
245                new_paths = self.get_dependent_paths_non_recursive(queued_path)
246
247                for new_path in new_paths:
248                    if new_path not in paths:
249                        unprocessed_paths.add(new_path)
250
251        return sorted(paths)
252
253    def get_dependent_paths_non_recursive(self, path):
254        """
255        :type path: str
256        :rtype: list[str]
257        """
258        paths = self.get_dependent_paths_internal(path)
259        paths += [target.path + '/' for target in self.paths_to_dependent_targets.get(path, set())]
260        paths = sorted(set(paths))
261
262        return paths
263
264    def get_dependent_paths_internal(self, path):
265        """
266        :type path: str
267        :rtype: list[str]
268        """
269        ext = os.path.splitext(os.path.split(path)[1])[1]
270
271        if is_subdir(path, data_context().content.module_utils_path):
272            if ext == '.py':
273                return self.get_python_module_utils_usage(path)
274
275            if ext == '.psm1':
276                return self.get_powershell_module_utils_usage(path)
277
278            if ext == '.cs':
279                return self.get_csharp_module_utils_usage(path)
280
281        if is_subdir(path, data_context().content.integration_targets_path):
282            return self.get_integration_target_usage(path)
283
284        return []
285
286    def get_python_module_utils_usage(self, path):
287        """
288        :type path: str
289        :rtype: list[str]
290        """
291        if not self.python_module_utils_imports:
292            display.info('Analyzing python module_utils imports...')
293            before = time.time()
294            self.python_module_utils_imports = get_python_module_utils_imports(self.compile_targets)
295            after = time.time()
296            display.info('Processed %d python module_utils in %d second(s).' % (len(self.python_module_utils_imports), after - before))
297
298        name = get_python_module_utils_name(path)
299
300        return sorted(self.python_module_utils_imports[name])
301
302    def get_powershell_module_utils_usage(self, path):
303        """
304        :type path: str
305        :rtype: list[str]
306        """
307        if not self.powershell_module_utils_imports:
308            display.info('Analyzing powershell module_utils imports...')
309            before = time.time()
310            self.powershell_module_utils_imports = get_powershell_module_utils_imports(self.powershell_targets)
311            after = time.time()
312            display.info('Processed %d powershell module_utils in %d second(s).' % (len(self.powershell_module_utils_imports), after - before))
313
314        name = get_powershell_module_utils_name(path)
315
316        return sorted(self.powershell_module_utils_imports[name])
317
318    def get_csharp_module_utils_usage(self, path):
319        """
320        :type path: str
321        :rtype: list[str]
322        """
323        if not self.csharp_module_utils_imports:
324            display.info('Analyzing C# module_utils imports...')
325            before = time.time()
326            self.csharp_module_utils_imports = get_csharp_module_utils_imports(self.powershell_targets, self.csharp_targets)
327            after = time.time()
328            display.info('Processed %d C# module_utils in %d second(s).' % (len(self.csharp_module_utils_imports), after - before))
329
330        name = get_csharp_module_utils_name(path)
331
332        return sorted(self.csharp_module_utils_imports[name])
333
334    def get_integration_target_usage(self, path):
335        """
336        :type path: str
337        :rtype: list[str]
338        """
339        target_name = path.split('/')[3]
340        dependents = [os.path.join(data_context().content.integration_targets_path, target) + os.path.sep
341                      for target in sorted(self.integration_dependencies.get(target_name, set()))]
342
343        return dependents
344
345    def classify(self, path):
346        """
347        :type path: str
348        :rtype: dict[str, str] | None
349        """
350        result = self._classify(path)
351
352        # run all tests when no result given
353        if result is None:
354            return None
355
356        # run sanity on path unless result specified otherwise
357        if path in self.sanity_paths and 'sanity' not in result:
358            result['sanity'] = path
359
360        return result
361
362    def _classify(self, path):  # type: (str) -> t.Optional[t.Dict[str, str]]
363        """Return the classification for the given path."""
364        if data_context().content.is_ansible:
365            return self._classify_ansible(path)
366
367        if data_context().content.collection:
368            return self._classify_collection(path)
369
370        return None
371
372    def _classify_common(self, path):  # type: (str) -> t.Optional[t.Dict[str, str]]
373        """Return the classification for the given path using rules common to all layouts."""
374        dirname = os.path.dirname(path)
375        filename = os.path.basename(path)
376        name, ext = os.path.splitext(filename)
377
378        minimal = {}
379
380        if os.path.sep not in path:
381            if filename in (
382                    'azure-pipelines.yml',
383                    'shippable.yml',
384            ):
385                return all_tests(self.args)  # test infrastructure, run all tests
386
387        if is_subdir(path, '.azure-pipelines'):
388            return all_tests(self.args)  # test infrastructure, run all tests
389
390        if is_subdir(path, '.github'):
391            return minimal
392
393        if is_subdir(path, data_context().content.integration_targets_path):
394            if not os.path.exists(path):
395                return minimal
396
397            target = self.integration_targets_by_name.get(path.split('/')[3])
398
399            if not target:
400                display.warning('Unexpected non-target found: %s' % path)
401                return minimal
402
403            if 'hidden/' in target.aliases:
404                return minimal  # already expanded using get_dependent_paths
405
406            return {
407                'integration': target.name if 'posix/' in target.aliases else None,
408                'windows-integration': target.name if 'windows/' in target.aliases else None,
409                'network-integration': target.name if 'network/' in target.aliases else None,
410                FOCUSED_TARGET: True,
411            }
412
413        if is_subdir(path, data_context().content.integration_path):
414            if dirname == data_context().content.integration_path:
415                for command in (
416                        'integration',
417                        'windows-integration',
418                        'network-integration',
419                ):
420                    if name == command and ext == '.cfg':
421                        return {
422                            command: self.integration_all_target,
423                        }
424
425                    if name == command + '.requirements' and ext == '.txt':
426                        return {
427                            command: self.integration_all_target,
428                        }
429
430            return {
431                'integration': self.integration_all_target,
432                'windows-integration': self.integration_all_target,
433                'network-integration': self.integration_all_target,
434            }
435
436        if is_subdir(path, data_context().content.sanity_path):
437            return {
438                'sanity': 'all',  # test infrastructure, run all sanity checks
439            }
440
441        if is_subdir(path, data_context().content.unit_path):
442            if path in self.units_paths:
443                return {
444                    'units': path,
445                }
446
447            # changes to files which are not unit tests should trigger tests from the nearest parent directory
448
449            test_path = os.path.dirname(path)
450
451            while test_path:
452                if test_path + '/' in self.units_paths:
453                    return {
454                        'units': test_path + '/',
455                    }
456
457                test_path = os.path.dirname(test_path)
458
459        if is_subdir(path, data_context().content.module_path):
460            module_name = self.module_names_by_path.get(path)
461
462            if module_name:
463                return {
464                    'units': module_name if module_name in self.units_modules else None,
465                    'integration': self.posix_integration_by_module.get(module_name) if ext == '.py' else None,
466                    'windows-integration': self.windows_integration_by_module.get(module_name) if ext in ['.cs', '.ps1'] else None,
467                    'network-integration': self.network_integration_by_module.get(module_name),
468                    FOCUSED_TARGET: True,
469                }
470
471            return minimal
472
473        if is_subdir(path, data_context().content.module_utils_path):
474            if ext == '.cs':
475                return minimal  # already expanded using get_dependent_paths
476
477            if ext == '.psm1':
478                return minimal  # already expanded using get_dependent_paths
479
480            if ext == '.py':
481                return minimal  # already expanded using get_dependent_paths
482
483        if is_subdir(path, data_context().content.plugin_paths['action']):
484            if ext == '.py':
485                if name.startswith('net_'):
486                    network_target = 'network/.*_%s' % name[4:]
487
488                    if any(re.search(r'^%s$' % network_target, alias) for alias in self.integration_targets_by_alias):
489                        return {
490                            'network-integration': network_target,
491                            'units': 'all',
492                        }
493
494                    return {
495                        'network-integration': self.integration_all_target,
496                        'units': 'all',
497                    }
498
499                if self.prefixes.get(name) == 'network':
500                    network_platform = name
501                elif name.endswith('_config') and self.prefixes.get(name[:-7]) == 'network':
502                    network_platform = name[:-7]
503                elif name.endswith('_template') and self.prefixes.get(name[:-9]) == 'network':
504                    network_platform = name[:-9]
505                else:
506                    network_platform = None
507
508                if network_platform:
509                    network_target = 'network/%s/' % network_platform
510
511                    if network_target in self.integration_targets_by_alias:
512                        return {
513                            'network-integration': network_target,
514                            'units': 'all',
515                        }
516
517                    display.warning('Integration tests for "%s" not found.' % network_target, unique=True)
518
519                    return {
520                        'units': 'all',
521                    }
522
523        if is_subdir(path, data_context().content.plugin_paths['connection']):
524            units_dir = os.path.join(data_context().content.unit_path, 'plugins', 'connection')
525            if name == '__init__':
526                return {
527                    'integration': self.integration_all_target,
528                    'windows-integration': self.integration_all_target,
529                    'network-integration': self.integration_all_target,
530                    'units': os.path.join(units_dir, ''),
531                }
532
533            units_path = os.path.join(units_dir, 'test_%s.py' % name)
534
535            if units_path not in self.units_paths:
536                units_path = None
537
538            integration_name = 'connection_%s' % name
539
540            if integration_name not in self.integration_targets_by_name:
541                integration_name = None
542
543            windows_integration_name = 'connection_windows_%s' % name
544
545            if windows_integration_name not in self.integration_targets_by_name:
546                windows_integration_name = None
547
548            # entire integration test commands depend on these connection plugins
549
550            if name in ['winrm', 'psrp']:
551                return {
552                    'windows-integration': self.integration_all_target,
553                    'units': units_path,
554                }
555
556            if name == 'local':
557                return {
558                    'integration': self.integration_all_target,
559                    'network-integration': self.integration_all_target,
560                    'units': units_path,
561                }
562
563            if name == 'network_cli':
564                return {
565                    'network-integration': self.integration_all_target,
566                    'units': units_path,
567                }
568
569            if name == 'paramiko_ssh':
570                return {
571                    'integration': integration_name,
572                    'network-integration': self.integration_all_target,
573                    'units': units_path,
574                }
575
576            # other connection plugins have isolated integration and unit tests
577
578            return {
579                'integration': integration_name,
580                'windows-integration': windows_integration_name,
581                'units': units_path,
582            }
583
584        if is_subdir(path, data_context().content.plugin_paths['doc_fragments']):
585            return {
586                'sanity': 'all',
587            }
588
589        if is_subdir(path, data_context().content.plugin_paths['inventory']):
590            if name == '__init__':
591                return all_tests(self.args)  # broad impact, run all tests
592
593            # These inventory plugins are enabled by default (see INVENTORY_ENABLED).
594            # Without dedicated integration tests for these we must rely on the incidental coverage from other tests.
595            test_all = [
596                'host_list',
597                'script',
598                'yaml',
599                'ini',
600                'auto',
601            ]
602
603            if name in test_all:
604                posix_integration_fallback = get_integration_all_target(self.args)
605            else:
606                posix_integration_fallback = None
607
608            target = self.integration_targets_by_name.get('inventory_%s' % name)
609            units_dir = os.path.join(data_context().content.unit_path, 'plugins', 'inventory')
610            units_path = os.path.join(units_dir, 'test_%s.py' % name)
611
612            if units_path not in self.units_paths:
613                units_path = None
614
615            return {
616                'integration': target.name if target and 'posix/' in target.aliases else posix_integration_fallback,
617                'windows-integration': target.name if target and 'windows/' in target.aliases else None,
618                'network-integration': target.name if target and 'network/' in target.aliases else None,
619                'units': units_path,
620                FOCUSED_TARGET: target is not None,
621            }
622
623        if (is_subdir(path, data_context().content.plugin_paths['terminal']) or
624                is_subdir(path, data_context().content.plugin_paths['cliconf']) or
625                is_subdir(path, data_context().content.plugin_paths['netconf'])):
626            if ext == '.py':
627                if name in self.prefixes and self.prefixes[name] == 'network':
628                    network_target = 'network/%s/' % name
629
630                    if network_target in self.integration_targets_by_alias:
631                        return {
632                            'network-integration': network_target,
633                            'units': 'all',
634                        }
635
636                    display.warning('Integration tests for "%s" not found.' % network_target, unique=True)
637
638                    return {
639                        'units': 'all',
640                    }
641
642                return {
643                    'network-integration': self.integration_all_target,
644                    'units': 'all',
645                }
646
647        return None
648
649    def _classify_collection(self, path):  # type: (str) -> t.Optional[t.Dict[str, str]]
650        """Return the classification for the given path using rules specific to collections."""
651        result = self._classify_common(path)
652
653        if result is not None:
654            return result
655
656        filename = os.path.basename(path)
657        dummy, ext = os.path.splitext(filename)
658
659        minimal = {}
660
661        if path.startswith('changelogs/'):
662            return minimal
663
664        if path.startswith('docs/'):
665            return minimal
666
667        if '/' not in path:
668            if path in (
669                    '.gitignore',
670                    'COPYING',
671                    'LICENSE',
672                    'Makefile',
673            ):
674                return minimal
675
676            if ext in (
677                    '.in',
678                    '.md',
679                    '.rst',
680                    '.toml',
681                    '.txt',
682            ):
683                return minimal
684
685        return None
686
687    def _classify_ansible(self, path):  # type: (str) -> t.Optional[t.Dict[str, str]]
688        """Return the classification for the given path using rules specific to Ansible."""
689        if path.startswith('test/units/compat/'):
690            return {
691                'units': 'test/units/',
692            }
693
694        result = self._classify_common(path)
695
696        if result is not None:
697            return result
698
699        dirname = os.path.dirname(path)
700        filename = os.path.basename(path)
701        name, ext = os.path.splitext(filename)
702
703        minimal = {}
704
705        if path.startswith('bin/'):
706            return all_tests(self.args)  # broad impact, run all tests
707
708        if path.startswith('changelogs/'):
709            return minimal
710
711        if path.startswith('contrib/'):
712            return {
713                'units': 'test/units/contrib/'
714            }
715
716        if path.startswith('docs/'):
717            return minimal
718
719        if path.startswith('examples/'):
720            if path == 'examples/scripts/ConfigureRemotingForAnsible.ps1':
721                return {
722                    'windows-integration': 'connection_winrm',
723                }
724
725            return minimal
726
727        if path.startswith('hacking/'):
728            return minimal
729
730        if path.startswith('lib/ansible/executor/powershell/'):
731            units_path = 'test/units/executor/powershell/'
732
733            if units_path not in self.units_paths:
734                units_path = None
735
736            return {
737                'windows-integration': self.integration_all_target,
738                'units': units_path,
739            }
740
741        if path.startswith('lib/ansible/'):
742            return all_tests(self.args)  # broad impact, run all tests
743
744        if path.startswith('licenses/'):
745            return minimal
746
747        if path.startswith('packaging/'):
748            if path.startswith('packaging/requirements/'):
749                if name.startswith('requirements-') and ext == '.txt':
750                    component = name.split('-', 1)[1]
751
752                    candidates = (
753                        'cloud/%s/' % component,
754                    )
755
756                    for candidate in candidates:
757                        if candidate in self.integration_targets_by_alias:
758                            return {
759                                'integration': candidate,
760                            }
761
762                return all_tests(self.args)  # broad impact, run all tests
763
764            return minimal
765
766        if path.startswith('test/ansible_test/'):
767            return minimal  # these tests are not invoked from ansible-test
768
769        if path.startswith('test/legacy/'):
770            return minimal
771
772        if path.startswith('test/lib/ansible_test/config/'):
773            if name.startswith('cloud-config-'):
774                cloud_target = 'cloud/%s/' % name.split('-')[2].split('.')[0]
775
776                if cloud_target in self.integration_targets_by_alias:
777                    return {
778                        'integration': cloud_target,
779                    }
780
781        if path.startswith('test/lib/ansible_test/_data/completion/'):
782            if path == 'test/lib/ansible_test/_data/completion/docker.txt':
783                return all_tests(self.args, force=True)  # force all tests due to risk of breaking changes in new test environment
784
785        if path.startswith('test/lib/ansible_test/_internal/cloud/'):
786            cloud_target = 'cloud/%s/' % name
787
788            if cloud_target in self.integration_targets_by_alias:
789                return {
790                    'integration': cloud_target,
791                }
792
793            return all_tests(self.args)  # test infrastructure, run all tests
794
795        if path.startswith('test/lib/ansible_test/_internal/sanity/'):
796            return {
797                'sanity': 'all',  # test infrastructure, run all sanity checks
798            }
799
800        if path.startswith('test/lib/ansible_test/_data/sanity/'):
801            return {
802                'sanity': 'all',  # test infrastructure, run all sanity checks
803            }
804
805        if path.startswith('test/lib/ansible_test/_internal/units/'):
806            return {
807                'units': 'all',  # test infrastructure, run all unit tests
808            }
809
810        if path.startswith('test/lib/ansible_test/_data/units/'):
811            return {
812                'units': 'all',  # test infrastructure, run all unit tests
813            }
814
815        if path.startswith('test/lib/ansible_test/_data/pytest/'):
816            return {
817                'units': 'all',  # test infrastructure, run all unit tests
818            }
819
820        if path.startswith('test/lib/ansible_test/_data/requirements/'):
821            if name in (
822                    'integration',
823                    'network-integration',
824                    'windows-integration',
825            ):
826                return {
827                    name: self.integration_all_target,
828                }
829
830            if name in (
831                    'sanity',
832                    'units',
833            ):
834                return {
835                    name: 'all',
836                }
837
838            if name.startswith('integration.cloud.'):
839                cloud_target = 'cloud/%s/' % name.split('.')[2]
840
841                if cloud_target in self.integration_targets_by_alias:
842                    return {
843                        'integration': cloud_target,
844                    }
845
846        if path.startswith('test/lib/'):
847            return all_tests(self.args)  # test infrastructure, run all tests
848
849        if path.startswith('test/utils/shippable/tools/'):
850            return minimal  # not used by tests
851
852        if path.startswith('test/utils/shippable/'):
853            if dirname == 'test/utils/shippable':
854                test_map = {
855                    'cloud.sh': 'integration:cloud/',
856                    'freebsd.sh': 'integration:all',
857                    'linux.sh': 'integration:all',
858                    'network.sh': 'network-integration:all',
859                    'osx.sh': 'integration:all',
860                    'rhel.sh': 'integration:all',
861                    'sanity.sh': 'sanity:all',
862                    'units.sh': 'units:all',
863                    'windows.sh': 'windows-integration:all',
864                }
865
866                test_match = test_map.get(filename)
867
868                if test_match:
869                    test_command, test_target = test_match.split(':')
870
871                    return {
872                        test_command: test_target,
873                    }
874
875                cloud_target = 'cloud/%s/' % name
876
877                if cloud_target in self.integration_targets_by_alias:
878                    return {
879                        'integration': cloud_target,
880                    }
881
882            return all_tests(self.args)  # test infrastructure, run all tests
883
884        if path.startswith('test/utils/'):
885            return minimal
886
887        if '/' not in path:
888            if path in (
889                    '.gitattributes',
890                    '.gitignore',
891                    '.mailmap',
892                    'COPYING',
893                    'Makefile',
894            ):
895                return minimal
896
897            if path in (
898                    'setup.py',
899            ):
900                return all_tests(self.args)  # broad impact, run all tests
901
902            if ext in (
903                    '.in',
904                    '.md',
905                    '.rst',
906                    '.toml',
907                    '.txt',
908            ):
909                return minimal
910
911        return None  # unknown, will result in fall-back to run all tests
912
913
914def all_tests(args, force=False):
915    """
916    :type args: TestConfig
917    :type force: bool
918    :rtype: dict[str, str]
919    """
920    if force:
921        integration_all_target = 'all'
922    else:
923        integration_all_target = get_integration_all_target(args)
924
925    return {
926        'sanity': 'all',
927        'units': 'all',
928        'integration': integration_all_target,
929        'windows-integration': integration_all_target,
930        'network-integration': integration_all_target,
931    }
932
933
934def get_integration_all_target(args):
935    """
936    :type args: TestConfig
937    :rtype: str
938    """
939    if isinstance(args, IntegrationConfig):
940        return args.changed_all_target
941
942    return 'all'
943