"""Execute unit tests using pytest.""" from __future__ import (absolute_import, division, print_function) __metaclass__ = type import os import sys from ..io import ( write_text_file, make_dirs, ) from ..util import ( ANSIBLE_TEST_DATA_ROOT, display, get_available_python_versions, is_subdir, SubprocessError, SUPPORTED_PYTHON_VERSIONS, CONTROLLER_MIN_PYTHON_VERSION, CONTROLLER_PYTHON_VERSIONS, REMOTE_ONLY_PYTHON_VERSIONS, ANSIBLE_LIB_ROOT, ) from ..util_common import ( intercept_command, ResultType, handle_layout_messages, create_temp_dir, ) from ..ansible_util import ( ansible_environment, check_pyyaml, get_ansible_python_path, ) from ..target import ( walk_internal_targets, walk_units_targets, ) from ..config import ( UnitsConfig, ) from ..coverage_util import ( coverage_context, ) from ..data import ( data_context, ) from ..executor import ( AllTargetsSkipped, Delegate, get_changes_filter, install_command_requirements, ) from ..content_config import ( get_content_config, ) 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): """ :type args: UnitsConfig """ 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() 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() if args.python and args.python in REMOTE_ONLY_PYTHON_VERSIONS: if args.python not in supported_remote_python_versions: display.warning('Python %s is not supported by this collection. Supported Python versions are: %s' % ( args.python, ', '.join(content_config.python_versions))) raise AllTargetsSkipped() if not remote_paths: display.warning('Python %s is only supported by module and module_utils unit tests, but none were selected.' % args.python) raise AllTargetsSkipped() if args.python and args.python not in supported_remote_python_versions and not controller_paths: display.warning('Python %s is not supported by this collection for modules/module_utils. Supported Python versions are: %s' % ( args.python, ', '.join(supported_remote_python_versions))) raise AllTargetsSkipped() if args.delegate: raise Delegate(require=changes, exclude=args.exclude) test_sets = [] available_versions = sorted(get_available_python_versions(list(SUPPORTED_PYTHON_VERSIONS)).keys()) for version in SUPPORTED_PYTHON_VERSIONS: # run all versions unless version given, in which case run only that version if args.python and version != args.python_version: 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, version, paths, env)) if not test_candidates: continue if not args.python and version not in available_versions: display.warning("Skipping unit tests on Python %s due to missing interpreter." % version) continue if args.requirements_mode != 'skip': install_command_requirements(args, version) check_pyyaml(args, version) test_sets.extend(test_candidates) if args.requirements_mode == 'only': sys.exit() for test_context, version, paths, env in test_sets: cmd = [ 'pytest', '--boxed', '-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.ini'), '--junit-xml', os.path.join(ResultType.JUNIT.path, 'python%s-%s-units.xml' % (version, test_context)), ] if not data_context().content.collection: cmd.append('--durations=25') if version != '2.6': # added in pytest 4.5.0, which requires python 2.7+ cmd.append('--strict-markers') 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_DATA_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, version)) try: with coverage_context(args): intercept_command(args, cmd, target_name=test_context, env=env, python_version=version) 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, test_context): # type: (UnitsConfig, 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 except AttributeError: cache = get_units_ansible_python_path.cache = {} 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) os.symlink(os.path.join(ANSIBLE_TEST_DATA_ROOT, 'legacy_collection_loader'), os.path.join(ansible_test_path, '_internal', 'legacy_collection_loader')) 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