summaryrefslogtreecommitdiff
path: root/test/lib/ansible_test/_internal/commands/units/__init__.py
blob: f666d418b7d789eb1ccd3d566b95037fc97bf14f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
"""Execute unit tests using pytest."""
from __future__ import annotations

import os
import sys
import typing as t

from ...constants import (
    CONTROLLER_MIN_PYTHON_VERSION,
    CONTROLLER_PYTHON_VERSIONS,
    REMOTE_ONLY_PYTHON_VERSIONS,
    SUPPORTED_PYTHON_VERSIONS,
)

from ...io import (
    write_text_file,
    make_dirs,
)

from ...util import (
    ANSIBLE_TEST_DATA_ROOT,
    display,
    is_subdir,
    str_to_version,
    SubprocessError,
    ANSIBLE_LIB_ROOT,
    ANSIBLE_TEST_TARGET_ROOT,
)

from ...util_common import (
    ResultType,
    handle_layout_messages,
    create_temp_dir,
)

from ...ansible_util import (
    ansible_environment,
    get_ansible_python_path,
)

from ...target import (
    walk_internal_targets,
    walk_units_targets,
)

from ...config import (
    UnitsConfig,
)

from ...coverage_util import (
    cover_python,
)

from ...data import (
    data_context,
)

from ...executor import (
    AllTargetsSkipped,
    Delegate,
    get_changes_filter,
)

from ...python_requirements import (
    install_requirements,
)

from ...content_config import (
    get_content_config,
)

from ...host_configs import (
    PosixConfig,
)

from ...provisioning import (
    prepare_profiles,
)

from ...pypi_proxy import (
    configure_pypi_proxy,
)

from ...host_profiles import (
    PosixProfile,
)


class TestContext:
    """Contexts that unit tests run in based on the type of content."""
    controller = 'controller'
    modules = 'modules'
    module_utils = 'module_utils'


def command_units(args: UnitsConfig) -> None:
    """Run unit tests."""
    handle_layout_messages(data_context().content.unit_messages)

    changes = get_changes_filter(args)
    require = args.require + changes
    include = walk_internal_targets(walk_units_targets(), args.include, args.exclude, require)

    paths = [target.path for target in include]

    content_config = get_content_config(args)
    supported_remote_python_versions = content_config.modules.python_versions

    if content_config.modules.controller_only:
        # controller-only collections run modules/module_utils unit tests as controller-only tests
        module_paths = []
        module_utils_paths = []
    else:
        # normal collections run modules/module_utils unit tests isolated from controller code due to differences in python version requirements
        module_paths = [path for path in paths if is_subdir(path, data_context().content.unit_module_path)]
        module_utils_paths = [path for path in paths if is_subdir(path, data_context().content.unit_module_utils_path)]

    controller_paths = sorted(path for path in set(paths) - set(module_paths) - set(module_utils_paths))

    remote_paths = module_paths or module_utils_paths

    test_context_paths = {
        TestContext.modules: module_paths,
        TestContext.module_utils: module_utils_paths,
        TestContext.controller: controller_paths,
    }

    if not paths:
        raise AllTargetsSkipped()

    targets = t.cast(list[PosixConfig], args.targets)
    target_versions: dict[str, PosixConfig] = {target.python.version: target for target in targets}
    skipped_versions = args.host_settings.skipped_python_versions
    warn_versions = []

    # requested python versions that are remote-only and not supported by this collection
    test_versions = [version for version in target_versions if version in REMOTE_ONLY_PYTHON_VERSIONS and version not in supported_remote_python_versions]

    if test_versions:
        for version in test_versions:
            display.warning(f'Skipping unit tests on Python {version} because it is not supported by this collection.'
                            f' Supported Python versions are: {", ".join(content_config.python_versions)}')

        warn_versions.extend(test_versions)

        if warn_versions == list(target_versions):
            raise AllTargetsSkipped()

    if not remote_paths:
        # all selected unit tests are controller tests

        # requested python versions that are remote-only
        test_versions = [version for version in target_versions if version in REMOTE_ONLY_PYTHON_VERSIONS and version not in warn_versions]

        if test_versions:
            for version in test_versions:
                display.warning(f'Skipping unit tests on Python {version} because it is only supported by module/module_utils unit tests.'
                                ' No module/module_utils unit tests were selected.')

            warn_versions.extend(test_versions)

            if warn_versions == list(target_versions):
                raise AllTargetsSkipped()

    if not controller_paths:
        # all selected unit tests are remote tests

        # requested python versions that are not supported by remote tests for this collection
        test_versions = [version for version in target_versions if version not in supported_remote_python_versions and version not in warn_versions]

        if test_versions:
            for version in test_versions:
                display.warning(f'Skipping unit tests on Python {version} because it is not supported by module/module_utils unit tests of this collection.'
                                f' Supported Python versions are: {", ".join(supported_remote_python_versions)}')

            warn_versions.extend(test_versions)

            if warn_versions == list(target_versions):
                raise AllTargetsSkipped()

    host_state = prepare_profiles(args, targets_use_pypi=True)  # units

    if args.delegate:
        raise Delegate(host_state=host_state, require=changes, exclude=args.exclude)

    test_sets = []

    if args.requirements_mode != 'skip':
        configure_pypi_proxy(args, host_state.controller_profile)  # units

    for version in SUPPORTED_PYTHON_VERSIONS:
        if version not in target_versions and version not in skipped_versions:
            continue

        test_candidates = []

        for test_context, paths in test_context_paths.items():
            if test_context == TestContext.controller:
                if version not in CONTROLLER_PYTHON_VERSIONS:
                    continue
            else:
                if version not in supported_remote_python_versions:
                    continue

            if not paths:
                continue

            env = ansible_environment(args)

            env.update(
                PYTHONPATH=get_units_ansible_python_path(args, test_context),
                ANSIBLE_CONTROLLER_MIN_PYTHON_VERSION=CONTROLLER_MIN_PYTHON_VERSION,
            )

            test_candidates.append((test_context, paths, env))

        if not test_candidates:
            continue

        if version in skipped_versions:
            display.warning("Skipping unit tests on Python %s because it could not be found." % version)
            continue

        target_profiles: dict[str, PosixProfile] = {profile.config.python.version: profile for profile in host_state.targets(PosixProfile)}
        target_profile = target_profiles[version]

        final_candidates = [(test_context, target_profile.python, paths, env) for test_context, paths, env in test_candidates]
        controller = any(test_context == TestContext.controller for test_context, python, paths, env in final_candidates)

        if args.requirements_mode != 'skip':
            install_requirements(args, target_profile.python, ansible=controller, command=True, controller=False)  # units

        test_sets.extend(final_candidates)

    if args.requirements_mode == 'only':
        sys.exit()

    for test_context, python, paths, env in test_sets:
        # When using pytest-mock, make sure that features introduced in Python 3.8 are available to older Python versions.
        # This is done by enabling the mock_use_standalone_module feature, which forces use of mock even when unittest.mock is available.
        # Later Python versions have not introduced additional unittest.mock features, so use of mock is not needed as of Python 3.8.
        # If future Python versions introduce new unittest.mock features, they will not be available to older Python versions.
        # Having the cutoff at Python 3.8 also eases packaging of ansible-core since no supported controller version requires the use of mock.
        #
        # NOTE: This only affects use of pytest-mock.
        #       Collection unit tests may directly import mock, which will be provided by ansible-test when it installs requirements using pip.
        #       Although mock is available for ansible-core unit tests, they should import units.compat.mock instead.
        if str_to_version(python.version) < (3, 8):
            config_name = 'legacy.ini'
        else:
            config_name = 'default.ini'

        cmd = [
            'pytest',
            '--forked',
            '-r', 'a',
            '-n', str(args.num_workers) if args.num_workers else 'auto',
            '--color',
            'yes' if args.color else 'no',
            '-p', 'no:cacheprovider',
            '-c', os.path.join(ANSIBLE_TEST_DATA_ROOT, 'pytest', 'config', config_name),
            '--junit-xml', os.path.join(ResultType.JUNIT.path, 'python%s-%s-units.xml' % (python.version, test_context)),
            '--strict-markers',  # added in pytest 4.5.0
            '--rootdir', data_context().content.root,
        ]

        if not data_context().content.collection:
            cmd.append('--durations=25')

        plugins = []

        if args.coverage:
            plugins.append('ansible_pytest_coverage')

        if data_context().content.collection:
            plugins.append('ansible_pytest_collections')

        if plugins:
            env['PYTHONPATH'] += ':%s' % os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'pytest/plugins')
            env['PYTEST_PLUGINS'] = ','.join(plugins)

        if args.collect_only:
            cmd.append('--collect-only')

        if args.verbosity:
            cmd.append('-' + ('v' * args.verbosity))

        cmd.extend(paths)

        display.info('Unit test %s with Python %s' % (test_context, python.version))

        try:
            cover_python(args, python, cmd, test_context, env, capture=False)
        except SubprocessError as ex:
            # pytest exits with status code 5 when all tests are skipped, which isn't an error for our use case
            if ex.status != 5:
                raise


def get_units_ansible_python_path(args: UnitsConfig, test_context: str) -> str:
    """
    Return a directory usable for PYTHONPATH, containing only the modules and module_utils portion of the ansible package.
    The temporary directory created will be cached for the lifetime of the process and cleaned up at exit.
    """
    if test_context == TestContext.controller:
        return get_ansible_python_path(args)

    try:
        cache = get_units_ansible_python_path.cache  # type: ignore[attr-defined]
    except AttributeError:
        cache = get_units_ansible_python_path.cache = {}  # type: ignore[attr-defined]

    python_path = cache.get(test_context)

    if python_path:
        return python_path

    python_path = create_temp_dir(prefix='ansible-test-')
    ansible_path = os.path.join(python_path, 'ansible')
    ansible_test_path = os.path.join(python_path, 'ansible_test')

    write_text_file(os.path.join(ansible_path, '__init__.py'), '', True)
    os.symlink(os.path.join(ANSIBLE_LIB_ROOT, 'module_utils'), os.path.join(ansible_path, 'module_utils'))

    if data_context().content.collection:
        # built-in runtime configuration for the collection loader
        make_dirs(os.path.join(ansible_path, 'config'))
        os.symlink(os.path.join(ANSIBLE_LIB_ROOT, 'config', 'ansible_builtin_runtime.yml'), os.path.join(ansible_path, 'config', 'ansible_builtin_runtime.yml'))

        # current collection loader required by all python versions supported by the controller
        write_text_file(os.path.join(ansible_path, 'utils', '__init__.py'), '', True)
        os.symlink(os.path.join(ANSIBLE_LIB_ROOT, 'utils', 'collection_loader'), os.path.join(ansible_path, 'utils', 'collection_loader'))

        # legacy collection loader required by all python versions not supported by the controller
        write_text_file(os.path.join(ansible_test_path, '__init__.py'), '', True)
        write_text_file(os.path.join(ansible_test_path, '_internal', '__init__.py'), '', True)
    elif test_context == TestContext.modules:
        # only non-collection ansible module tests should have access to ansible built-in modules
        os.symlink(os.path.join(ANSIBLE_LIB_ROOT, 'modules'), os.path.join(ansible_path, 'modules'))

    cache[test_context] = python_path

    return python_path