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

pantsbuild / pants / 22285320763

22 Feb 2026 09:05PM UTC coverage: 92.853% (-0.08%) from 92.936%
22285320763

Pull #23121

github

web-flow
Merge a8b8903b0 into ba8359840
Pull Request #23121: fix issue with optional fields in dependency validator

39 of 40 new or added lines in 3 files covered. (97.5%)

92 existing lines in 5 files now uncovered.

90830 of 97821 relevant lines covered (92.85%)

4.06 hits per line

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

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

4
from __future__ import annotations
5✔
5

6
import dataclasses
5✔
7
import logging
5✔
8
import os
5✔
9
import textwrap
5✔
10
import uuid
5✔
11
from dataclasses import dataclass
5✔
12
from enum import Enum
5✔
13
from typing import cast
5✔
14

15
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
5✔
16
from pants.backend.python.subsystems.setup import PythonSetup
5✔
17
from pants.backend.python.target_types import PexLayout, PythonResolveField, PythonSourceField
5✔
18
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
5✔
19
from pants.backend.python.util_rules.local_dists_pep660 import (
5✔
20
    EditableLocalDistsRequest,
21
    build_editable_local_dists,
22
)
23
from pants.backend.python.util_rules.pex import (
5✔
24
    PexRequest,
25
    create_pex,
26
    create_venv_pex,
27
    find_interpreter,
28
)
29
from pants.backend.python.util_rules.pex_cli import PexPEX
5✔
30
from pants.backend.python.util_rules.pex_environment import PexEnvironment, PythonExecutable
5✔
31
from pants.backend.python.util_rules.pex_requirements import EntireLockfile, Lockfile
5✔
32
from pants.core.goals.export import (
5✔
33
    Export,
34
    ExportError,
35
    ExportRequest,
36
    ExportResult,
37
    ExportResults,
38
    ExportSubsystem,
39
    PostProcessingCommand,
40
)
41
from pants.core.goals.resolves import ExportableTool
5✔
42
from pants.core.util_rules.source_files import SourceFiles
5✔
43
from pants.core.util_rules.stripped_source_files import strip_source_roots
5✔
44
from pants.engine.engine_aware import EngineAwareParameter
5✔
45
from pants.engine.fs import CreateDigest, FileContent
5✔
46
from pants.engine.internals.graph import hydrate_sources
5✔
47
from pants.engine.internals.native_engine import EMPTY_DIGEST, AddPrefix, Digest, MergeDigests
5✔
48
from pants.engine.internals.selectors import concurrently
5✔
49
from pants.engine.intrinsics import add_prefix, create_digest, digest_to_snapshot, merge_digests
5✔
50
from pants.engine.process import Process, ProcessCacheScope, execute_process_or_raise
5✔
51
from pants.engine.rules import collect_rules, implicitly, rule
5✔
52
from pants.engine.target import AllTargets, HydrateSourcesRequest, SourcesField
5✔
53
from pants.engine.unions import UnionMembership, UnionRule
5✔
54
from pants.option.option_types import EnumOption, StrListOption
5✔
55
from pants.util.strutil import path_safe, softwrap
5✔
56

57
logger = logging.getLogger(__name__)
5✔
58

59

60
@dataclass(frozen=True)
5✔
61
class ExportVenvsRequest(ExportRequest):
5✔
62
    pass
5✔
63

64

65
@dataclass(frozen=True)
5✔
66
class _ExportVenvForResolveRequest(EngineAwareParameter):
5✔
67
    resolve: str
5✔
68

69

70
class PythonResolveExportFormat(Enum):
5✔
71
    """How to export Python resolves."""
72

73
    mutable_virtualenv = "mutable_virtualenv"
5✔
74
    symlinked_immutable_virtualenv = "symlinked_immutable_virtualenv"
5✔
75

76

77
class ExportPluginOptions:
5✔
78
    py_resolve_format = EnumOption(
5✔
79
        default=PythonResolveExportFormat.mutable_virtualenv,
80
        help=softwrap(
81
            """\
82
            Export Python resolves using this format. Options are:
83
              - `mutable_virtualenv`: Export a standalone mutable virtualenv that you can
84
                further modify.
85
              - `symlinked_immutable_virtualenv`: Export a symlink into a cached Python virtualenv.
86
                This virtualenv will have no pip binary, and will be immutable. Any attempt to
87
                modify it will corrupt the cache! It may, however, take significantly less time
88
                to export than a standalone, mutable virtualenv.
89
            """
90
        ),
91
    )
92

93
    py_editable_in_resolve = StrListOption(
5✔
94
        # TODO: Is there a way to get [python].resolves in a memoized_property here?
95
        #       If so, then we can validate that all resolves here are defined there.
96
        help=softwrap(
97
            """
98
            When exporting a mutable virtualenv for a resolve, do PEP-660 editable installs
99
            of all `python_distribution` targets that own code in the exported resolve.
100

101
            If a resolve name is not in this list, `python_distribution` targets will not
102
            be installed in the virtualenv. This defaults to an empty list for backwards
103
            compatibility and to prevent unnecessary work to generate and install the
104
            PEP-660 editable wheels.
105

106
            This only applies when `[python].enable_resolves` is true and when exporting a
107
            `mutable_virtualenv` (`symlinked_immutable_virtualenv` exports are not "full"
108
            virtualenvs because they must not be edited, and do not include `pip`).
109
            """
110
        ),
111
        advanced=True,
112
    )
113

114
    py_non_hermetic_scripts_in_resolve = StrListOption(
5✔
115
        help=softwrap(
116
            """
117
            When exporting a mutable virtualenv for a resolve listed in this option, by default
118
            console script shebang lines will be made "hermetic". Specifically, the shebang of
119
            hermetic console scripts will uses the python args `-sE` where:
120

121
              - `-s` skips inclusion of the user site-packages directory,
122
              - `-E` ignores all `PYTHON*` env vars like `PYTHONPATH`.
123

124
            If you need "non-hermetic" scripts for a partcular resolve, then add that resolve's name
125
            to this option. This will allow simple python shebangs that respect vars like
126
            `PYTHONPATH`, which, for example, will allow IDEs like PyCharm to inject its debugger,
127
            coverage, or other IDE-specific libs when running a script.
128

129
            This only applies when when exporting a `mutable_virtualenv`
130
            (`symlinked_immutable_virtualenv` exports are not "full"
131
            virtualenvs because they are used internally by pants itself.
132
            Pants requires hermetic scripts to provide its reproduciblity
133
            guarantee, fine-grained caching, and other features).
134
            """
135
        ),
136
        advanced=True,
137
    )
138

139
    py_generated_sources_in_resolve = StrListOption(
5✔
140
        help=softwrap(
141
            """
142
            When exporting a mutable virtualenv for a resolve listed in this option, generate sources which result from
143
            code generation (for example, the `protobuf_sources` and `thrift_sources` target types) into the mutable
144
            virtualenv exported for that resolve. Generated sources will be placed in the appropriate location within
145
            the site-packages directory of the mutable virtualenv.
146
            """
147
        ),
148
        advanced=True,
149
    )
150

151

152
async def _get_full_python_version(python: PythonExecutable) -> str:
5✔
153
    # Get the full python version (including patch #).
154
    argv = [
1✔
155
        python.path,
156
        "-c",
157
        "import sys; print('.'.join(str(x) for x in sys.version_info[0:3]))",
158
    ]
159
    res = await execute_process_or_raise(
1✔
160
        **implicitly(Process(argv, description="Get interpreter version"))
161
    )
162
    return res.stdout.strip().decode()
1✔
163

164

165
@dataclass(frozen=True)
5✔
166
class VenvExportRequest:
5✔
167
    py_version: str
5✔
168
    pex_request: PexRequest
5✔
169
    dest_prefix: str
5✔
170
    resolve_name: str
5✔
171
    qualify_path_with_python_version: bool
5✔
172
    editable_local_dists_digest: Digest | None = None
5✔
173

174

175
@rule
5✔
176
async def do_export(
5✔
177
    req: VenvExportRequest,
178
    pex_pex: PexPEX,
179
    pex_env: PexEnvironment,
180
    export_subsys: ExportSubsystem,
181
) -> ExportResult:
182
    if not req.pex_request.internal_only:
1✔
183
        raise ExportError(f"The PEX to be exported for {req.resolve_name} must be internal_only.")
×
184
    dest_prefix = (
1✔
185
        os.path.join(req.dest_prefix, path_safe(req.resolve_name))
186
        if req.resolve_name
187
        else req.dest_prefix
188
    )
189
    # digest_root is the absolute path to build_root/dest_prefix/py_version
190
    # (py_version may be left off in some cases)
191
    output_path = "{digest_root}"
1✔
192

193
    complete_pex_env = pex_env.in_workspace()
1✔
194

195
    export_format = export_subsys.options.py_resolve_format
1✔
196

197
    if export_format == PythonResolveExportFormat.symlinked_immutable_virtualenv:
1✔
198
        # NB: The symlink performance hack leaks an internal named cache location as output (via
199
        #  the symlink target). If the user partially or fully deletes the named cache, the symlink
200
        #  target might point to a malformed venv, or it might not exist at all.
201
        #  To prevent returning a symlink to a busted or nonexistent venv from a cached process
202
        #  (or a memoized rule) we force the process to rerun per-session.
203
        #  This does mean re-running the process superfluously when the named cache is intact, but
204
        #  that is generally fast, since all wheels are already cached, and it's best to be safe.
UNCOV
205
        session_request = dataclasses.replace(
×
206
            req.pex_request, cache_scope=ProcessCacheScope.PER_SESSION
207
        )
UNCOV
208
        requirements_venv_pex = await create_venv_pex(**implicitly({session_request: PexRequest}))
×
209
        # Note that for symlinking we ignore qualify_path_with_python_version and always qualify,
210
        # since we need some name for the symlink anyway.
UNCOV
211
        dest = f"{dest_prefix}/{req.py_version}"
×
UNCOV
212
        description = (
×
213
            f"symlink to immutable virtualenv for {req.resolve_name or 'requirements'} "
214
            f"(using Python {req.py_version})"
215
        )
UNCOV
216
        venv_abspath = os.path.join(complete_pex_env.pex_root, requirements_venv_pex.venv_rel_dir)
×
UNCOV
217
        return ExportResult(
×
218
            description,
219
            dest,
220
            post_processing_cmds=[
221
                # export creates an empty directory for us when the digest gets written.
222
                # We have to remove that before creating the symlink in its place.
223
                PostProcessingCommand(["rmdir", output_path]),
224
                PostProcessingCommand(["ln", "-s", venv_abspath, output_path]),
225
            ],
226
            resolve=req.resolve_name or None,
227
        )
228
    elif export_format == PythonResolveExportFormat.mutable_virtualenv:
1✔
229
        # Note that an internal-only pex will always have the `python` field set.
230
        # See the build_pex() rule and _determine_pex_python_and_platforms() helper in pex.py.
231
        requirements_pex = await create_pex(req.pex_request)
1✔
232
        assert requirements_pex.python is not None
1✔
233
        if req.qualify_path_with_python_version:
1✔
234
            dest = f"{dest_prefix}/{req.py_version}"
1✔
235
        else:
236
            dest = dest_prefix
×
237
        description = (
1✔
238
            f"mutable virtualenv for {req.resolve_name or 'requirements'} "
239
            f"(using Python {req.py_version})"
240
        )
241

242
        merged_digest = await merge_digests(MergeDigests([pex_pex.digest, requirements_pex.digest]))
1✔
243
        tmpdir_prefix = f".{uuid.uuid4().hex}.tmp"
1✔
244
        tmpdir_under_digest_root = os.path.join("{digest_root}", tmpdir_prefix)
1✔
245
        merged_digest_under_tmpdir = await add_prefix(AddPrefix(merged_digest, tmpdir_prefix))
1✔
246

247
        venv_prompt = f"{req.resolve_name}/{req.py_version}" if req.resolve_name else req.py_version
1✔
248

249
        pex_args = [
1✔
250
            os.path.join(tmpdir_under_digest_root, requirements_pex.name),
251
            "venv",
252
            "--pip",
253
            "--collisions-ok",
254
            f"--prompt={venv_prompt}",
255
            output_path,
256
        ]
257
        if req.resolve_name in export_subsys.options.py_non_hermetic_scripts_in_resolve:
1✔
UNCOV
258
            pex_args.insert(-1, "--non-hermetic-scripts")
×
259

260
        post_processing_cmds = [
1✔
261
            PostProcessingCommand(
262
                complete_pex_env.create_argv(
263
                    os.path.join(tmpdir_under_digest_root, pex_pex.exe),
264
                    *pex_args,
265
                    python=requirements_pex.python,
266
                ),
267
                {
268
                    **complete_pex_env.environment_dict(python_configured=True),
269
                    "PEX_SCRIPT": "pex-tools",
270
                },
271
            ),
272
            # Remove the requirements and pex pexes, to avoid confusion.
273
            PostProcessingCommand(["rm", "-rf", tmpdir_under_digest_root]),
274
        ]
275

276
        # Insert editable wheel post-processing commands if needed.
277
        if req.editable_local_dists_digest is not None:
1✔
278
            # We need the snapshot to get the wheel file names which are something like:
279
            #   - pkg_name-1.2.3-0.editable-py3-none-any.whl
UNCOV
280
            wheels_snapshot = await digest_to_snapshot(req.editable_local_dists_digest)
×
281
            # We need the paths to the installed .dist-info directories to finish installation.
UNCOV
282
            py_major_minor_version = ".".join(req.py_version.split(".", 2)[:2])
×
UNCOV
283
            lib_dir = os.path.join(
×
284
                output_path, "lib", f"python{py_major_minor_version}", "site-packages"
285
            )
UNCOV
286
            dist_info_dirs = [
×
287
                # This builds: dist/.../resolve/3.8.9/lib/python3.8/site-packages/pkg_name-1.2.3.dist-info
288
                os.path.join(lib_dir, "-".join(f.split("-")[:2]) + ".dist-info")
289
                for f in wheels_snapshot.files
290
            ]
291
            # We use slice assignment to insert multiple elements at index 1.
UNCOV
292
            post_processing_cmds[1:1] = [
×
293
                PostProcessingCommand(
294
                    [
295
                        # The wheels are "sources" in the pex and get dumped in lib_dir
296
                        # so we move them to tmpdir where they will be removed at the end.
297
                        "mv",
298
                        *(os.path.join(lib_dir, f) for f in wheels_snapshot.files),
299
                        tmpdir_under_digest_root,
300
                    ]
301
                ),
302
                PostProcessingCommand(
303
                    [
304
                        # Now install the editable wheels.
305
                        os.path.join(output_path, "bin", "pip"),
306
                        "install",
307
                        "--no-deps",  # The deps were already installed via requirements.pex.
308
                        "--no-build-isolation",  # Avoid VCS dep downloads (as they are installed).
309
                        *(os.path.join(tmpdir_under_digest_root, f) for f in wheels_snapshot.files),
310
                    ]
311
                ),
312
                PostProcessingCommand(
313
                    [
314
                        # Replace pip's direct_url.json (which points to the temp editable wheel)
315
                        # with ours (which points to build_dir sources and is marked "editable").
316
                        # Also update INSTALLER file to indicate that pants installed it.
317
                        "sh",
318
                        "-c",
319
                        " ".join(
320
                            [
321
                                f"mv -f {src} {dst}; echo pants > {installer};"
322
                                for src, dst, installer in zip(
323
                                    [
324
                                        os.path.join(d, "direct_url__pants__.json")
325
                                        for d in dist_info_dirs
326
                                    ],
327
                                    [os.path.join(d, "direct_url.json") for d in dist_info_dirs],
328
                                    [os.path.join(d, "INSTALLER") for d in dist_info_dirs],
329
                                )
330
                            ]
331
                        ),
332
                    ]
333
                ),
334
            ]
335

336
        return ExportResult(
1✔
337
            description,
338
            dest,
339
            digest=merged_digest_under_tmpdir,
340
            post_processing_cmds=post_processing_cmds,
341
            resolve=req.resolve_name or None,
342
        )
343
    else:
344
        raise ExportError("Unsupported value for [export].py_resolve_format")
×
345

346

347
@dataclass(frozen=True)
5✔
348
class MaybeExportResult:
5✔
349
    result: ExportResult | None
5✔
350

351

352
@dataclass(frozen=True)
5✔
353
class _ExportPythonCodegenRequest:
5✔
354
    resolve: str
5✔
355

356

357
@dataclass(frozen=True)
5✔
358
class _ExportPythonCodegenResult:
5✔
359
    digest: Digest
5✔
360

361

362
@dataclass(frozen=True)
5✔
363
class _ExportPythonCodegenSetup:
5✔
364
    PKG_DIR = "__pants_codegen__"
5✔
365
    SCRIPT_NAME = "codegen_setup.py"
5✔
366

367
    setup_script_digest: Digest
5✔
368

369

370
@rule
5✔
371
async def python_codegen_export_setup() -> _ExportPythonCodegenSetup:
5✔
372
    codegen_setup_script_digest = await create_digest(
1✔
373
        CreateDigest(
374
            [
375
                FileContent(
376
                    path=f"{_ExportPythonCodegenSetup.PKG_DIR}/{_ExportPythonCodegenSetup.SCRIPT_NAME}",
377
                    is_executable=True,
378
                    content=textwrap.dedent(
379
                        f"""\
380
                        import os
381
                        import shutil
382
                        import site
383
                        import sys
384

385
                        site_packages_dirs = site.getsitepackages()
386
                        if not site_packages_dirs:
387
                            raise Exception("Unable to determine location of site-packages directory in venv.")
388
                        site_packages_dir = site_packages_dirs[0]
389

390
                        codegen_dir = sys.argv[1]
391

392
                        for item in os.listdir(codegen_dir):
393
                            if item == "{_ExportPythonCodegenSetup.SCRIPT_NAME}":
394
                                continue
395
                            src = os.path.join(codegen_dir, item)
396
                            dest = os.path.join(site_packages_dir, item)
397
                            shutil.copytree(src, dest, dirs_exist_ok=True)
398
                            shutil.rmtree(src)
399
                        """
400
                    ).encode(),
401
                )
402
            ]
403
        )
404
    )
405

406
    return _ExportPythonCodegenSetup(codegen_setup_script_digest)
1✔
407

408

409
@rule
5✔
410
async def export_python_codegen(
5✔
411
    request: _ExportPythonCodegenRequest, python_setup: PythonSetup, all_targets: AllTargets
412
) -> _ExportPythonCodegenResult:
413
    non_python_sources_in_python_resolve = [
1✔
414
        tgt.get(SourcesField)
415
        for tgt in all_targets
416
        if tgt.has_field(PythonResolveField)
417
        and tgt[PythonResolveField].normalized_value(python_setup) == request.resolve
418
        and tgt.has_field(SourcesField)
419
        and not tgt.has_field(PythonSourceField)
420
    ]
421

422
    if not non_python_sources_in_python_resolve:
1✔
423
        return _ExportPythonCodegenResult(EMPTY_DIGEST)
×
424

425
    hydrated_non_python_sources = await concurrently(
1✔
426
        hydrate_sources(
427
            HydrateSourcesRequest(
428
                sources,
429
                for_sources_types=(PythonSourceField,),
430
                enable_codegen=True,
431
            ),
432
            **implicitly(),
433
        )
434
        for sources in non_python_sources_in_python_resolve
435
    )
436

437
    merged_snapshot = await digest_to_snapshot(
1✔
438
        **implicitly(
439
            MergeDigests(
440
                hydrated_sources.snapshot.digest for hydrated_sources in hydrated_non_python_sources
441
            )
442
        )
443
    )
444

445
    stripped_source_files = await strip_source_roots(SourceFiles(merged_snapshot, ()))
1✔
446

447
    return _ExportPythonCodegenResult(stripped_source_files.snapshot.digest)
1✔
448

449

450
# Generate codegen Python sources and add them to the virtualenv to be exported.
451
async def add_codegen_to_export_result(
5✔
452
    resolve: str, export_result: ExportResult, codegen_setup: _ExportPythonCodegenSetup
453
) -> ExportResult:
454
    # Generate Python sources from codegen targets in this resolve.
455
    codegen_result = await export_python_codegen(
1✔
456
        _ExportPythonCodegenRequest(resolve=resolve), **implicitly()
457
    )
458
    if codegen_result.digest == EMPTY_DIGEST:
1✔
459
        return export_result
×
460

461
    codegen_digest = await add_prefix(
1✔
462
        AddPrefix(codegen_result.digest, _ExportPythonCodegenSetup.PKG_DIR)
463
    )
464
    export_digest_with_codegen = await merge_digests(
1✔
465
        MergeDigests([export_result.digest, codegen_digest, codegen_setup.setup_script_digest])
466
    )
467

468
    pkg_dir_path = os.path.join(
1✔
469
        "{digest_root}",
470
        _ExportPythonCodegenSetup.PKG_DIR,
471
    )
472
    script_path = os.path.join(pkg_dir_path, _ExportPythonCodegenSetup.SCRIPT_NAME)
1✔
473

474
    codegen_post_processing_cmds = (
1✔
475
        PostProcessingCommand(
476
            [
477
                os.path.join("{digest_root}", "bin", "python"),
478
                script_path,
479
                pkg_dir_path,
480
            ]
481
        ),
482
        PostProcessingCommand(["rm", script_path]),
483
        PostProcessingCommand(["rmdir", pkg_dir_path]),
484
    )
485

486
    return dataclasses.replace(
1✔
487
        export_result,
488
        digest=export_digest_with_codegen,
489
        post_processing_cmds=export_result.post_processing_cmds + codegen_post_processing_cmds,
490
    )
491

492

493
@rule
5✔
494
async def export_virtualenv_for_resolve(
5✔
495
    request: _ExportVenvForResolveRequest,
496
    python_setup: PythonSetup,
497
    export_subsys: ExportSubsystem,
498
    union_membership: UnionMembership,
499
    codegen_setup: _ExportPythonCodegenSetup,
500
) -> MaybeExportResult:
501
    resolve = request.resolve
1✔
502
    lockfile_path = python_setup.resolves.get(resolve)
1✔
503
    if lockfile_path:
1✔
504
        lockfile = Lockfile(
1✔
505
            url=lockfile_path,
506
            url_description_of_origin=f"the resolve `{resolve}`",
507
            resolve_name=resolve,
508
        )
509
    else:
510
        maybe_exportable = ExportableTool.filter_for_subclasses(
1✔
511
            union_membership, PythonToolBase
512
        ).get(resolve)
513
        if maybe_exportable:
1✔
514
            lockfile = cast(
1✔
515
                PythonToolBase, maybe_exportable
516
            ).pex_requirements_for_default_lockfile()
517
        else:
518
            lockfile = None
×
519

520
    if not lockfile:
1✔
521
        return MaybeExportResult(None)
×
522

523
    interpreter_constraints = InterpreterConstraints(
1✔
524
        python_setup.resolves_to_interpreter_constraints.get(
525
            request.resolve, python_setup.interpreter_constraints
526
        )
527
    )
528

529
    python = await find_interpreter(interpreter_constraints, **implicitly())
1✔
530
    py_version = await _get_full_python_version(python)
1✔
531

532
    if resolve in export_subsys.options.py_editable_in_resolve:
1✔
533
        editable_local_dists = await build_editable_local_dists(
1✔
534
            EditableLocalDistsRequest(resolve=resolve), **implicitly()
535
        )
UNCOV
536
        editable_local_dists_digest = editable_local_dists.optional_digest
×
537
    else:
538
        editable_local_dists_digest = None
1✔
539

540
    pex_request = PexRequest(
1✔
541
        description=f"Build pex for resolve `{resolve}`",
542
        output_filename=f"{path_safe(resolve)}.pex",
543
        internal_only=True,
544
        requirements=EntireLockfile(lockfile),
545
        sources=editable_local_dists_digest,
546
        python=python,
547
        # Packed layout should lead to the best performance in this use case.
548
        layout=PexLayout.PACKED,
549
    )
550

551
    dest_prefix = os.path.join("python", "virtualenvs")
1✔
552
    export_result = await do_export(
1✔
553
        VenvExportRequest(
554
            py_version,
555
            pex_request,
556
            dest_prefix,
557
            resolve,
558
            qualify_path_with_python_version=True,
559
            editable_local_dists_digest=editable_local_dists_digest,
560
        ),
561
        **implicitly(),
562
    )
563

564
    # Add generated Python sources from codegen targets to the mutable virtualenv.
565
    if (
1✔
566
        resolve in export_subsys.options.py_generated_sources_in_resolve
567
        and export_subsys.options.py_resolve_format == PythonResolveExportFormat.mutable_virtualenv
568
    ):
569
        export_result = await add_codegen_to_export_result(
1✔
570
            request.resolve, export_result, codegen_setup
571
        )
572

573
    return MaybeExportResult(export_result)
1✔
574

575

576
@rule
5✔
577
async def export_virtualenvs(
5✔
578
    request: ExportVenvsRequest,
579
    export_subsys: ExportSubsystem,
580
) -> ExportResults:
581
    maybe_venvs = await concurrently(
1✔
582
        export_virtualenv_for_resolve(_ExportVenvForResolveRequest(resolve), **implicitly())
583
        for resolve in export_subsys.options.resolve
584
    )
585
    return ExportResults(mv.result for mv in maybe_venvs if mv.result is not None)
1✔
586

587

588
def rules():
5✔
589
    return [
5✔
590
        *collect_rules(),
591
        Export.subsystem_cls.register_plugin_options(ExportPluginOptions),
592
        UnionRule(ExportRequest, ExportVenvsRequest),
593
    ]
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