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

pantsbuild / pants / 24055979590

06 Apr 2026 11:17PM UTC coverage: 52.37% (-40.5%) from 92.908%
24055979590

Pull #23225

github

web-flow
Merge 67474653c into 542ca048d
Pull Request #23225: Add --test-show-all-batch-targets to expose all targets in batched pytest

6 of 17 new or added lines in 2 files covered. (35.29%)

23030 existing lines in 605 files now uncovered.

31643 of 60422 relevant lines covered (52.37%)

1.05 hits per line

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

48.28
/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
2✔
5

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

14
import toml
2✔
15

16
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
2✔
17
from pants.backend.python.target_types import ConsoleScript
2✔
18
from pants.backend.python.util_rules.pex import VenvPex, VenvPexProcess, create_venv_pex
2✔
19
from pants.backend.python.util_rules.python_sources import (
2✔
20
    PythonSourceFilesRequest,
21
    prepare_python_sources,
22
)
23
from pants.core.goals.resolves import ExportableTool
2✔
24
from pants.core.goals.test import (
2✔
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
2✔
33
from pants.core.util_rules.distdir import DistDir
2✔
34
from pants.engine.addresses import Address
2✔
35
from pants.engine.fs import (
2✔
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
2✔
46
from pants.engine.intrinsics import (
2✔
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
2✔
56
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
2✔
57
from pants.engine.target import TransitiveTargetsRequest
2✔
58
from pants.engine.unions import UnionRule
2✔
59
from pants.option.global_options import KeepSandboxes
2✔
60
from pants.option.option_types import (
2✔
61
    BoolOption,
62
    EnumListOption,
63
    FileOption,
64
    FloatOption,
65
    StrListOption,
66
    StrOption,
67
)
68
from pants.source.source_root import AllSourceRoots
2✔
69
from pants.util.logging import LogLevel
2✔
70
from pants.util.strutil import softwrap
2✔
71

72
# pants: infer-dep(../subsystems/coverage_py.lock*)
73

74
"""
2✔
75
An overview:
76

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

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

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

91
Step 4: `test.py` outputs the final report.
92
"""
93

94

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

103
    _report_name: str
2✔
104

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

111
    @property
2✔
112
    def report_name(self) -> str:
2✔
113
        return self._report_name
×
114

115
    @property
2✔
116
    def value(self) -> str:
2✔
117
        return cast(str, super().value)
2✔
118

119

120
class CoverageSubsystem(PythonToolBase):
2✔
121
    options_scope = "coverage-py"
2✔
122
    help_short = "Configuration for Python test coverage measurement."
2✔
123

124
    default_main = ConsoleScript("coverage")
2✔
125
    default_requirements = ["coverage[toml]>=6.5,<8"]
2✔
126

127
    register_interpreter_constraints = True
2✔
128

129
    default_lockfile_resource = ("pants.backend.python.subsystems", "coverage_py.lock")
2✔
130

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

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

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

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

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

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

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

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

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

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

218
    def output_dir(self, distdir: DistDir) -> PurePath:
2✔
219
        return PurePath(self._output_dir.format(distdir=distdir.relpath))
×
220

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

236

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

242

243
class PytestCoverageDataCollection(CoverageDataCollection[PytestCoverageData]):
2✔
244
    element_type = PytestCoverageData
2✔
245

246

247
@dataclass(frozen=True)
2✔
248
class CoverageConfig:
2✔
249
    digest: Digest
2✔
250
    path: str
2✔
251

252

253
class InvalidCoverageConfigError(Exception):
2✔
254
    pass
2✔
255

256

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

268
                Parse error: {repr(exc)}
269
                """
270
            )
271
        )
272

273

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

286
                Parse error: {repr(exc)}
287
                """
288
            )
289
        )
290

291

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

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

316

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

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

331

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

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

348

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

367

368
@dataclass(frozen=True)
2✔
369
class CoverageSetup:
2✔
370
    pex: VenvPex
2✔
371

372

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

378

379
@dataclass(frozen=True)
2✔
380
class MergedCoverageData:
2✔
381
    coverage_data: Digest
2✔
382
    addresses: tuple[Address, ...]
2✔
383

384

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

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

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

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

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

441
        no_op_exe_py_path = global_coverage_base_dir / "no-op-exe.py"
×
442

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

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

508

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

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

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

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

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

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

605
    return CoverageReports(tuple(coverage_reports))
×
606

607

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

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

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

636

637
def rules():
2✔
UNCOV
638
    return [
×
639
        *collect_rules(),
640
        UnionRule(CoverageDataCollection, PytestCoverageDataCollection),
641
        UnionRule(ExportableTool, CoverageSubsystem),
642
    ]
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

© 2026 Coveralls, Inc