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

pantsbuild / pants / 24149123771

08 Apr 2026 05:25PM UTC coverage: 92.925% (+0.02%) from 92.91%
24149123771

Pull #23227

github

web-flow
Merge c74227668 into 9036734c9
Pull Request #23227: Fix uv PEX builder to use pex3 lock export

85 of 86 new or added lines in 2 files covered. (98.84%)

2 existing lines in 2 files now uncovered.

91671 of 98650 relevant lines covered (92.93%)

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
                    uploaded_prior_to=None,
746
                ),
747
                "pants.backend.python.util_rules.pex.get_req_strings": lambda _: PexRequirementsInfo(
748
                    (
749
                        tuple(str(x) for x in requirements.req_strings_or_addrs)
750
                        if isinstance(requirements, PexRequirements)
751
                        else tuple()
752
                    ),
753
                    ("imma/link",) if include_find_links else tuple(),
754
                ),
755
                "pants.engine.intrinsics.create_digest": lambda _: constraints_digest,
756
                "pants.backend.python.util_rules.pex_requirements.load_lockfile": lambda _: create_loaded_lockfile(
757
                    is_pex_lock
758
                ),
759
                "pants.backend.python.util_rules.pex_requirements.get_lockfile_for_resolve": lambda _: lockfile_obj,
760
            },
761
        )
762
        assert result == expected
1✔
763

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

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

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

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

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

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

820

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

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

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

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

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

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

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

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

890

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

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

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

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

926

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

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

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

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

952

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

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

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

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

980

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

1007

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

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

1037

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

1047
    def mock_fallible_to_exec_result_or_raise(*args, **kwargs):
1✔
1048
        nonlocal install_argv
1049
        req = args[0]
1✔
1050
        if isinstance(req, PexCliProcess):
1✔
1051
            assert req.subcommand == ("lock", "export-subset")
1✔
1052
            assert "--format" in req.extra_args
1✔
1053
            export_format = req.extra_args[req.extra_args.index("--format") + 1]
1✔
1054
            assert export_format in {"pip-no-hashes", "pep-751"}
1✔
1055
            # Verify requirement strings are passed as positional args.
1056
            assert "ansicolors==1.1.8" in req.extra_args
1✔
1057
            # Verify --lock is used instead of positional lockfile path.
1058
            assert "--lock" in req.extra_args
1✔
1059
            return SimpleNamespace(output_digest=exported_digest)
1✔
1060
        assert isinstance(req, Process)
1✔
1061
        if req.argv[1:3] == ("pip", "install"):
1✔
1062
            install_argv = req.argv
1✔
1063
        return SimpleNamespace(output_digest=EMPTY_DIGEST)
1✔
1064

1065
    def mock_create_digest(request: CreateDigest) -> Digest:
1✔
1066
        for entry in request:
1✔
1067
            assert not (isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt"), (
1✔
1068
                "exported lockfile path should not synthesize requirements content"
1069
            )
1070
        return EMPTY_DIGEST
1✔
1071

1072
    result = run_uv_venv_with_mocks(
1✔
1073
        uv_request,
1074
        lockfile,
1075
        loaded_lockfile,
1076
        mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise,
1077
        mock_create_digest=mock_create_digest,
1078
    )
1079

1080
    assert result.venv_digest == EMPTY_DIGEST
1✔
1081
    assert install_argv is not None
1✔
1082
    assert "--no-deps" in install_argv
1✔
1083

1084

1085
def test_build_uv_venv_falls_back_when_lock_export_has_no_digest(rule_runner: RuleRunner) -> None:
1✔
1086
    uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs(
1✔
1087
        rule_runner, description="test uv fallback path"
1088
    )
1089
    export_attempts = 0
1✔
1090
    install_argv: tuple[str, ...] | None = None
1✔
1091
    synthesized_reqs: bytes | None = None
1✔
1092

1093
    def mock_fallible_to_exec_result_or_raise(*args, **kwargs):
1✔
1094
        nonlocal export_attempts, install_argv
1095
        req = args[0]
1✔
1096
        if isinstance(req, PexCliProcess):
1✔
1097
            export_attempts += 1
1✔
1098
            return SimpleNamespace(output_digest=None)
1✔
1099
        assert isinstance(req, Process)
1✔
1100
        if req.argv[1:3] == ("pip", "install"):
1✔
1101
            install_argv = req.argv
1✔
1102
        return SimpleNamespace(output_digest=EMPTY_DIGEST)
1✔
1103

1104
    def mock_create_digest(request: CreateDigest) -> Digest:
1✔
1105
        nonlocal synthesized_reqs
1106
        for entry in request:
1✔
1107
            if isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt":
1✔
1108
                synthesized_reqs = entry.content
1✔
1109
        return EMPTY_DIGEST
1✔
1110

1111
    result = run_uv_venv_with_mocks(
1✔
1112
        uv_request,
1113
        lockfile,
1114
        loaded_lockfile,
1115
        mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise,
1116
        mock_create_digest=mock_create_digest,
1117
    )
1118

1119
    assert result.venv_digest == EMPTY_DIGEST
1✔
1120
    assert export_attempts == 1
1✔
1121
    assert install_argv is not None
1✔
1122
    assert "--no-deps" not in install_argv
1✔
1123
    assert synthesized_reqs == b"ansicolors==1.1.8\n"
1✔
1124

1125

1126
def test_build_uv_venv_propagates_unexpected_export_errors(rule_runner: RuleRunner) -> None:
1✔
1127
    uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs(
1✔
1128
        rule_runner, description="test unexpected error path"
1129
    )
1130

1131
    def mock_fallible_to_exec_result_or_raise(*args, **kwargs):
1✔
1132
        req = args[0]
1✔
1133
        if isinstance(req, PexCliProcess):
1✔
1134
            raise ValueError("unexpected failure type")
1✔
NEW
1135
        return SimpleNamespace(output_digest=EMPTY_DIGEST)
×
1136

1137
    with pytest.raises(ValueError, match="unexpected failure type"):
1✔
1138
        run_uv_venv_with_mocks(
1✔
1139
            uv_request,
1140
            lockfile,
1141
            loaded_lockfile,
1142
            mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise,
1143
        )
1144

1145

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

1160

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

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

1206

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

1245

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