summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMatt Clay <mclay@redhat.com>2020-02-06 07:16:15 +0100
committerGitHub <noreply@github.com>2020-02-06 07:16:15 +0100
commit5e68bb3d93c4782e266420ee1f57a4502fadea6e (patch)
tree5597943fdc2ead9a782f739414a388ac54d5d6ba
parentupdate nmap inventory plugin to not depend on rdns (#56457) (diff)
downloadansible-5e68bb3d93c4782e266420ee1f57a4502fadea6e.tar.xz
ansible-5e68bb3d93c4782e266420ee1f57a4502fadea6e.zip
Add code coverage target analysis to ansible-test. (#67141)
* Refactor coverage file enumeration. * Relocate sanitize_filename function. * Support sets when writing JSON files. * Generalize setting of info_stderr mode. * Split out coverage path checking. * Split out collection regex logic. * Improve sanitize_filename type hints and docs. * Clean up coverage erase command. * Fix docs and type hints for initialize_coverage. * Update type hints on CoverageConfig. * Split out logic for finding modules. * Split out arc enumeration. * Split out powershell coverage enumeration. * Raise verbosity level of empty coverage warnings. * Add code coverage target analysis to ansible-test.
-rw-r--r--changelogs/fragments/ansible-test-coverage-analyze-targets.yml2
-rw-r--r--test/lib/ansible_test/_internal/cli.py156
-rw-r--r--test/lib/ansible_test/_internal/config.py3
-rw-r--r--test/lib/ansible_test/_internal/coverage/__init__.py228
-rw-r--r--test/lib/ansible_test/_internal/coverage/analyze/__init__.py19
-rw-r--r--test/lib/ansible_test/_internal/coverage/analyze/targets/__init__.py115
-rw-r--r--test/lib/ansible_test/_internal/coverage/analyze/targets/combine.py63
-rw-r--r--test/lib/ansible_test/_internal/coverage/analyze/targets/expand.py58
-rw-r--r--test/lib/ansible_test/_internal/coverage/analyze/targets/generate.py143
-rw-r--r--test/lib/ansible_test/_internal/coverage/analyze/targets/missing.py110
-rw-r--r--test/lib/ansible_test/_internal/coverage/combine.py187
-rw-r--r--test/lib/ansible_test/_internal/coverage/erase.py9
-rw-r--r--test/lib/ansible_test/_internal/io.py24
-rw-r--r--test/lib/ansible_test/_internal/util_common.py11
-rwxr-xr-xtest/utils/shippable/shippable.sh3
15 files changed, 944 insertions, 187 deletions
diff --git a/changelogs/fragments/ansible-test-coverage-analyze-targets.yml b/changelogs/fragments/ansible-test-coverage-analyze-targets.yml
new file mode 100644
index 0000000000..72340f0b7f
--- /dev/null
+++ b/changelogs/fragments/ansible-test-coverage-analyze-targets.yml
@@ -0,0 +1,2 @@
+minor_changes:
+ - "ansible-test - Added a ``ansible-test coverage analyze targets`` command to analyze integration test code coverage by test target."
diff --git a/test/lib/ansible_test/_internal/cli.py b/test/lib/ansible_test/_internal/cli.py
index acf8d72272..c2c2f9d5f6 100644
--- a/test/lib/ansible_test/_internal/cli.py
+++ b/test/lib/ansible_test/_internal/cli.py
@@ -12,6 +12,8 @@ from .init import (
CURRENT_RLIMIT_NOFILE,
)
+from . import types as t
+
from .util import (
ApplicationError,
display,
@@ -42,7 +44,6 @@ from .executor import (
)
from .config import (
- IntegrationConfig,
PosixIntegrationConfig,
WindowsIntegrationConfig,
NetworkIntegrationConfig,
@@ -113,11 +114,34 @@ from .coverage.xml import (
command_coverage_xml,
)
+from .coverage.analyze.targets.generate import (
+ command_coverage_analyze_targets_generate,
+ CoverageAnalyzeTargetsGenerateConfig,
+)
+
+from .coverage.analyze.targets.expand import (
+ command_coverage_analyze_targets_expand,
+ CoverageAnalyzeTargetsExpandConfig,
+)
+
+from .coverage.analyze.targets.combine import (
+ command_coverage_analyze_targets_combine,
+ CoverageAnalyzeTargetsCombineConfig,
+)
+
+from .coverage.analyze.targets.missing import (
+ command_coverage_analyze_targets_missing,
+ CoverageAnalyzeTargetsMissingConfig,
+)
+
from .coverage import (
COVERAGE_GROUPS,
CoverageConfig,
)
+if t.TYPE_CHECKING:
+ import argparse as argparse_module
+
def main():
"""Main program function."""
@@ -131,7 +155,7 @@ def main():
display.truncate = config.truncate
display.redact = config.redact
display.color = config.color
- display.info_stderr = (isinstance(config, SanityConfig) and config.lint) or (isinstance(config, IntegrationConfig) and config.list_targets)
+ display.info_stderr = config.info_stderr
check_startup()
check_delegation_args(config)
configure_timeout(config)
@@ -147,6 +171,7 @@ def main():
delegate_args = (ex.exclude, ex.require, ex.integration_targets)
if delegate_args:
+ # noinspection PyTypeChecker
delegate(config, *delegate_args)
display.review_warnings()
@@ -513,6 +538,8 @@ def parse_args():
coverage_subparsers = coverage.add_subparsers(metavar='COMMAND')
coverage_subparsers.required = True # work-around for python 3 bug which makes subparsers optional
+ add_coverage_analyze(coverage_subparsers, coverage_common)
+
coverage_combine = coverage_subparsers.add_parser('combine',
parents=[coverage_common],
help='combine coverage data and rewrite remote paths')
@@ -608,6 +635,129 @@ def parse_args():
return args
+# noinspection PyProtectedMember
+def add_coverage_analyze(coverage_subparsers, coverage_common): # type: (argparse_module._SubParsersAction, argparse_module.ArgumentParser) -> None
+ """Add the `coverage analyze` subcommand."""
+ analyze = coverage_subparsers.add_parser(
+ 'analyze',
+ help='analyze collected coverage data',
+ )
+
+ analyze_subparsers = analyze.add_subparsers(metavar='COMMAND')
+ analyze_subparsers.required = True # work-around for python 3 bug which makes subparsers optional
+
+ targets = analyze_subparsers.add_parser(
+ 'targets',
+ help='analyze integration test target coverage',
+ )
+
+ targets_subparsers = targets.add_subparsers(metavar='COMMAND')
+ targets_subparsers.required = True # work-around for python 3 bug which makes subparsers optional
+
+ targets_generate = targets_subparsers.add_parser(
+ 'generate',
+ parents=[coverage_common],
+ help='aggregate coverage by integration test target',
+ )
+
+ targets_generate.set_defaults(
+ func=command_coverage_analyze_targets_generate,
+ config=CoverageAnalyzeTargetsGenerateConfig,
+ )
+
+ targets_generate.add_argument(
+ 'input_dir',
+ nargs='?',
+ help='directory to read coverage from',
+ )
+
+ targets_generate.add_argument(
+ 'output_file',
+ help='output file for aggregated coverage',
+ )
+
+ targets_expand = targets_subparsers.add_parser(
+ 'expand',
+ parents=[coverage_common],
+ help='expand target names from integers in aggregated coverage',
+ )
+
+ targets_expand.set_defaults(
+ func=command_coverage_analyze_targets_expand,
+ config=CoverageAnalyzeTargetsExpandConfig,
+ )
+
+ targets_expand.add_argument(
+ 'input_file',
+ help='input file to read aggregated coverage from',
+ )
+
+ targets_expand.add_argument(
+ 'output_file',
+ help='output file to write expanded coverage to',
+ )
+
+ targets_combine = targets_subparsers.add_parser(
+ 'combine',
+ parents=[coverage_common],
+ help='combine multiple aggregated coverage files',
+ )
+
+ targets_combine.set_defaults(
+ func=command_coverage_analyze_targets_combine,
+ config=CoverageAnalyzeTargetsCombineConfig,
+ )
+
+ targets_combine.add_argument(
+ 'input_file',
+ nargs='+',
+ help='input file to read aggregated coverage from',
+ )
+
+ targets_combine.add_argument(
+ 'output_file',
+ help='output file to write aggregated coverage to',
+ )
+
+ targets_missing = targets_subparsers.add_parser(
+ 'missing',
+ parents=[coverage_common],
+ help='identify coverage in one file missing in another',
+ )
+
+ targets_missing.set_defaults(
+ func=command_coverage_analyze_targets_missing,
+ config=CoverageAnalyzeTargetsMissingConfig,
+ )
+
+ targets_missing.add_argument(
+ 'from_file',
+ help='input file containing aggregated coverage',
+ )
+
+ targets_missing.add_argument(
+ 'to_file',
+ help='input file containing aggregated coverage',
+ )
+
+ targets_missing.add_argument(
+ 'output_file',
+ help='output file to write aggregated coverage to',
+ )
+
+ targets_missing.add_argument(
+ '--only-gaps',
+ action='store_true',
+ help='report only arcs/lines not hit by any target',
+ )
+
+ targets_missing.add_argument(
+ '--only-exists',
+ action='store_true',
+ help='limit results to files that exist',
+ )
+
+
def add_lint(parser):
"""
:type parser: argparse.ArgumentParser
@@ -923,6 +1073,6 @@ def complete_sanity_test(prefix, parsed_args, **_):
"""
del parsed_args
- tests = sorted(t.name for t in sanity_get_tests())
+ tests = sorted(test.name for test in sanity_get_tests())
return [i for i in tests if i.startswith(prefix)]
diff --git a/test/lib/ansible_test/_internal/config.py b/test/lib/ansible_test/_internal/config.py
index 4a7e63606a..1979ef735b 100644
--- a/test/lib/ansible_test/_internal/config.py
+++ b/test/lib/ansible_test/_internal/config.py
@@ -225,6 +225,8 @@ class SanityConfig(TestConfig):
else:
self.base_branch = ''
+ self.info_stderr = self.lint
+
class IntegrationConfig(TestConfig):
"""Configuration for the integration command."""
@@ -260,6 +262,7 @@ class IntegrationConfig(TestConfig):
if self.list_targets:
self.explain = True
+ self.info_stderr = True
def get_ansible_config(self): # type: () -> str
"""Return the path to the Ansible config for the given config."""
diff --git a/test/lib/ansible_test/_internal/coverage/__init__.py b/test/lib/ansible_test/_internal/coverage/__init__.py
index de94f36849..4da90551d1 100644
--- a/test/lib/ansible_test/_internal/coverage/__init__.py
+++ b/test/lib/ansible_test/_internal/coverage/__init__.py
@@ -3,17 +3,28 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
+import re
from .. import types as t
+from ..encoding import (
+ to_bytes,
+)
+
+from ..io import (
+ read_json_file,
+)
+
from ..util import (
ApplicationError,
common_environment,
+ display,
ANSIBLE_TEST_DATA_ROOT,
)
from ..util_common import (
intercept_command,
+ ResultType,
)
from ..config import (
@@ -25,16 +36,24 @@ from ..executor import (
install_command_requirements,
)
+from .. target import (
+ walk_module_targets,
+)
+
+from ..data import (
+ data_context,
+)
+
+if t.TYPE_CHECKING:
+ import coverage as coverage_module
+
COVERAGE_GROUPS = ('command', 'target', 'environment', 'version')
COVERAGE_CONFIG_PATH = os.path.join(ANSIBLE_TEST_DATA_ROOT, 'coveragerc')
COVERAGE_OUTPUT_FILE_NAME = 'coverage'
-def initialize_coverage(args):
- """
- :type args: CoverageConfig
- :rtype: coverage
- """
+def initialize_coverage(args): # type: (CoverageConfig) -> coverage_module
+ """Delegate execution if requested, install requirements, then import and return the coverage module. Raises an exception if coverage is not available."""
if args.delegate:
raise Delegate()
@@ -62,15 +81,206 @@ def run_coverage(args, output_file, command, cmd): # type: (CoverageConfig, str
intercept_command(args, target_name='coverage', env=env, cmd=cmd, disable_coverage=True)
+def get_python_coverage_files(): # type: () -> t.List[str]
+ """Return the list of Python coverage file paths."""
+ return get_coverage_files('python')
+
+
+def get_powershell_coverage_files(): # type: () -> t.List[str]
+ """Return the list of PowerShell coverage file paths."""
+ return get_coverage_files('powershell')
+
+
+def get_coverage_files(language): # type: (str) -> t.List[str]
+ """Return the list of coverage file paths for the given language."""
+ coverage_dir = ResultType.COVERAGE.path
+ coverage_files = [os.path.join(coverage_dir, f) for f in os.listdir(coverage_dir)
+ if '=coverage.' in f and '=%s' % language in f]
+
+ return coverage_files
+
+
+def get_collection_path_regexes(): # type: () -> t.Tuple[t.Optional[t.Pattern], t.Optional[t.Pattern]]
+ """Return a pair of regexes used for identifying and manipulating collection paths."""
+ if data_context().content.collection:
+ collection_search_re = re.compile(r'/%s/' % data_context().content.collection.directory)
+ collection_sub_re = re.compile(r'^.*?/%s/' % data_context().content.collection.directory)
+ else:
+ collection_search_re = None
+ collection_sub_re = None
+
+ return collection_search_re, collection_sub_re
+
+
+def get_python_modules(): # type: () -> t.Dict[str, str]
+ """Return a dictionary of Ansible module names and their paths."""
+ return dict((target.module, target.path) for target in list(walk_module_targets()) if target.path.endswith('.py'))
+
+
+def enumerate_python_arcs(
+ path, # type: str
+ coverage, # type: coverage_module
+ modules, # type: t.Dict[str, str]
+ collection_search_re, # type: t.Optional[t.Pattern]
+ collection_sub_re, # type: t.Optional[t.Pattern]
+): # type: (...) -> t.Generator[t.Tuple[str, t.Set[t.Tuple[int, int]]]]
+ """Enumerate Python code coverage arcs in the given file."""
+ if os.path.getsize(path) == 0:
+ display.warning('Empty coverage file: %s' % path, verbosity=2)
+ return
+
+ original = coverage.CoverageData()
+
+ try:
+ original.read_file(path)
+ except Exception as ex: # pylint: disable=locally-disabled, broad-except
+ display.error(u'%s' % ex)
+ return
+
+ for filename in original.measured_files():
+ arcs = original.arcs(filename)
+
+ if not arcs:
+ # This is most likely due to using an unsupported version of coverage.
+ display.warning('No arcs found for "%s" in coverage file: %s' % (filename, path))
+ continue
+
+ filename = sanitize_filename(filename, modules=modules, collection_search_re=collection_search_re, collection_sub_re=collection_sub_re)
+
+ if not filename:
+ continue
+
+ yield filename, set(arcs)
+
+
+def enumerate_powershell_lines(path): # type: (str) -> t.Generator[t.Tuple[str, t.Dict[int, int]]]
+ """Enumerate PowerShell code coverage lines in the given file."""
+ if os.path.getsize(path) == 0:
+ display.warning('Empty coverage file: %s' % path, verbosity=2)
+ return
+
+ try:
+ coverage_run = read_json_file(path)
+ except Exception as ex: # pylint: disable=locally-disabled, broad-except
+ display.error(u'%s' % ex)
+ return
+
+ for filename, hits in coverage_run.items():
+ filename = sanitize_filename(filename)
+
+ if not filename:
+ continue
+
+ # PowerShell unpacks arrays if there's only a single entry so this is a defensive check on that
+ if not isinstance(hits, list):
+ hits = [hits]
+
+ hits = dict((hit['Line'], hit['HitCount']) for hit in hits if hit)
+
+ yield filename, hits
+
+
+def sanitize_filename(
+ filename, # type: str
+ modules=None, # type: t.Optional[t.Dict[str, str]]
+ collection_search_re=None, # type: t.Optional[t.Pattern]
+ collection_sub_re=None, # type: t.Optional[t.Pattern]
+): # type: (...) -> t.Optional[str]
+ """Convert the given code coverage path to a local absolute path and return its, or None if the path is not valid."""
+ ansible_path = os.path.abspath('lib/ansible/') + '/'
+ root_path = data_context().content.root + '/'
+ integration_temp_path = os.path.sep + os.path.join(ResultType.TMP.relative_path, 'integration') + os.path.sep
+
+ if modules is None:
+ modules = {}
+
+ if '/ansible_modlib.zip/ansible/' in filename:
+ # Rewrite the module_utils path from the remote host to match the controller. Ansible 2.6 and earlier.
+ new_name = re.sub('^.*/ansible_modlib.zip/ansible/', ansible_path, filename)
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif collection_search_re and collection_search_re.search(filename):
+ new_name = os.path.abspath(collection_sub_re.sub('', filename))
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif re.search(r'/ansible_[^/]+_payload\.zip/ansible/', filename):
+ # Rewrite the module_utils path from the remote host to match the controller. Ansible 2.7 and later.
+ new_name = re.sub(r'^.*/ansible_[^/]+_payload\.zip/ansible/', ansible_path, filename)
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif '/ansible_module_' in filename:
+ # Rewrite the module path from the remote host to match the controller. Ansible 2.6 and earlier.
+ module_name = re.sub('^.*/ansible_module_(?P<module>.*).py$', '\\g<module>', filename)
+ if module_name not in modules:
+ display.warning('Skipping coverage of unknown module: %s' % module_name)
+ return None
+ new_name = os.path.abspath(modules[module_name])
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif re.search(r'/ansible_[^/]+_payload(_[^/]+|\.zip)/__main__\.py$', filename):
+ # Rewrite the module path from the remote host to match the controller. Ansible 2.7 and later.
+ # AnsiballZ versions using zipimporter will match the `.zip` portion of the regex.
+ # AnsiballZ versions not using zipimporter will match the `_[^/]+` portion of the regex.
+ module_name = re.sub(r'^.*/ansible_(?P<module>[^/]+)_payload(_[^/]+|\.zip)/__main__\.py$',
+ '\\g<module>', filename).rstrip('_')
+ if module_name not in modules:
+ display.warning('Skipping coverage of unknown module: %s' % module_name)
+ return None
+ new_name = os.path.abspath(modules[module_name])
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif re.search('^(/.*?)?/root/ansible/', filename):
+ # Rewrite the path of code running on a remote host or in a docker container as root.
+ new_name = re.sub('^(/.*?)?/root/ansible/', root_path, filename)
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+ elif integration_temp_path in filename:
+ # Rewrite the path of code running from an integration test temporary directory.
+ new_name = re.sub(r'^.*' + re.escape(integration_temp_path) + '[^/]+/', root_path, filename)
+ display.info('%s -> %s' % (filename, new_name), verbosity=3)
+ filename = new_name
+
+ return filename
+
+
class CoverageConfig(EnvironmentConfig):
"""Configuration for the coverage command."""
- def __init__(self, args):
- """
- :type args: any
- """
+ def __init__(self, args): # type: (t.Any) -> None
super(CoverageConfig, self).__init__(args, 'coverage')
self.group_by = frozenset(args.group_by) if 'group_by' in args and args.group_by else set() # type: t.FrozenSet[str]
self.all = args.all if 'all' in args else False # type: bool
self.stub = args.stub if 'stub' in args else False # type: bool
self.coverage = False # temporary work-around to support intercept_command in cover.py
+
+
+class PathChecker:
+ """Checks code coverage paths to verify they are valid and reports on the findings."""
+ def __init__(self, args, collection_search_re=None): # type: (CoverageConfig, t.Optional[t.Pattern]) -> None
+ self.args = args
+ self.collection_search_re = collection_search_re
+ self.invalid_paths = []
+ self.invalid_path_chars = 0
+
+ def check_path(self, path): # type: (str) -> bool
+ """Return True if the given coverage path is valid, otherwise display a warning and return False."""
+ if os.path.isfile(to_bytes(path)):
+ return True
+
+ if self.collection_search_re and self.collection_search_re.search(path) and os.path.basename(path) == '__init__.py':
+ # the collection loader uses implicit namespace packages, so __init__.py does not need to exist on disk
+ # coverage is still reported for these non-existent files, but warnings are not needed
+ return False
+
+ self.invalid_paths.append(path)
+ self.invalid_path_chars += len(path)
+
+ if self.args.verbosity > 1:
+ display.warning('Invalid coverage path: %s' % path)
+
+ return False
+
+ def report(self): # type: () -> None
+ """Display a warning regarding invalid paths if any were found."""
+ if self.invalid_paths:
+ display.warning('Ignored %d characters from %d invalid coverage path(s).' % (self.invalid_path_chars, len(self.invalid_paths)))
diff --git a/test/lib/ansible_test/_internal/coverage/analyze/__init__.py b/test/lib/ansible_test/_internal/coverage/analyze/__init__.py
new file mode 100644
index 0000000000..457703731d
--- /dev/null
+++ b/test/lib/ansible_test/_internal/coverage/analyze/__init__.py
@@ -0,0 +1,19 @@
+"""Common logic for the `coverage analyze` subcommand."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ... import types as t
+
+from .. import (
+ CoverageConfig,
+)
+
+
+class CoverageAnalyzeConfig(CoverageConfig):
+ """Configuration for the `coverage analyze` command."""
+ def __init__(self, args): # type: (t.Any) -> None
+ super(CoverageAnalyzeConfig, self).__init__(args)
+
+ # avoid mixing log messages with file output when using `/dev/stdout` for the output file on commands
+ # this may be worth considering as the default behavior in the future, instead of being dependent on the command or options used
+ self.info_stderr = True
diff --git a/test/lib/ansible_test/_internal/coverage/analyze/targets/__init__.py b/test/lib/ansible_test/_internal/coverage/analyze/targets/__init__.py
new file mode 100644
index 0000000000..a01b804f26
--- /dev/null
+++ b/test/lib/ansible_test/_internal/coverage/analyze/targets/__init__.py
@@ -0,0 +1,115 @@
+"""Analyze integration test target code coverage."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+
+from .... import types as t
+
+from ....io import (
+ read_json_file,
+ write_json_file,
+)
+
+from ....util import (
+ ApplicationError,
+ display,
+)
+
+from .. import (
+ CoverageAnalyzeConfig,
+)
+
+if t.TYPE_CHECKING:
+ Arcs = t.Dict[str, t.Dict[t.Tuple[int, int], t.Set[int]]]
+ Lines = t.Dict[str, t.Dict[int, t.Set[int]]]
+ TargetIndexes = t.Dict[str, int]
+ TargetSetIndexes = t.Dict[t.FrozenSet[int], int]
+
+
+def make_report(target_indexes, arcs, lines): # type: (TargetIndexes, Arcs, Lines) -> t.Dict[str, t.Any]
+ """Condense target indexes, arcs and lines into a compact report."""
+ set_indexes = {}
+ arc_refs = dict((path, dict((format_arc(arc), get_target_set_index(indexes, set_indexes)) for arc, indexes in data.items())) for path, data in arcs.items())
+ line_refs = dict((path, dict((line, get_target_set_index(indexes, set_indexes)) for line, indexes in data.items())) for path, data in lines.items())
+
+ report = dict(
+ targets=[name for name, index in sorted(target_indexes.items(), key=lambda kvp: kvp[1])],
+ target_sets=[sorted(data) for data, index in sorted(set_indexes.items(), key=lambda kvp: kvp[1])],
+ arcs=arc_refs,
+ lines=line_refs,
+ )
+
+ return report
+
+
+def load_report(report): # type: (t.Dict[str, t.Any]) -> t.Tuple[t.List[str], Arcs, Lines]
+ """Extract target indexes, arcs and lines from an existing report."""
+ try:
+ target_indexes = report['targets'] # type: t.List[str]
+ target_sets = report['target_sets'] # type: t.List[t.List[int]]
+ arc_data = report['arcs'] # type: t.Dict[str, t.Dict[str, int]]
+ line_data = report['lines'] # type: t.Dict[str, t.Dict[int, int]]
+ except KeyError as ex:
+ raise ApplicationError('Document is missing key "%s".' % ex.args)
+ except TypeError:
+ raise ApplicationError('Document is type "%s" instead of "dict".' % type(report).__name__)
+
+ arcs = dict((path, dict((parse_arc(arc), set(target_sets[index])) for arc, index in data.items())) for path, data in arc_data.items())
+ lines = dict((path, dict((int(line), set(target_sets[index])) for line, index in data.items())) for path, data in line_data.items())
+
+ return target_indexes, arcs, lines
+
+
+def read_report(path): # type: (str) -> t.Tuple[t.List[str], Arcs, Lines]
+ """Read a JSON report from disk."""
+ try:
+ report = read_json_file(path)
+ except Exception as ex:
+ raise ApplicationError('File "%s" is not valid JSON: %s' % (path, ex))
+
+ try:
+ return load_report(report)
+ except ApplicationError as ex:
+ raise ApplicationError('File "%s" is not an aggregated coverage data file. %s' % (path, ex))
+
+
+def write_report(args, report, path): # type: (CoverageAnalyzeTargetsConfig, t.Dict[str, t.Any], str) -> None
+ """Write a JSON report to disk."""
+ if args.explain:
+ return
+
+ write_json_file(path, report, formatted=False)
+
+ display.info('Generated %d byte report with %d targets covering %d files.' % (
+ os.path.getsize(path), len(report['targets']), len(set(report['arcs'].keys()) | set(report['lines'].keys())),
+ ), verbosity=1)
+
+
+def format_arc(value): # type: (t.Tuple[int, int]) -> str
+ """Format an arc tuple as a string."""
+ return '%d:%d' % value
+
+
+def parse_arc(value): # type: (str) -> t.Tuple[int, int]
+ """Parse an arc string into a tuple."""
+ first, last = tuple(map(int, value.split(':')))
+ return first, last
+
+
+def get_target_set_index(data, target_set_indexes): # type: (t.Set[int], TargetSetIndexes) -> int
+ """Find or add the target set in the result set and return the target set index."""
+ return target_set_indexes.setdefault(frozenset(data), len(target_set_indexes))
+
+
+def get_target_index(name, target_indexes): # type: (str, TargetIndexes) -> int
+ """Find or add the target in the result set and return the target index."""
+ return target_indexes.setdefault(name, len(target_indexes))
+
+
+class CoverageAnalyzeTargetsConfig(CoverageAnalyzeConfig):
+ """Configuration for the `coverage analyze targets` command."""
+ def __init__(self, args): # type: (t.Any) -> None
+ super(CoverageAnalyzeTargetsConfig, self).__init__(args)
+
+ self.info_stderr = True
diff --git a/test/lib/ansible_test/_internal/coverage/analyze/targets/combine.py b/test/lib/ansible_test/_internal/coverage/analyze/targets/combine.py
new file mode 100644
index 0000000000..3a98ef9a9a
--- /dev/null
+++ b/test/lib/ansible_test/_internal/coverage/analyze/targets/combine.py
@@ -0,0 +1,63 @@
+"""Combine integration test target code coverage reports."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from .... import types as t
+
+from . import (
+ CoverageAnalyzeTargetsConfig,
+ get_target_index,
+ make_report,
+ read_report,
+ write_report,
+)
+
+if t.TYPE_CHECKING:
+ from . import (
+ Arcs,
+ Lines,
+ TargetIndexes,
+ )
+
+
+def command_coverage_analyze_targets_combine(args): # type: (CoverageAnalyzeTargetsCombineConfig) -> None
+ """Combine integration test target code coverage reports."""
+ combined_target_indexes = {} # type: TargetIndexes
+ combined_path_arcs = {} # type: Arcs
+ combined_path_lines = {} # type: Lines
+
+ for report_path in args.input_files:
+ covered_targets, covered_path_arcs, covered_path_lines = read_report(report_path)
+
+ merge_indexes(covered_path_arcs, covered_targets, combined_path_arcs, combined_target_indexes)
+ merge_indexes(covered_path_lines, covered_targets, combined_path_lines, combined_target_indexes)
+
+ report = make_report(combined_target_indexes, combined_path_arcs, combined_path_lines)
+
+ write_report(args, report, args.output_file)
+
+
+def merge_indexes(
+ source_data, # type: t.Dict[str, t.Dict[t.Any, t.Set[int]]]
+ source_index, # type: t.List[str]
+ combined_data, # type: t.Dict[str, t.Dict[t.Any, t.Set[int]]]
+ combined_index, # type: TargetIndexes
+): # type: (...) -> None
+ """Merge indexes from the source into the combined data set (arcs or lines)."""
+ for covered_path, covered_points in source_data.items():
+ combined_points = combined_data.setdefault(covered_path, {})
+
+ for covered_point, covered_target_indexes in covered_points.items():
+ combined_point = combined_points.setdefault(covered_point, set())
+
+ for covered_target_index in covered_target_indexes:
+ combined_point.add(get_target_index(source_index[covered_target_index], combined_index))
+
+
+class CoverageAnalyzeTargetsCombineConfig(CoverageAnalyzeTargetsConfig):
+ """Configuration for the `coverage analyze targets combine` command."""
+ def __init__(self, args): # type: (t.Any) -> None
+ super(CoverageAnalyzeTargetsCombineConfig, self).__init__(args)
+
+ self.input_files = args.input_file # type: t.List[str]
+ self.output_file = args.output_file # type: str
diff --git a/test/lib/ansible_test/_internal/coverage/analyze/targets/expand.py b/test/lib/ansible_test/_internal/coverage/analyze/targets/expand.py
new file mode 100644
index 0000000000..536a877d70
--- /dev/null
+++ b/test/lib/ansible_test/_internal/coverage/analyze/targets/expand.py
@@ -0,0 +1,58 @@
+"""Expand target names in an aggregated coverage file."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from .... import types as t
+
+from ....io import (
+ SortedSetEncoder,
+ write_json_file,
+)
+
+from . import (
+ CoverageAnalyzeTargetsConfig,
+ format_arc,
+ read_report,
+)
+
+
+def command_coverage_analyze_targets_expand(args): # type: (CoverageAnalyzeTargetsExpandConfig) -> None
+ """Expand target names in an aggregated coverage file."""
+ covered_targets, covered_path_arcs, covered_path_lines = read_report(args.input_file)
+
+ report = dict(
+ arcs=expand_indexes(covered_path_arcs, covered_targets, format_arc),
+ lines=expand_indexes(covered_path_lines, covered_targets, str),
+ )
+
+ if not args.explain:
+ write_json_file(args.output_file, report, encoder=SortedSetEncoder)
+
+
+def expand_indexes(
+ source_data, # type: t.Dict[str, t.Dict[t.Any, t.Set[int]]]
+ source_index, # type: t.List[str]
+ format_func, # type: t.Callable[t.Tuple[t.Any], str]
+): # type: (...) -> t.Dict[str, t.Dict[t.Any, t.Set[str]]]
+ """Merge indexes from the source into the combined data set (arcs or lines)."""
+ combined_data = {} # type: t.Dict[str, t.Dict[t.Any, t.Set[str]]]
+
+ for covered_path, covered_points in source_data.items():
+ combined_points = combined_data.setdefault(covered_path, {})
+
+ for covered_point, covered_target_indexes in covered_points.items():
+ combined_point = combined_points.setdefault(format_func(covered_point), set())
+
+ for covered_target_index in covered_target_indexes:
+ combined_point.add(source_index[covered_target_index])
+
+ return combined_data
+
+
+class CoverageAnalyzeTargetsExpandConfig(CoverageAnalyzeTargetsConfig):
+ """Configuration for the `coverage analyze targets expand` command."""
+ def __init__(self, args): # type: (t.Any) -> None
+ super(CoverageAnalyzeTargetsExpandConfig, self).__init__(args)
+
+ self.input_file = args.input_file # type: str
+ self.output_file = args.output_file # type: str
diff --git a/test/lib/ansible_test/_internal/coverage/analyze/targets/generate.py b/test/lib/ansible_test/_internal/coverage/analyze/targets/generate.py
new file mode 100644
index 0000000000..4e33255946
--- /dev/null
+++ b/test/lib/ansible_test/_internal/coverage/analyze/targets/generate.py
@@ -0,0 +1,143 @@
+"""Analyze code coverage data to determine which integration test targets provide coverage for each arc or line."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+
+from .... import types as t
+
+from ....encoding import (
+ to_text,
+)
+
+from ....data import (
+ data_context,
+)
+
+from ....util_common import (
+ ResultType,
+)
+
+from ... import (
+ enumerate_powershell_lines,
+ enumerate_python_arcs,
+ get_collection_path_regexes,
+ get_powershell_coverage_files,
+ get_python_coverage_files,
+ get_python_modules,
+ initialize_coverage,
+ PathChecker,
+)
+
+from . import (
+ CoverageAnalyzeTargetsConfig,
+ get_target_index,
+ make_report,
+ write_report,
+)
+
+if t.TYPE_CHECKING:
+ from . import (
+ Arcs,
+ Lines,
+ TargetIndexes,
+ )
+
+
+def command_coverage_analyze_targets_generate(args): # type: (CoverageAnalyzeTargetsGenerateConfig) -> None
+ """Analyze code coverage data to determine which integration test targets provide coverage for each arc or line."""
+ root = data_context().content.root
+ target_indexes = {}
+ arcs = dict((os.path.relpath(path, root), data) for path, data in analyze_python_coverage(args, target_indexes).items())
+ lines = dict((os.path.relpath(path, root), data) for path, data in analyze_powershell_coverage(args, target_indexes).items())
+ report = make_report(target_indexes, arcs, lines)
+ write_report(args, report, args.output_file)
+
+
+def analyze_python_coverage(
+ args, # type: CoverageAnalyzeTargetsConfig
+ target_indexes, # type: TargetIndexes
+): # type: (...) -> Arcs
+ """Analyze Python code coverage."""
+ results = {} # type: Arcs
+ collection_search_re, collection_sub_re = get_collection_path_regexes()
+ modules = get_python_modules()
+ python_files = get_python_coverage_files()
+ coverage = initialize_coverage(args)
+
+ for python_file in python_files:
+ if not is_integration_coverage_file(python_file):
+ continue
+
+ target_name = get_target_name(python_file)
+ target_index = get_target_index(target_name, target_indexes)
+
+ for filename, covered_arcs in enumerate_python_arcs(python_file, coverage, modules, collection_search_re, collection_sub_re):
+ arcs = results.setdefault(filename, {})
+
+ for covered_arc in covered_arcs:
+ arc = arcs.setdefault(covered_arc, set())
+ arc.add(target_index)
+
+ prune_invalid_filenames(args, results, collection_search_re=collection_search_re)
+
+ return results
+
+
+def analyze_powershell_coverage(
+ args, # type: CoverageAnalyzeTargetsConfig
+ target_indexes, # type: TargetIndexes
+): # type: (...) -> Lines
+ """Analyze PowerShell code coverage"""
+ results = {} # type: Lines
+ powershell_files = get_powershell_coverage_files()
+
+ for powershell_file in powershell_files:
+ if not is_integration_coverage_file(powershell_file):
+ continue
+
+ target_name = get_target_name(powershell_file)
+ target_index = get_target_index(target_name, target_indexes)
+
+ for filename, hits in enumerate_powershell_lines(powershell_file):
+ lines = results.setdefault(filename, {})
+
+ for covered_line in hits:
+ line = lines.setdefault(covered_line, set())
+ line.add(target_index)
+
+ prune_invalid_filenames(args, results)
+
+ return results
+
+
+def prune_invalid_filenames(
+ args, # type: CoverageAnalyzeTargetsConfig
+ results, # type: t.Dict[str, t.Any]
+ collection_search_re=None, # type: t.Optional[str]
+): # type: (...) -> None
+ """Remove invalid filenames from the given result set."""
+ path_checker = PathChecker(args, collection_search_re)
+
+ for path in list(results.keys()):
+ if not path_checker.check_path(path):
+ del results[path]
+
+
+def get_target_name(path): # type: (str) -> str
+ """Extract the test target name from the given coverage path."""
+ return to_text(os.path.basename(path).split('=')[1])
+
+
+def is_integration_coverage_file(path): # type: (str) -> bool
+ """Returns True if the coverage file came from integration tests, otherwise False."""
+ return os.path.basename(path).split('=')[0] in ('integration', 'windows-integration', 'network-integration')
+
+
+class CoverageAnalyzeTargetsGenerateConfig(CoverageAnalyzeTargetsConfig):
+ """Configuration for the `coverage analyze targets generate` command."""
+ def __init__(self, args): # type: (t.Any) -> None
+ super(CoverageAnalyzeTargetsGenerateConfig, self).__init__(args)
+
+ self.input_dir = args.input_dir or ResultType.COVERAGE.path # type: str
+ self.output_file = args.output_file # type: str
diff --git a/test/lib/ansible_test/_internal/coverage/analyze/targets/missing.py b/test/lib/ansible_test/_internal/coverage/analyze/targets/missing.py
new file mode 100644
index 0000000000..22443615eb
--- /dev/null
+++ b/test/lib/ansible_test/_internal/coverage/analyze/targets/missing.py
@@ -0,0 +1,110 @@
+"""Identify aggregated coverage in one file missing from another."""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+
+from .... import types as t
+
+from ....encoding import (
+ to_bytes,
+)
+
+from . import (
+ CoverageAnalyzeTargetsConfig,
+ get_target_index,
+ make_report,
+ read_report,
+ write_report,
+)
+
+if t.TYPE_CHECKING:
+ from . import (
+ TargetIndexes,
+ )
+
+ TargetKey = t.TypeVar('TargetKey', int, t.Tuple[int, int])
+
+
+def command_coverage_analyze_targets_missing(args): # type: (CoverageAnalyzeTargetsMissingConfig) -> None
+ """Identify aggregated coverage in one file missing from another."""
+ from_targets, from_path_arcs, from_path_lines = read_report(args.from_file)
+ to_targets, to_path_arcs, to_path_lines = read_report(args.to_file)
+ target_indexes = {}
+
+ if args.only_gaps:
+ arcs = find_gaps(from_path_arcs, from_targets, to_path_arcs, target_indexes, args.only_exists)
+ lines = find_gaps(from_path_lines, from_targets, to_path_lines, target_indexes, args.only_exists)
+ else:
+ arcs = find_missing(from_path_arcs, from_targets, to_path_arcs, to_targets, target_indexes, args.only_exists)
+ lines = find_missing(from_path_lines, from_targets, to_path_lines, to_targets, target_indexes, args.only_exists)
+
+ report = make_report(target_indexes, arcs, lines)
+ write_report(args, report, args.output_file)
+
+
+def find_gaps(
+ from_data, # type: t.Dict[str, t.Dict[TargetKey, t.Set[int]]]
+ from_index, # type: t.List[str]
+ to_data, # type: t.Dict[str, t.Dict[TargetKey, t.Set[int]]]
+ target_indexes, # type: TargetIndexes,
+ only_exists, # type: bool
+): # type: (...) -> t.Dict[str, t.Dict[TargetKey, t.Set[int]]]
+ """Find gaps in coverage between the from and to data sets."""
+ target_data = {}
+
+ for from_path, from_points in from_data.items():
+ if only_exists and not os.path.isfile(to_bytes(from_path)):
+ continue
+
+ to_points = to_data.get(from_path, {})
+
+ gaps = set(from_points.keys()) - set(to_points.keys())
+
+ if gaps:
+ gap_points = dict((key, value) for key, value in from_points.items() if key in gaps)
+ target_data[from_path] = dict((gap, set(get_target_index(from_index[i], target_indexes) for i in indexes)) for gap, indexes in gap_points.items())
+
+ return target_data
+
+
+def find_missing(
+ from_data, # type: t.Dict[str, t.Dict[TargetKey, t.Set[int]]]
+ from_index, # type: t.List[str]
+ to_data, # type: t.Dict[str, t.Dict[TargetKey, t.Set[int]]]
+ to_index, # type: t.List[str]
+ target_indexes, # type: TargetIndexes,
+ only_exists, # type: bool
+): # type: (...) -> t.Dict[str, t.Dict[TargetKey, t.Set[int]]]
+ """Find coverage in from_data not present in to_data (arcs or lines)."""
+ target_data = {}
+
+ for from_path, from_points in from_data.items():
+ if only_exists and not os.path.isfile(to_bytes(from_path)):
+ continue
+
+ to_points = to_data.get(from_path, {})
+
+ for from_point, from_target_indexes in from_points.items():
+ to_target_indexes = to_points.get(from_point, set())
+
+ remaining_targets = set(from_index[i] for i in from_target_indexes) - set(to_index[i] for i in to_target_indexes)
+
+ if remaining_targets:
+ target_index = target_data.setdefault(from_path, {}).setdefault(from_point, set())
+ target_index.update(get_target_index(name, target_indexes) for name in remaining_targets)
+
+ return target_data
+
+
+class CoverageAnalyzeTargetsMissingConfig(CoverageAnalyzeTargetsConfig):
+ """Configuration for the `coverage analyze targets missing` command."""
+ def __init__(self, args): # type: (t.Any) -> None
+ super(CoverageAnalyzeTargetsMissingConfig, self).__init__(args)
+
+ self.from_file = args.from_file # type: str
+ self.to_file = args.to_file # type: str
+ self.output_file = args.output_file # type: str
+
+ self.only_gaps = args.only_gaps # type: bool
+ self.only_exists = args.only_exists # type: bool
diff --git a/test/lib/ansible_test/_internal/coverage/combine.py b/test/lib/ansible_test/_internal/coverage/combine.py
index a07a4dd6de..e4a6f61415 100644
--- a/test/lib/ansible_test/_internal/coverage/combine.py
+++ b/test/lib/ansible_test/_internal/coverage/combine.py
@@ -3,16 +3,13 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
-import re
from ..target import (
- walk_module_targets,
walk_compile_targets,
walk_powershell_targets,
)
from ..io import (
- read_json_file,
read_text_file,
)
@@ -25,15 +22,18 @@ from ..util_common import (
write_json_test_results,
)
-from ..data import (
- data_context,
-)
-
from . import (
+ enumerate_python_arcs,
+ enumerate_powershell_lines,
+ get_collection_path_regexes,
+ get_python_coverage_files,
+ get_python_modules,
+ get_powershell_coverage_files,
initialize_coverage,
COVERAGE_OUTPUT_FILE_NAME,
COVERAGE_GROUPS,
CoverageConfig,
+ PathChecker,
)
@@ -57,58 +57,27 @@ def _command_coverage_combine_python(args):
"""
coverage = initialize_coverage(args)
- modules = dict((target.module, target.path) for target in list(walk_module_targets()) if target.path.endswith('.py'))
+ modules = get_python_modules()
- coverage_dir = ResultType.COVERAGE.path
- coverage_files = [os.path.join(coverage_dir, f) for f in os.listdir(coverage_dir)
- if '=coverage.' in f and '=python' in f]
+ coverage_files = get_python_coverage_files()
counter = 0
sources = _get_coverage_targets(args, walk_compile_targets)
groups = _build_stub_groups(args, sources, lambda line_count: set())
- if data_context().content.collection:
- collection_search_re = re.compile(r'/%s/' % data_context().content.collection.directory)
- collection_sub_re = re.compile(r'^.*?/%s/' % data_context().content.collection.directory)
- else:
- collection_search_re = None
- collection_sub_re = None
+ collection_search_re, collection_sub_re = get_collection_path_regexes()
for coverage_file in coverage_files:
counter += 1
display.info('[%4d/%4d] %s' % (counter, len(coverage_files), coverage_file), verbosity=2)
- original = coverage.CoverageData()
-
group = get_coverage_group(args, coverage_file)
if group is None:
display.warning('Unexpected name for coverage file: %s' % coverage_file)
continue
- if os.path.getsize(coverage_file) == 0:
- display.warning('Empty coverage file: %s' % coverage_file)
- continue
-
- try:
- original.read_file(coverage_file)
- except Exception as ex: # pylint: disable=locally-disabled, broad-except
- display.error(u'%s' % ex)
- continue
-
- for filename in original.measured_files():
- arcs = set(original.arcs(filename) or [])
-
- if not arcs:
- # This is most likely due to using an unsupported version of coverage.
- display.warning('No arcs found for "%s" in coverage file: %s' % (filename, coverage_file))
- continue
-
- filename = _sanitize_filename(filename, modules=modules, collection_search_re=collection_search_re,
- collection_sub_re=collection_sub_re)
- if not filename:
- continue
-
+ for filename, arcs in enumerate_python_arcs(coverage_file, coverage, modules, collection_search_re, collection_sub_re):
if group not in groups:
groups[group] = {}
@@ -120,28 +89,18 @@ def _command_coverage_combine_python(args):
arc_data[filename].update(arcs)
output_files = []
- invalid_path_count = 0
- invalid_path_chars = 0
coverage_file = os.path.join(ResultType.COVERAGE.path, COVERAGE_OUTPUT_FILE_NAME)
+ path_checker = PathChecker(args, collection_search_re)
+
for group in sorted(groups):
arc_data = groups[group]
updated = coverage.CoverageData()
for filename in arc_data:
- if not os.path.isfile(filename):
- if collection_search_re and collection_search_re.search(filename) and os.path.basename(filename) == '__init__.py':
- # the collection loader uses implicit namespace packages, so __init__.py does not need to exist on disk
- continue
-
- invalid_path_count += 1
- invalid_path_chars += len(filename)
-
- if args.verbosity > 1:
- display.warning('Invalid coverage path: %s' % filename)
-
+ if not path_checker.check_path(filename):
continue
updated.add_arcs({filename: list(arc_data[filename])})
@@ -154,8 +113,7 @@ def _command_coverage_combine_python(args):
updated.write_file(output_file)
output_files.append(output_file)
- if invalid_path_count > 0:
- display.warning('Ignored %d characters from %d invalid coverage path(s).' % (invalid_path_chars, invalid_path_count))
+ path_checker.report()
return sorted(output_files)
@@ -165,9 +123,7 @@ def _command_coverage_combine_powershell(args):
:type args: CoverageConfig
:rtype: list[str]
"""
- coverage_dir = ResultType.COVERAGE.path
- coverage_files = [os.path.join(coverage_dir, f) for f in os.listdir(coverage_dir)
- if '=coverage.' in f and '=powershell' in f]
+ coverage_files = get_powershell_coverage_files()
def _default_stub_value(lines):
val = {}
@@ -189,57 +145,26 @@ def _command_coverage_combine_powershell(args):
display.warning('Unexpected name for coverage file: %s' % coverage_file)
continue
- if os.path.getsize(coverage_file) == 0:
- display.warning('Empty coverage file: %s' % coverage_file)
- continue
-
- try:
- coverage_run = read_json_file(coverage_file)
- except Exception as ex: # pylint: disable=locally-disabled, broad-except
- display.error(u'%s' % ex)
- continue
-
- for filename, hit_info in coverage_run.items():
+ for filename, hits in enumerate_powershell_lines(coverage_file):
if group not in groups:
groups[group] = {}
coverage_data = groups[group]
- filename = _sanitize_filename(filename)
- if not filename:
- continue
-
if filename not in coverage_data:
coverage_data[filename] = {}
file_coverage = coverage_data[filename]
- if not isinstance(hit_info, list):
- hit_info = [hit_info]
-
- for hit_entry in hit_info:
- if not hit_entry:
- continue
-
- line_count = file_coverage.get(hit_entry['Line'], 0) + hit_entry['HitCount']
- file_coverage[hit_entry['Line']] = line_count
+ for line_no, hit_count in hits.items():
+ file_coverage[line_no] = file_coverage.get(line_no, 0) + hit_count
output_files = []
- invalid_path_count = 0
- invalid_path_chars = 0
- for group in sorted(groups):
- coverage_data = groups[group]
+ path_checker = PathChecker(args)
- for filename in coverage_data:
- if not os.path.isfile(filename):
- invalid_path_count += 1
- invalid_path_chars += len(filename)
-
- if args.verbosity > 1:
- display.warning('Invalid coverage path: %s' % filename)
-
- continue
+ for group in sorted(groups):
+ coverage_data = dict((filename, data) for filename, data in groups[group].items() if path_checker.check_path(filename))
if args.all:
# Add 0 line entries for files not in coverage_data
@@ -256,9 +181,7 @@ def _command_coverage_combine_powershell(args):
output_files.append(os.path.join(ResultType.COVERAGE.path, output_file))
- if invalid_path_count > 0:
- display.warning(
- 'Ignored %d characters from %d invalid coverage path(s).' % (invalid_path_chars, invalid_path_count))
+ path_checker.report()
return sorted(output_files)
@@ -346,67 +269,3 @@ def get_coverage_group(args, coverage_file):
group += '=%s' % names[part]
return group
-
-
-def _sanitize_filename(filename, modules=None, collection_search_re=None, collection_sub_re=None):
- """
- :type filename: str
- :type modules: dict | None
- :type collection_search_re: Pattern | None
- :type collection_sub_re: Pattern | None
- :rtype: str | None
- """
- ansible_path = os.path.abspath('lib/ansible/') + '/'
- root_path = data_context().content.root + '/'
- integration_temp_path = os.path.sep + os.path.join(ResultType.TMP.relative_path, 'integration') + os.path.sep
-
- if modules is None:
- modules = {}
-
- if '/ansible_modlib.zip/ansible/' in filename:
- # Rewrite the module_utils path from the remote host to match the controller. Ansible 2.6 and earlier.
- new_name = re.sub('^.*/ansible_modlib.zip/ansible/', ansible_path, filename)
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
- elif collection_search_re and collection_search_re.search(filename):
- new_name = os.path.abspath(collection_sub_re.sub('', filename))
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
- elif re.search(r'/ansible_[^/]+_payload\.zip/ansible/', filename):
- # Rewrite the module_utils path from the remote host to match the controller. Ansible 2.7 and later.
- new_name = re.sub(r'^.*/ansible_[^/]+_payload\.zip/ansible/', ansible_path, filename)
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
- elif '/ansible_module_' in filename:
- # Rewrite the module path from the remote host to match the controller. Ansible 2.6 and earlier.
- module_name = re.sub('^.*/ansible_module_(?P<module>.*).py$', '\\g<module>', filename)
- if module_name not in modules:
- display.warning('Skipping coverage of unknown module: %s' % module_name)
- return None
- new_name = os.path.abspath(modules[module_name])
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
- elif re.search(r'/ansible_[^/]+_payload(_[^/]+|\.zip)/__main__\.py$', filename):
- # Rewrite the module path from the remote host to match the controller. Ansible 2.7 and later.
- # AnsiballZ versions using zipimporter will match the `.zip` portion of the regex.
- # AnsiballZ versions not using zipimporter will match the `_[^/]+` portion of the regex.
- module_name = re.sub(r'^.*/ansible_(?P<module>[^/]+)_payload(_[^/]+|\.zip)/__main__\.py$',
- '\\g<module>', filename).rstrip('_')
- if module_name not in modules:
- display.warning('Skipping coverage of unknown module: %s' % module_name)
- return None
- new_name = os.path.abspath(modules[module_name])
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
- elif re.search('^(/.*?)?/root/ansible/', filename):
- # Rewrite the path of code running on a remote host or in a docker container as root.
- new_name = re.sub('^(/.*?)?/root/ansible/', root_path, filename)
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
- elif integration_temp_path in filename:
- # Rewrite the path of code running from an integration test temporary directory.
- new_name = re.sub(r'^.*' + re.escape(integration_temp_path) + '[^/]+/', root_path, filename)
- display.info('%s -> %s' % (filename, new_name), verbosity=3)
- filename = new_name
-
- return filename
diff --git a/test/lib/ansible_test/_internal/coverage/erase.py b/test/lib/ansible_test/_internal/coverage/erase.py
index 8b1f6f3b11..92d241c7fb 100644
--- a/test/lib/ansible_test/_internal/coverage/erase.py
+++ b/test/lib/ansible_test/_internal/coverage/erase.py
@@ -9,17 +9,12 @@ from ..util_common import (
)
from . import (
- initialize_coverage,
CoverageConfig,
)
-def command_coverage_erase(args):
- """
- :type args: CoverageConfig
- """
- initialize_coverage(args)
-
+def command_coverage_erase(args): # type: (CoverageConfig) -> None
+ """Erase code coverage data files collected during test runs."""
coverage_dir = ResultType.COVERAGE.path
for name in os.listdir(coverage_dir):
diff --git a/test/lib/ansible_test/_internal/io.py b/test/lib/ansible_test/_internal/io.py
index 8daa4dda01..0f61cd2df2 100644
--- a/test/lib/ansible_test/_internal/io.py
+++ b/test/lib/ansible_test/_internal/io.py
@@ -41,9 +41,20 @@ def make_dirs(path): # type: (str) -> None
raise
-def write_json_file(path, content, create_directories=False, formatted=True): # type: (str, t.Union[t.List[t.Any], t.Dict[str, t.Any]], bool, bool) -> None
+def write_json_file(path, # type: str
+ content, # type: t.Union[t.List[t.Any], t.Dict[str, t.Any]]
+ create_directories=False, # type: bool
+ formatted=True, # type: bool
+ encoder=None, # type: t.Optional[t.Callable[[t.Any], t.Any]]
+ ): # type: (...) -> None
"""Write the given json content to the specified path, optionally creating missing directories."""
- text_content = json.dumps(content, sort_keys=formatted, indent=4 if formatted else None, separators=(', ', ': ') if formatted else (',', ':')) + '\n'
+ text_content = json.dumps(content,
+ sort_keys=formatted,
+ indent=4 if formatted else None,
+ separators=(', ', ': ') if formatted else (',', ':'),
+ cls=encoder,
+ ) + '\n'
+
write_text_file(path, text_content, create_directories=create_directories)
@@ -72,3 +83,12 @@ def open_binary_file(path, mode='rb'): # type: (str, str) -> t.BinaryIO
# noinspection PyTypeChecker
return io.open(to_bytes(path), mode)
+
+
+class SortedSetEncoder(json.JSONEncoder):
+ """Encode sets as sorted lists."""
+ def default(self, obj): # pylint: disable=method-hidden, arguments-differ
+ if isinstance(obj, set):
+ return sorted(obj)
+
+ return super(SortedSetEncoder).default(self, obj)
diff --git a/test/lib/ansible_test/_internal/util_common.py b/test/lib/ansible_test/_internal/util_common.py
index c7ff422752..1cb0602358 100644
--- a/test/lib/ansible_test/_internal/util_common.py
+++ b/test/lib/ansible_test/_internal/util_common.py
@@ -101,6 +101,8 @@ class CommonConfig:
self.truncate = args.truncate # type: int
self.redact = args.redact # type: bool
+ self.info_stderr = False # type: bool
+
self.cache = {}
def get_ansible_config(self): # type: () -> str
@@ -143,10 +145,15 @@ def named_temporary_file(args, prefix, suffix, directory, content):
yield tempfile_fd.name
-def write_json_test_results(category, name, content, formatted=True): # type: (ResultType, str, t.Union[t.List[t.Any], t.Dict[str, t.Any]], bool) -> None
+def write_json_test_results(category, # type: ResultType
+ name, # type: str
+ content, # type: t.Union[t.List[t.Any], t.Dict[str, t.Any]]
+ formatted=True, # type: bool
+ encoder=None, # type: t.Optional[t.Callable[[t.Any], t.Any]]
+ ): # type: (...) -> None
"""Write the given json content to the specified test results path, creating directories as needed."""
path = os.path.join(category.path, name)
- write_json_file(path, content, create_directories=True, formatted=formatted)
+ write_json_file(path, content, create_directories=True, formatted=formatted, encoder=encoder)
def write_text_test_results(category, name, content): # type: (ResultType, str, str) -> None
diff --git a/test/utils/shippable/shippable.sh b/test/utils/shippable/shippable.sh
index 52d62a5d1c..4141eee87f 100755
--- a/test/utils/shippable/shippable.sh
+++ b/test/utils/shippable/shippable.sh
@@ -103,6 +103,9 @@ function cleanup
ansible-test coverage xml --color -v --requirements --group-by command --group-by version ${stub:+"$stub"}
cp -a test/results/reports/coverage=*.xml shippable/codecoverage/
+ # analyze and capture code coverage aggregated by integration test target
+ ansible-test coverage analyze targets generate -v shippable/testresults/coverage-analyze-targets.json
+
# upload coverage report to codecov.io only when using complete on-demand coverage
if [ "${COVERAGE}" == "--coverage" ] && [ "${CHANGED}" == "" ]; then
for file in test/results/reports/coverage=*.xml; do