diff --git a/docs/notes/2.32.x.md b/docs/notes/2.32.x.md index f11e558d15f..7050f5b0f66 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 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-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, + ) + + 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"]