Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] use uv for resolving Pants plugins #21991

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions pants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ backend_packages.add = [
"internal_plugins.releases",
"internal_plugins.test_lockfile_fixtures",
]
experimental_use_uv_for_plugin_resolution = true
plugins = [
"hdrhistogram", # For use with `--stats-log`.
]
Expand Down
1 change: 1 addition & 0 deletions src/python/pants/backend/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ __dependents_rules__(
),
(
(
"[/python/subsystems/repos.py]",
"[/python/util_rules/interpreter_constraints.py]",
"[/python/util_rules/pex_environment.py]",
"[/python/util_rules/pex_requirements.py]",
Expand Down
57 changes: 57 additions & 0 deletions src/python/pants/core/subsystems/uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright 2025 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from pants.core.util_rules import external_tool
from pants.core.util_rules.external_tool import (
DownloadedExternalTool,
ExternalToolRequest,
TemplatedExternalTool,
)
from pants.engine.platform import Platform
from pants.engine.rules import Get, collect_rules, rule


class UvSubsystem(TemplatedExternalTool):
options_scope = "uv"
name = "uv"
help = "UV, An extremely fast Python package and project manager, written in Rust (https://docs.astral.sh/uv/)"

default_version = "0.6.2"
default_known_versions = [
"0.6.2|linux_arm64|ca4c08724764a2b6c8f2173c4e3ca9dcde0d9d328e73b4d725cfb6b17a925eed|15345219",
"0.6.2|linux_x86_64|37ea31f099678a3bee56f8a757d73551aad43f8025d377a8dde80dd946c1b7f2|16399655",
"0.6.2|macos_arm64|4af802a1216053650dd82eee85ea4241994f432937d41c8b0bc90f2639e6ae14|14608217",
"0.6.2|macos_x86_64|2b9e78b2562aea93f13e42df1177cb07c59a4d4f1c8ff8907d0c31f3a5e5e8db|15658458",
]
default_url_template = (
"https://github.com/astral-sh/uv/releases/download/{version}/uv-{platform}.tar.gz"
)
default_url_platform_mapping = {
"linux_arm64": "aarch64-unknown-linux-gnu",
"linux_x86_64": "x86_64-unknown-linux-gnu",
"macos_arm64": "aarch64-apple-darwin",
"macos_x86_64": "x86_64-apple-darwin",
}

def generate_exe(self, plat: Platform) -> str:
platform = self.url_platform_mapping.get(plat.value, "")
return f"./uv-{platform}/uv"


class UvTool(DownloadedExternalTool):
"""The UV tool, downloaded."""


@rule
async def download_uv_tool(uv_subsystem: UvSubsystem, platform: Platform) -> UvTool:
pex_pex = await Get(
DownloadedExternalTool, ExternalToolRequest, uv_subsystem.get_request(platform)
)
return UvTool(digest=pex_pex.digest, exe=pex_pex.exe)


def rules():
return (
*collect_rules(),
*external_tool.rules(),
)
226 changes: 220 additions & 6 deletions src/python/pants/init/plugin_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,39 @@
import logging
import site
import sys
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from io import StringIO
from typing import cast

from pkg_resources import Requirement, WorkingSet
from pkg_resources import working_set as global_working_set

from pants.backend.python.subsystems.repos import PythonRepos
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
from pants.backend.python.util_rules.pex import PexRequest, VenvPex, VenvPexProcess
from pants.backend.python.util_rules.pex_environment import PythonExecutable
from pants.backend.python.util_rules.pex_requirements import PexRequirements
from pants.core.subsystems.uv import UvTool
from pants.core.subsystems.uv import rules as uv_rules
from pants.core.util_rules.adhoc_binaries import PythonBuildStandaloneBinary
from pants.core.util_rules.adhoc_binaries import rules as adhoc_binaries_rules
from pants.core.util_rules.environments import determine_bootstrap_environment
from pants.engine.collection import DeduplicatedCollection
from pants.engine.env_vars import CompleteEnvironmentVars
from pants.engine.env_vars import CompleteEnvironmentVars, EnvironmentVars, EnvironmentVarsRequest
from pants.engine.environment import EnvironmentName
from pants.engine.fs import CreateDigest, Digest, FileContent, MergeDigests
from pants.engine.internals.selectors import Params
from pants.engine.internals.session import SessionValues
from pants.engine.process import ProcessCacheScope, ProcessResult
from pants.engine.rules import Get, QueryRule, collect_rules, rule
from pants.engine.intrinsics import execute_process
from pants.engine.platform import Platform
from pants.engine.process import (
Process,
ProcessCacheScope,
ProcessExecutionEnvironment,
ProcessResult,
)
from pants.engine.rules import Get, MultiGet, QueryRule, collect_rules, rule
from pants.init.bootstrap_scheduler import BootstrapScheduler
from pants.option.global_options import GlobalOptions
from pants.option.options_bootstrapper import OptionsBootstrapper
Expand All @@ -49,8 +63,7 @@ class ResolvedPluginDistributions(DeduplicatedCollection[str]):
sort_input = True


@rule
async def resolve_plugins(
async def resolve_plugins_via_pex(
request: PluginsRequest, global_options: GlobalOptions
) -> ResolvedPluginDistributions:
"""This rule resolves plugins using a VenvPex, and exposes the absolute paths of their dists.
Expand All @@ -59,6 +72,8 @@ async def resolve_plugins(
`named_caches` directory), but consequently needs to disable the process cache: see the
ProcessCacheScope reference in the body.
"""
logger.info("resolve_plugins_via_pex")

req_strings = sorted(global_options.plugins + request.requirements)

requirements = PexRequirements(
Expand Down Expand Up @@ -108,6 +123,203 @@ async def resolve_plugins(
return ResolvedPluginDistributions(plugins_process_result.stdout.decode().strip().split("\n"))


@dataclass(frozen=True)
class _UvPluginResolveScript:
digest: Digest
path: str


# Script which invokes `uv` to resolve plugin distributions. It builds a mini-uv project in a subdirectory
# of the repository and uses `uv` to manage the venv in that project.
_UV_PLUGIN_RESOLVE_SCRIPT = r"""\
import os
from pathlib import Path
import shutil
import subprocess
import sys


def pyproject_changed(current_path: Path, previous_path: Path) -> bool:
with open(current_path, "rb") as f:
current_pyproject = f.read()

with open(previous_path, "rb") as f:
previous_pyproject = f.read()

return current_pyproject != previous_pyproject


def run(args):
return subprocess.run(args, check=True)


inputs_dir = Path(sys.argv[1])
uv_path = inputs_dir / sys.argv[2]
pyproject_path = inputs_dir / sys.argv[3]

plugins_path = Path(".pants.d/plugins")
plugins_path.mkdir(parents=True, exist_ok=True)
os.chdir(plugins_path)

reinstall = (
not os.path.exists("pyproject.toml")
or not os.path.exists("uv.lock")
or pyproject_changed(pyproject_path, "./pyproject.toml")
)

if reinstall:
shutil.copy(pyproject_path, ".")
run([uv_path, "sync", f"--python={sys.executable}"])
else:
run([uv_path, "sync", "--frozen", f"--python={sys.executable}"])

run(["./.venv/bin/python", "-c", "import os, site; print(os.linesep.join(site.getsitepackages()))"])
"""


@rule
async def _setup_uv_plugin_resolve_script() -> _UvPluginResolveScript:
digest = await Get(
Digest,
CreateDigest(
[FileContent(content=_UV_PLUGIN_RESOLVE_SCRIPT.encode(), path="uv_plugin_resolve.py")]
),
)
return _UvPluginResolveScript(digest=digest, path="uv_plugin_resolve.py")


_PYPROJECT_TEMPLATE = """\
[project]
name = "pants-plugins"
version = "0.0.1"
description = "Plugins for your Pants"
requires-python = "==3.11.*"
dependencies = [{requirements_formatted}]
[tool.uv]
package = false
environments = ["sys_platform == '{platform}'"]
constraint-dependencies = [{constraints_formatted}]
find-links = [{find_links_formatted}]
{indexes_formatted}
[tool.__pants_internal__]
version = {version}
"""


def _generate_pyproject_toml(
requirements: Iterable[str],
constraints: Iterable[str],
python_indexes: Sequence[str],
python_find_links: Iterable[str],
) -> str:
requirements_formatted = ", ".join([f'"{x}"' for x in requirements])
constraints_formatted = ", ".join([f'"{x}"' for x in constraints])
find_links_formatted = ", ".join([f'"{x}"' for x in python_find_links])

indexes_formatted = StringIO()
if python_indexes:
for python_index in python_indexes:
indexes_formatted.write(f"""[[tool.uv.index]]\nurl = "{python_index}"\n""")
indexes_formatted.write("default = true")
else:
indexes_formatted.write("no-index = true")

return _PYPROJECT_TEMPLATE.format(
constraints_formatted=constraints_formatted,
find_links_formatted=find_links_formatted,
indexes_formatted=indexes_formatted.getvalue(),
platform=sys.platform,
requirements_formatted=requirements_formatted,
version=0,
)


async def resolve_plugins_via_uv(
request: PluginsRequest, global_options: GlobalOptions
) -> ResolvedPluginDistributions:
req_strings = sorted(global_options.plugins + request.requirements)
if not req_strings:
return ResolvedPluginDistributions()

python_repos = await Get(PythonRepos)

pyproject_content = _generate_pyproject_toml(
requirements=req_strings,
constraints=(str(c) for c in request.constraints),
python_indexes=python_repos.indexes,
python_find_links=python_repos.find_links,
)

uv_tool, uv_plugin_resolve_script, platform, python_binary, data_digest = await MultiGet(
Get(UvTool),
Get(_UvPluginResolveScript),
Get(Platform),
Get(PythonBuildStandaloneBinary),
Get(
Digest,
CreateDigest(
[
FileContent(content=pyproject_content.encode(), path="pyproject.toml"),
]
),
),
)

cache_scope = (
ProcessCacheScope.PER_SESSION
if global_options.plugins_force_resolve
else ProcessCacheScope.PER_RESTART_SUCCESSFUL
)

input_digest = await Get(
Digest, MergeDigests([uv_plugin_resolve_script.digest, uv_tool.digest, data_digest])
)

env = await Get(
EnvironmentVars, EnvironmentVarsRequest(["PATH", "HOME"], allowed=["PATH", "HOME"])
)

process = Process(
argv=(
python_binary.path,
f"{{chroot}}/{uv_plugin_resolve_script.path}",
"{chroot}",
f"{uv_tool.exe}",
"pyproject.toml",
),
env=env,
input_digest=input_digest,
append_only_caches=python_binary.APPEND_ONLY_CACHES,
description=f"Resolving plugins: {', '.join(req_strings)}",
cache_scope=cache_scope,
)

workspace_process_execution_environment = ProcessExecutionEnvironment(
environment_name=None,
platform=platform.value,
docker_image=None,
remote_execution=False,
remote_execution_extra_platform_properties=(),
execute_in_workspace=True,
)

result = await execute_process(process, workspace_process_execution_environment)
if result.exit_code != 0:
raise ValueError(f"Plugin resolution failed: stderr={result.stderr.decode()}")

return ResolvedPluginDistributions(result.stdout.decode().strip().split("\n"))


@rule
async def resolve_plugins(
request: PluginsRequest, global_options: GlobalOptions
) -> ResolvedPluginDistributions:
if global_options.experimental_use_uv_for_plugin_resolution:
return await resolve_plugins_via_uv(request=request, global_options=global_options)
else:
return await resolve_plugins_via_pex(request=request, global_options=global_options)


class PluginResolver:
"""Encapsulates the state of plugin loading for the given WorkingSet.

Expand Down Expand Up @@ -172,4 +384,6 @@ def rules():
return [
QueryRule(ResolvedPluginDistributions, [PluginsRequest, EnvironmentName]),
*collect_rules(),
*adhoc_binaries_rules(),
*uv_rules(),
]
12 changes: 12 additions & 0 deletions src/python/pants/option/global_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -2017,6 +2017,18 @@ class GlobalOptions(BootstrapOptions, Subsystem):
default=[],
)

experimental_use_uv_for_plugin_resolution = BoolOption(
default=False,
advanced=True,
help=softwrap(
"""
If true, use `uv` for resolving Pants plugins instead of `pex`.

This is an experimental option and no stability is guaranteed.
"""
),
)

@classmethod
def validate_instance(cls, opts):
"""Validates an instance of global options for cases that are not prohibited via
Expand Down
Loading
Loading