• 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

81.22
/src/python/pants/backend/python/goals/pytest_runner.py
1
# Copyright 2018 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 logging
2✔
7
import re
2✔
8
from abc import ABC, abstractmethod
2✔
9
from collections import defaultdict
2✔
10
from dataclasses import dataclass
2✔
11

12
from packaging.utils import canonicalize_name as canonicalize_project_name
2✔
13

14
from pants.backend.python.goals.coverage_py import (
2✔
15
    CoverageConfig,
16
    CoverageSubsystem,
17
    PytestCoverageData,
18
)
19
from pants.backend.python.subsystems import pytest
2✔
20
from pants.backend.python.subsystems.debugpy import DebugPy
2✔
21
from pants.backend.python.subsystems.pytest import PyTest, PythonTestFieldSet
2✔
22
from pants.backend.python.subsystems.python_tool_base import get_lockfile_metadata
2✔
23
from pants.backend.python.subsystems.setup import PythonSetup
2✔
24
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
2✔
25
from pants.backend.python.util_rules.local_dists import LocalDistsPexRequest, build_local_dists
2✔
26
from pants.backend.python.util_rules.lockfile_metadata import (
2✔
27
    PythonLockfileMetadataV2,
28
    PythonLockfileMetadataV3,
29
)
30
from pants.backend.python.util_rules.pex import (
2✔
31
    Pex,
32
    PexRequest,
33
    VenvPexProcess,
34
    create_pex,
35
    create_venv_pex,
36
    get_req_strings,
37
    setup_venv_pex_process,
38
)
39
from pants.backend.python.util_rules.pex_from_targets import RequirementsPexRequest
2✔
40
from pants.backend.python.util_rules.pex_requirements import PexRequirements
2✔
41
from pants.backend.python.util_rules.python_sources import (
2✔
42
    PythonSourceFilesRequest,
43
    prepare_python_sources,
44
)
45
from pants.core.goals.test import (
2✔
46
    BuildPackageDependenciesRequest,
47
    RuntimePackageDependenciesField,
48
    TestDebugAdapterRequest,
49
    TestDebugRequest,
50
    TestExtraEnv,
51
    TestRequest,
52
    TestResult,
53
    TestSubsystem,
54
    build_runtime_package_dependencies,
55
)
56
from pants.core.subsystems.debug_adapter import DebugAdapterSubsystem
2✔
57
from pants.core.util_rules.config_files import find_config_file
2✔
58
from pants.core.util_rules.env_vars import environment_vars_subset
2✔
59
from pants.core.util_rules.partitions import Partition, PartitionerType, Partitions
2✔
60
from pants.core.util_rules.source_files import SourceFilesRequest, determine_source_files
2✔
61
from pants.engine.addresses import Address
2✔
62
from pants.engine.collection import Collection
2✔
63
from pants.engine.env_vars import EnvironmentVarsRequest
2✔
64
from pants.engine.environment import EnvironmentName
2✔
65
from pants.engine.fs import (
2✔
66
    EMPTY_DIGEST,
67
    CreateDigest,
68
    Digest,
69
    DigestContents,
70
    DigestSubset,
71
    Directory,
72
    MergeDigests,
73
    PathGlobs,
74
    RemovePrefix,
75
)
76
from pants.engine.internals.graph import resolve_target
2✔
77
from pants.engine.internals.graph import transitive_targets as transitive_targets_get
2✔
78
from pants.engine.intrinsics import (
2✔
79
    create_digest,
80
    digest_subset_to_digest,
81
    digest_to_snapshot,
82
    execute_process_with_retry,
83
    get_digest_contents,
84
    merge_digests,
85
)
86
from pants.engine.process import InteractiveProcess, Process, ProcessWithRetries
2✔
87
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
2✔
88
from pants.engine.target import Target, TransitiveTargetsRequest, WrappedTargetRequest
2✔
89
from pants.engine.unions import UnionMembership, UnionRule, union
2✔
90
from pants.option.global_options import GlobalOptions
2✔
91
from pants.util.docutil import doc_url
2✔
92
from pants.util.frozendict import FrozenDict
2✔
93
from pants.util.logging import LogLevel
2✔
94
from pants.util.ordered_set import OrderedSet
2✔
95
from pants.util.pip_requirement import PipRequirement
2✔
96
from pants.util.strutil import softwrap
2✔
97

98
logger = logging.getLogger()
2✔
99

100

101
# -----------------------------------------------------------------------------------------
102
# Plugin hook
103
# -----------------------------------------------------------------------------------------
104

105

106
@dataclass(frozen=True)
2✔
107
class PytestPluginSetup:
2✔
108
    """The result of custom set up logic before Pytest runs.
109

110
    Please reach out it if you would like certain functionality, such as allowing your plugin to set
111
    environment variables.
112
    """
113

114
    digest: Digest = EMPTY_DIGEST
2✔
115
    extra_sys_path: tuple[str, ...] = ()
2✔
116

117

118
@union(in_scope_types=[EnvironmentName])
2✔
119
@dataclass(frozen=True)
2✔
120
class PytestPluginSetupRequest(ABC):
2✔
121
    """A request to set up the test environment before Pytest runs, e.g. to set up databases.
122

123
    To use, subclass PytestPluginSetupRequest, register the rule
124
    `UnionRule(PytestPluginSetupRequest, MyCustomPytestPluginSetupRequest)`, and add a rule that
125
    takes your subclass as a parameter and returns `PytestPluginSetup`.
126
    """
127

128
    target: Target
2✔
129

130
    @classmethod
2✔
131
    @abstractmethod
2✔
132
    def is_applicable(cls, target: Target) -> bool:
2✔
133
        """Whether the setup implementation should be used for this target or not."""
134

135

136
@rule(polymorphic=True)
2✔
137
async def get_pytest_plugin_setup(req: PytestPluginSetupRequest) -> PytestPluginSetup:
2✔
138
    raise NotImplementedError()
×
139

140

141
class AllPytestPluginSetups(Collection[PytestPluginSetup]):
2✔
142
    pass
2✔
143

144

145
# TODO: Why is this necessary? We should be able to use `PythonTestFieldSet` as the rule param.
146
@dataclass(frozen=True)
2✔
147
class AllPytestPluginSetupsRequest:
2✔
148
    addresses: tuple[Address, ...]
2✔
149

150

151
@rule
2✔
152
async def run_all_setup_plugins(
2✔
153
    request: AllPytestPluginSetupsRequest, union_membership: UnionMembership
154
) -> AllPytestPluginSetups:
155
    wrapped_tgts = await concurrently(
2✔
156
        resolve_target(
157
            WrappedTargetRequest(address, description_of_origin="<infallible>"), **implicitly()
158
        )
159
        for address in request.addresses
160
    )
161
    setup_requests = [
2✔
162
        request_type(wrapped_tgt.target)  # type: ignore[abstract]
163
        for request_type in union_membership.get(PytestPluginSetupRequest)
164
        for wrapped_tgt in wrapped_tgts
165
        if request_type.is_applicable(wrapped_tgt.target)
166
    ]
167
    setups = await concurrently(
2✔
168
        get_pytest_plugin_setup(**implicitly({request: PytestPluginSetupRequest}))
169
        for request in setup_requests
170
    )
171
    return AllPytestPluginSetups(setups)
2✔
172

173

174
# -----------------------------------------------------------------------------------------
175
# Core logic
176
# -----------------------------------------------------------------------------------------
177

178

179
# If a user wants extra pytest output (e.g., plugin output) to show up in dist/
180
# they must ensure that output goes under this directory. E.g.,
181
# ./pants test <target> -- --html=extra-output/report.html
182
_EXTRA_OUTPUT_DIR = "extra-output"
2✔
183

184

185
@dataclass(frozen=True)
2✔
186
class TestMetadata:
2✔
187
    """Parameters that must be constant for all test targets in a `pytest` batch."""
188

189
    interpreter_constraints: InterpreterConstraints
2✔
190
    extra_env_vars: tuple[str, ...]
2✔
191
    xdist_concurrency: int | None
2✔
192
    resolve: str
2✔
193
    environment: str
2✔
194
    compatability_tag: str | None = None
2✔
195

196
    # Prevent this class from being detected by pytest as a test class.
197
    __test__ = False
2✔
198

199
    @property
2✔
200
    def description(self) -> str | None:
2✔
201
        if not self.compatability_tag:
2✔
202
            return None
2✔
203

204
        # TODO: Put more info here.
205
        return self.compatability_tag
2✔
206

207

208
@dataclass(frozen=True)
2✔
209
class TestSetupRequest:
2✔
210
    field_sets: tuple[PythonTestFieldSet, ...]
2✔
211
    metadata: TestMetadata
2✔
212
    is_debug: bool
2✔
213
    extra_env: FrozenDict[str, str] = FrozenDict()
2✔
214
    prepend_argv: tuple[str, ...] = ()
2✔
215
    additional_pexes: tuple[Pex, ...] = ()
2✔
216

217

218
@dataclass(frozen=True)
2✔
219
class TestSetup:
2✔
220
    process: Process
2✔
221
    results_file_name: str | None
2✔
222

223
    # Prevent this class from being detected by pytest as a test class.
224
    __test__ = False
2✔
225

226

227
_TEST_PATTERN = re.compile(b"def\\s+test_")
2✔
228

229

230
def _count_pytest_tests(contents: DigestContents) -> int:
2✔
UNCOV
231
    return sum(len(_TEST_PATTERN.findall(file.content)) for file in contents)
×
232

233

234
async def validate_pytest_cov_included(_pytest: PyTest):
2✔
UNCOV
235
    if _pytest.requirements:
×
236
        # We'll only be using this subset of the lockfile.
UNCOV
237
        req_strings = (await get_req_strings(PexRequirements(_pytest.requirements))).req_strings
×
UNCOV
238
        requirements = {PipRequirement.parse(req_string) for req_string in req_strings}
×
239
    else:
240
        # We'll be using the entire lockfile.
UNCOV
241
        lockfile_metadata = await get_lockfile_metadata(_pytest)
×
UNCOV
242
        if not isinstance(lockfile_metadata, (PythonLockfileMetadataV2, PythonLockfileMetadataV3)):
×
243
            return
×
UNCOV
244
        requirements = lockfile_metadata.requirements
×
UNCOV
245
    if not any(canonicalize_project_name(req.name) == "pytest-cov" for req in requirements):
×
UNCOV
246
        raise ValueError(
×
247
            softwrap(
248
                f"""\
249
                You set `[test].use_coverage`, but the custom resolve
250
                `{_pytest.install_from_resolve}` used to install pytest is missing
251
                `pytest-cov`, which is needed to collect coverage data.
252

253
                See {doc_url("docs/python/goals/test#pytest-version-and-plugins")} for details
254
                on how to set up a custom resolve for use by pytest.
255
                """
256
            )
257
        )
258

259

260
@rule(level=LogLevel.DEBUG)
2✔
261
async def setup_pytest_for_target(
2✔
262
    request: TestSetupRequest,
263
    pytest: PyTest,
264
    test_subsystem: TestSubsystem,
265
    coverage_config: CoverageConfig,
266
    coverage_subsystem: CoverageSubsystem,
267
    test_extra_env: TestExtraEnv,
268
) -> TestSetup:
269
    addresses = tuple(field_set.address for field_set in request.field_sets)
2✔
270

271
    transitive_targets, plugin_setups = await concurrently(
2✔
272
        transitive_targets_get(TransitiveTargetsRequest(addresses), **implicitly()),
273
        run_all_setup_plugins(AllPytestPluginSetupsRequest(addresses), **implicitly()),
274
    )
275
    all_targets = transitive_targets.closure
2✔
276

277
    interpreter_constraints = request.metadata.interpreter_constraints
2✔
278

279
    requirements_pex_get = create_pex(**implicitly(RequirementsPexRequest(addresses)))
2✔
280
    pytest_pex_get = create_pex(
2✔
281
        pytest.to_pex_request(interpreter_constraints=interpreter_constraints)
282
    )
283

284
    # Ensure that the empty extra output dir exists.
285
    extra_output_directory_digest_get = create_digest(CreateDigest([Directory(_EXTRA_OUTPUT_DIR)]))
2✔
286

287
    prepared_sources_get = prepare_python_sources(
2✔
288
        PythonSourceFilesRequest(all_targets, include_files=True), **implicitly()
289
    )
290

291
    # Get the file names for the test_target so that we can specify to Pytest precisely which files
292
    # to test, rather than using auto-discovery.
293
    field_set_source_files_get = determine_source_files(
2✔
294
        SourceFilesRequest([field_set.source for field_set in request.field_sets])
295
    )
296

297
    field_set_extra_env_get = environment_vars_subset(
2✔
298
        EnvironmentVarsRequest(request.metadata.extra_env_vars), **implicitly()
299
    )
300

301
    (
2✔
302
        pytest_pex,
303
        requirements_pex,
304
        prepared_sources,
305
        field_set_source_files,
306
        field_set_extra_env,
307
        extra_output_directory_digest,
308
    ) = await concurrently(
309
        pytest_pex_get,
310
        requirements_pex_get,
311
        prepared_sources_get,
312
        field_set_source_files_get,
313
        field_set_extra_env_get,
314
        extra_output_directory_digest_get,
315
    )
316

317
    local_dists = await build_local_dists(
2✔
318
        LocalDistsPexRequest(
319
            addresses,
320
            interpreter_constraints=interpreter_constraints,
321
            sources=prepared_sources,
322
        )
323
    )
324

325
    pytest_runner_pex_get = create_venv_pex(
2✔
326
        **implicitly(
327
            PexRequest(
328
                output_filename="pytest_runner.pex",
329
                interpreter_constraints=interpreter_constraints,
330
                main=pytest.main,
331
                internal_only=True,
332
                pex_path=[pytest_pex, requirements_pex, local_dists.pex, *request.additional_pexes],
333
            )
334
        )
335
    )
336
    config_files_get = find_config_file(pytest.config_request(field_set_source_files.snapshot.dirs))
2✔
337
    pytest_runner_pex, config_files = await concurrently(pytest_runner_pex_get, config_files_get)
2✔
338

339
    # The coverage and pytest config may live in the same config file (e.g., setup.cfg, tox.ini
340
    # or pyproject.toml), and wee may have rewritten those files to augment the coverage config,
341
    # in which case we must ensure that the original and rewritten files don't collide.
342
    pytest_config_digest = config_files.snapshot.digest
2✔
343
    if coverage_config.path in config_files.snapshot.files:
2✔
UNCOV
344
        subset_paths = list(config_files.snapshot.files)
×
345
        # Remove the original file, and rely on the rewritten file, which contains all the
346
        # pytest-related config unchanged.
UNCOV
347
        subset_paths.remove(coverage_config.path)
×
UNCOV
348
        pytest_config_digest = await digest_subset_to_digest(
×
349
            DigestSubset(pytest_config_digest, PathGlobs(subset_paths))
350
        )
351

352
    input_digest = await merge_digests(
2✔
353
        MergeDigests(
354
            (
355
                coverage_config.digest,
356
                local_dists.remaining_sources.source_files.snapshot.digest,
357
                pytest_config_digest,
358
                extra_output_directory_digest,
359
                *(plugin_setup.digest for plugin_setup in plugin_setups),
360
            )
361
        )
362
    )
363

364
    # Don't forget to keep "Customize Pytest command line options per target" section in
365
    # docs/markdown/Python/python-goals/python-test-goal.md up to date when changing
366
    # which flags are added to `pytest_args`.
367
    pytest_args = [
2✔
368
        # Always include colors and strip them out for display below (if required), for better cache
369
        # hit rates
370
        "--color=yes"
371
    ]
372
    output_files = []
2✔
373

374
    results_file_name = None
2✔
375
    if not request.is_debug:
2✔
376
        results_file_prefix = request.field_sets[0].address.path_safe_spec
2✔
377
        if len(request.field_sets) > 1:
2✔
378
            results_file_prefix = (
2✔
379
                f"batch-of-{results_file_prefix}+{len(request.field_sets) - 1}-files"
380
            )
381
        results_file_name = f"{results_file_prefix}.xml"
2✔
382
        pytest_args.extend(
2✔
383
            (f"--junit-xml={results_file_name}", "-o", f"junit_family={pytest.junit_family}")
384
        )
385
        output_files.append(results_file_name)
2✔
386

387
    if test_subsystem.use_coverage and not request.is_debug:
2✔
UNCOV
388
        await validate_pytest_cov_included(pytest)
×
UNCOV
389
        output_files.append(".coverage")
×
390

UNCOV
391
        if coverage_subsystem.filter:
×
392
            cov_args = [f"--cov={morf}" for morf in coverage_subsystem.filter]
×
393
        else:
394
            # N.B.: Passing `--cov=` or `--cov=.` to communicate "record coverage for all sources"
395
            # fails in certain contexts as detailed in:
396
            #   https://github.com/pantsbuild/pants/issues/12390
397
            # Instead we focus coverage on just the directories containing python source files
398
            # materialized to the Process chroot.
UNCOV
399
            cov_args = [f"--cov={source_root}" for source_root in prepared_sources.source_roots]
×
400

UNCOV
401
        pytest_args.extend(
×
402
            (
403
                "--cov-report=",  # Turn off output.
404
                f"--cov-config={coverage_config.path}",
405
                *cov_args,
406
            )
407
        )
408

409
    extra_sys_path = OrderedSet(
2✔
410
        (
411
            *prepared_sources.source_roots,
412
            *(entry for plugin_setup in plugin_setups for entry in plugin_setup.extra_sys_path),
413
        )
414
    )
415
    extra_env = {
2✔
416
        "PEX_EXTRA_SYS_PATH": ":".join(extra_sys_path),
417
        **request.extra_env,
418
        **test_extra_env.env,
419
        # NOTE: field_set_extra_env intentionally after `test_extra_env` to allow overriding within
420
        # `python_tests`.
421
        **field_set_extra_env,
422
    }
423

424
    # Cache test runs only if they are successful, or not at all if `--test-force`.
425
    cache_scope = test_subsystem.default_process_cache_scope
2✔
426

427
    xdist_concurrency = 0
2✔
428
    if pytest.xdist_enabled and not request.is_debug:
2✔
UNCOV
429
        concurrency = request.metadata.xdist_concurrency
×
UNCOV
430
        if concurrency is None:
×
431
            contents = await get_digest_contents(field_set_source_files.snapshot.digest)
×
432
            concurrency = _count_pytest_tests(contents)
×
UNCOV
433
        xdist_concurrency = concurrency
×
434

435
    timeout_seconds: int | None = None
2✔
436
    for field_set in request.field_sets:
2✔
437
        timeout = field_set.timeout.calculate_from_global_options(test_subsystem, pytest)
2✔
438
        if timeout:
2✔
439
            if timeout_seconds:
×
440
                timeout_seconds += timeout
×
441
            else:
442
                timeout_seconds = timeout
×
443

444
    if test_subsystem.show_all_batch_targets and len(request.field_sets) > 1:
2✔
NEW
445
        run_description = ", ".join(fs.address.spec for fs in request.field_sets)
×
446
    else:
447
        run_description = request.field_sets[0].address.spec
2✔
448
        if len(request.field_sets) > 1:
2✔
449
            run_description = (
2✔
450
                f"batch of {run_description} and {len(request.field_sets) - 1} other files"
451
            )
452
    process = await setup_venv_pex_process(
2✔
453
        VenvPexProcess(
454
            pytest_runner_pex,
455
            argv=(
456
                *request.prepend_argv,
457
                *pytest.args,
458
                *(("-c", pytest.config) if pytest.config else ()),
459
                *(("-n", "{pants_concurrency}") if xdist_concurrency else ()),
460
                # N.B.: Now that we're using command-line options instead of the PYTEST_ADDOPTS
461
                # environment variable, it's critical that `pytest_args` comes after `pytest.args`.
462
                *pytest_args,
463
                *field_set_source_files.files,
464
            ),
465
            extra_env=extra_env,
466
            input_digest=input_digest,
467
            output_directories=(_EXTRA_OUTPUT_DIR,),
468
            output_files=output_files,
469
            timeout_seconds=timeout_seconds,
470
            execution_slot_variable=pytest.execution_slot_var,
471
            concurrency_available=xdist_concurrency,
472
            description=f"Run Pytest for {run_description}",
473
            level=LogLevel.DEBUG,
474
            cache_scope=cache_scope,
475
        ),
476
        **implicitly(),
477
    )
478
    return TestSetup(process, results_file_name=results_file_name)
2✔
479

480

481
class PyTestRequest(TestRequest):
2✔
482
    tool_subsystem = PyTest  # type: ignore[assignment]
2✔
483
    field_set_type = PythonTestFieldSet
2✔
484
    partitioner_type = PartitionerType.CUSTOM
2✔
485
    supports_debug = True
2✔
486
    supports_debug_adapter = True
2✔
487

488

489
@rule(desc="Partition Pytest", level=LogLevel.DEBUG)
2✔
490
async def partition_python_tests(
2✔
491
    request: PyTestRequest.PartitionRequest[PythonTestFieldSet],
492
    python_setup: PythonSetup,
493
) -> Partitions[PythonTestFieldSet, TestMetadata]:
494
    partitions = []
2✔
495
    compatible_tests = defaultdict(list)
2✔
496

497
    for field_set in request.field_sets:
2✔
498
        metadata = TestMetadata(
2✔
499
            interpreter_constraints=InterpreterConstraints.create_from_field_sets(
500
                [field_set], python_setup
501
            ),
502
            extra_env_vars=field_set.extra_env_vars.sorted(),
503
            xdist_concurrency=field_set.xdist_concurrency.value,
504
            resolve=field_set.resolve.normalized_value(python_setup),
505
            environment=field_set.environment.value,
506
            compatability_tag=field_set.batch_compatibility_tag.value,
507
        )
508

509
        if not metadata.compatability_tag:
2✔
510
            # Tests without a compatibility tag are assumed to be incompatible with all others.
511
            partitions.append(Partition((field_set,), metadata))
2✔
512
        else:
513
            # Group tests by their common metadata.
514
            compatible_tests[metadata].append(field_set)
2✔
515

516
    for metadata, field_sets in compatible_tests.items():
2✔
517
        partitions.append(Partition(tuple(field_sets), metadata))
2✔
518

519
    return Partitions(partitions)
2✔
520

521

522
@rule(desc="Run Pytest", level=LogLevel.DEBUG)
2✔
523
async def run_python_tests(
2✔
524
    batch: PyTestRequest.Batch[PythonTestFieldSet, TestMetadata],
525
    pytest: PyTest,
526
    test_subsystem: TestSubsystem,
527
    global_options: GlobalOptions,
528
) -> TestResult:
529
    setup = await setup_pytest_for_target(
2✔
530
        TestSetupRequest(batch.elements, batch.partition_metadata, is_debug=False), **implicitly()
531
    )
532

533
    results = await execute_process_with_retry(
2✔
534
        ProcessWithRetries(setup.process, test_subsystem.attempts_default)
535
    )
536
    last_result = results.last
2✔
537

538
    def warning_description() -> str:
2✔
NEW
539
        if test_subsystem.show_all_batch_targets and len(batch.elements) > 1:
×
NEW
540
            description = ", ".join(fs.address.spec for fs in batch.elements)
×
541
        else:
NEW
542
            description = batch.elements[0].address.spec
×
NEW
543
            if len(batch.elements) > 1:
×
NEW
544
                description = (
×
545
                    f"batch containing {description} and {len(batch.elements) - 1} other files"
546
                )
547
        if batch.partition_metadata.description:
×
548
            description = f"{description} ({batch.partition_metadata.description})"
×
549
        return description
×
550

551
    coverage_data = None
2✔
552
    if test_subsystem.use_coverage:
2✔
UNCOV
553
        coverage_snapshot = await digest_to_snapshot(
×
554
            **implicitly(DigestSubset(last_result.output_digest, PathGlobs([".coverage"])))
555
        )
UNCOV
556
        if coverage_snapshot.files == (".coverage",):
×
UNCOV
557
            coverage_data = PytestCoverageData(
×
558
                tuple(field_set.address for field_set in batch.elements), coverage_snapshot.digest
559
            )
560
        else:
561
            logger.warning(f"Failed to generate coverage data for {warning_description()}.")
×
562

563
    xml_results_snapshot = None
2✔
564
    if setup.results_file_name:
2✔
565
        xml_results_snapshot = await digest_to_snapshot(
2✔
566
            **implicitly(
567
                DigestSubset(last_result.output_digest, PathGlobs([setup.results_file_name]))
568
            )
569
        )
570
        if xml_results_snapshot.files != (setup.results_file_name,):
2✔
571
            logger.warning(f"Failed to generate JUnit XML data for {warning_description()}.")
×
572
    extra_output_snapshot = await digest_to_snapshot(
2✔
573
        **implicitly(
574
            DigestSubset(last_result.output_digest, PathGlobs([f"{_EXTRA_OUTPUT_DIR}/**"]))
575
        )
576
    )
577
    extra_output_snapshot = await digest_to_snapshot(
2✔
578
        **implicitly(RemovePrefix(extra_output_snapshot.digest, _EXTRA_OUTPUT_DIR))
579
    )
580

581
    if last_result.exit_code == 5 and pytest.allow_empty_test_collection:
2✔
UNCOV
582
        return TestResult.no_tests_found_in_batch(batch, test_subsystem.output)
×
583

584
    return TestResult.from_batched_fallible_process_result(
2✔
585
        results.results,
586
        batch=batch,
587
        output_setting=test_subsystem.output,
588
        coverage_data=coverage_data,
589
        xml_results=xml_results_snapshot,
590
        extra_output=extra_output_snapshot,
591
        output_simplifier=global_options.output_simplifier(),
592
    )
593

594

595
@rule(desc="Set up Pytest to run interactively", level=LogLevel.DEBUG)
2✔
596
async def debug_python_test(
2✔
597
    batch: PyTestRequest.Batch[PythonTestFieldSet, TestMetadata],
598
) -> TestDebugRequest:
599
    setup = await setup_pytest_for_target(
2✔
600
        TestSetupRequest(batch.elements, batch.partition_metadata, is_debug=True), **implicitly()
601
    )
602
    return TestDebugRequest(
2✔
603
        InteractiveProcess.from_process(
604
            setup.process, forward_signals_to_process=False, restartable=True
605
        )
606
    )
607

608

609
@rule(desc="Set up debugpy to run an interactive Pytest session", level=LogLevel.DEBUG)
2✔
610
async def debugpy_python_test(
2✔
611
    batch: PyTestRequest.Batch[PythonTestFieldSet, TestMetadata],
612
    debugpy: DebugPy,
613
    debug_adapter: DebugAdapterSubsystem,
614
    python_setup: PythonSetup,
615
) -> TestDebugAdapterRequest:
616
    debugpy_pex = await create_pex(
2✔
617
        debugpy.to_pex_request(
618
            interpreter_constraints=InterpreterConstraints.create_from_field_sets(
619
                batch.elements, python_setup
620
            )
621
        )
622
    )
623

624
    setup = await setup_pytest_for_target(
2✔
625
        TestSetupRequest(
626
            batch.elements,
627
            batch.partition_metadata,
628
            is_debug=True,
629
            prepend_argv=debugpy.get_args(debug_adapter),
630
            extra_env=FrozenDict(PEX_MODULE="debugpy"),
631
            additional_pexes=(debugpy_pex,),
632
        ),
633
        **implicitly(),
634
    )
635
    return TestDebugAdapterRequest(
2✔
636
        InteractiveProcess.from_process(
637
            setup.process, forward_signals_to_process=False, restartable=True
638
        )
639
    )
640

641

642
# -----------------------------------------------------------------------------------------
643
# `runtime_package_dependencies` plugin
644
# -----------------------------------------------------------------------------------------
645

646

647
@dataclass(frozen=True)
2✔
648
class RuntimePackagesPluginRequest(PytestPluginSetupRequest):
2✔
649
    @classmethod
2✔
650
    def is_applicable(cls, target: Target) -> bool:
2✔
651
        return bool(target.get(RuntimePackageDependenciesField).value)
2✔
652

653

654
@rule
2✔
655
async def setup_runtime_packages(request: RuntimePackagesPluginRequest) -> PytestPluginSetup:
2✔
UNCOV
656
    built_packages = await build_runtime_package_dependencies(
×
657
        BuildPackageDependenciesRequest(request.target.get(RuntimePackageDependenciesField))
658
    )
UNCOV
659
    digest = await merge_digests(MergeDigests(pkg.digest for pkg in built_packages))
×
UNCOV
660
    return PytestPluginSetup(digest)
×
661

662

663
def rules():
2✔
664
    return [
2✔
665
        *collect_rules(),
666
        *pytest.rules(),
667
        UnionRule(PytestPluginSetupRequest, RuntimePackagesPluginRequest),
668
        *PyTestRequest.rules(),
669
    ]
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