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 news/4181.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
(`uv`) Support executable wrappers with multiple outputs and preserve their
runfiles in lock actions and runnable targets. Export downloaded `uv` binaries
so wrappers can declare them as dependencies.
([#4181](https://github.com/bazel-contrib/rules_python/issues/4181))
19 changes: 11 additions & 8 deletions python/uv/private/lock.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ _RunLockInfo = provider(
fields = {
"args": "The args passed to the `uv` by default when running the runnable target.",
"env": "The env passed to the execution.",
"srcs": "Source files required to run the runnable target.",
# Preserve the wrapper's runtime files and symlink mappings together;
# a `srcs` `depset` cannot represent the full runfiles layout.
"runfiles": "Runtime files required by the runnable target.",
"template": "The template file for writing a script.",
},
)
Expand Down Expand Up @@ -129,7 +131,8 @@ def _common_lock(ctx, locker):

output = ctx.actions.declare_file(fname)
toolchain_info = ctx.toolchains[UV_TOOLCHAIN_TYPE]
uv = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable
uv_default_info = toolchain_info.uv_toolchain_info.uv[DefaultInfo]
uv = uv_default_info.files_to_run.executable

args = _args(ctx)
args.add(uv)
Expand Down Expand Up @@ -259,7 +262,7 @@ def _common_lock(ctx, locker):
# exec "$@" in the .sh script.
arguments = [args.run_shell] if not ctx.attr.is_windows else [],
tools = [
uv,
uv_default_info.files_to_run,
python_files,
script,
],
Expand All @@ -278,10 +281,10 @@ def _common_lock(ctx, locker):
_RunLockInfo(
args = args.run_info,
env = ctx.attr.env,
srcs = depset(
srcs + [uv],
transitive = [python_files],
),
runfiles = ctx.runfiles(
files = srcs + [uv],
transitive_files = python_files,
).merge(uv_default_info.default_runfiles),
template = ctx.files._template[0],
),
]
Expand Down Expand Up @@ -501,7 +504,7 @@ def _run_impl(ctx):
return [
DefaultInfo(
executable = executable,
runfiles = ctx.runfiles(transitive_files = info.srcs),
runfiles = info.runfiles,
),
RunEnvironmentInfo(
environment = info.env,
Expand Down
2 changes: 2 additions & 0 deletions python/uv/private/uv_repository.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ UV_BUILD_TMPL = """\
# Generated by repositories.bzl
load("@rules_python//python/uv:uv_toolchain.bzl", "uv_toolchain")

exports_files(["{binary}"], visibility = ["//visibility:public"])

uv_toolchain(
name = "uv_toolchain",
uv = "{binary}",
Expand Down
12 changes: 10 additions & 2 deletions python/uv/private/uv_toolchain.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@ uv_toolchain = rule(
implementation = _uv_toolchain_impl,
attrs = {
"uv": attr.label(
doc = "A static uv binary.",
doc = """
The `uv` executable or a wrapper that forwards its arguments to `uv`.
Runtime dependencies belong in the executable target's runfiles.

:::{versionchanged} VERSION_NEXT_PATCH
Executable targets with multiple output files are supported. Lock actions
and runnable targets include the executable target's runfiles.
:::
""",
mandatory = True,
allow_single_file = True,
allow_files = True,
executable = True,
cfg = "exec",
),
Expand Down
96 changes: 96 additions & 0 deletions tests/uv/lock/lock_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,60 @@
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("@bazel_skylib//rules:native_binary.bzl", "native_test")
load("@rules_testing//lib:test_suite.bzl", "test_suite")
load("//python:py_binary.bzl", "py_binary")
load("//python/uv:lock.bzl", "lock")
load("//python/uv:uv_toolchain.bzl", "uv_toolchain")
load("//python/uv/private:lock.bzl", lock_testing = "testing") # buildifier: disable=bzl-visibility
load("//tests/support:py_reconfig.bzl", "py_reconfig_test")

_basic_tests = []

def _uv_with_runfiles_impl(ctx):
binary = ctx.attr.binary[DefaultInfo]
extension = ".exe" if binary.files_to_run.executable.basename.endswith(".exe") else ""
executable = ctx.actions.declare_file(ctx.label.name + extension)
ctx.actions.symlink(
output = executable,
target_file = binary.files_to_run.executable,
is_executable = True,
)
metadata = ctx.actions.declare_file(ctx.label.name + ".metadata")
ctx.actions.write(metadata, "uv wrapper metadata\n")
symlink_payload = ctx.actions.declare_file(ctx.label.name + ".symlink_payload")
ctx.actions.write(symlink_payload, "symlink payload\n")
root_symlink_payload = ctx.actions.declare_file(ctx.label.name + ".root_symlink_payload")
ctx.actions.write(root_symlink_payload, "root symlink payload\n")

launcher_files = []
if extension:
# The Windows launcher reads its sibling bootstrap or `.zip` archive.
stem = binary.files_to_run.executable.basename[:-len(extension)]
for file in binary.files.to_list():
if file.basename in [stem, stem + ".zip"]:
companion = ctx.actions.declare_file(ctx.label.name + file.basename[len(stem):])
ctx.actions.symlink(output = companion, target_file = file)
launcher_files.append(companion)

# Keep the payloads out of ordinary runfiles to require their symlink mappings.
runfiles = ctx.runfiles(
files = launcher_files,
symlinks = {"uv_wrapper/symlink_payload.txt": symlink_payload},
root_symlinks = {"uv_wrapper/root_symlink_payload.txt": root_symlink_payload},
).merge(binary.default_runfiles)
return [DefaultInfo(
executable = executable,
files = depset([executable, metadata]),
runfiles = runfiles,
)]

_uv_with_runfiles = rule(
implementation = _uv_with_runfiles_impl,
attrs = {
"binary": attr.label(executable = True, cfg = "target", mandatory = True),
},
executable = True,
)

def _test_reroot(env):
reroot = lock_testing.reroot
env.expect.that_str(
Expand Down Expand Up @@ -190,6 +238,52 @@ def lock_test_suite(name):
}),
)

py_binary(
name = "uv_with_runfiles_main",
srcs = ["uv_with_runfiles.py"],
main = "uv_with_runfiles.py",
data = ["testdata/toolchain_payload.txt"],
deps = ["//python/runfiles"],
)

_uv_with_runfiles(
name = "uv_with_runfiles",
binary = ":uv_with_runfiles_main",
)

uv_toolchain(
name = "uv_with_runfiles_impl",
uv = ":uv_with_runfiles",
version = "0.0.0",
)
Comment thread
hartikainen marked this conversation as resolved.

native.toolchain(
name = "uv_with_runfiles_toolchain",
toolchain = ":uv_with_runfiles_impl",
toolchain_type = "//python/uv:uv_toolchain_type",
)

lock(
name = "toolchain_requirements",
srcs = ["testdata/requirements.in"],
out = "toolchain_requirements.txt",
directory = None,
)

for mode in ["run", "update"]:
py_reconfig_test(
name = "toolchain_runfiles_" + mode + "_test",
srcs = ["toolchain_runfiles_test.py"],
main = "toolchain_runfiles_test.py",
args = ["$(rlocationpath :toolchain_requirements." + mode + ")"],
data = [":toolchain_requirements." + mode],
deps = ["//python/runfiles"],
extra_toolchains = [
str(Label(":uv_with_runfiles_toolchain")),
str(Label("//tests/support/cc_toolchains:all")),
],
)

test_suite(
name = name + "_basic",
basic_tests = _basic_tests,
Expand All @@ -199,6 +293,8 @@ def lock_test_suite(name):
name = name,
tests = [
":" + name + "_basic",
":toolchain_runfiles_run_test",
":toolchain_runfiles_update_test",
":requirements_test",
":requirements_directory_test",
"//tests/uv/lock/pyproject_toml:requirements_test",
Expand Down
1 change: 1 addition & 0 deletions tests/uv/lock/testdata/toolchain_payload.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
custom uv toolchain runfiles
34 changes: 34 additions & 0 deletions tests/uv/lock/toolchain_runfiles_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

from python import runfiles

LAUNCHER = sys.argv.pop(1)


class ToolchainRunfilesTest(unittest.TestCase):
def test_lock_with_toolchain_runfiles(self):
files = runfiles.Create()
assert files is not None
launcher = files.Rlocation(LAUNCHER)
assert launcher is not None
with tempfile.TemporaryDirectory() as directory:
output = Path(directory, "tests/uv/lock/toolchain_requirements.txt")
output.parent.mkdir(parents=True)
env = dict(os.environ, BUILD_WORKSPACE_DIRECTORY=directory)
env.update(files.EnvVars())
env.pop("TEST_SRCDIR", None)
command = [launcher]
if os.name == "nt" and launcher.endswith(".bat"):
command = ["cmd.exe", "/c", launcher]
result = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(output.read_text(), "custom uv toolchain runfiles\n")


if __name__ == "__main__":
unittest.main()
26 changes: 26 additions & 0 deletions tests/uv/lock/uv_with_runfiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import argparse
from pathlib import Path

from python import runfiles


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output-file", type=Path, required=True)
args, _ = parser.parse_known_args()
files = runfiles.Create()
assert files is not None
for location, expected in [
("_main/uv_wrapper/symlink_payload.txt", "symlink payload\n"),
("uv_wrapper/root_symlink_payload.txt", "root symlink payload\n"),
]:
path = files.Rlocation(location)
assert path is not None, location
assert Path(path).read_text() == expected, location
payload = files.Rlocation("_main/tests/uv/lock/testdata/toolchain_payload.txt")
assert payload is not None
args.output_file.write_bytes(Path(payload).read_bytes())


if __name__ == "__main__":
main()
Loading