Skip to content
Merged
10 changes: 5 additions & 5 deletions src/python/pants/backend/python/subsystems/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
66 changes: 40 additions & 26 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).
# Fall back to letting uv resolve transitively if no lockfile.
all_resolved_reqs: tuple[str, ...] = ()
# 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"

if isinstance(uv_request.requirements, PexRequirements) and isinstance(
uv_request.requirements.from_superset, Resolve
):
Expand All @@ -573,42 +576,53 @@ 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-subset"),
extra_args=(
*uv_request.req_strings,
"--lock",
loaded_lockfile.lockfile_path,
"--format",
"pep-751",
"-o",
reqs_file,
),
additional_input_digest=loaded_lockfile.lockfile_digest,
description=f"Export lockfile subset 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 subset 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 subset 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 +675,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
103 changes: 103 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 @@ -7,6 +7,7 @@
import pkgutil
import re
import shutil
import subprocess
import textwrap
import zipfile
from pathlib import Path
Expand Down Expand Up @@ -973,6 +974,108 @@ def test_digest_complete_platforms_codegen(rule_runner: RuleRunner) -> None:
assert complete_platforms.digest != 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}"],
)

# 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"
"""
)
)
(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:
"""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