From ce0405679706e110c08eda65b36617a84d4a4de8 Mon Sep 17 00:00:00 2001
From: Matt Clay <matt@mystile.com>
Date: Fri, 14 May 2021 17:19:40 -0700
Subject: [PATCH] Expand delegation options for coverage commands.

---
 .../ansible-test-coverage-delegation.yml      |  2 +
 test/lib/ansible_test/_internal/cli.py        | 25 ++++---
 .../_internal/commands/coverage/__init__.py   | 11 ++--
 .../_internal/commands/coverage/combine.py    | 66 +++++++++++++++++--
 .../_internal/commands/coverage/erase.py      |  6 +-
 .../_internal/commands/coverage/html.py       |  8 ++-
 .../_internal/commands/coverage/report.py     |  4 +-
 .../_internal/commands/coverage/xml.py        |  8 ++-
 8 files changed, 101 insertions(+), 29 deletions(-)
 create mode 100644 changelogs/fragments/ansible-test-coverage-delegation.yml

diff --git a/changelogs/fragments/ansible-test-coverage-delegation.yml b/changelogs/fragments/ansible-test-coverage-delegation.yml
new file mode 100644
index 00000000000..106fb0d7143
--- /dev/null
+++ b/changelogs/fragments/ansible-test-coverage-delegation.yml
@@ -0,0 +1,2 @@
+minor_changes:
+  - ansible-test - The ``ansible-test coverage`` commands ``combine``, ``report``, ``html`` and ``xml`` now support delegation.
diff --git a/test/lib/ansible_test/_internal/cli.py b/test/lib/ansible_test/_internal/cli.py
index 4d547ade58b..2cc78f52362 100644
--- a/test/lib/ansible_test/_internal/cli.py
+++ b/test/lib/ansible_test/_internal/cli.py
@@ -108,14 +108,17 @@ from .util_common import (
 
 from .commands.coverage.combine import (
     command_coverage_combine,
+    CoverageCombineConfig,
 )
 
 from .commands.coverage.erase import (
     command_coverage_erase,
+    CoverageEraseConfig,
 )
 
 from .commands.coverage.html import (
     command_coverage_html,
+    CoverageHtmlConfig,
 )
 
 from .commands.coverage.report import (
@@ -125,6 +128,7 @@ from .commands.coverage.report import (
 
 from .commands.coverage.xml import (
     command_coverage_xml,
+    CoverageXmlConfig,
 )
 
 from .commands.coverage.analyze.targets.generate import (
@@ -154,7 +158,6 @@ from .commands.coverage.analyze.targets.missing import (
 
 from .commands.coverage import (
     COVERAGE_GROUPS,
-    CoverageConfig,
 )
 
 if t.TYPE_CHECKING:
@@ -579,6 +582,10 @@ def parse_args():
 
     add_environments(coverage_common, argparse, isolated_delegation=False)
 
+    coverage_common_isolated_delegation = argparse.ArgumentParser(add_help=False, parents=[common])
+
+    add_environments(coverage_common_isolated_delegation, argparse)
+
     coverage = subparsers.add_parser('coverage',
                                      help='code coverage management and reporting')
 
@@ -588,11 +595,11 @@ def parse_args():
     add_coverage_analyze(coverage_subparsers, coverage_common)
 
     coverage_combine = coverage_subparsers.add_parser('combine',
-                                                      parents=[coverage_common],
+                                                      parents=[coverage_common_isolated_delegation],
                                                       help='combine coverage data and rewrite remote paths')
 
     coverage_combine.set_defaults(func=command_coverage_combine,
-                                  config=CoverageConfig)
+                                  config=CoverageCombineConfig)
 
     coverage_combine.add_argument('--export',
                                   help='directory to export combined coverage files to')
@@ -604,10 +611,10 @@ def parse_args():
                                                     help='erase coverage data files')
 
     coverage_erase.set_defaults(func=command_coverage_erase,
-                                config=CoverageConfig)
+                                config=CoverageEraseConfig)
 
     coverage_report = coverage_subparsers.add_parser('report',
-                                                     parents=[coverage_common],
+                                                     parents=[coverage_common_isolated_delegation],
                                                      help='generate console coverage report')
 
     coverage_report.set_defaults(func=command_coverage_report,
@@ -629,20 +636,20 @@ def parse_args():
     add_extra_coverage_options(coverage_report)
 
     coverage_html = coverage_subparsers.add_parser('html',
-                                                   parents=[coverage_common],
+                                                   parents=[coverage_common_isolated_delegation],
                                                    help='generate html coverage report')
 
     coverage_html.set_defaults(func=command_coverage_html,
-                               config=CoverageConfig)
+                               config=CoverageHtmlConfig)
 
     add_extra_coverage_options(coverage_html)
 
     coverage_xml = coverage_subparsers.add_parser('xml',
-                                                  parents=[coverage_common],
+                                                  parents=[coverage_common_isolated_delegation],
                                                   help='generate xml coverage report')
 
     coverage_xml.set_defaults(func=command_coverage_xml,
-                              config=CoverageConfig)
+                              config=CoverageXmlConfig)
 
     add_extra_coverage_options(coverage_xml)
 
diff --git a/test/lib/ansible_test/_internal/commands/coverage/__init__.py b/test/lib/ansible_test/_internal/commands/coverage/__init__.py
index e21c302486e..940dd2e325d 100644
--- a/test/lib/ansible_test/_internal/commands/coverage/__init__.py
+++ b/test/lib/ansible_test/_internal/commands/coverage/__init__.py
@@ -59,12 +59,6 @@ class CoverageConfig(EnvironmentConfig):
     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.export = args.export if 'export' in args else None  # type: str
-        self.coverage = False  # temporary work-around to support intercept_command in cover.py
-
 
 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."""
@@ -111,6 +105,11 @@ 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_all_coverage_files():  # type: () -> t.List[str]
+    """Return a list of all coverage file paths."""
+    return get_python_coverage_files() + get_powershell_coverage_files()
+
+
 def get_python_coverage_files(path=None):  # type: (t.Optional[str]) -> t.List[str]
     """Return the list of Python coverage file paths."""
     return get_coverage_files('python', path)
diff --git a/test/lib/ansible_test/_internal/commands/coverage/combine.py b/test/lib/ansible_test/_internal/commands/coverage/combine.py
index f163dff76b6..0dd6f0fa873 100644
--- a/test/lib/ansible_test/_internal/commands/coverage/combine.py
+++ b/test/lib/ansible_test/_internal/commands/coverage/combine.py
@@ -5,6 +5,8 @@ __metaclass__ = type
 import os
 import json
 
+from ... import types as t
+
 from ...target import (
     walk_compile_targets,
     walk_powershell_targets,
@@ -17,7 +19,7 @@ from ...io import (
 from ...util import (
     ANSIBLE_TEST_DATA_ROOT,
     display,
-    find_executable,
+    ApplicationError,
 )
 
 from ...util_common import (
@@ -27,10 +29,19 @@ from ...util_common import (
     write_json_test_results,
 )
 
+from ...executor import (
+    Delegate,
+)
+
+from ...data import (
+    data_context,
+)
+
 from . import (
     enumerate_python_arcs,
     enumerate_powershell_lines,
     get_collection_path_regexes,
+    get_all_coverage_files,
     get_python_coverage_files,
     get_python_modules,
     get_powershell_coverage_files,
@@ -44,9 +55,28 @@ from . import (
 
 def command_coverage_combine(args):
     """Patch paths in coverage files and merge into a single file.
-    :type args: CoverageConfig
+    :type args: CoverageCombineConfig
     :rtype: list[str]
     """
+    if args.delegate:
+        if args.docker or args.remote:
+            paths = get_all_coverage_files()
+            exported_paths = [path for path in paths if path.endswith('=coverage.combined')]
+
+            if not exported_paths:
+                raise ExportedCoverageDataNotFound()
+
+            pairs = [(path, os.path.relpath(path, data_context().content.root)) for path in exported_paths]
+
+            def coverage_callback(files):  # type: (t.List[t.Tuple[str, str]]) -> None
+                """Add the coverage files to the payload file list."""
+                display.info('Including %d exported coverage file(s) in payload.' % len(pairs), verbosity=1)
+                files.extend(pairs)
+
+            data_context().register_payload_callback(coverage_callback)
+
+        raise Delegate()
+
     paths = _command_coverage_combine_powershell(args) + _command_coverage_combine_python(args)
 
     for path in paths:
@@ -55,9 +85,18 @@ def command_coverage_combine(args):
     return paths
 
 
+class ExportedCoverageDataNotFound(ApplicationError):
+    """Exception when no combined coverage data is present yet is required."""
+    def __init__(self):
+        super(ExportedCoverageDataNotFound, self).__init__(
+            'Coverage data must be exported before processing with the `--docker` or `--remote` option.\n'
+            'Export coverage with `ansible-test coverage combine` using the `--export` option.\n'
+            'The exported files must be in the directory: %s/' % ResultType.COVERAGE.relative_path)
+
+
 def _command_coverage_combine_python(args):
     """
-    :type args: CoverageConfig
+    :type args: CoverageCombineConfig
     :rtype: list[str]
     """
     coverage = initialize_coverage(args)
@@ -136,7 +175,7 @@ def _command_coverage_combine_python(args):
 
 def _command_coverage_combine_powershell(args):
     """
-    :type args: CoverageConfig
+    :type args: CoverageCombineConfig
     :rtype: list[str]
     """
     coverage_files = get_powershell_coverage_files()
@@ -217,7 +256,7 @@ def _command_coverage_combine_powershell(args):
 
 def _get_coverage_targets(args, walk_func):
     """
-    :type args: CoverageConfig
+    :type args: CoverageCombineConfig
     :type walk_func: Func
     :rtype: list[tuple[str, int]]
     """
@@ -240,7 +279,7 @@ def _get_coverage_targets(args, walk_func):
 
 def _build_stub_groups(args, sources, default_stub_value):
     """
-    :type args: CoverageConfig
+    :type args: CoverageCombineConfig
     :type sources: List[tuple[str, int]]
     :type default_stub_value: Func[List[str]]
     :rtype: dict
@@ -273,7 +312,7 @@ def _build_stub_groups(args, sources, default_stub_value):
 
 def get_coverage_group(args, coverage_file):
     """
-    :type args: CoverageConfig
+    :type args: CoverageCombineConfig
     :type coverage_file: str
     :rtype: str
     """
@@ -306,3 +345,16 @@ def get_coverage_group(args, coverage_file):
         group = group.lstrip('=')
 
     return group
+
+
+class CoverageCombineConfig(CoverageConfig):
+    """Configuration for the coverage combine command."""
+    def __init__(self, args):  # type: (t.Any) -> None
+        super(CoverageCombineConfig, self).__init__(args)
+
+        self.group_by = frozenset(args.group_by) if args.group_by else frozenset()  # type: t.FrozenSet[str]
+        self.all = args.all  # type: bool
+        self.stub = args.stub  # type: bool
+
+        # only available to coverage combine
+        self.export = args.export if 'export' in args else False  # type: str
diff --git a/test/lib/ansible_test/_internal/commands/coverage/erase.py b/test/lib/ansible_test/_internal/commands/coverage/erase.py
index 7ba9608dfbc..7a41f56f470 100644
--- a/test/lib/ansible_test/_internal/commands/coverage/erase.py
+++ b/test/lib/ansible_test/_internal/commands/coverage/erase.py
@@ -13,7 +13,7 @@ from . import (
 )
 
 
-def command_coverage_erase(args):  # type: (CoverageConfig) -> None
+def command_coverage_erase(args):  # type: (CoverageEraseConfig) -> None
     """Erase code coverage data files collected during test runs."""
     coverage_dir = ResultType.COVERAGE.path
 
@@ -25,3 +25,7 @@ def command_coverage_erase(args):  # type: (CoverageConfig) -> None
 
         if not args.explain:
             os.remove(path)
+
+
+class CoverageEraseConfig(CoverageConfig):
+    """Configuration for the coverage erase command."""
diff --git a/test/lib/ansible_test/_internal/commands/coverage/html.py b/test/lib/ansible_test/_internal/commands/coverage/html.py
index 717f237d0e6..b34e1ef4ecb 100644
--- a/test/lib/ansible_test/_internal/commands/coverage/html.py
+++ b/test/lib/ansible_test/_internal/commands/coverage/html.py
@@ -18,17 +18,17 @@ from ...util_common import (
 
 from .combine import (
     command_coverage_combine,
+    CoverageCombineConfig,
 )
 
 from . import (
     run_coverage,
-    CoverageConfig,
 )
 
 
 def command_coverage_html(args):
     """
-    :type args: CoverageConfig
+    :type args: CoverageHtmlConfig
     """
     output_files = command_coverage_combine(args)
 
@@ -43,3 +43,7 @@ def command_coverage_html(args):
         run_coverage(args, output_file, 'html', ['-i', '-d', dir_name])
 
         display.info('HTML report generated: file:///%s' % os.path.join(dir_name, 'index.html'))
+
+
+class CoverageHtmlConfig(CoverageCombineConfig):
+    """Configuration for the coverage html command."""
diff --git a/test/lib/ansible_test/_internal/commands/coverage/report.py b/test/lib/ansible_test/_internal/commands/coverage/report.py
index ae13b0db500..498d543403f 100644
--- a/test/lib/ansible_test/_internal/commands/coverage/report.py
+++ b/test/lib/ansible_test/_internal/commands/coverage/report.py
@@ -18,11 +18,11 @@ from ...data import (
 
 from .combine import (
     command_coverage_combine,
+    CoverageCombineConfig,
 )
 
 from . import (
     run_coverage,
-    CoverageConfig,
 )
 
 
@@ -143,7 +143,7 @@ def _generate_powershell_output_report(args, coverage_file):
     return report
 
 
-class CoverageReportConfig(CoverageConfig):
+class CoverageReportConfig(CoverageCombineConfig):
     """Configuration for the coverage report command."""
     def __init__(self, args):
         """
diff --git a/test/lib/ansible_test/_internal/commands/coverage/xml.py b/test/lib/ansible_test/_internal/commands/coverage/xml.py
index b02520ec5b9..2296ef61c28 100644
--- a/test/lib/ansible_test/_internal/commands/coverage/xml.py
+++ b/test/lib/ansible_test/_internal/commands/coverage/xml.py
@@ -36,17 +36,17 @@ from ...data import (
 
 from .combine import (
     command_coverage_combine,
+    CoverageCombineConfig,
 )
 
 from . import (
     run_coverage,
-    CoverageConfig,
 )
 
 
 def command_coverage_xml(args):
     """
-    :type args: CoverageConfig
+    :type args: CoverageXmlConfig
     """
     output_files = command_coverage_combine(args)
 
@@ -189,3 +189,7 @@ def _add_cobertura_package(packages, package_name, package_data):
     })
 
     return total_lines_hit, total_line_count
+
+
+class CoverageXmlConfig(CoverageCombineConfig):
+    """Configuration for the coverage xml command."""