Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/notes/2.32.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/nhurden/protoc-gen-grpc-python-prebuilt]. This also brings `macos_arm64` support.
Expand Down
62 changes: 37 additions & 25 deletions src/python/pants/backend/python/util_rules/pex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment on lines +563 to +564
Copy link
Copy Markdown
Contributor

@jsirois jsirois Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By "pinned versions" you really mean "locked requirements". I think the language of "pined versions" - which is common parlance - led you astray on your earlier foray. In the world of Python ecosystem requirements, requirements you can pin (name + version) cover 1/4 of the landscape only. There's also VCS, source from non-sdist archives and source from local project directories. These latter 3 do not necessarily have a known version up front - you have to build them to find that out.

# 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
Comment on lines 570 to 571
Copy link
Copy Markdown
Contributor

@jsirois jsirois Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just reading words here - I don't know the Pants code - but "Pex requirements from superset Resolve" suggest you only need a subset of the locked resolve to be exported / venv'd / PEXed up. Pex supports this:

# Export the full lock:
:; pex3 lock create ansible -o lock.json
:; pex3 lock export --format pep-751 -o pylock.full.toml lock.json 
:; tomlq -r '.packages[] | .name + "==" + .version' pylock.full.toml
ansible==13.5
ansible-core==2.20.4
cffi==2
cryptography==46.0.6
jinja2==3.1.6
markupsafe==3.0.3
packaging==26
pycparser==3
pyyaml==6.0.3
resolvelib==1.2.1

# Export just a subset:
:; pex3 lock export-subset --format pep-751 -o pylock.subset.toml --lock lock.json cryptography 
:; tomlq -r '.packages[] | .name + "==" + .version' pylock.subset.toml
cffi==2
cryptography==46.0.6
pycparser==3

# ~5ms hit for performing the subset resolve on the full lock to narrow it down to the transitive subset that covers `cryptography`:
:; hyperfine \
    -w2 \
    -n full \
    'pex3 lock export --format pep-751 -o pylock.full.toml lock.json' \
    -n subset \
    'pex3 lock export-subset --format pep-751 -o pylock.subset.toml --lock lock.json cryptography'
Benchmark 1: full
  Time (mean ± σ):     321.1 ms ±   2.5 ms    [User: 296.5 ms, System: 24.6 ms]
  Range (min … max):   317.9 ms … 326.2 ms    10 runs
 
Benchmark 2: subset
  Time (mean ± σ):     326.5 ms ±   3.2 ms    [User: 302.6 ms, System: 23.8 ms]
  Range (min … max):   318.4 ms … 330.7 ms    10 runs
 
Summary
  full ran
    1.02 ± 0.01 times faster than subset

So you may want to factor this in going forward. The savings may be in the margin, but even uv is slowed down a little bit if the full venv includes torch's transitive dependencies. If the PEX you're building needs some other portion of the venv, but none of the torch stuff - you just did a lot of extra work for nothing and this may show up on someones perf radar. I leave the experimentation / analysis to you all though.

):
Expand All @@ -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"
Expand Down Expand Up @@ -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,
)

Expand Down
166 changes: 166 additions & 0 deletions src/python/pants/backend/python/util_rules/pex_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import textwrap
import zipfile
from pathlib import Path
from types import SimpleNamespace

import pytest
import requests
Expand All @@ -20,6 +21,7 @@
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
Expand All @@ -36,12 +38,15 @@
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,
Expand Down Expand Up @@ -971,6 +976,167 @@ 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"]
Expand Down
Loading