Repository: pantsbuild/pants

Issue:
coverage-py bug: `global_report=true` errors when `branch=true` for top level projects without source roots
**Describe the bug**
I'm running coverage through pytest (`./pants test ::` with setting `[test].use_coverage = true` in `pants.toml`).
I have top-level project directory without source root like so
```
library_a/
  __init__.py
  BUILD
  simple.py
test/
  __init__.py
  library_a/
    __init__.py
    BUILD
    test_simple.py
pants
pants.toml
pyproject.toml
```

If I run pytest with coverage with the following two configurations
- `[coverage-py].global_report = true` in pants.toml
- `[tool.coverage.run].branch = true` in pyproject.toml

I get the following error
```
  ProcessExecutionFailure: Process 'Merge 2 Pytest coverage reports.' failed with exit code 1.
stdout:
Can't combine arc data with line data

stderr:
```

Expected coverage output is
```
Name                        Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------
library_a/__init__.py       0      0      0      0   100%
library_a/simple.py         4      0      2      0   100%
library_a/untested.py       5      5      0      0     0%
-------------------------------------------------------------
TOTAL                           9      5      2      0    55%
```


**Pants version**
2.8..0

**OS**
MacOS + Ubuntu

**Additional info**

This is specific to project structure with [no source root](https://www.pantsbuild.org/docs/source-roots#no-source-root).

Attached are two zipped projects:
- [example-a.zip](https://github.com/pantsbuild/pants/files/8103241/example-a.zip): simple project to reproduce the bug reported above
- [example-b.zip](https://github.com/pantsbuild/pants/files/8103242/example-b.zip): sample project but using a source root, showing successful pytest with coverage-py run


Discussion:
Thanks for the detailed report!
The issue is this: Because we run pytest in several processes (currently one per test) we have to merge them into a global coverage file, using `coverage combine`. But first we create an initial, trivial, coverage file to merge into, by running coverage on an empty source file. And in that run we don't set branch=True even if you set it in your own coverage config.

In the second (success) case - the dummy empty .py file we run coverage against to create an empty file is not created under source/ but directly at the package root. So in this second case that run sees no files at all, and evidently creates a coverage file that is even more trivial, and that doesn't cause issues

Resolving patch:
diff --git a/src/python/pants/backend/python/goals/coverage_py.py b/src/python/pants/backend/python/goals/coverage_py.py
--- a/src/python/pants/backend/python/goals/coverage_py.py
+++ b/src/python/pants/backend/python/goals/coverage_py.py
@@ -8,7 +8,7 @@
 from enum import Enum
 from io import StringIO
 from pathlib import PurePath
-from typing import cast
+from typing import Any, MutableMapping, cast
 
 import toml
 
@@ -284,33 +284,42 @@ class InvalidCoverageConfigError(Exception):
     pass
 
 
-def _update_config(fc: FileContent) -> FileContent:
-    if PurePath(fc.path).suffix == ".toml":
-        try:
-            all_config = toml.loads(fc.content.decode())
-        except toml.TomlDecodeError as exc:
-            raise InvalidCoverageConfigError(
-                f"Failed to parse the coverage.py config `{fc.path}` as TOML. Please either fix "
-                f"the config or update `[coverage-py].config` and/or "
-                f"`[coverage-py].config_discovery`.\n\nParse error: {repr(exc)}"
-            )
-        tool = all_config.setdefault("tool", {})
-        coverage = tool.setdefault("coverage", {})
-        run = coverage.setdefault("run", {})
-        run["relative_files"] = True
-        if "pytest.pex/*" not in run.get("omit", []):
-            run["omit"] = [*run.get("omit", []), "pytest.pex/*"]
-        return FileContent(fc.path, toml.dumps(all_config).encode())
+def _parse_toml_config(fc: FileContent) -> MutableMapping[str, Any]:
+    try:
+        return toml.loads(fc.content.decode())
+    except toml.TomlDecodeError as exc:
+        raise InvalidCoverageConfigError(
+            f"Failed to parse the coverage.py config `{fc.path}` as TOML. Please either fix "
+            f"the config or update `[coverage-py].config` and/or "
+            f"`[coverage-py].config_discovery`.\n\nParse error: {repr(exc)}"
+        )
+
 
+def _parse_ini_config(fc: FileContent) -> configparser.ConfigParser:
     cp = configparser.ConfigParser()
     try:
         cp.read_string(fc.content.decode())
+        return cp
     except configparser.Error as exc:
         raise InvalidCoverageConfigError(
             f"Failed to parse the coverage.py config `{fc.path}` as INI. Please either fix "
             f"the config or update `[coverage-py].config` and/or `[coverage-py].config_discovery`."
             f"\n\nParse error: {repr(exc)}"
         )
+
+
+def _update_config(fc: FileContent) -> FileContent:
+    if PurePath(fc.path).suffix == ".toml":
+        all_config = _parse_toml_config(fc)
+        tool = all_config.setdefault("tool", {})
+        coverage = tool.setdefault("coverage", {})
+        run = coverage.setdefault("run", {})
+        run["relative_files"] = True
+        if "pytest.pex/*" not in run.get("omit", []):
+            run["omit"] = [*run.get("omit", []), "pytest.pex/*"]
+        return FileContent(fc.path, toml.dumps(all_config).encode())
+
+    cp = _parse_ini_config(fc)
     run_section = "coverage:run" if fc.path in ("tox.ini", "setup.cfg") else "run"
     if not cp.has_section(run_section):
         cp.add_section(run_section)
@@ -324,6 +333,21 @@ def _update_config(fc: FileContent) -> FileContent:
     return FileContent(fc.path, stream.getvalue().encode())
 
 
+def get_branch_value_from_config(fc: FileContent) -> bool:
+    # Note that coverage's default value for the branch setting is False, which we mirror here.
+    if PurePath(fc.path).suffix == ".toml":
+        all_config = _parse_toml_config(fc)
+        return bool(
+            all_config.get("tool", {}).get("coverage", {}).get("run", {}).get("branch", False)
+        )
+
+    cp = _parse_ini_config(fc)
+    run_section = "coverage:run" if fc.path in ("tox.ini", "setup.cfg") else "run"
+    if not cp.has_section(run_section):
+        return False
+    return cp.getboolean(run_section, "branch", fallback=False)
+
+
 @rule
 async def create_or_update_coverage_config(coverage: CoverageSubsystem) -> CoverageConfig:
     config_files = await Get(ConfigFiles, ConfigFilesRequest, coverage.config_request)
@@ -372,6 +396,7 @@ class MergedCoverageData:
 async def merge_coverage_data(
     data_collection: PytestCoverageDataCollection,
     coverage_setup: CoverageSetup,
+    coverage_config: CoverageConfig,
     coverage: CoverageSubsystem,
     source_roots: AllSourceRoots,
 ) -> MergedCoverageData:
@@ -391,8 +416,13 @@ async def merge_coverage_data(
         addresses.append(data.address)
 
     if coverage.global_report:
+        # It's important to set the `branch` value in the empty base report to the value it will
+        # have when running on real inputs, so that the reports are of the same type, and can be
+        # merged successfully. Otherwise we may get "Can't combine arc data with line data" errors.
+        # See https://github.com/pantsbuild/pants/issues/14542 .
+        config_contents = await Get(DigestContents, Digest, coverage_config.digest)
+        branch = get_branch_value_from_config(config_contents[0]) if config_contents else False
         global_coverage_base_dir = PurePath("__global_coverage__")
-
         global_coverage_config_path = global_coverage_base_dir / "pyproject.toml"
         global_coverage_config_content = toml.dumps(
             {
@@ -401,6 +431,7 @@ async def merge_coverage_data(
                         "run": {
                             "relative_files": True,
                             "source": list(source_root.path for source_root in source_roots),
+                            "branch": branch,
                         }
                     }
                 }
@@ -458,7 +489,8 @@ async def merge_coverage_data(
         ProcessResult,
         VenvPexProcess(
             coverage_setup.pex,
-            argv=("combine", *sorted(coverage_data_file_paths)),
+            # We tell combine to keep the original input files, to aid debugging in the sandbox.
+            argv=("combine", "--keep", *sorted(coverage_data_file_paths)),
             input_digest=input_digest,
             output_files=(".coverage",),
             description=f"Merge {len(coverage_data_file_paths)} Pytest coverage reports.",


Test patch:
diff --git a/src/python/pants/backend/python/goals/coverage_py_test.py b/src/python/pants/backend/python/goals/coverage_py_test.py
--- a/src/python/pants/backend/python/goals/coverage_py_test.py
+++ b/src/python/pants/backend/python/goals/coverage_py_test.py
@@ -8,6 +8,7 @@
 from pants.backend.python.goals.coverage_py import (
     CoverageSubsystem,
     create_or_update_coverage_config,
+    get_branch_value_from_config,
 )
 from pants.core.util_rules.config_files import ConfigFiles, ConfigFilesRequest
 from pants.engine.fs import (
@@ -224,3 +225,113 @@ def test_update_run_section() -> None:
             """  # noqa: W291
         )
     )
+
+
+def branch(path: str, content: str) -> bool:
+    fc = FileContent(path, content.encode())
+    return get_branch_value_from_config(fc)
+
+
+def test_get_branch_value_from_config() -> None:
+    assert (
+        branch(
+            "pyproject.toml",
+            dedent(
+                """\
+        [tool.coverage.run]
+        relative_files = false
+        foo = "bar"
+        """
+            ),
+        )
+        is False
+    )
+
+    assert (
+        branch(
+            "pyproject.toml",
+            dedent(
+                """\
+        [tool.coverage.run]
+        relative_files = false
+        branch = true
+        foo = "bar"
+        """
+            ),
+        )
+        is True
+    )
+
+    assert (
+        branch(
+            "pyproject.toml",
+            dedent(
+                """\
+            [tool.coverage]
+                [tool.coverage.run]
+                branch = true
+            """
+            ),
+        )
+        is True
+    )
+
+    assert (
+        branch(
+            ".coveragerc",
+            dedent(
+                """\
+                [run]
+                relative_files: False
+                branch: False
+                foo: bar
+                """
+            ),
+        )
+        is False
+    )
+
+    assert (
+        branch(
+            ".coveragerc",
+            dedent(
+                """\
+                    [run]
+                    relative_files: False
+                    branch: True
+                    foo: bar
+                    """
+            ),
+        )
+        is True
+    )
+
+    assert (
+        branch(
+            "setup.cfg",
+            dedent(
+                """\
+                [coverage:run]
+                relative_files: False
+                branch: False
+                foo: bar
+                """
+            ),
+        )
+        is False
+    )
+
+    assert (
+        branch(
+            "setup.cfg",
+            dedent(
+                """\
+                    [coverage:run]
+                    relative_files: False
+                    branch: True
+                    foo: bar
+                    """
+            ),
+        )
+        is True
+    )