From 497c59fb850c9210dcda4ddcb6f2089a278b47f0 Mon Sep 17 00:00:00 2001 From: seungwoo-ji-03 Date: Tue, 7 Apr 2026 15:43:01 +0900 Subject: [PATCH 1/8] fix: use pex3 lock export in uv PEX builder instead of parsing lockfile json --- .../pants/backend/python/util_rules/pex.py | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/src/python/pants/backend/python/util_rules/pex.py b/src/python/pants/backend/python/util_rules/pex.py index 0dc5f9580d5..cac417c45d4 100644 --- a/src/python/pants/backend/python/util_rules/pex.py +++ b/src/python/pants/backend/python/util_rules/pex.py @@ -85,13 +85,13 @@ add_prefix, create_digest, digest_to_snapshot, - get_digest_contents, merge_digests, remove_prefix, ) from pants.engine.process import ( Process, ProcessCacheScope, + ProcessExecutionFailure, ProcessResult, execute_process_or_raise, fallible_to_exec_result_or_raise, @@ -560,10 +560,13 @@ async def _build_uv_venv( uv_request.description, ) - # Try to extract the full resolved package list from the lockfile - # so we can pass pinned versions with --no-deps (reproducible). + # Try to export the lockfile via `pex3 lock export` so we can pass pinned + # versions with --no-deps (reproducible). This uses Pex's stable CLI rather + # than parsing the internal lockfile JSON directly. # Fall back to letting uv resolve transitively if no lockfile. - all_resolved_reqs: tuple[str, ...] = () + exported_reqs_digest: Digest | None = None + reqs_file = "pylock.toml" + if isinstance(uv_request.requirements, PexRequirements) and isinstance( uv_request.requirements.from_superset, Resolve ): @@ -573,42 +576,51 @@ async def _build_uv_venv( loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly()) if loaded_lockfile.is_pex_native: try: - digest_contents = await get_digest_contents(loaded_lockfile.lockfile_digest) - lockfile_bytes = next( - c.content for c in digest_contents if c.path == loaded_lockfile.lockfile_path - ) - lockfile_data = json.loads(lockfile_bytes) - all_resolved_reqs = tuple( - f"{req['project_name']}=={req['version']}" - for resolve in lockfile_data.get("locked_resolves", ()) - for req in resolve.get("locked_requirements", ()) + export_result = await fallible_to_exec_result_or_raise( + **implicitly( + PexCliProcess( + subcommand=("lock", "export"), + extra_args=( + "--format", + "pep-751", + "-o", + reqs_file, + loaded_lockfile.lockfile_path, + ), + additional_input_digest=loaded_lockfile.lockfile_digest, + description=f"Export lockfile for {uv_request.description}", + output_files=(reqs_file,), + ) + ) ) - except (json.JSONDecodeError, KeyError, StopIteration) as e: + exported_reqs_digest = export_result.output_digest + except ProcessExecutionFailure as e: logger.warning( - "pex_builder=uv: failed to parse lockfile for %s: %s. " + "pex_builder=uv: failed to export lockfile for %s: %s. " "Falling back to transitive uv resolution.", uv_request.description, e, ) - all_resolved_reqs = () - uv_reqs = all_resolved_reqs or uv_request.req_strings + use_exported_lockfile = exported_reqs_digest is not None - if all_resolved_reqs: + if use_exported_lockfile: logger.debug( - "pex_builder=uv: using %d pinned packages from lockfile with --no-deps for %s", - len(all_resolved_reqs), + "pex_builder=uv: using exported lockfile with --no-deps for %s", uv_request.description, ) + assert exported_reqs_digest is not None + reqs_digest = exported_reqs_digest else: logger.debug( "pex_builder=uv: no lockfile available, using transitive uv resolution for %s", uv_request.description, ) - - reqs_file = "__uv_requirements.txt" - reqs_content = "\n".join(uv_reqs) + "\n" - reqs_digest = await create_digest(CreateDigest([FileContent(reqs_file, reqs_content.encode())])) + reqs_file = "__uv_requirements.txt" + reqs_content = "\n".join(uv_request.req_strings) + "\n" + reqs_digest = await create_digest( + CreateDigest([FileContent(reqs_file, reqs_content.encode())]) + ) complete_pex_env = pex_env.in_sandbox(working_directory=None) uv_cache_dir = ".cache/uv_cache" @@ -661,7 +673,7 @@ async def _build_uv_venv( os.path.join(_UV_VENV_DIR, "bin", "python"), "-r", reqs_file, - *(("--no-deps",) if all_resolved_reqs else ()), + *(("--no-deps",) if use_exported_lockfile else ()), *downloaded_uv.args_for_uv_pip_install, ) From 0872e7b9f94b4434a264edd6885cb61a3a384f92 Mon Sep 17 00:00:00 2001 From: seungwoo-ji-03 Date: Tue, 7 Apr 2026 15:43:10 +0900 Subject: [PATCH 2/8] test: add tests for pex3 lock export in uv PEX builder --- .../backend/python/util_rules/pex_test.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/python/pants/backend/python/util_rules/pex_test.py b/src/python/pants/backend/python/util_rules/pex_test.py index 2a23116b14f..4ff93ed0acb 100644 --- a/src/python/pants/backend/python/util_rules/pex_test.py +++ b/src/python/pants/backend/python/util_rules/pex_test.py @@ -10,6 +10,7 @@ import textwrap import zipfile from pathlib import Path +from types import SimpleNamespace import pytest import requests @@ -20,8 +21,10 @@ from pants.backend.python.goals import lockfile from pants.backend.python.goals.lockfile import GeneratePythonLockfile from pants.backend.python.subsystems.setup import PythonSetup +from pants.backend.python.subsystems.uv import DownloadedUv from pants.backend.python.target_types import EntryPoint, PexCompletePlatformsField from pants.backend.python.util_rules import pex_test_utils +from pants.backend.python.util_rules.pex_cli import PexCliProcess from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata from pants.backend.python.util_rules.pex import ( @@ -36,10 +39,12 @@ VenvPex, VenvPexProcess, _build_pex_description, + _build_uv_venv, _BuildPexPythonSetup, _BuildPexRequirementsSetup, _determine_pex_python_and_platforms, _setup_pex_requirements, + _UvVenvRequest, ) from pants.backend.python.util_rules.pex import rules as pex_rules from pants.backend.python.util_rules.pex_environment import PythonExecutable @@ -971,6 +976,165 @@ def test_digest_complete_platforms_codegen(rule_runner: RuleRunner) -> None: assert complete_platforms.digest != EMPTY_DIGEST +def create_uv_venv_test_inputs( + rule_runner: RuleRunner, + *, + description: str, +) -> tuple[_UvVenvRequest, Lockfile, LoadedLockfile]: + resolve = Resolve("python-default", False) + lockfile = Lockfile( + "3rdparty/python/default.lock", url_description_of_origin="test", resolve_name=resolve.name + ) + loaded_lockfile = LoadedLockfile( + lockfile_digest=rule_runner.make_snapshot_of_empty_files([lockfile.url]).digest, + lockfile_path=lockfile.url, + metadata=None, + requirement_estimate=1, + is_pex_native=True, + as_constraints_strings=None, + original_lockfile=lockfile, + ) + uv_request = _UvVenvRequest( + req_strings=("ansicolors==1.1.8",), + requirements=PexRequirements(("ansicolors==1.1.8",), from_superset=resolve), + python_path="/usr/bin/python3", + description=description, + ) + return uv_request, lockfile, loaded_lockfile + + +def run_uv_venv_with_mocks( + uv_request: _UvVenvRequest, + lockfile: Lockfile, + loaded_lockfile: LoadedLockfile, + *, + mock_fallible_to_exec_result_or_raise, + mock_create_digest=None, +): + pex_env = SimpleNamespace(append_only_caches={}) + pex_env.in_sandbox = lambda *, working_directory: pex_env + pex_env.environment_dict = lambda *, python_configured: {} + + return run_rule_with_mocks( + _build_uv_venv, + rule_args=[uv_request, pex_env], + mock_calls={ + "pants.backend.python.subsystems.uv.download_uv_binary": lambda: DownloadedUv( + digest=EMPTY_DIGEST, + exe="uv", + args_for_uv_pip_install=(), + ), + "pants.backend.python.util_rules.pex_requirements.get_lockfile_for_resolve": lambda _: lockfile, + "pants.backend.python.util_rules.pex_requirements.load_lockfile": lambda _: loaded_lockfile, + "pants.engine.process.fallible_to_exec_result_or_raise": mock_fallible_to_exec_result_or_raise, + "pants.engine.intrinsics.create_digest": mock_create_digest or (lambda _: EMPTY_DIGEST), + "pants.engine.intrinsics.merge_digests": lambda _: EMPTY_DIGEST, + }, + ) + + +def test_build_uv_venv_uses_exported_lockfile_with_no_deps(rule_runner: RuleRunner) -> None: + uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( + rule_runner, description="test uv export path" + ) + exported_digest = rule_runner.make_snapshot({"__uv_requirements.txt": "ansicolors==1.1.8\n"}).digest + install_argv: tuple[str, ...] | None = None + + def mock_fallible_to_exec_result_or_raise(*args, **kwargs): + nonlocal install_argv + req = args[0] + if isinstance(req, PexCliProcess): + assert req.subcommand == ("lock", "export") + assert "--format" in req.extra_args + export_format = req.extra_args[req.extra_args.index("--format") + 1] + assert export_format in {"pip-no-hashes", "pep-751"} + return SimpleNamespace(output_digest=exported_digest) + assert isinstance(req, Process) + if req.argv[1:3] == ("pip", "install"): + install_argv = req.argv + return SimpleNamespace(output_digest=EMPTY_DIGEST) + + def mock_create_digest(request: CreateDigest) -> Digest: + for entry in request: + assert not (isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt"), ( + "exported lockfile path should not synthesize requirements content" + ) + return EMPTY_DIGEST + + result = run_uv_venv_with_mocks( + uv_request, + lockfile, + loaded_lockfile, + mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise, + mock_create_digest=mock_create_digest, + ) + + assert result.venv_digest == EMPTY_DIGEST + assert install_argv is not None + assert "--no-deps" in install_argv + + +def test_build_uv_venv_falls_back_when_lock_export_has_no_digest(rule_runner: RuleRunner) -> None: + uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( + rule_runner, description="test uv fallback path" + ) + export_attempts = 0 + install_argv: tuple[str, ...] | None = None + synthesized_reqs: bytes | None = None + + def mock_fallible_to_exec_result_or_raise(*args, **kwargs): + nonlocal export_attempts, install_argv + req = args[0] + if isinstance(req, PexCliProcess): + export_attempts += 1 + return SimpleNamespace(output_digest=None) + assert isinstance(req, Process) + if req.argv[1:3] == ("pip", "install"): + install_argv = req.argv + return SimpleNamespace(output_digest=EMPTY_DIGEST) + + def mock_create_digest(request: CreateDigest) -> Digest: + nonlocal synthesized_reqs + for entry in request: + if isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt": + synthesized_reqs = entry.content + return EMPTY_DIGEST + + result = run_uv_venv_with_mocks( + uv_request, + lockfile, + loaded_lockfile, + mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise, + mock_create_digest=mock_create_digest, + ) + + assert result.venv_digest == EMPTY_DIGEST + assert export_attempts == 1 + assert install_argv is not None + assert "--no-deps" not in install_argv + assert synthesized_reqs == b"ansicolors==1.1.8\n" + + +def test_build_uv_venv_propagates_unexpected_export_errors(rule_runner: RuleRunner) -> None: + uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( + rule_runner, description="test unexpected error path" + ) + + def mock_fallible_to_exec_result_or_raise(*args, **kwargs): + req = args[0] + if isinstance(req, PexCliProcess): + raise ValueError("unexpected failure type") + return SimpleNamespace(output_digest=EMPTY_DIGEST) + + with pytest.raises(ValueError, match="unexpected failure type"): + run_uv_venv_with_mocks( + uv_request, + lockfile, + loaded_lockfile, + mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise, + ) + + def test_uv_pex_builder_resolves_dependencies(rule_runner: RuleRunner) -> None: """When pex_builder=uv, PEX should be built via uv venv + --venv-repository.""" req_strings = ["six==1.12.0", "jsonschema==2.6.0"] From 7f50667e78d20253c42cac2dbea6c1b1a748efc5 Mon Sep 17 00:00:00 2001 From: seungwoo-ji-03 Date: Tue, 7 Apr 2026 15:47:59 +0900 Subject: [PATCH 3/8] docs: add release note for uv PEX builder lockfile export fix --- docs/notes/2.32.x.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/notes/2.32.x.md b/docs/notes/2.32.x.md index c5ff5aa2833..303b8b4eabd 100644 --- a/docs/notes/2.32.x.md +++ b/docs/notes/2.32.x.md @@ -121,6 +121,10 @@ with `--no-deps`, preserving reproducibility. This only applies to non-internal, explicit requirement strings and a local Python interpreter; other builds silently fall back to pip. See [#20679](https://github.com/pantsbuild/pants/issues/20679) for background. +The experimental uv PEX builder (`[python].pex_builder = "uv"`) now correctly handles VCS +(`git+https://...`) and direct URL requirements from PEX-native lockfiles by using +`pex3 lock export` instead of parsing the internal lockfile format directly. + The `runtime` field of [`aws_python_lambda_layer`](https://www.pantsbuild.org/2.32/reference/targets/python_aws_lambda_layer#runtime) or [`aws_python_lambda_function`](https://www.pantsbuild.org/2.32/reference/targets/python_aws_lambda_function#runtime) now has built-in complete platform configurations for x86-64 and arm64 Python 3.14. This provides stable support for Python 3.14 lambdas out of the box, allowing deleting manual `complete_platforms` configuration if any. The `grpc-python-plugin` tool now uses an updated `v1.73.1` plugin built from Date: Tue, 7 Apr 2026 16:10:42 +0900 Subject: [PATCH 4/8] chore: fix import ordering --- src/python/pants/backend/python/util_rules/pex_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/python/pants/backend/python/util_rules/pex_test.py b/src/python/pants/backend/python/util_rules/pex_test.py index 4ff93ed0acb..d76f777f4f7 100644 --- a/src/python/pants/backend/python/util_rules/pex_test.py +++ b/src/python/pants/backend/python/util_rules/pex_test.py @@ -24,7 +24,6 @@ from pants.backend.python.subsystems.uv import DownloadedUv from pants.backend.python.target_types import EntryPoint, PexCompletePlatformsField from pants.backend.python.util_rules import pex_test_utils -from pants.backend.python.util_rules.pex_cli import PexCliProcess from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata from pants.backend.python.util_rules.pex import ( @@ -47,6 +46,7 @@ _UvVenvRequest, ) from pants.backend.python.util_rules.pex import rules as pex_rules +from pants.backend.python.util_rules.pex_cli import PexCliProcess from pants.backend.python.util_rules.pex_environment import PythonExecutable from pants.backend.python.util_rules.pex_requirements import ( EntireLockfile, @@ -1037,7 +1037,9 @@ def test_build_uv_venv_uses_exported_lockfile_with_no_deps(rule_runner: RuleRunn uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( rule_runner, description="test uv export path" ) - exported_digest = rule_runner.make_snapshot({"__uv_requirements.txt": "ansicolors==1.1.8\n"}).digest + exported_digest = rule_runner.make_snapshot( + {"__uv_requirements.txt": "ansicolors==1.1.8\n"} + ).digest install_argv: tuple[str, ...] | None = None def mock_fallible_to_exec_result_or_raise(*args, **kwargs): From 107cd62c98d00c07e20707647f1642c99bfbecdd Mon Sep 17 00:00:00 2001 From: seungwoo-ji-03 Date: Thu, 9 Apr 2026 00:33:48 +0900 Subject: [PATCH 5/8] fix: use pex3 lock export-subset to export only needed locked requirements --- .../pants/backend/python/util_rules/pex.py | 20 ++++++++++--------- .../backend/python/util_rules/pex_test.py | 6 +++++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/python/pants/backend/python/util_rules/pex.py b/src/python/pants/backend/python/util_rules/pex.py index cac417c45d4..32e97ee7c2c 100644 --- a/src/python/pants/backend/python/util_rules/pex.py +++ b/src/python/pants/backend/python/util_rules/pex.py @@ -560,10 +560,10 @@ async def _build_uv_venv( uv_request.description, ) - # Try to export the lockfile via `pex3 lock export` so we can pass pinned - # versions with --no-deps (reproducible). This uses Pex's stable CLI rather - # than parsing the internal lockfile JSON directly. - # Fall back to letting uv resolve transitively if no lockfile. + # Try to export a subset of the lockfile via `pex3 lock export-subset` so we + # can pass only the needed locked requirements with --no-deps (reproducible). + # This uses Pex's stable CLI rather than parsing the internal lockfile JSON + # directly. Fall back to letting uv resolve transitively if no lockfile. exported_reqs_digest: Digest | None = None reqs_file = "pylock.toml" @@ -579,16 +579,18 @@ async def _build_uv_venv( export_result = await fallible_to_exec_result_or_raise( **implicitly( PexCliProcess( - subcommand=("lock", "export"), + subcommand=("lock", "export-subset"), extra_args=( + *uv_request.req_strings, + "--lock", + loaded_lockfile.lockfile_path, "--format", "pep-751", "-o", reqs_file, - loaded_lockfile.lockfile_path, ), additional_input_digest=loaded_lockfile.lockfile_digest, - description=f"Export lockfile for {uv_request.description}", + description=f"Export lockfile subset for {uv_request.description}", output_files=(reqs_file,), ) ) @@ -596,7 +598,7 @@ async def _build_uv_venv( exported_reqs_digest = export_result.output_digest except ProcessExecutionFailure as e: logger.warning( - "pex_builder=uv: failed to export lockfile for %s: %s. " + "pex_builder=uv: failed to export lockfile subset for %s: %s. " "Falling back to transitive uv resolution.", uv_request.description, e, @@ -606,7 +608,7 @@ async def _build_uv_venv( if use_exported_lockfile: logger.debug( - "pex_builder=uv: using exported lockfile with --no-deps for %s", + "pex_builder=uv: using exported lockfile subset with --no-deps for %s", uv_request.description, ) assert exported_reqs_digest is not None diff --git a/src/python/pants/backend/python/util_rules/pex_test.py b/src/python/pants/backend/python/util_rules/pex_test.py index d76f777f4f7..6f9032fc56a 100644 --- a/src/python/pants/backend/python/util_rules/pex_test.py +++ b/src/python/pants/backend/python/util_rules/pex_test.py @@ -1046,10 +1046,14 @@ def mock_fallible_to_exec_result_or_raise(*args, **kwargs): nonlocal install_argv req = args[0] if isinstance(req, PexCliProcess): - assert req.subcommand == ("lock", "export") + assert req.subcommand == ("lock", "export-subset") assert "--format" in req.extra_args export_format = req.extra_args[req.extra_args.index("--format") + 1] assert export_format in {"pip-no-hashes", "pep-751"} + # Verify requirement strings are passed as positional args. + assert "ansicolors==1.1.8" in req.extra_args + # Verify --lock is used instead of positional lockfile path. + assert "--lock" in req.extra_args return SimpleNamespace(output_digest=exported_digest) assert isinstance(req, Process) if req.argv[1:3] == ("pip", "install"): From 5e2a436bb0e91a24b19df64dff93b4e2308da251 Mon Sep 17 00:00:00 2001 From: seungwoo-ji-03 Date: Tue, 14 Apr 2026 21:20:56 +0900 Subject: [PATCH 6/8] revert: remove release note for unreleased feature --- docs/notes/2.32.x.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/notes/2.32.x.md b/docs/notes/2.32.x.md index 7050f5b0f66..f11e558d15f 100644 --- a/docs/notes/2.32.x.md +++ b/docs/notes/2.32.x.md @@ -121,10 +121,6 @@ with `--no-deps`, preserving reproducibility. This only applies to non-internal, explicit requirement strings and a local Python interpreter; other builds silently fall back to pip. See [#20679](https://github.com/pantsbuild/pants/issues/20679) for background. -The experimental uv PEX builder (`[python].pex_builder = "uv"`) now correctly handles VCS -(`git+https://...`) and direct URL requirements from PEX-native lockfiles by using -`pex3 lock export` instead of parsing the internal lockfile format directly. - The `runtime` field of [`aws_python_lambda_layer`](https://www.pantsbuild.org/2.32/reference/targets/python_aws_lambda_layer#runtime) or [`aws_python_lambda_function`](https://www.pantsbuild.org/2.32/reference/targets/python_aws_lambda_function#runtime) now has built-in complete platform configurations for x86-64 and arm64 Python 3.14. This provides stable support for Python 3.14 lambdas out of the box, allowing deleting manual `complete_platforms` configuration if any. The `grpc-python-plugin` tool now uses an updated `v1.73.1` plugin built from Date: Tue, 14 Apr 2026 22:03:00 +0900 Subject: [PATCH 7/8] chore: bump uv to 0.6.15 for pylock.toml support --- src/python/pants/backend/python/subsystems/uv.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/python/pants/backend/python/subsystems/uv.py b/src/python/pants/backend/python/subsystems/uv.py index c401f4d2190..99f47e202e7 100644 --- a/src/python/pants/backend/python/subsystems/uv.py +++ b/src/python/pants/backend/python/subsystems/uv.py @@ -21,12 +21,12 @@ class Uv(TemplatedExternalTool): name = "uv" help = "The uv Python package manager (https://github.com/astral-sh/uv)." - default_version = "0.6.14" + default_version = "0.6.15" default_known_versions = [ - "0.6.14|macos_x86_64|1d8ecb2eb3b68fb50e4249dc96ac9d2458dc24068848f04f4c5b42af2fd26552|16276555", - "0.6.14|macos_arm64|4ea4731010fbd1bc8e790e07f199f55a5c7c2c732e9b77f85e302b0bee61b756|15138933", - "0.6.14|linux_x86_64|0cac4df0cb3457b154f2039ae471e89cd4e15f3bd790bbb3cb0b8b40d940b93e|17032361", - "0.6.14|linux_arm64|94e22c4be44d205def456427639ca5ca1c1a9e29acc31808a7b28fdd5dcf7f17|15577079", + "0.6.15|macos_x86_64|97adf61511c0f6ea42c090443c38d8d71116b78ae626363f9f149924c91ae886|16612743", + "0.6.15|macos_arm64|1c5b25f75c6438b6910dbc4c6903debe53f31ee14aee55d02243dfe7bf7c9f72|15356260", + "0.6.15|linux_x86_64|78289c93836cb32b8b24e3216b5b316e7fdf483365de2fc571844d308387e8a4|17337856", + "0.6.15|linux_arm64|183cebae8c9d91bbd48219f9006a5c0c41c90a075d6724aec53a7ea0503c665a|15820802", ] version_constraints = ">=0.6.0,<1.0" From 78024910b4740469f9138ad2de108faf7eaec3ad Mon Sep 17 00:00:00 2001 From: seungwoo-ji-03 Date: Tue, 14 Apr 2026 22:03:04 +0900 Subject: [PATCH 8/8] test: add lock export-subset integration test for uv PEX builder --- .../backend/python/util_rules/pex_test.py | 261 +++++++----------- 1 file changed, 97 insertions(+), 164 deletions(-) diff --git a/src/python/pants/backend/python/util_rules/pex_test.py b/src/python/pants/backend/python/util_rules/pex_test.py index 2e90d356f8d..534958da2db 100644 --- a/src/python/pants/backend/python/util_rules/pex_test.py +++ b/src/python/pants/backend/python/util_rules/pex_test.py @@ -7,10 +7,10 @@ import pkgutil import re import shutil +import subprocess import textwrap import zipfile from pathlib import Path -from types import SimpleNamespace import pytest import requests @@ -21,7 +21,6 @@ from pants.backend.python.goals import lockfile from pants.backend.python.goals.lockfile import GeneratePythonLockfile from pants.backend.python.subsystems.setup import PythonSetup -from pants.backend.python.subsystems.uv import DownloadedUv from pants.backend.python.target_types import EntryPoint, PexCompletePlatformsField from pants.backend.python.util_rules import pex_test_utils from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints @@ -38,15 +37,12 @@ VenvPex, VenvPexProcess, _build_pex_description, - _build_uv_venv, _BuildPexPythonSetup, _BuildPexRequirementsSetup, _determine_pex_python_and_platforms, _setup_pex_requirements, - _UvVenvRequest, ) from pants.backend.python.util_rules.pex import rules as pex_rules -from pants.backend.python.util_rules.pex_cli import PexCliProcess from pants.backend.python.util_rules.pex_environment import PythonExecutable from pants.backend.python.util_rules.pex_requirements import ( EntireLockfile, @@ -978,169 +974,106 @@ def test_digest_complete_platforms_codegen(rule_runner: RuleRunner) -> None: assert complete_platforms.digest != EMPTY_DIGEST -def create_uv_venv_test_inputs( - rule_runner: RuleRunner, - *, - description: str, -) -> tuple[_UvVenvRequest, Lockfile, LoadedLockfile]: - resolve = Resolve("python-default", False) - lockfile = Lockfile( - "3rdparty/python/default.lock", url_description_of_origin="test", resolve_name=resolve.name - ) - loaded_lockfile = LoadedLockfile( - lockfile_digest=rule_runner.make_snapshot_of_empty_files([lockfile.url]).digest, - lockfile_path=lockfile.url, - metadata=None, - requirement_estimate=1, - is_pex_native=True, - as_constraints_strings=None, - original_lockfile=lockfile, - ) - uv_request = _UvVenvRequest( - req_strings=("ansicolors==1.1.8",), - requirements=PexRequirements(("ansicolors==1.1.8",), from_superset=resolve), - python_path="/usr/bin/python3", - description=description, - ) - return uv_request, lockfile, loaded_lockfile - - -def run_uv_venv_with_mocks( - uv_request: _UvVenvRequest, - lockfile: Lockfile, - loaded_lockfile: LoadedLockfile, - *, - mock_fallible_to_exec_result_or_raise, - mock_create_digest=None, -): - pex_env = SimpleNamespace(append_only_caches={}) - pex_env.in_sandbox = lambda *, working_directory: pex_env - pex_env.environment_dict = lambda *, python_configured: {} - - return run_rule_with_mocks( - _build_uv_venv, - rule_args=[uv_request, pex_env], - mock_calls={ - "pants.backend.python.subsystems.uv.download_uv_binary": lambda: DownloadedUv( - digest=EMPTY_DIGEST, - exe="uv", - args_for_uv_pip_install=(), - ), - "pants.backend.python.util_rules.pex_requirements.get_lockfile_for_resolve": lambda _: lockfile, - "pants.backend.python.util_rules.pex_requirements.load_lockfile": lambda _: loaded_lockfile, - "pants.engine.process.fallible_to_exec_result_or_raise": mock_fallible_to_exec_result_or_raise, - "pants.engine.intrinsics.create_digest": mock_create_digest or (lambda _: EMPTY_DIGEST), - "pants.engine.intrinsics.merge_digests": lambda _: EMPTY_DIGEST, - }, - ) +def test_uv_pex_builder_vcs_requirement_with_lockfile(tmp_path) -> None: + """uv builder should handle VCS requirements via pex3 lock export-subset. + Without lock export-subset, VCS requirements in a lockfile would be formatted + as ``name==version`` which loses the VCS URL, causing uv to fail resolution. + """ + rule_runner = RuleRunner( + rules=[ + *pex_test_utils.rules(), + *pex_rules(), + *lockfile.rules(), + QueryRule(GenerateLockfileResult, [GeneratePythonLockfile]), + ], + bootstrap_args=[f"--named-caches-dir={tmp_path}"], + ) -def test_build_uv_venv_uses_exported_lockfile_with_no_deps(rule_runner: RuleRunner) -> None: - uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( - rule_runner, description="test uv export path" + # Create a minimal Python package in a local git repo. + pkg_dir = tmp_path / "projects" / "mypkg" + pkg_dir.mkdir(parents=True) + (pkg_dir / "pyproject.toml").write_text( + textwrap.dedent( + """\ + [build-system] + requires = ["setuptools==66.1.0", "wheel==0.37.0"] + build-backend = "setuptools.build_meta" + """ + ) ) - exported_digest = rule_runner.make_snapshot( - {"__uv_requirements.txt": "ansicolors==1.1.8\n"} - ).digest - install_argv: tuple[str, ...] | None = None - - def mock_fallible_to_exec_result_or_raise(*args, **kwargs): - nonlocal install_argv - req = args[0] - if isinstance(req, PexCliProcess): - assert req.subcommand == ("lock", "export-subset") - assert "--format" in req.extra_args - export_format = req.extra_args[req.extra_args.index("--format") + 1] - assert export_format in {"pip-no-hashes", "pep-751"} - # Verify requirement strings are passed as positional args. - assert "ansicolors==1.1.8" in req.extra_args - # Verify --lock is used instead of positional lockfile path. - assert "--lock" in req.extra_args - return SimpleNamespace(output_digest=exported_digest) - assert isinstance(req, Process) - if req.argv[1:3] == ("pip", "install"): - install_argv = req.argv - return SimpleNamespace(output_digest=EMPTY_DIGEST) - - def mock_create_digest(request: CreateDigest) -> Digest: - for entry in request: - assert not (isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt"), ( - "exported lockfile path should not synthesize requirements content" - ) - return EMPTY_DIGEST - - result = run_uv_venv_with_mocks( - uv_request, - lockfile, - loaded_lockfile, - mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise, - mock_create_digest=mock_create_digest, - ) - - assert result.venv_digest == EMPTY_DIGEST - assert install_argv is not None - assert "--no-deps" in install_argv - - -def test_build_uv_venv_falls_back_when_lock_export_has_no_digest(rule_runner: RuleRunner) -> None: - uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( - rule_runner, description="test uv fallback path" - ) - export_attempts = 0 - install_argv: tuple[str, ...] | None = None - synthesized_reqs: bytes | None = None - - def mock_fallible_to_exec_result_or_raise(*args, **kwargs): - nonlocal export_attempts, install_argv - req = args[0] - if isinstance(req, PexCliProcess): - export_attempts += 1 - return SimpleNamespace(output_digest=None) - assert isinstance(req, Process) - if req.argv[1:3] == ("pip", "install"): - install_argv = req.argv - return SimpleNamespace(output_digest=EMPTY_DIGEST) - - def mock_create_digest(request: CreateDigest) -> Digest: - nonlocal synthesized_reqs - for entry in request: - if isinstance(entry, FileContent) and entry.path == "__uv_requirements.txt": - synthesized_reqs = entry.content - return EMPTY_DIGEST - - result = run_uv_venv_with_mocks( - uv_request, - lockfile, - loaded_lockfile, - mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise, - mock_create_digest=mock_create_digest, - ) - - assert result.venv_digest == EMPTY_DIGEST - assert export_attempts == 1 - assert install_argv is not None - assert "--no-deps" not in install_argv - assert synthesized_reqs == b"ansicolors==1.1.8\n" - - -def test_build_uv_venv_propagates_unexpected_export_errors(rule_runner: RuleRunner) -> None: - uv_request, lockfile, loaded_lockfile = create_uv_venv_test_inputs( - rule_runner, description="test unexpected error path" - ) - - def mock_fallible_to_exec_result_or_raise(*args, **kwargs): - req = args[0] - if isinstance(req, PexCliProcess): - raise ValueError("unexpected failure type") - return SimpleNamespace(output_digest=EMPTY_DIGEST) - - with pytest.raises(ValueError, match="unexpected failure type"): - run_uv_venv_with_mocks( - uv_request, - lockfile, - loaded_lockfile, - mock_fallible_to_exec_result_or_raise=mock_fallible_to_exec_result_or_raise, + (pkg_dir / "setup.cfg").write_text( + textwrap.dedent( + """\ + [metadata] + name = mypkg + version = 0.1.0 + """ ) + ) + (pkg_dir / "mypkg.py").write_text("hello = 'vcs_ok'\n") + subprocess.check_call(["git", "init"], cwd=pkg_dir) + subprocess.check_call(["git", "config", "user.name", "dummy"], cwd=pkg_dir) + subprocess.check_call(["git", "config", "user.email", "dummy@dummy.com"], cwd=pkg_dir) + subprocess.check_call(["git", "add", "--all"], cwd=pkg_dir) + subprocess.check_call(["git", "commit", "-m", "initial commit"], cwd=pkg_dir) + subprocess.check_call(["git", "branch", "v0.1.0"], cwd=pkg_dir) + + vcs_req = f"mypkg @ git+file://localhost{pkg_dir.as_posix()}@v0.1.0" + + # Generate a PEX-native lockfile containing the VCS requirement. + rule_runner.set_options([], env_inherit=PYTHON_BOOTSTRAP_ENV) + lock_result = rule_runner.request( + GenerateLockfileResult, + [ + GeneratePythonLockfile( + requirements=FrozenOrderedSet([vcs_req]), + find_links=FrozenOrderedSet([]), + interpreter_constraints=InterpreterConstraints([">=3.8,<3.15"]), + resolve_name="test", + lockfile_dest="test.lock", + diff=False, + lock_style="universal", + complete_platforms=(), + ) + ], + ) + lock_digest_contents = rule_runner.request(DigestContents, [lock_result.digest]) + assert len(lock_digest_contents) == 1 + rule_runner.write_files({"test.lock": lock_digest_contents[0].content}) + + # Build a PEX with the uv builder using the lockfile resolve. + sources = rule_runner.request( + Digest, + [CreateDigest((FileContent("main.py", b"import mypkg; print(mypkg.hello)"),))], + ) + pex_data = create_pex_and_get_all_data( + rule_runner, + pex_type=Pex, + requirements=PexRequirements(["mypkg"], from_superset=Resolve("test", False)), + main=EntryPoint("main"), + sources=sources, + additional_pants_args=( + "--python-pex-builder=uv", + "--python-enable-resolves", + "--python-resolves={'test': 'test.lock'}", + "--python-invalid-lockfile-behavior=ignore", + ), + internal_only=False, + ) + pex_exe = ( + f"./{pex_data.sandbox_path}" + if pex_data.is_zipapp + else os.path.join(pex_data.sandbox_path, "__main__.py") + ) + process = Process( + argv=(pex_exe,), + env={"PATH": os.getenv("PATH", "")}, + input_digest=pex_data.pex.digest, + description="Run uv-built pex with VCS requirement from lockfile", + ) + result = rule_runner.request(ProcessResult, [process]) + assert b"vcs_ok" in result.stdout def test_uv_pex_builder_resolves_dependencies(rule_runner: RuleRunner) -> None: