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

pantsbuild / pants / 18252174847

05 Oct 2025 01:36AM UTC coverage: 43.382% (-36.9%) from 80.261%
18252174847

push

github

web-flow
run tests on mac arm (#22717)

Just doing the minimal to pull forward the x86_64 pattern.

ref #20993

25776 of 59416 relevant lines covered (43.38%)

1.3 hits per line

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

42.67
/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
3✔
5

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

14
import toml
3✔
15

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

72
"""
3✔
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):
3✔
94
    CONSOLE = ("console", "report")
3✔
95
    XML = ("xml", None)
3✔
96
    HTML = ("html", None)
3✔
97
    RAW = ("raw", None)
3✔
98
    JSON = ("json", None)
3✔
99
    LCOV = ("lcov", None)
3✔
100

101
    _report_name: str
3✔
102

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

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

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

117

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

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

125
    register_interpreter_constraints = True
3✔
126

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

129
    filter = StrListOption(
3✔
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(
3✔
150
        default=[CoverageReportType.CONSOLE],
151
        help="Which coverage report type(s) to emit.",
152
    )
153
    _output_dir = StrOption(
3✔
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(
3✔
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(
3✔
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(
3✔
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(
3✔
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:
3✔
217
        return PurePath(self._output_dir.format(distdir=distdir.relpath))
×
218

219
    @property
3✔
220
    def config_request(self) -> ConfigFilesRequest:
3✔
221
        # Refer to https://coverage.readthedocs.io/en/stable/config.html.
222
        return ConfigFilesRequest(
×
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)
3✔
236
class PytestCoverageData(CoverageData):
3✔
237
    addresses: tuple[Address, ...]
3✔
238
    digest: Digest
3✔
239

240

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

244

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

250

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

254

255
def _parse_toml_config(fc: FileContent) -> MutableMapping[str, Any]:
3✔
256
    try:
×
257
        return toml.loads(fc.content.decode())
×
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:
3✔
273
    cp = configparser.ConfigParser()
×
274
    try:
×
275
        cp.read_string(fc.content.decode())
×
276
        return cp
×
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:
3✔
291
    if PurePath(fc.path).suffix == ".toml":
×
292
        all_config = _parse_toml_config(fc)
×
293
        tool = all_config.setdefault("tool", {})
×
294
        coverage = tool.setdefault("coverage", {})
×
295
        run = coverage.setdefault("run", {})
×
296
        run["relative_files"] = True
×
297
        if "pytest.pex/*" not in run.get("omit", []):
×
298
            run["omit"] = [*run.get("omit", []), "pytest.pex/*"]
×
299
        return FileContent(fc.path, toml.dumps(all_config).encode())
×
300

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

314

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

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

329

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

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

346

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

365

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

370

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

376

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

382

383
@rule(desc="Merge Pytest coverage data", level=LogLevel.DEBUG)
3✔
384
async def merge_coverage_data(
3✔
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)
3✔
508
async def generate_coverage_reports(
3✔
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(
3✔
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():
3✔
636
    return [
×
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