• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 18812500213

26 Oct 2025 03:42AM UTC coverage: 80.284% (+0.005%) from 80.279%
18812500213

Pull #22804

github

web-flow
Merge 2a56fdb46 into 4834308dc
Pull Request #22804: test_shell_command: use correct default cache scope for a test's environment

29 of 31 new or added lines in 2 files covered. (93.55%)

1314 existing lines in 64 files now uncovered.

77900 of 97030 relevant lines covered (80.28%)

3.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.81
/src/python/pants/backend/python/goals/coverage_py.py
1
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
10✔
5

6
import configparser
10✔
7
from collections.abc import MutableMapping
10✔
8
from dataclasses import dataclass
10✔
9
from enum import Enum
10✔
10
from io import StringIO
10✔
11
from pathlib import PurePath
10✔
12
from typing import Any, cast
10✔
13

14
import toml
10✔
15

16
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
10✔
17
from pants.backend.python.target_types import ConsoleScript
10✔
18
from pants.backend.python.util_rules.pex import VenvPex, VenvPexProcess, create_venv_pex
10✔
19
from pants.backend.python.util_rules.python_sources import (
10✔
20
    PythonSourceFilesRequest,
21
    prepare_python_sources,
22
)
23
from pants.core.goals.resolves import ExportableTool
10✔
24
from pants.core.goals.test import (
10✔
25
    ConsoleCoverageReport,
26
    CoverageData,
27
    CoverageDataCollection,
28
    CoverageReport,
29
    CoverageReports,
30
    FilesystemCoverageReport,
31
)
32
from pants.core.util_rules.config_files import ConfigFilesRequest, find_config_file
10✔
33
from pants.core.util_rules.distdir import DistDir
10✔
34
from pants.engine.addresses import Address
10✔
35
from pants.engine.fs import (
10✔
36
    EMPTY_DIGEST,
37
    AddPrefix,
38
    CreateDigest,
39
    Digest,
40
    FileContent,
41
    MergeDigests,
42
    PathGlobs,
43
    Snapshot,
44
)
45
from pants.engine.internals.graph import transitive_targets as transitive_targets_get
10✔
46
from pants.engine.intrinsics import (
10✔
47
    add_prefix,
48
    create_digest,
49
    digest_to_snapshot,
50
    execute_process,
51
    get_digest_contents,
52
    merge_digests,
53
    path_globs_to_digest,
54
)
55
from pants.engine.process import ProcessExecutionFailure, fallible_to_exec_result_or_raise
10✔
56
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
10✔
57
from pants.engine.target import TransitiveTargetsRequest
10✔
58
from pants.engine.unions import UnionRule
10✔
59
from pants.option.global_options import KeepSandboxes
10✔
60
from pants.option.option_types import (
10✔
61
    BoolOption,
62
    EnumListOption,
63
    FileOption,
64
    FloatOption,
65
    StrListOption,
66
    StrOption,
67
)
68
from pants.source.source_root import AllSourceRoots
10✔
69
from pants.util.logging import LogLevel
10✔
70
from pants.util.strutil import softwrap
10✔
71

72
"""
10✔
73
An overview:
74

75
Step 1: Run each test with the appropriate `--cov` arguments.
76
In `python_test_runner.py`, we pass options so that the pytest-cov plugin runs and records which
77
lines were encountered in the test. For each test, it will save a `.coverage` file (SQLite DB
78
format).
79

80
Step 2: Merge the results with `coverage combine`.
81
We now have a bunch of individual `PytestCoverageData` values, each with their own `.coverage` file.
82
We run `coverage combine` to convert this into a single `.coverage` file.
83

84
Step 3: Generate the report with `coverage {html,xml,console}`.
85
All the files in the single merged `.coverage` file are still stripped, and we want to generate a
86
report with the source roots restored. Coverage requires that the files it's reporting on be present
87
when it generates the report, so we populate all the source files.
88

89
Step 4: `test.py` outputs the final report.
90
"""
91

92

93
class CoverageReportType(Enum):
10✔
94
    CONSOLE = ("console", "report")
10✔
95
    XML = ("xml", None)
10✔
96
    HTML = ("html", None)
10✔
97
    RAW = ("raw", None)
10✔
98
    JSON = ("json", None)
10✔
99
    LCOV = ("lcov", None)
10✔
100

101
    _report_name: str
10✔
102

103
    def __new__(cls, value: str, report_name: str | None = None) -> CoverageReportType:
10✔
104
        member: CoverageReportType = object.__new__(cls)
10✔
105
        member._value_ = value
10✔
106
        member._report_name = report_name if report_name is not None else value
10✔
107
        return member
10✔
108

109
    @property
10✔
110
    def report_name(self) -> str:
10✔
111
        return self._report_name
×
112

113
    @property
10✔
114
    def value(self) -> str:
10✔
115
        return cast(str, super().value)
×
116

117

118
class CoverageSubsystem(PythonToolBase):
10✔
119
    options_scope = "coverage-py"
10✔
120
    help_short = "Configuration for Python test coverage measurement."
10✔
121

122
    default_main = ConsoleScript("coverage")
10✔
123
    default_requirements = ["coverage[toml]>=6.5,<8"]
10✔
124

125
    register_interpreter_constraints = True
10✔
126

127
    default_lockfile_resource = ("pants.backend.python.subsystems", "coverage_py.lock")
10✔
128

129
    filter = StrListOption(
10✔
130
        help=softwrap(
131
            """
132
            A list of Python modules or filesystem paths to use in the coverage report, e.g.
133
            `['helloworld_test', 'helloworld/util/dirutil']`.
134

135
            For including files without any test in coverage calculation pass paths instead of modules.
136
            Paths need to be relative to the `pants.toml`.
137

138
            Both modules and directory paths are recursive: any submodules or child paths,
139
            respectively, will be included.
140

141
            If you leave this off, the coverage report will include every file
142
            in the transitive closure of the address/file arguments; for example, `test ::`
143
            will include every Python file in your project, whereas
144
            `test project/app_test.py` will include `app_test.py` and any of its transitive
145
            dependencies.
146
            """
147
        ),
148
    )
149
    report = EnumListOption(
10✔
150
        default=[CoverageReportType.CONSOLE],
151
        help="Which coverage report type(s) to emit.",
152
    )
153
    _output_dir = StrOption(
10✔
154
        default=str(PurePath("{distdir}", "coverage", "python")),
155
        advanced=True,
156
        help="Path to write the Pytest Coverage report to. Must be relative to the build root.",
157
    )
158
    config = FileOption(
10✔
159
        default=None,
160
        advanced=True,
161
        help=lambda cls: softwrap(
162
            f"""
163
            Path to an INI or TOML config file understood by coverage.py
164
            (https://coverage.readthedocs.io/en/latest/config.html).
165

166
            Setting this option will disable `[{cls.options_scope}].config_discovery`. Use
167
            this option if the config is located in a non-standard location.
168
            """
169
        ),
170
    )
171
    config_discovery = BoolOption(
10✔
172
        default=True,
173
        advanced=True,
174
        help=lambda cls: softwrap(
175
            f"""
176
            If true, Pants will include any relevant config files during runs
177
            (`.coveragerc`, `setup.cfg`, `tox.ini`, and `pyproject.toml`).
178

179
            Use `[{cls.options_scope}].config` instead if your config is in a
180
            non-standard location.
181
            """
182
        ),
183
    )
184
    global_report = BoolOption(
10✔
185
        default=False,
186
        help=softwrap(
187
            """
188
            If true, Pants will generate a global coverage report.
189

190
            The global report will include all Python source files in the workspace and not just
191
            those depended on by the tests that were run.
192
            """
193
        ),
194
    )
195
    fail_under = FloatOption(
10✔
196
        default=None,
197
        help=softwrap(
198
            """
199
            Fail if the total combined coverage percentage for all tests is less than this
200
            number.
201

202
            Use this instead of setting `fail_under` in a coverage.py config file,
203
            as the config will apply to each test separately, while you typically want this
204
            to apply to the combined coverage for all tests run.
205

206
            Note that you must generate at least one (non-raw) coverage report for this
207
            check to trigger.
208

209
            Note also that if you specify a non-integral value, you must
210
            also set `[report] precision` properly in the coverage.py config file to make use
211
            of the decimal places. See https://coverage.readthedocs.io/en/latest/config.html.
212
            """
213
        ),
214
    )
215

216
    def output_dir(self, distdir: DistDir) -> PurePath:
10✔
217
        return PurePath(self._output_dir.format(distdir=distdir.relpath))
×
218

219
    @property
10✔
220
    def config_request(self) -> ConfigFilesRequest:
10✔
221
        # Refer to https://coverage.readthedocs.io/en/stable/config.html.
UNCOV
222
        return ConfigFilesRequest(
1✔
223
            specified=self.config,
224
            specified_option_name=f"[{self.options_scope}].config",
225
            discovery=self.config_discovery,
226
            check_existence=[".coveragerc"],
227
            check_content={
228
                "setup.cfg": b"[coverage:",
229
                "tox.ini": b"[coverage:]",
230
                "pyproject.toml": b"[tool.coverage",
231
            },
232
        )
233

234

235
@dataclass(frozen=True)
10✔
236
class PytestCoverageData(CoverageData):
10✔
237
    addresses: tuple[Address, ...]
10✔
238
    digest: Digest
10✔
239

240

241
class PytestCoverageDataCollection(CoverageDataCollection[PytestCoverageData]):
10✔
242
    element_type = PytestCoverageData
10✔
243

244

245
@dataclass(frozen=True)
10✔
246
class CoverageConfig:
10✔
247
    digest: Digest
10✔
248
    path: str
10✔
249

250

251
class InvalidCoverageConfigError(Exception):
10✔
252
    pass
10✔
253

254

255
def _parse_toml_config(fc: FileContent) -> MutableMapping[str, Any]:
10✔
UNCOV
256
    try:
1✔
UNCOV
257
        return toml.loads(fc.content.decode())
1✔
258
    except toml.TomlDecodeError as exc:
×
259
        raise InvalidCoverageConfigError(
×
260
            softwrap(
261
                f"""
262
                Failed to parse the coverage.py config `{fc.path}` as TOML. Please either fix
263
                the config or update `[coverage-py].config` and/or
264
                `[coverage-py].config_discovery`.
265

266
                Parse error: {repr(exc)}
267
                """
268
            )
269
        )
270

271

272
def _parse_ini_config(fc: FileContent) -> configparser.ConfigParser:
10✔
UNCOV
273
    cp = configparser.ConfigParser()
1✔
UNCOV
274
    try:
1✔
UNCOV
275
        cp.read_string(fc.content.decode())
1✔
UNCOV
276
        return cp
1✔
277
    except configparser.Error as exc:
×
278
        raise InvalidCoverageConfigError(
×
279
            softwrap(
280
                f"""
281
                Failed to parse the coverage.py config `{fc.path}` as INI. Please either fix
282
                the config or update `[coverage-py].config` and/or `[coverage-py].config_discovery`.
283

284
                Parse error: {repr(exc)}
285
                """
286
            )
287
        )
288

289

290
def _update_config(fc: FileContent) -> FileContent:
10✔
UNCOV
291
    if PurePath(fc.path).suffix == ".toml":
1✔
UNCOV
292
        all_config = _parse_toml_config(fc)
1✔
UNCOV
293
        tool = all_config.setdefault("tool", {})
1✔
UNCOV
294
        coverage = tool.setdefault("coverage", {})
1✔
UNCOV
295
        run = coverage.setdefault("run", {})
1✔
UNCOV
296
        run["relative_files"] = True
1✔
UNCOV
297
        if "pytest.pex/*" not in run.get("omit", []):
1✔
UNCOV
298
            run["omit"] = [*run.get("omit", []), "pytest.pex/*"]
1✔
UNCOV
299
        return FileContent(fc.path, toml.dumps(all_config).encode())
1✔
300

UNCOV
301
    cp = _parse_ini_config(fc)
1✔
UNCOV
302
    run_section = "coverage:run" if fc.path in ("tox.ini", "setup.cfg") else "run"
1✔
UNCOV
303
    if not cp.has_section(run_section):
1✔
UNCOV
304
        cp.add_section(run_section)
1✔
UNCOV
305
    cp.set(run_section, "relative_files", "True")
1✔
UNCOV
306
    omit_elements = cp[run_section].get("omit", "").split("\n") or ["\n"]
1✔
UNCOV
307
    if "pytest.pex/*" not in omit_elements:
1✔
UNCOV
308
        omit_elements.append("pytest.pex/*")
1✔
UNCOV
309
    cp.set(run_section, "omit", "\n".join(omit_elements))
1✔
UNCOV
310
    stream = StringIO()
1✔
UNCOV
311
    cp.write(stream)
1✔
UNCOV
312
    return FileContent(fc.path, stream.getvalue().encode())
1✔
313

314

315
def get_branch_value_from_config(fc: FileContent) -> bool:
10✔
316
    # Note that coverage's default value for the branch setting is False, which we mirror here.
UNCOV
317
    if PurePath(fc.path).suffix == ".toml":
1✔
UNCOV
318
        all_config = _parse_toml_config(fc)
1✔
UNCOV
319
        return bool(
1✔
320
            all_config.get("tool", {}).get("coverage", {}).get("run", {}).get("branch", False)
321
        )
322

UNCOV
323
    cp = _parse_ini_config(fc)
1✔
UNCOV
324
    run_section = "coverage:run" if fc.path in ("tox.ini", "setup.cfg") else "run"
1✔
UNCOV
325
    if not cp.has_section(run_section):
1✔
326
        return False
×
UNCOV
327
    return cp.getboolean(run_section, "branch", fallback=False)
1✔
328

329

330
def get_namespace_value_from_config(fc: FileContent) -> bool:
10✔
UNCOV
331
    if PurePath(fc.path).suffix == ".toml":
1✔
UNCOV
332
        all_config = _parse_toml_config(fc)
1✔
UNCOV
333
        return bool(
1✔
334
            all_config.get("tool", {})
335
            .get("coverage", {})
336
            .get("report", {})
337
            .get("include_namespace_packages", False)
338
        )
339

UNCOV
340
    cp = _parse_ini_config(fc)
1✔
UNCOV
341
    report_section = "coverage:report" if fc.path in ("tox.ini", "setup.cfg") else "report"
1✔
UNCOV
342
    if not cp.has_section(report_section):
1✔
343
        return False
×
UNCOV
344
    return cp.getboolean(report_section, "include_namespace_packages", fallback=False)
1✔
345

346

347
@rule
10✔
348
async def create_or_update_coverage_config(coverage: CoverageSubsystem) -> CoverageConfig:
10✔
UNCOV
349
    config_files = await find_config_file(coverage.config_request)
1✔
UNCOV
350
    if config_files.snapshot.files:
1✔
UNCOV
351
        digest_contents = await get_digest_contents(config_files.snapshot.digest)
1✔
UNCOV
352
        file_content = _update_config(digest_contents[0])
1✔
353
    else:
UNCOV
354
        cp = configparser.ConfigParser()
1✔
UNCOV
355
        cp.add_section("run")
1✔
UNCOV
356
        cp.set("run", "relative_files", "True")
1✔
UNCOV
357
        cp.set("run", "omit", "\npytest.pex/*")
1✔
UNCOV
358
        stream = StringIO()
1✔
UNCOV
359
        cp.write(stream)
1✔
360
        # We know that .coveragerc doesn't exist, so it's fine to create one.
UNCOV
361
        file_content = FileContent(".coveragerc", stream.getvalue().encode())
1✔
UNCOV
362
    digest = await create_digest(CreateDigest([file_content]))
1✔
UNCOV
363
    return CoverageConfig(digest, file_content.path)
1✔
364

365

366
@dataclass(frozen=True)
10✔
367
class CoverageSetup:
10✔
368
    pex: VenvPex
10✔
369

370

371
@rule
10✔
372
async def setup_coverage(coverage: CoverageSubsystem) -> CoverageSetup:
10✔
373
    pex = await create_venv_pex(**implicitly(coverage.to_pex_request()))
×
374
    return CoverageSetup(pex)
×
375

376

377
@dataclass(frozen=True)
10✔
378
class MergedCoverageData:
10✔
379
    coverage_data: Digest
10✔
380
    addresses: tuple[Address, ...]
10✔
381

382

383
@rule(desc="Merge Pytest coverage data", level=LogLevel.DEBUG)
10✔
384
async def merge_coverage_data(
10✔
385
    data_collection: PytestCoverageDataCollection,
386
    coverage_setup: CoverageSetup,
387
    coverage_config: CoverageConfig,
388
    coverage: CoverageSubsystem,
389
    source_roots: AllSourceRoots,
390
) -> MergedCoverageData:
391
    coverage_digest_gets = []
×
392
    coverage_data_file_paths = []
×
393
    addresses: list[Address] = []
×
394
    for data in data_collection:
×
395
        path_prefix = data.addresses[0].path_safe_spec
×
396
        if len(data.addresses) > 1:
×
397
            path_prefix = f"{path_prefix}+{len(data.addresses) - 1}-others"
×
398

399
        # We prefix each .coverage file with its corresponding address to avoid collisions.
400
        coverage_digest_gets.append(add_prefix(AddPrefix(data.digest, prefix=path_prefix)))
×
401
        coverage_data_file_paths.append(f"{path_prefix}/.coverage")
×
402
        addresses.extend(data.addresses)
×
403

404
    if coverage.global_report or coverage.filter:
×
405
        # It's important to set the `branch` value in the empty base report to the value it will
406
        # have when running on real inputs, so that the reports are of the same type, and can be
407
        # merged successfully. Otherwise we may get "Can't combine arc data with line data" errors.
408
        # See https://github.com/pantsbuild/pants/issues/14542 .
409
        config_contents = await get_digest_contents(coverage_config.digest)
×
410
        branch = get_branch_value_from_config(config_contents[0]) if config_contents else False
×
411
        namespace_packages = (
×
412
            get_namespace_value_from_config(config_contents[0]) if config_contents else False
413
        )
414
        global_coverage_base_dir = PurePath("__global_coverage__")
×
415
        global_coverage_config_path = global_coverage_base_dir / "pyproject.toml"
×
416

417
        if coverage.filter:
×
418
            source = list(coverage.filter)
×
419
        else:
420
            source = [source_root.path for source_root in source_roots]
×
421

422
        global_coverage_config_content = toml.dumps(
×
423
            {
424
                "tool": {
425
                    "coverage": {
426
                        "run": {
427
                            "relative_files": True,
428
                            "source": source,
429
                            "branch": branch,
430
                        },
431
                        "report": {
432
                            "include_namespace_packages": namespace_packages,
433
                        },
434
                    }
435
                }
436
            }
437
        ).encode()
438

439
        no_op_exe_py_path = global_coverage_base_dir / "no-op-exe.py"
×
440

441
        all_sources_digest, no_op_exe_py_digest, global_coverage_config_digest = await concurrently(
×
442
            path_globs_to_digest(
443
                PathGlobs(globs=[f"{source_root.path}/**/*.py" for source_root in source_roots])
444
            ),
445
            create_digest(CreateDigest([FileContent(path=str(no_op_exe_py_path), content=b"")])),
446
            create_digest(
447
                CreateDigest(
448
                    [
449
                        FileContent(
450
                            path=str(global_coverage_config_path),
451
                            content=global_coverage_config_content,
452
                        ),
453
                    ]
454
                )
455
            ),
456
        )
457
        extra_sources_digest = await merge_digests(
×
458
            MergeDigests((all_sources_digest, no_op_exe_py_digest))
459
        )
460
        input_digest = await merge_digests(
×
461
            MergeDigests((extra_sources_digest, global_coverage_config_digest))
462
        )
463
        result = await fallible_to_exec_result_or_raise(
×
464
            **implicitly(
465
                VenvPexProcess(
466
                    coverage_setup.pex,
467
                    argv=(
468
                        "run",
469
                        "--rcfile",
470
                        str(global_coverage_config_path),
471
                        str(no_op_exe_py_path),
472
                    ),
473
                    input_digest=input_digest,
474
                    output_files=(".coverage",),
475
                    description="Create base global Pytest coverage report.",
476
                    level=LogLevel.DEBUG,
477
                )
478
            )
479
        )
480
        coverage_digest_gets.append(
×
481
            add_prefix(AddPrefix(digest=result.output_digest, prefix=str(global_coverage_base_dir)))
482
        )
483
        coverage_data_file_paths.append(str(global_coverage_base_dir / ".coverage"))
×
484
    else:
485
        extra_sources_digest = EMPTY_DIGEST
×
486

487
    input_digest = await merge_digests(MergeDigests(await concurrently(coverage_digest_gets)))
×
488
    result = await fallible_to_exec_result_or_raise(
×
489
        **implicitly(
490
            VenvPexProcess(
491
                coverage_setup.pex,
492
                # We tell combine to keep the original input files, to aid debugging in the sandbox.
493
                argv=("combine", "--keep", *sorted(coverage_data_file_paths)),
494
                input_digest=input_digest,
495
                output_files=(".coverage",),
496
                description=f"Merge {len(coverage_data_file_paths)} Pytest coverage reports.",
497
                level=LogLevel.DEBUG,
498
            )
499
        )
500
    )
501
    return MergedCoverageData(
×
502
        await merge_digests(MergeDigests((result.output_digest, extra_sources_digest))),
503
        tuple(sorted(addresses)),
504
    )
505

506

507
@rule(desc="Generate Pytest coverage reports", level=LogLevel.DEBUG)
10✔
508
async def generate_coverage_reports(
10✔
509
    data_collection: PytestCoverageDataCollection,
510
    coverage_setup: CoverageSetup,
511
    coverage_config: CoverageConfig,
512
    coverage_subsystem: CoverageSubsystem,
513
    keep_sandboxes: KeepSandboxes,
514
    distdir: DistDir,
515
) -> CoverageReports:
516
    """Takes all Python test results and generates a single coverage report."""
517
    merged_coverage_data = await merge_coverage_data(data_collection, **implicitly())
×
518

519
    transitive_targets = await transitive_targets_get(
×
520
        TransitiveTargetsRequest(merged_coverage_data.addresses), **implicitly()
521
    )
522
    sources = await prepare_python_sources(
×
523
        PythonSourceFilesRequest(transitive_targets.closure, include_resources=True), **implicitly()
524
    )
525
    input_digest = await merge_digests(
×
526
        MergeDigests(
527
            (
528
                merged_coverage_data.coverage_data,
529
                coverage_config.digest,
530
                sources.source_files.snapshot.digest,
531
            )
532
        )
533
    )
534

535
    pex_processes: list[VenvPexProcess] = []
×
536
    report_types = []
×
537
    result_snapshot = await digest_to_snapshot(merged_coverage_data.coverage_data)
×
538
    coverage_reports: list[CoverageReport] = []
×
539
    output_dir: PurePath = coverage_subsystem.output_dir(distdir)
×
540
    for report_type in coverage_subsystem.report:
×
541
        if report_type == CoverageReportType.RAW:
×
542
            coverage_reports.append(
×
543
                FilesystemCoverageReport(
544
                    # We don't know yet if the coverage is sufficient, so we let some other report
545
                    # trigger the failure if necessary.
546
                    coverage_insufficient=False,
547
                    report_type=CoverageReportType.RAW.value,
548
                    result_snapshot=result_snapshot,
549
                    directory_to_materialize_to=output_dir,
550
                    report_file=output_dir / ".coverage",
551
                )
552
            )
553
            continue
×
554

555
        report_types.append(report_type)
×
556
        output_file = (
×
557
            f"coverage.{report_type.value}"
558
            if report_type
559
            in {CoverageReportType.XML, CoverageReportType.JSON, CoverageReportType.LCOV}
560
            else None
561
        )
562
        args = [report_type.report_name, f"--rcfile={coverage_config.path}"]
×
563
        if coverage_subsystem.fail_under is not None:
×
564
            args.append(f"--fail-under={coverage_subsystem.fail_under}")
×
565
        pex_processes.append(
×
566
            VenvPexProcess(
567
                coverage_setup.pex,
568
                argv=tuple(args),
569
                input_digest=input_digest,
570
                output_directories=("htmlcov",) if report_type == CoverageReportType.HTML else None,
571
                output_files=(output_file,) if output_file else None,
572
                description=f"Generate Pytest {report_type.report_name} coverage report.",
573
                level=LogLevel.DEBUG,
574
            )
575
        )
576
    results = await concurrently(
×
577
        execute_process(**implicitly({process: VenvPexProcess})) for process in pex_processes
578
    )
579
    for proc, res in zip(pex_processes, results):
×
580
        if res.exit_code not in {0, 2}:
×
581
            # coverage.py uses exit code 2 if --fail-under triggers, in which case the
582
            # reports are still generated.
583
            raise ProcessExecutionFailure(
×
584
                res.exit_code,
585
                res.stdout,
586
                res.stderr,
587
                proc.description,
588
                keep_sandboxes=keep_sandboxes,
589
            )
590

591
    # In practice if one result triggers --fail-under, they all will, but no need to rely on that.
592
    result_exit_codes = tuple(res.exit_code for res in results)
×
593
    result_stdouts = tuple(res.stdout for res in results)
×
594
    result_snapshots = await concurrently(digest_to_snapshot(res.output_digest) for res in results)
×
595

596
    coverage_reports.extend(
×
597
        _get_coverage_report(output_dir, report_type, exit_code != 0, stdout, snapshot)
598
        for (report_type, exit_code, stdout, snapshot) in zip(
599
            report_types, result_exit_codes, result_stdouts, result_snapshots
600
        )
601
    )
602

603
    return CoverageReports(tuple(coverage_reports))
×
604

605

606
def _get_coverage_report(
10✔
607
    output_dir: PurePath,
608
    report_type: CoverageReportType,
609
    coverage_insufficient: bool,
610
    result_stdout: bytes,
611
    result_snapshot: Snapshot,
612
) -> CoverageReport:
613
    if report_type == CoverageReportType.CONSOLE:
×
614
        return ConsoleCoverageReport(coverage_insufficient, result_stdout.decode())
×
615

616
    try:
×
617
        report_file = {
×
618
            CoverageReportType.HTML: output_dir / "htmlcov" / "index.html",
619
            CoverageReportType.XML: output_dir / "coverage.xml",
620
            CoverageReportType.JSON: output_dir / "coverage.json",
621
            CoverageReportType.LCOV: output_dir / "coverage.lcov",
622
        }[report_type]
623
    except KeyError:
×
624
        raise ValueError(f"Invalid coverage report type: {report_type}") from None
×
625

626
    return FilesystemCoverageReport(
×
627
        coverage_insufficient=coverage_insufficient,
628
        report_type=report_type.value,
629
        result_snapshot=result_snapshot,
630
        directory_to_materialize_to=output_dir,
631
        report_file=report_file,
632
    )
633

634

635
def rules():
10✔
636
    return [
4✔
637
        *collect_rules(),
638
        UnionRule(CoverageDataCollection, PytestCoverageDataCollection),
639
        UnionRule(ExportableTool, CoverageSubsystem),
640
    ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc