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

pantsbuild / pants / 24069121099

07 Apr 2026 07:10AM UTC coverage: 92.923% (+0.02%) from 92.908%
24069121099

Pull #23227

github

web-flow
Merge 15592134b into 542ca048d
Pull Request #23227: Fix uv PEX builder to use pex3 lock export

83 of 84 new or added lines in 2 files covered. (98.81%)

1 existing line in 1 file now uncovered.

91623 of 98601 relevant lines covered (92.92%)

4.04 hits per line

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

99.73
/src/python/pants/backend/python/util_rules/pex_test.py
1
# Copyright 2019 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
1✔
5

6
import os.path
1✔
7
import pkgutil
1✔
8
import re
1✔
9
import shutil
1✔
10
import textwrap
1✔
11
import zipfile
1✔
12
from pathlib import Path
1✔
13
from types import SimpleNamespace
1✔
14

15
import pytest
1✔
16
import requests
1✔
17
from packaging.requirements import Requirement
1✔
18
from packaging.specifiers import SpecifierSet
1✔
19
from packaging.version import Version
1✔
20

21
from pants.backend.python.goals import lockfile
1✔
22
from pants.backend.python.goals.lockfile import GeneratePythonLockfile
1✔
23
from pants.backend.python.subsystems.setup import PythonSetup
1✔
24
from pants.backend.python.subsystems.uv import DownloadedUv
1✔
25
from pants.backend.python.target_types import EntryPoint, PexCompletePlatformsField
1✔
26
from pants.backend.python.util_rules import pex_test_utils
1✔
27
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
1✔
28
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
1✔
29
from pants.backend.python.util_rules.pex import (
1✔
30
    CompletePlatforms,
31
    Pex,
32
    PexDistributionInfo,
33
    PexPlatforms,
34
    PexProcess,
35
    PexRequest,
36
    PexRequirementsInfo,
37
    PexResolveInfo,
38
    VenvPex,
39
    VenvPexProcess,
40
    _build_pex_description,
41
    _build_uv_venv,
42
    _BuildPexPythonSetup,
43
    _BuildPexRequirementsSetup,
44
    _determine_pex_python_and_platforms,
45
    _setup_pex_requirements,
46
    _UvVenvRequest,
47
)
48
from pants.backend.python.util_rules.pex import rules as pex_rules
1✔
49
from pants.backend.python.util_rules.pex_cli import PexCliProcess
1✔
50
from pants.backend.python.util_rules.pex_environment import PythonExecutable
1✔
51
from pants.backend.python.util_rules.pex_requirements import (
1✔
52
    EntireLockfile,
53
    LoadedLockfile,
54
    Lockfile,
55
    PexRequirements,
56
    Resolve,
57
    ResolvePexConfig,
58
)
59
from pants.backend.python.util_rules.pex_test_utils import (
1✔
60
    create_pex_and_get_all_data,
61
    create_pex_and_get_pex_info,
62
    parse_requirements,
63
)
64
from pants.core.goals.generate_lockfiles import GenerateLockfileResult
1✔
65
from pants.core.register import wrap_as_resources
1✔
66
from pants.core.target_types import FileTarget, ResourceTarget
1✔
67
from pants.core.util_rules.lockfile_metadata import InvalidLockfileError
1✔
68
from pants.engine.fs import (
1✔
69
    EMPTY_DIGEST,
70
    CreateDigest,
71
    Digest,
72
    DigestContents,
73
    Directory,
74
    FileContent,
75
)
76
from pants.engine.internals.native_engine import Address
1✔
77
from pants.engine.process import Process, ProcessCacheScope, ProcessResult
1✔
78
from pants.option.global_options import GlobalOptions
1✔
79
from pants.testutil.option_util import create_subsystem
1✔
80
from pants.testutil.rule_runner import (
1✔
81
    PYTHON_BOOTSTRAP_ENV,
82
    QueryRule,
83
    RuleRunner,
84
    engine_error,
85
    run_rule_with_mocks,
86
)
87
from pants.util.contextutil import temporary_dir
1✔
88
from pants.util.dirutil import safe_rmtree
1✔
89
from pants.util.ordered_set import FrozenOrderedSet
1✔
90
from pants.util.pip_requirement import PipRequirement
1✔
91

92

93
@pytest.fixture
1✔
94
def rule_runner() -> RuleRunner:
1✔
95
    return RuleRunner(
1✔
96
        rules=[
97
            *pex_test_utils.rules(),
98
            *pex_rules(),
99
            *wrap_as_resources.rules,
100
            QueryRule(GlobalOptions, []),
101
            QueryRule(ProcessResult, (Process,)),
102
            QueryRule(PexResolveInfo, (Pex,)),
103
            QueryRule(PexResolveInfo, (VenvPex,)),
104
            QueryRule(CompletePlatforms, (PexCompletePlatformsField,)),
105
        ],
106
        target_types=[
107
            ResourceTarget,
108
            FileTarget,
109
            *wrap_as_resources.target_types,
110
        ],
111
    )
112

113

114
@pytest.mark.parametrize("pex_type", [Pex, VenvPex])
1✔
115
@pytest.mark.parametrize("internal_only", [True, False])
1✔
116
def test_pex_execution(
1✔
117
    rule_runner: RuleRunner, pex_type: type[Pex | VenvPex], internal_only: bool
118
) -> None:
119
    sources = rule_runner.request(
1✔
120
        Digest,
121
        [
122
            CreateDigest(
123
                (
124
                    FileContent("main.py", b'print("from main")'),
125
                    FileContent("subdir/sub.py", b'print("from sub")'),
126
                )
127
            ),
128
        ],
129
    )
130
    pex_data = create_pex_and_get_all_data(
1✔
131
        rule_runner,
132
        pex_type=pex_type,
133
        internal_only=internal_only,
134
        main=EntryPoint("main"),
135
        sources=sources,
136
    )
137

138
    assert "main.py" in pex_data.files
1✔
139
    assert "subdir/sub.py" in pex_data.files
1✔
140

141
    # This should run the Pex using the same interpreter used to create it. We must set the `PATH`
142
    # so that the shebang works.
143
    pex_exe = (
1✔
144
        f"./{pex_data.sandbox_path}"
145
        if pex_data.is_zipapp
146
        else os.path.join(pex_data.sandbox_path, "__main__.py")
147
    )
148
    process = Process(
1✔
149
        argv=(pex_exe,),
150
        env={"PATH": os.getenv("PATH", "")},
151
        input_digest=pex_data.pex.digest,
152
        description="Run the pex and make sure it works",
153
    )
154
    result = rule_runner.request(ProcessResult, [process])
1✔
155
    assert result.stdout == b"from main\n"
1✔
156

157

158
@pytest.mark.parametrize("pex_type", [Pex, VenvPex])
1✔
159
def test_pex_environment(rule_runner: RuleRunner, pex_type: type[Pex | VenvPex]) -> None:
1✔
160
    sources = rule_runner.request(
1✔
161
        Digest,
162
        [
163
            CreateDigest(
164
                (
165
                    FileContent(
166
                        path="main.py",
167
                        content=textwrap.dedent(
168
                            """
169
                            from os import environ
170
                            print(f"LANG={environ.get('LANG')}")
171
                            print(f"ftp_proxy={environ.get('ftp_proxy')}")
172
                            """
173
                        ).encode(),
174
                    ),
175
                )
176
            ),
177
        ],
178
    )
179
    pex_data = create_pex_and_get_all_data(
1✔
180
        rule_runner,
181
        pex_type=pex_type,
182
        main=EntryPoint("main"),
183
        sources=sources,
184
        additional_pants_args=(
185
            "--subprocess-environment-env-vars=LANG",  # Value should come from environment.
186
            "--subprocess-environment-env-vars=ftp_proxy=dummyproxy",
187
        ),
188
        interpreter_constraints=InterpreterConstraints(["CPython>=3.8"]),
189
        env={"LANG": "es_PY.UTF-8"},
190
    )
191

192
    pex_process_type = PexProcess if isinstance(pex_data.pex, Pex) else VenvPexProcess
1✔
193
    process = rule_runner.request(
1✔
194
        Process,
195
        [
196
            pex_process_type(
197
                pex_data.pex,  # type: ignore[arg-type]
198
                description="Run the pex and check its reported environment",
199
            ),
200
        ],
201
    )
202

203
    result = rule_runner.request(ProcessResult, [process])
1✔
204
    assert b"LANG=es_PY.UTF-8" in result.stdout
1✔
205
    assert b"ftp_proxy=dummyproxy" in result.stdout
1✔
206

207

208
@pytest.mark.parametrize("pex_type", [Pex, VenvPex])
1✔
209
def test_pex_working_directory(rule_runner: RuleRunner, pex_type: type[Pex | VenvPex]) -> None:
1✔
210
    named_caches_dir = rule_runner.request(GlobalOptions, []).named_caches_dir
1✔
211
    sources = rule_runner.request(
1✔
212
        Digest,
213
        [
214
            CreateDigest(
215
                (
216
                    FileContent(
217
                        path="main.py",
218
                        content=textwrap.dedent(
219
                            """
220
                            import os
221
                            cwd = os.getcwd()
222
                            print(f"CWD: {cwd}")
223
                            for path, dirs, _ in os.walk(cwd):
224
                                for name in dirs:
225
                                    print(f"DIR: {os.path.relpath(os.path.join(path, name), cwd)}")
226
                            """
227
                        ).encode(),
228
                    ),
229
                )
230
            ),
231
        ],
232
    )
233

234
    pex_data = create_pex_and_get_all_data(
1✔
235
        rule_runner,
236
        pex_type=pex_type,
237
        main=EntryPoint("main"),
238
        sources=sources,
239
        interpreter_constraints=InterpreterConstraints(["CPython>=3.8"]),
240
    )
241

242
    pex_process_type = PexProcess if isinstance(pex_data.pex, Pex) else VenvPexProcess
1✔
243

244
    dirpath = "foo/bar/baz"
1✔
245
    runtime_files = rule_runner.request(Digest, [CreateDigest([Directory(path=dirpath)])])
1✔
246

247
    dirpath_parts = os.path.split(dirpath)
1✔
248
    for i in range(0, len(dirpath_parts)):
1✔
249
        working_dir = os.path.join(*dirpath_parts[:i]) if i > 0 else None
1✔
250
        expected_subdir = os.path.join(*dirpath_parts[i:]) if i < len(dirpath_parts) else None
1✔
251
        process = rule_runner.request(
1✔
252
            Process,
253
            [
254
                pex_process_type(
255
                    pex_data.pex,  # type: ignore[arg-type]
256
                    description="Run the pex and check its cwd",
257
                    working_directory=working_dir,
258
                    input_digest=runtime_files,
259
                    # We skip the process cache for this PEX to ensure that it re-runs.
260
                    cache_scope=ProcessCacheScope.PER_SESSION,
261
                )
262
            ],
263
        )
264

265
        # For VenvPexes, run the PEX twice while clearing the venv dir in between. This emulates
266
        # situations where a PEX creation hits the process cache, while venv seeding misses the PEX
267
        # cache.
268
        if isinstance(pex_data.pex, VenvPex):
1✔
269
            # Request once to ensure that the directory is seeded, and then start a new session so
270
            # that the second run happens as well.
271
            _ = rule_runner.request(ProcessResult, [process])
1✔
272
            rule_runner.new_session("re-run-for-venv-pex")
1✔
273
            rule_runner.set_options(
1✔
274
                ["--backend-packages=pants.backend.python"],
275
                env_inherit={"PATH", "PYENV_ROOT", "HOME"},
276
            )
277

278
            # Clear the cache.
279
            venv_dir = os.path.join(named_caches_dir, "pex_root", pex_data.pex.venv_rel_dir)
1✔
280
            assert os.path.isdir(venv_dir)
1✔
281
            safe_rmtree(venv_dir)
1✔
282

283
        result = rule_runner.request(ProcessResult, [process])
1✔
284
        output_str = result.stdout.decode()
1✔
285
        mo = re.search(r"CWD: (.*)\n", output_str)
1✔
286
        assert mo is not None
1✔
287
        reported_cwd = mo.group(1)
1✔
288
        if working_dir:
1✔
289
            assert reported_cwd.endswith(working_dir)
1✔
290
        if expected_subdir:
1✔
291
            assert f"DIR: {expected_subdir}" in output_str
1✔
292

293

294
def test_resolves_dependencies(rule_runner: RuleRunner) -> None:
1✔
295
    req_strings = ["six==1.12.0", "jsonschema==2.6.0", "requests==2.23.0"]
1✔
296
    requirements = PexRequirements(req_strings)
1✔
297
    pex_info = create_pex_and_get_pex_info(rule_runner, requirements=requirements)
1✔
298
    # NB: We do not check for transitive dependencies, which PEX-INFO will include. We only check
299
    # that at least the dependencies we requested are included.
300
    assert set(parse_requirements(req_strings)).issubset(
1✔
301
        set(parse_requirements(pex_info["requirements"]))
302
    )
303

304

305
def test_requirement_constraints(rule_runner: RuleRunner) -> None:
1✔
306
    direct_deps = ["requests>=1.0.0,<=2.23.0"]
1✔
307

308
    def assert_direct_requirements(pex_info):
1✔
309
        assert {PipRequirement.parse(r) for r in pex_info["requirements"]} == {
1✔
310
            PipRequirement.parse(d) for d in direct_deps
311
        }
312

313
    # Unconstrained, we should always pick the top of the range (requests 2.23.0) since the top of
314
    # the range is a transitive closure over universal wheels.
315
    direct_pex_info = create_pex_and_get_pex_info(
1✔
316
        rule_runner, requirements=PexRequirements(direct_deps)
317
    )
318
    assert_direct_requirements(direct_pex_info)
1✔
319
    assert "requests-2.23.0-py2.py3-none-any.whl" in set(direct_pex_info["distributions"].keys())
1✔
320

321
    constraints = [
1✔
322
        "requests==2.16.0",
323
        "certifi==2019.6.16",
324
        "chardet==3.0.2",
325
        "idna==2.5",
326
        "urllib3==1.21.1",
327
    ]
328
    rule_runner.write_files({"constraints.txt": "\n".join(constraints)})
1✔
329
    constrained_pex_info = create_pex_and_get_pex_info(
1✔
330
        rule_runner,
331
        requirements=PexRequirements(direct_deps, constraints_strings=constraints),
332
        additional_pants_args=("--python-requirement-constraints=constraints.txt",),
333
    )
334
    assert_direct_requirements(constrained_pex_info)
1✔
335
    assert {
1✔
336
        "certifi-2019.6.16-py2.py3-none-any.whl",
337
        "chardet-3.0.2-py2.py3-none-any.whl",
338
        "idna-2.5-py2.py3-none-any.whl",
339
        "requests-2.16.0-py2.py3-none-any.whl",
340
        "urllib3-1.21.1-py2.py3-none-any.whl",
341
    } == set(constrained_pex_info["distributions"].keys())
342

343

344
def test_lockfiles(rule_runner: RuleRunner) -> None:
1✔
345
    rule_runner.set_options(["--python-invalid-lockfile-behavior=ignore"])
1✔
346
    rule_runner.write_files(
1✔
347
        {
348
            "pex_lock.json": textwrap.dedent(
349
                """\
350
                // Some Pants header
351
                // blah blah
352
                {
353
                  "allow_builds": true,
354
                  "allow_prereleases": false,
355
                  "allow_wheels": true,
356
                  "build_isolation": true,
357
                  "constraints": [],
358
                  "locked_resolves": [
359
                    {
360
                      "locked_requirements": [
361
                        {
362
                          "artifacts": [
363
                            {
364
                              "algorithm": "sha256",
365
                              "hash": "00d2dde5a675579325902536738dd27e4fac1fd68f773fe36c21044eb559e187",
366
                              "url": "https://files.pythonhosted.org/packages/53/18/a56e2fe47b259bb52201093a3a9d4a32014f9d85071ad07e9d60600890ca/ansicolors-1.1.8-py2.py3-none-any.whl"
367
                            },
368
                            {
369
                              "algorithm": "sha256",
370
                              "hash": "99f94f5e3348a0bcd43c82e5fc4414013ccc19d70bd939ad71e0133ce9c372e0",
371
                              "url": "https://files.pythonhosted.org/packages/76/31/7faed52088732704523c259e24c26ce6f2f33fbeff2ff59274560c27628e/ansicolors-1.1.8.zip"
372
                            }
373
                          ],
374
                          "project_name": "ansicolors",
375
                          "requires_dists": [],
376
                          "requires_python": null,
377
                          "version": "1.1.8"
378
                        }
379
                      ],
380
                      "platform_tag": [
381
                        "cp39",
382
                        "cp39",
383
                        "macosx_11_0_arm64"
384
                      ]
385
                    }
386
                  ],
387
                  "pex_version": "2.1.70",
388
                  "prefer_older_binary": false,
389
                  "requirements": [
390
                    "ansicolors"
391
                  ],
392
                  "requires_python": [],
393
                  "resolver_version": "pip-2020-resolver",
394
                  "style": "universal",
395
                  "transitive": true,
396
                  "use_pep517": null
397
                }
398
                """
399
            ),
400
            "reqs_lock.txt": textwrap.dedent(
401
                """\
402
                ansicolors==1.1.8 \
403
                    --hash=sha256:00d2dde5a675579325902536738dd27e4fac1fd68f773fe36c21044eb559e187 \
404
                    --hash=sha256:99f94f5e3348a0bcd43c82e5fc4414013ccc19d70bd939ad71e0133ce9c372e0
405
                """
406
            ),
407
        }
408
    )
409

410
    def create_lock(path: str) -> None:
1✔
411
        lock = Lockfile(
1✔
412
            path,
413
            url_description_of_origin="foo",
414
            resolve_name="a",
415
        )
416
        create_pex_and_get_pex_info(
1✔
417
            rule_runner,
418
            requirements=EntireLockfile(lock, ("ansicolors",)),
419
            additional_pants_args=("--python-invalid-lockfile-behavior=ignore",),
420
        )
421

422
    create_lock("pex_lock.json")
1✔
423
    create_lock("reqs_lock.txt")
1✔
424

425

426
def test_entry_point(rule_runner: RuleRunner) -> None:
1✔
427
    entry_point = "pydoc"
1✔
428
    pex_info = create_pex_and_get_pex_info(rule_runner, main=EntryPoint(entry_point))
1✔
429
    assert pex_info["entry_point"] == entry_point
1✔
430

431

432
def test_interpreter_constraints(rule_runner: RuleRunner) -> None:
1✔
433
    constraints = InterpreterConstraints(["CPython>=2.7,<3", "CPython>=3.8,<3.12"])
1✔
434
    pex_info = create_pex_and_get_pex_info(
1✔
435
        rule_runner, interpreter_constraints=constraints, internal_only=False
436
    )
437
    assert set(pex_info["interpreter_constraints"]) == {str(c) for c in constraints}
1✔
438

439

440
def test_additional_args(rule_runner: RuleRunner) -> None:
1✔
441
    pex_info = create_pex_and_get_pex_info(rule_runner, additional_pex_args=("--no-strip-pex-env",))
1✔
442
    assert pex_info["strip_pex_env"] is False
1✔
443

444

445
def test_platforms(rule_runner: RuleRunner) -> None:
1✔
446
    # We use Python 2.7, rather than Python 3, to ensure that the specified platform is
447
    # actually used.
448
    platforms = PexPlatforms(["linux-x86_64-cp-27-cp27mu"])
1✔
449
    constraints = InterpreterConstraints(["CPython>=2.7,<3", "CPython>=3.8"])
1✔
450
    pex_data = create_pex_and_get_all_data(
1✔
451
        rule_runner,
452
        requirements=PexRequirements(["cryptography==2.9"]),
453
        platforms=platforms,
454
        interpreter_constraints=constraints,
455
        internal_only=False,  # Internal only PEXes do not support (foreign) platforms.
456
    )
457
    assert any(
1✔
458
        "cryptography-2.9-cp27-cp27mu-manylinux2010_x86_64.whl" in fp for fp in pex_data.files
459
    )
460
    assert not any("cryptography-2.9-cp27-cp27m-" in fp for fp in pex_data.files)
1✔
461
    assert not any("cryptography-2.9-cp35-abi3" in fp for fp in pex_data.files)
1✔
462

463
    # NB: Platforms override interpreter constraints.
464
    assert pex_data.info["interpreter_constraints"] == []
1✔
465

466

467
@pytest.mark.parametrize("use_pep440_rather_than_find_links", [True, False])
1✔
468
def test_local_requirements_and_path_mappings(
1✔
469
    use_pep440_rather_than_find_links: bool, tmp_path
470
) -> None:
471
    rule_runner = RuleRunner(
1✔
472
        rules=[
473
            *pex_test_utils.rules(),
474
            *pex_rules(),
475
            *lockfile.rules(),
476
            QueryRule(GenerateLockfileResult, [GeneratePythonLockfile]),
477
            QueryRule(PexResolveInfo, (Pex,)),
478
        ],
479
        bootstrap_args=[f"--named-caches-dir={tmp_path}"],
480
    )
481

482
    wheel_content = requests.get(
1✔
483
        "https://files.pythonhosted.org/packages/53/18/a56e2fe47b259bb52201093a3a9d4a32014f9d85071ad07e9d60600890ca/ansicolors-1.1.8-py2.py3-none-any.whl"
484
    ).content
485

486
    with temporary_dir() as wheel_base_dir:
1✔
487
        dir1_path = Path(wheel_base_dir, "dir1")
1✔
488
        dir2_path = Path(wheel_base_dir, "dir2")
1✔
489
        dir1_path.mkdir()
1✔
490
        dir2_path.mkdir()
1✔
491

492
        wheel_path = dir1_path / "ansicolors-1.1.8-py2.py3-none-any.whl"
1✔
493
        wheel_req_str = (
1✔
494
            f"ansicolors @ file://{wheel_path}"
495
            if use_pep440_rather_than_find_links
496
            else "ansicolors"
497
        )
498
        wheel_path.write_bytes(wheel_content)
1✔
499

500
        def options(path_mappings_dir: Path) -> tuple[str, ...]:
1✔
501
            return (
1✔
502
                "--python-repos-indexes=[]",
503
                (
504
                    "--python-repos-find-links=[]"
505
                    if use_pep440_rather_than_find_links
506
                    else f"--python-repos-find-links={path_mappings_dir}"
507
                ),
508
                f"--python-repos-path-mappings=WHEEL_DIR|{path_mappings_dir}",
509
                f"--named-caches-dir={tmp_path}",
510
                # Use the vendored pip, so we don't have to set up a wheel for it in dir1_path.
511
                "--python-pip-version=20.3.4-patched",
512
            )
513

514
        rule_runner.set_options(options(dir1_path), env_inherit=PYTHON_BOOTSTRAP_ENV)
1✔
515
        lock_result = rule_runner.request(
1✔
516
            GenerateLockfileResult,
517
            [
518
                GeneratePythonLockfile(
519
                    requirements=FrozenOrderedSet([wheel_req_str]),
520
                    find_links=FrozenOrderedSet([]),
521
                    interpreter_constraints=InterpreterConstraints([">=3.8,<3.15"]),
522
                    resolve_name="test",
523
                    lockfile_dest="test.lock",
524
                    diff=False,
525
                    lock_style="universal",
526
                    complete_platforms=(),
527
                )
528
            ],
529
        )
530
        lock_digest_contents = rule_runner.request(DigestContents, [lock_result.digest])
1✔
531
        assert len(lock_digest_contents) == 1
1✔
532
        lock_file_content = lock_digest_contents[0]
1✔
533
        assert b"${WHEEL_DIR}/ansicolors-1.1.8-py2.py3-none-any.whl" in lock_file_content.content
1✔
534
        assert b"files.pythonhosted.org" not in lock_file_content.content
1✔
535

536
        rule_runner.write_files({"test.lock": lock_file_content.content})
1✔
537
        lockfile_obj = EntireLockfile(
1✔
538
            Lockfile(url="test.lock", url_description_of_origin="test", resolve_name="test"),
539
            (wheel_req_str,),
540
        )
541

542
        # Wipe cache to ensure `--path-mappings` works.
543
        shutil.rmtree(tmp_path)
1✔
544
        shutil.rmtree(dir1_path)
1✔
545
        (dir2_path / "ansicolors-1.1.8-py2.py3-none-any.whl").write_bytes(wheel_content)
1✔
546
        pex_info = create_pex_and_get_all_data(
1✔
547
            rule_runner, requirements=lockfile_obj, additional_pants_args=options(dir2_path)
548
        ).info
549
        assert "ansicolors-1.1.8-py2.py3-none-any.whl" in pex_info["distributions"]
1✔
550

551
        # Confirm that pointing to a bad path fails.
552
        shutil.rmtree(tmp_path)
1✔
553
        shutil.rmtree(dir2_path)
1✔
554
        with engine_error():
1✔
555
            create_pex_and_get_all_data(
1✔
556
                rule_runner,
557
                requirements=lockfile_obj,
558
                additional_pants_args=options(Path(wheel_base_dir, "dir3")),
559
            )
560

561

562
@pytest.mark.parametrize("pex_type", [Pex, VenvPex])
1✔
563
@pytest.mark.parametrize("internal_only", [True, False])
1✔
564
def test_additional_inputs(
1✔
565
    rule_runner: RuleRunner, pex_type: type[Pex | VenvPex], internal_only: bool
566
) -> None:
567
    # We use Pex's --sources-directory option to add an extra source file to the PEX.
568
    # This verifies that the file was indeed provided as additional input to the pex call.
569
    extra_src_dir = "extra_src"
1✔
570
    data_file = os.path.join("data", "file")
1✔
571
    data = "42"
1✔
572
    additional_inputs = rule_runner.request(
1✔
573
        Digest,
574
        [
575
            CreateDigest(
576
                [FileContent(path=os.path.join(extra_src_dir, data_file), content=data.encode())]
577
            )
578
        ],
579
    )
580
    additional_pex_args = ("--sources-directory", extra_src_dir)
1✔
581
    pex_data = create_pex_and_get_all_data(
1✔
582
        rule_runner,
583
        pex_type=pex_type,
584
        internal_only=internal_only,
585
        additional_inputs=additional_inputs,
586
        additional_pex_args=additional_pex_args,
587
    )
588
    if pex_data.is_zipapp:
1✔
589
        with zipfile.ZipFile(pex_data.local_path, "r") as zipfp:
1✔
590
            with zipfp.open(data_file, "r") as datafp:
1✔
591
                data_file_content = datafp.read()
1✔
592
    else:
593
        with open(pex_data.local_path / data_file, "rb") as datafp:
1✔
594
            data_file_content = datafp.read()
1✔
595
    assert data == data_file_content.decode()
1✔
596

597

598
@pytest.mark.parametrize("pex_type", [Pex, VenvPex])
1✔
599
def test_venv_pex_resolve_info(rule_runner: RuleRunner, pex_type: type[Pex | VenvPex]) -> None:
1✔
600
    constraints = [
1✔
601
        "requests==2.23.0",
602
        "certifi==2020.12.5",
603
        "chardet==3.0.4",
604
        "idna==2.10",
605
        "urllib3==1.25.11",
606
    ]
607
    rule_runner.write_files({"constraints.txt": "\n".join(constraints)})
1✔
608
    pex = create_pex_and_get_all_data(
1✔
609
        rule_runner,
610
        pex_type=pex_type,
611
        requirements=PexRequirements(["requests==2.23.0"], constraints_strings=constraints),
612
        additional_pants_args=("--python-requirement-constraints=constraints.txt",),
613
    ).pex
614
    dists = rule_runner.request(PexResolveInfo, [pex])
1✔
615
    assert dists[0] == PexDistributionInfo("certifi", Version("2020.12.5"), None, ())
1✔
616
    assert dists[1] == PexDistributionInfo("chardet", Version("3.0.4"), None, ())
1✔
617
    assert dists[2] == PexDistributionInfo(
1✔
618
        "idna", Version("2.10"), SpecifierSet("!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"), ()
619
    )
620
    assert dists[3].project_name == "requests"
1✔
621
    assert dists[3].version == Version("2.23.0")
1✔
622
    # requires_dists is parsed from metadata written by the pex tool, and is always
623
    #   a set of valid requirements.
624
    assert Requirement('PySocks!=1.5.7,>=1.5.6; extra == "socks"') in dists[3].requires_dists
1✔
625
    assert dists[4].project_name == "urllib3"
1✔
626

627

628
def test_determine_pex_python_and_platforms() -> None:
1✔
629
    hardcoded_python = PythonExecutable("/hardcoded/python")
1✔
630
    discovered_python = PythonExecutable("/discovered/python")
1✔
631
    ics = InterpreterConstraints(["==3.8"])
1✔
632

633
    def assert_setup(
1✔
634
        *,
635
        input_python: PythonExecutable | None = None,
636
        platforms: PexPlatforms = PexPlatforms(),
637
        complete_platforms: CompletePlatforms = CompletePlatforms(),
638
        interpreter_constraints: InterpreterConstraints = InterpreterConstraints(),
639
        internal_only: bool = False,
640
        expected: _BuildPexPythonSetup,
641
    ) -> None:
642
        request = PexRequest(
1✔
643
            output_filename="foo.pex",
644
            internal_only=internal_only,
645
            python=input_python,
646
            platforms=platforms,
647
            complete_platforms=complete_platforms,
648
            interpreter_constraints=interpreter_constraints,
649
        )
650
        result = run_rule_with_mocks(
1✔
651
            _determine_pex_python_and_platforms,
652
            rule_args=[request],
653
            mock_calls={
654
                "pants.backend.python.util_rules.pex.find_interpreter": lambda _: discovered_python
655
            },
656
        )
657
        assert result == expected
1✔
658

659
    assert_setup(
1✔
660
        expected=_BuildPexPythonSetup(discovered_python, ["--python-path", discovered_python.path])
661
    )
662
    assert_setup(
1✔
663
        interpreter_constraints=ics,
664
        expected=_BuildPexPythonSetup(
665
            discovered_python,
666
            ["--interpreter-constraint", "CPython==3.8", "--python-path", discovered_python.path],
667
        ),
668
    )
669
    assert_setup(
1✔
670
        internal_only=True,
671
        interpreter_constraints=ics,
672
        expected=_BuildPexPythonSetup(discovered_python, ["--python", discovered_python.path]),
673
    )
674
    assert_setup(
1✔
675
        internal_only=True,
676
        input_python=hardcoded_python,
677
        expected=_BuildPexPythonSetup(hardcoded_python, ["--python", hardcoded_python.path]),
678
    )
679
    assert_setup(
1✔
680
        platforms=PexPlatforms(["plat"]),
681
        interpreter_constraints=ics,
682
        expected=_BuildPexPythonSetup(None, ["--platform", "plat"]),
683
    )
684
    assert_setup(
1✔
685
        complete_platforms=CompletePlatforms(["plat"]),
686
        interpreter_constraints=ics,
687
        expected=_BuildPexPythonSetup(None, ["--complete-platform", "plat"]),
688
    )
689

690

691
def test_setup_pex_requirements() -> None:
1✔
692
    rule_runner = RuleRunner()
1✔
693

694
    reqs = ("req1", "req2")
1✔
695

696
    constraints_content = "constraint"
1✔
697
    constraints_digest = rule_runner.make_snapshot(
1✔
698
        {"__constraints.txt": constraints_content}
699
    ).digest
700

701
    lockfile_path = "foo.lock"
1✔
702
    lockfile_digest = rule_runner.make_snapshot_of_empty_files([lockfile_path]).digest
1✔
703
    lockfile_obj = Lockfile(lockfile_path, url_description_of_origin="foo", resolve_name="resolve")
1✔
704

705
    def create_loaded_lockfile(is_pex_lock: bool) -> LoadedLockfile:
1✔
706
        return LoadedLockfile(
1✔
707
            lockfile_digest,
708
            lockfile_path,
709
            metadata=None,
710
            requirement_estimate=2,
711
            is_pex_native=is_pex_lock,
712
            as_constraints_strings=None,
713
            original_lockfile=lockfile_obj,
714
        )
715

716
    def assert_setup(
1✔
717
        requirements: PexRequirements | EntireLockfile,
718
        expected: _BuildPexRequirementsSetup,
719
        *,
720
        is_pex_lock: bool = True,
721
        include_find_links: bool = False,
722
    ) -> None:
723
        request = PexRequest(
1✔
724
            output_filename="foo.pex",
725
            internal_only=True,
726
            requirements=requirements,
727
        )
728
        result = run_rule_with_mocks(
1✔
729
            _setup_pex_requirements,
730
            rule_args=[request, create_subsystem(PythonSetup)],
731
            mock_calls={
732
                "pants.backend.python.util_rules.pex_requirements.determine_resolve_pex_config": lambda _: ResolvePexConfig(
733
                    indexes=("custom-index",),
734
                    find_links=("custom-find-links",),
735
                    manylinux=None,
736
                    constraints_file=None,
737
                    only_binary=FrozenOrderedSet(),
738
                    no_binary=FrozenOrderedSet(),
739
                    path_mappings=(),
740
                    excludes=FrozenOrderedSet(),
741
                    overrides=FrozenOrderedSet(),
742
                    sources=FrozenOrderedSet(),
743
                    lock_style="universal",
744
                    complete_platforms=(),
745
                ),
746
                "pants.backend.python.util_rules.pex.get_req_strings": lambda _: PexRequirementsInfo(
747
                    (
748
                        tuple(str(x) for x in requirements.req_strings_or_addrs)
749
                        if isinstance(requirements, PexRequirements)
750
                        else tuple()
751
                    ),
752
                    ("imma/link",) if include_find_links else tuple(),
753
                ),
754
                "pants.engine.intrinsics.create_digest": lambda _: constraints_digest,
755
                "pants.backend.python.util_rules.pex_requirements.load_lockfile": lambda _: create_loaded_lockfile(
756
                    is_pex_lock
757
                ),
758
                "pants.backend.python.util_rules.pex_requirements.get_lockfile_for_resolve": lambda _: lockfile_obj,
759
            },
760
        )
761
        assert result == expected
1✔
762

763
    pex_args = [
1✔
764
        "--no-pypi",
765
        "--index=custom-index",
766
        "--find-links=custom-find-links",
767
        "--no-manylinux",
768
    ]
769
    pip_args = [*pex_args, "--resolver-version", "pip-2020-resolver"]
1✔
770

771
    # Normal resolves.
772
    assert_setup(PexRequirements(reqs), _BuildPexRequirementsSetup([], [*reqs, *pip_args], 2))
1✔
773
    assert_setup(
1✔
774
        PexRequirements(reqs),
775
        _BuildPexRequirementsSetup([], [*reqs, *pip_args, "--find-links=imma/link"], 2),
776
        include_find_links=True,
777
    )
778
    assert_setup(
1✔
779
        PexRequirements(reqs, constraints_strings=["constraint"]),
780
        _BuildPexRequirementsSetup(
781
            [constraints_digest], [*reqs, *pip_args, "--constraints", "__constraints.txt"], 2
782
        ),
783
    )
784

785
    # Pex lockfile.
786
    assert_setup(
1✔
787
        EntireLockfile(lockfile_obj, complete_req_strings=reqs),
788
        _BuildPexRequirementsSetup([lockfile_digest], ["--lock", lockfile_path, *pex_args], 2),
789
    )
790

791
    # Non-Pex lockfile.
792
    assert_setup(
1✔
793
        EntireLockfile(lockfile_obj, complete_req_strings=reqs),
794
        _BuildPexRequirementsSetup(
795
            [lockfile_digest], ["--requirement", lockfile_path, "--no-transitive", *pip_args], 2
796
        ),
797
        is_pex_lock=False,
798
    )
799

800
    # Subset of Pex lockfile.
801
    assert_setup(
1✔
802
        PexRequirements(["req1"], from_superset=Resolve("resolve", False)),
803
        _BuildPexRequirementsSetup(
804
            [lockfile_digest], ["req1", "--lock", lockfile_path, *pex_args], 1
805
        ),
806
    )
807

808
    # Subset of repository Pex.
809
    repository_pex_digest = rule_runner.make_snapshot_of_empty_files(["foo.pex"]).digest
1✔
810
    assert_setup(
1✔
811
        PexRequirements(
812
            ["req1"], from_superset=Pex(digest=repository_pex_digest, name="foo.pex", python=None)
813
        ),
814
        _BuildPexRequirementsSetup(
815
            [repository_pex_digest], ["req1", "--pex-repository", "foo.pex"], 1
816
        ),
817
    )
818

819

820
def test_build_pex_description(rule_runner: RuleRunner) -> None:
1✔
821
    def assert_description(
1✔
822
        requirements: PexRequirements | EntireLockfile,
823
        *,
824
        description: str | None = None,
825
        expected: str,
826
    ) -> None:
827
        request = PexRequest(
1✔
828
            output_filename="new.pex",
829
            internal_only=True,
830
            requirements=requirements,
831
            description=description,
832
        )
833
        req_strings = []
1✔
834
        if isinstance(requirements, PexRequirements):
1✔
835
            for s in requirements.req_strings_or_addrs:
1✔
836
                assert isinstance(s, str)
1✔
837
                req_strings.append(s)
1✔
838
        assert _build_pex_description(request, req_strings, {}) == expected
1✔
839

840
    repo_pex = Pex(EMPTY_DIGEST, "repo.pex", None)
1✔
841

842
    assert_description(PexRequirements(), description="Custom!", expected="Custom!")
1✔
843
    assert_description(
1✔
844
        PexRequirements(from_superset=repo_pex), description="Custom!", expected="Custom!"
845
    )
846

847
    assert_description(PexRequirements(), expected="Building new.pex")
1✔
848
    assert_description(PexRequirements(from_superset=repo_pex), expected="Building new.pex")
1✔
849

850
    assert_description(
1✔
851
        PexRequirements(["req"]), expected="Building new.pex with 1 requirement: req"
852
    )
853
    assert_description(
1✔
854
        PexRequirements(["req"], from_superset=repo_pex),
855
        expected="Extracting 1 requirement to build new.pex from repo.pex: req",
856
    )
857

858
    assert_description(
1✔
859
        PexRequirements(["req1", "req2"]),
860
        expected="Building new.pex with 2 requirements: req1, req2",
861
    )
862
    assert_description(
1✔
863
        PexRequirements(["req1", "req2"], from_superset=repo_pex),
864
        expected="Extracting 2 requirements to build new.pex from repo.pex: req1, req2",
865
    )
866

867
    assert_description(
1✔
868
        EntireLockfile(
869
            Lockfile(
870
                url="lock.txt",
871
                url_description_of_origin="test",
872
                resolve_name="a",
873
            )
874
        ),
875
        expected="Building new.pex from lock.txt",
876
    )
877

878
    assert_description(
1✔
879
        EntireLockfile(
880
            Lockfile(
881
                url="lock.txt",
882
                url_description_of_origin="foo",
883
                resolve_name="a",
884
            )
885
        ),
886
        expected="Building new.pex from lock.txt",
887
    )
888

889

890
def test_lockfile_validation(rule_runner: RuleRunner) -> None:
1✔
891
    """Check that we properly load and validate lockfile metadata for both types of locks.
892

893
    Note that we don't exhaustively test every source of lockfile failure nor the different options
894
    for `--invalid-lockfile-behavior`, as those are already tested in pex_requirements_test.py.
895
    """
896

897
    # We create a lockfile that claims it works with no requirements. It should fail when we try
898
    # to build a PEX with a requirement.
899
    lock_content = PythonLockfileMetadata.new(
1✔
900
        valid_for_interpreter_constraints=InterpreterConstraints(),
901
        requirements=set(),
902
        requirement_constraints=set(),
903
        only_binary=set(),
904
        no_binary=set(),
905
        manylinux=None,
906
        excludes=set(),
907
        overrides=set(),
908
        sources=set(),
909
        lock_style="universal",
910
        complete_platforms=(),
911
    ).add_header_to_lockfile(b"", regenerate_command="regen", delimeter="#")
912
    rule_runner.write_files({"lock.txt": lock_content.decode()})
1✔
913

914
    _lockfile = Lockfile(
1✔
915
        "lock.txt",
916
        url_description_of_origin="a test",
917
        resolve_name="a",
918
    )
919
    with engine_error(InvalidLockfileError):
1✔
920
        create_pex_and_get_all_data(
1✔
921
            rule_runner, requirements=EntireLockfile(_lockfile, ("ansicolors",))
922
        )
923

924

925
@pytest.mark.parametrize("target_type", ["file", "resource"])
1✔
926
def test_digest_complete_platforms(rule_runner: RuleRunner, target_type: str) -> None:
1✔
927
    # Read the complete_platforms content using pkgutil
928
    complete_platforms_content = pkgutil.get_data(__name__, "complete_platform_pex_test.json")
1✔
929
    assert complete_platforms_content is not None
1✔
930

931
    # Create a target with the complete platforms file
932
    rule_runner.write_files(
1✔
933
        {
934
            "BUILD": f"{target_type}(name='complete_platforms', source='complete_platforms.json')",
935
            "complete_platforms.json": complete_platforms_content,
936
        }
937
    )
938

939
    # Get the CompletePlatforms object
940
    target = rule_runner.get_target(Address("", target_name="complete_platforms"))
1✔
941
    complete_platforms = rule_runner.request(
1✔
942
        CompletePlatforms,
943
        [PexCompletePlatformsField([":complete_platforms"], target.address)],
944
    )
945

946
    # Verify the result
947
    assert len(complete_platforms) == 1
1✔
948
    assert complete_platforms.digest != EMPTY_DIGEST
1✔
949

950

951
def test_digest_complete_platforms_codegen(rule_runner: RuleRunner) -> None:
1✔
952
    # Read the complete_platforms content using pkgutil
953
    complete_platforms_content = pkgutil.get_data(__name__, "complete_platform_pex_test.json")
1✔
954
    assert complete_platforms_content is not None
1✔
955

956
    # Create a target with the complete platforms file
957
    rule_runner.write_files(
1✔
958
        {
959
            "BUILD": """\
960
file(name='complete_platforms', source='complete_platforms.json')
961
experimental_wrap_as_resources(name="codegen", inputs=[':complete_platforms'], )
962
            """,
963
            "complete_platforms.json": complete_platforms_content,
964
        }
965
    )
966

967
    # Get the CompletePlatforms object
968
    target = rule_runner.get_target(Address("", target_name="codegen"))
1✔
969
    complete_platforms = rule_runner.request(
1✔
970
        CompletePlatforms,
971
        [PexCompletePlatformsField([":codegen"], target.address)],
972
    )
973

974
    # Verify the result
975
    assert len(complete_platforms) == 1
1✔
976
    assert complete_platforms.digest != EMPTY_DIGEST
1✔
977

978

979
def create_uv_venv_test_inputs(
1✔
980
    rule_runner: RuleRunner,
981
    *,
982
    description: str,
983
) -> tuple[_UvVenvRequest, Lockfile, LoadedLockfile]:
984
    resolve = Resolve("python-default", False)
1✔
985
    lockfile = Lockfile(
1✔
986
        "3rdparty/python/default.lock", url_description_of_origin="test", resolve_name=resolve.name
987
    )
988
    loaded_lockfile = LoadedLockfile(
1✔
989
        lockfile_digest=rule_runner.make_snapshot_of_empty_files([lockfile.url]).digest,
990
        lockfile_path=lockfile.url,
991
        metadata=None,
992
        requirement_estimate=1,
993
        is_pex_native=True,
994
        as_constraints_strings=None,
995
        original_lockfile=lockfile,
996
    )
997
    uv_request = _UvVenvRequest(
1✔
998
        req_strings=("ansicolors==1.1.8",),
999
        requirements=PexRequirements(("ansicolors==1.1.8",), from_superset=resolve),
1000
        python_path="/usr/bin/python3",
1001
        description=description,
1002
    )
1003
    return uv_request, lockfile, loaded_lockfile
1✔
1004

1005

1006
def run_uv_venv_with_mocks(
1✔
1007
    uv_request: _UvVenvRequest,
1008
    lockfile: Lockfile,
1009
    loaded_lockfile: LoadedLockfile,
1010
    *,
1011
    mock_fallible_to_exec_result_or_raise,
1012
    mock_create_digest=None,
1013
):
1014
    pex_env = SimpleNamespace(append_only_caches={})
1✔
1015
    pex_env.in_sandbox = lambda *, working_directory: pex_env
1✔
1016
    pex_env.environment_dict = lambda *, python_configured: {}
1✔
1017

1018
    return run_rule_with_mocks(
1✔
1019
        _build_uv_venv,
1020
        rule_args=[uv_request, pex_env],
1021
        mock_calls={
1022
            "pants.backend.python.subsystems.uv.download_uv_binary": lambda: DownloadedUv(
1023
                digest=EMPTY_DIGEST,
1024
                exe="uv",
1025
                args_for_uv_pip_install=(),
1026
            ),
1027
            "pants.backend.python.util_rules.pex_requirements.get_lockfile_for_resolve": lambda _: lockfile,
1028
            "pants.backend.python.util_rules.pex_requirements.load_lockfile": lambda _: loaded_lockfile,
1029
            "pants.engine.process.fallible_to_exec_result_or_raise": mock_fallible_to_exec_result_or_raise,
1030
            "pants.engine.intrinsics.create_digest": mock_create_digest or (lambda _: EMPTY_DIGEST),
1031
            "pants.engine.intrinsics.merge_digests": lambda _: EMPTY_DIGEST,
1032
        },
1033
    )
1034

1035

1036
def test_build_uv_venv_uses_exported_lockfile_with_no_deps(rule_runner: RuleRunner) -> None:
1✔
1037
    uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs(
1✔
1038
        rule_runner, description="test uv export path"
1039
    )
1040
    exported_digest = rule_runner.make_snapshot(
1✔
1041
        {"__uv_requirements.txt": "ansicolors==1.1.8\n"}
1042
    ).digest
1043
    install_argv: tuple[str, ...] | None = None
1✔
1044

1045
    def mock_fallible_to_exec_result_or_raise(*args, **kwargs):
1✔
1046
        nonlocal install_argv
1047
        req = args[0]
1✔
1048
        if isinstance(req, PexCliProcess):
1✔
1049
            assert req.subcommand == ("lock", "export")
1✔
1050
            assert "--format" in req.extra_args
1✔
1051
            export_format = req.extra_args[req.extra_args.index("--format") + 1]
1✔
1052
            assert export_format in {"pip-no-hashes", "pep-751"}
1✔
1053
            return SimpleNamespace(output_digest=exported_digest)
1✔
1054
        assert isinstance(req, Process)
1✔
1055
        if req.argv[1:3] == ("pip", "install"):
1✔
1056
            install_argv = req.argv
1✔
1057
        return SimpleNamespace(output_digest=EMPTY_DIGEST)
1✔
1058

1059
    def mock_create_digest(request: CreateDigest) -> Digest:
1✔
1060
        for entry in request:
1✔
1061
            assert not (isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt"), (
1✔
1062
                "exported lockfile path should not synthesize requirements content"
1063
            )
1064
        return EMPTY_DIGEST
1✔
1065

1066
    result = run_uv_venv_with_mocks(
1✔
1067
        uv_request,
1068
        lockfile,
1069
        loaded_lockfile,
1070
        mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise,
1071
        mock_create_digest=mock_create_digest,
1072
    )
1073

1074
    assert result.venv_digest == EMPTY_DIGEST
1✔
1075
    assert install_argv is not None
1✔
1076
    assert "--no-deps" in install_argv
1✔
1077

1078

1079
def test_build_uv_venv_falls_back_when_lock_export_has_no_digest(rule_runner: RuleRunner) -> None:
1✔
1080
    uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs(
1✔
1081
        rule_runner, description="test uv fallback path"
1082
    )
1083
    export_attempts = 0
1✔
1084
    install_argv: tuple[str, ...] | None = None
1✔
1085
    synthesized_reqs: bytes | None = None
1✔
1086

1087
    def mock_fallible_to_exec_result_or_raise(*args, **kwargs):
1✔
1088
        nonlocal export_attempts, install_argv
1089
        req = args[0]
1✔
1090
        if isinstance(req, PexCliProcess):
1✔
1091
            export_attempts += 1
1✔
1092
            return SimpleNamespace(output_digest=None)
1✔
1093
        assert isinstance(req, Process)
1✔
1094
        if req.argv[1:3] == ("pip", "install"):
1✔
1095
            install_argv = req.argv
1✔
1096
        return SimpleNamespace(output_digest=EMPTY_DIGEST)
1✔
1097

1098
    def mock_create_digest(request: CreateDigest) -> Digest:
1✔
1099
        nonlocal synthesized_reqs
1100
        for entry in request:
1✔
1101
            if isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt":
1✔
1102
                synthesized_reqs = entry.content
1✔
1103
        return EMPTY_DIGEST
1✔
1104

1105
    result = run_uv_venv_with_mocks(
1✔
1106
        uv_request,
1107
        lockfile,
1108
        loaded_lockfile,
1109
        mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise,
1110
        mock_create_digest=mock_create_digest,
1111
    )
1112

1113
    assert result.venv_digest == EMPTY_DIGEST
1✔
1114
    assert export_attempts == 1
1✔
1115
    assert install_argv is not None
1✔
1116
    assert "--no-deps" not in install_argv
1✔
1117
    assert synthesized_reqs == b"ansicolors==1.1.8\n"
1✔
1118

1119

1120
def test_build_uv_venv_propagates_unexpected_export_errors(rule_runner: RuleRunner) -> None:
1✔
1121
    uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs(
1✔
1122
        rule_runner, description="test unexpected error path"
1123
    )
1124

1125
    def mock_fallible_to_exec_result_or_raise(*args, **kwargs):
1✔
1126
        req = args[0]
1✔
1127
        if isinstance(req, PexCliProcess):
1✔
1128
            raise ValueError("unexpected failure type")
1✔
NEW
1129
        return SimpleNamespace(output_digest=EMPTY_DIGEST)
×
1130

1131
    with pytest.raises(ValueError, match="unexpected failure type"):
1✔
1132
        run_uv_venv_with_mocks(
1✔
1133
            uv_request,
1134
            lockfile,
1135
            loaded_lockfile,
1136
            mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise,
1137
        )
1138

1139

1140
def test_uv_pex_builder_resolves_dependencies(rule_runner: RuleRunner) -> None:
1✔
1141
    """When pex_builder=uv, PEX should be built via uv venv + --venv-repository."""
1142
    req_strings = ["six==1.12.0", "jsonschema==2.6.0"]
1✔
1143
    requirements = PexRequirements(req_strings)
1✔
1144
    pex_info = create_pex_and_get_pex_info(
1✔
1145
        rule_runner,
1146
        requirements=requirements,
1147
        additional_pants_args=("--python-pex-builder=uv",),
1148
        internal_only=False,
1149
    )
1150
    assert set(parse_requirements(req_strings)).issubset(
1✔
1151
        set(parse_requirements(pex_info["requirements"]))
1152
    )
1153

1154

1155
def test_uv_pex_builder_includes_transitive_dependencies(rule_runner: RuleRunner) -> None:
1✔
1156
    """uv builder must install transitive dependencies, not just direct ones.
1157

1158
    `requests` depends on urllib3, certifi, charset-normalizer, idna - the PEX
1159
    must be able to import these at runtime even though only `requests` is declared.
1160
    We verify by actually executing the PEX and importing a transitive dep.
1161
    """
1162
    sources = rule_runner.request(
1✔
1163
        Digest,
1164
        [
1165
            CreateDigest(
1166
                (
1167
                    FileContent(
1168
                        "main.py",
1169
                        # Import both the direct dep and a transitive dep (certifi).
1170
                        b"import requests; import certifi; print(f'requests=={requests.__version__}'); print(f'certifi_where={certifi.where()}')",
1171
                    ),
1172
                )
1173
            ),
1174
        ],
1175
    )
1176
    pex_data = create_pex_and_get_all_data(
1✔
1177
        rule_runner,
1178
        pex_type=Pex,
1179
        requirements=PexRequirements(["requests==2.31.0"]),
1180
        main=EntryPoint("main"),
1181
        sources=sources,
1182
        additional_pants_args=("--python-pex-builder=uv",),
1183
        internal_only=False,
1184
    )
1185
    pex_exe = (
1✔
1186
        f"./{pex_data.sandbox_path}"
1187
        if pex_data.is_zipapp
1188
        else os.path.join(pex_data.sandbox_path, "__main__.py")
1189
    )
1190
    process = Process(
1✔
1191
        argv=(pex_exe,),
1192
        env={"PATH": os.getenv("PATH", "")},
1193
        input_digest=pex_data.pex.digest,
1194
        description="Run uv-built pex and verify transitive deps are importable",
1195
    )
1196
    result = rule_runner.request(ProcessResult, [process])
1✔
1197
    assert b"requests==2.31.0" in result.stdout
1✔
1198
    assert b"certifi_where=" in result.stdout
1✔
1199

1200

1201
def test_uv_pex_builder_execution(rule_runner: RuleRunner) -> None:
1✔
1202
    """PEX built via uv builder should actually execute and import installed packages."""
1203
    sources = rule_runner.request(
1✔
1204
        Digest,
1205
        [
1206
            CreateDigest(
1207
                (
1208
                    FileContent(
1209
                        "main.py",
1210
                        b"import six; print(f'six=={six.__version__}')",
1211
                    ),
1212
                )
1213
            ),
1214
        ],
1215
    )
1216
    pex_data = create_pex_and_get_all_data(
1✔
1217
        rule_runner,
1218
        pex_type=Pex,
1219
        requirements=PexRequirements(["six==1.12.0"]),
1220
        main=EntryPoint("main"),
1221
        sources=sources,
1222
        additional_pants_args=("--python-pex-builder=uv",),
1223
        internal_only=False,
1224
    )
1225
    pex_exe = (
1✔
1226
        f"./{pex_data.sandbox_path}"
1227
        if pex_data.is_zipapp
1228
        else os.path.join(pex_data.sandbox_path, "__main__.py")
1229
    )
1230
    process = Process(
1✔
1231
        argv=(pex_exe,),
1232
        env={"PATH": os.getenv("PATH", "")},
1233
        input_digest=pex_data.pex.digest,
1234
        description="Run uv-built pex and verify import works",
1235
    )
1236
    result = rule_runner.request(ProcessResult, [process])
1✔
1237
    assert result.stdout == b"six==1.12.0\n"
1✔
1238

1239

1240
def test_uv_pex_builder_skipped_for_internal_only(rule_runner: RuleRunner) -> None:
1✔
1241
    """Internal-only PEXes should fall back to the default pip path even with pex_builder=uv."""
1242
    req_strings = ["six==1.12.0"]
1✔
1243
    requirements = PexRequirements(req_strings)
1✔
1244
    pex_info = create_pex_and_get_pex_info(
1✔
1245
        rule_runner,
1246
        requirements=requirements,
1247
        additional_pants_args=("--python-pex-builder=uv",),
1248
        internal_only=True,
1249
    )
1250
    assert set(parse_requirements(req_strings)).issubset(
1✔
1251
        set(parse_requirements(pex_info["requirements"]))
1252
    )
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