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

pantsbuild / pants / 21042790249

15 Jan 2026 06:57PM UTC coverage: 43.263% (-35.4%) from 78.666%
21042790249

Pull #23021

github

web-flow
Merge cc03ad8de into d250c80fe
Pull Request #23021: WIP gh workflow scie pex

23 of 33 new or added lines in 3 files covered. (69.7%)

16147 existing lines in 521 files now uncovered.

26164 of 60477 relevant lines covered (43.26%)

0.87 hits per line

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

0.0
/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

UNCOV
4
from __future__ import annotations
×
5

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

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

UNCOV
57
logger = logging.getLogger(__name__)
×
58

59

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

64

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

69

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

UNCOV
73
    mutable_virtualenv = "mutable_virtualenv"
×
UNCOV
74
    symlinked_immutable_virtualenv = "symlinked_immutable_virtualenv"
×
75

76

UNCOV
77
class ExportPluginOptions:
×
UNCOV
78
    py_resolve_format = EnumOption(
×
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

UNCOV
93
    py_editable_in_resolve = StrListOption(
×
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

UNCOV
114
    py_non_hermetic_scripts_in_resolve = StrListOption(
×
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

UNCOV
139
    py_generated_sources_in_resolve = StrListOption(
×
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

UNCOV
152
async def _get_full_python_version(python: PythonExecutable) -> str:
×
153
    # Get the full python version (including patch #).
154
    argv = [
×
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(
×
160
        **implicitly(Process(argv, description="Get interpreter version"))
161
    )
162
    return res.stdout.strip().decode()
×
163

164

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

174

UNCOV
175
@rule
×
UNCOV
176
async def do_export(
×
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:
×
183
        raise ExportError(f"The PEX to be exported for {req.resolve_name} must be internal_only.")
×
184
    dest_prefix = (
×
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}"
×
192

193
    complete_pex_env = pex_env.in_workspace()
×
194

195
    export_format = export_subsys.options.py_resolve_format
×
196

197
    if export_format == PythonResolveExportFormat.symlinked_immutable_virtualenv:
×
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.
205
        session_request = dataclasses.replace(
×
206
            req.pex_request, cache_scope=ProcessCacheScope.PER_SESSION
207
        )
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.
211
        dest = f"{dest_prefix}/{req.py_version}"
×
212
        description = (
×
213
            f"symlink to immutable virtualenv for {req.resolve_name or 'requirements'} "
214
            f"(using Python {req.py_version})"
215
        )
216
        venv_abspath = os.path.join(complete_pex_env.pex_root, requirements_venv_pex.venv_rel_dir)
×
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:
×
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)
×
232
        assert requirements_pex.python is not None
×
233
        if req.qualify_path_with_python_version:
×
234
            dest = f"{dest_prefix}/{req.py_version}"
×
235
        else:
236
            dest = dest_prefix
×
237
        description = (
×
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]))
×
243
        tmpdir_prefix = f".{uuid.uuid4().hex}.tmp"
×
244
        tmpdir_under_digest_root = os.path.join("{digest_root}", tmpdir_prefix)
×
245
        merged_digest_under_tmpdir = await add_prefix(AddPrefix(merged_digest, tmpdir_prefix))
×
246

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

249
        pex_args = [
×
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:
×
258
            pex_args.insert(-1, "--non-hermetic-scripts")
×
259

260
        post_processing_cmds = [
×
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:
×
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
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.
282
            py_major_minor_version = ".".join(req.py_version.split(".", 2)[:2])
×
283
            lib_dir = os.path.join(
×
284
                output_path, "lib", f"python{py_major_minor_version}", "site-packages"
285
            )
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.
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(
×
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

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

351

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

356

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

361

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

UNCOV
367
    setup_script_digest: Digest
×
368

369

UNCOV
370
@rule
×
UNCOV
371
async def python_codegen_export_setup() -> _ExportPythonCodegenSetup:
×
372
    codegen_setup_script_digest = await create_digest(
×
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)
×
407

408

UNCOV
409
@rule
×
UNCOV
410
async def export_python_codegen(
×
411
    request: _ExportPythonCodegenRequest, python_setup: PythonSetup, all_targets: AllTargets
412
) -> _ExportPythonCodegenResult:
413
    non_python_sources_in_python_resolve = [
×
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:
×
423
        return _ExportPythonCodegenResult(EMPTY_DIGEST)
×
424

425
    hydrated_non_python_sources = await concurrently(
×
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(
×
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, ()))
×
446

447
    return _ExportPythonCodegenResult(stripped_source_files.snapshot.digest)
×
448

449

450
# Generate codegen Python sources and add them to the virtualenv to be exported.
UNCOV
451
async def add_codegen_to_export_result(
×
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(
×
456
        _ExportPythonCodegenRequest(resolve=resolve), **implicitly()
457
    )
458
    if codegen_result.digest == EMPTY_DIGEST:
×
459
        return export_result
×
460

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

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

474
    codegen_post_processing_cmds = (
×
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(
×
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

UNCOV
493
@rule
×
UNCOV
494
async def export_virtualenv_for_resolve(
×
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
×
502
    lockfile_path = python_setup.resolves.get(resolve)
×
503
    if lockfile_path:
×
504
        lockfile = Lockfile(
×
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(
×
511
            union_membership, PythonToolBase
512
        ).get(resolve)
513
        if maybe_exportable:
×
514
            lockfile = cast(
×
515
                PythonToolBase, maybe_exportable
516
            ).pex_requirements_for_default_lockfile()
517
        else:
518
            lockfile = None
×
519

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

523
    interpreter_constraints = InterpreterConstraints(
×
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())
×
530
    py_version = await _get_full_python_version(python)
×
531

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

540
    pex_request = PexRequest(
×
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")
×
552
    export_result = await do_export(
×
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 (
×
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(
×
570
            request.resolve, export_result, codegen_setup
571
        )
572

573
    return MaybeExportResult(export_result)
×
574

575

UNCOV
576
@rule
×
UNCOV
577
async def export_virtualenvs(
×
578
    request: ExportVenvsRequest,
579
    export_subsys: ExportSubsystem,
580
) -> ExportResults:
581
    maybe_venvs = await concurrently(
×
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)
×
586

587

UNCOV
588
def rules():
×
UNCOV
589
    return [
×
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