diff options
Diffstat (limited to 'test/lib/ansible_test/_internal/commands/units')
-rw-r--r-- | test/lib/ansible_test/_internal/commands/units/__init__.py | 343 |
1 files changed, 343 insertions, 0 deletions
diff --git a/test/lib/ansible_test/_internal/commands/units/__init__.py b/test/lib/ansible_test/_internal/commands/units/__init__.py new file mode 100644 index 0000000..f666d41 --- /dev/null +++ b/test/lib/ansible_test/_internal/commands/units/__init__.py @@ -0,0 +1,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 |