From d34813a6a4b2c0a84729d573e2e2d175a211752d Mon Sep 17 00:00:00 2001 From: Kristian Hartikainen Date: Sun, 20 Sep 2026 16:49:29 -0400 Subject: [PATCH 1/3] Preserve runtime dependencies of custom `uv` toolchains Allow executable wrappers with multiple output files and include their runfiles in lock actions and runnable targets. This lets a `py_binary` wrapper supply authentication without losing its runtime dependencies. Export downloaded `uv` binaries so wrappers can depend on them directly. Cover build-action and runtime resolution with a custom toolchain that reads a declared data file. --- news/4181.fixed.md | 4 ++ python/uv/private/lock.bzl | 19 +++++---- python/uv/private/uv_repository.bzl | 2 + python/uv/private/uv_toolchain.bzl | 12 +++++- tests/uv/lock/lock_tests.bzl | 44 ++++++++++++++++++++ tests/uv/lock/testdata/toolchain_payload.txt | 1 + tests/uv/lock/toolchain_runfiles_test.py | 34 +++++++++++++++ tests/uv/lock/uv_with_runfiles.py | 19 +++++++++ 8 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 news/4181.fixed.md create mode 100644 tests/uv/lock/testdata/toolchain_payload.txt create mode 100644 tests/uv/lock/toolchain_runfiles_test.py create mode 100644 tests/uv/lock/uv_with_runfiles.py diff --git a/news/4181.fixed.md b/news/4181.fixed.md new file mode 100644 index 0000000000..1323770842 --- /dev/null +++ b/news/4181.fixed.md @@ -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)) diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index 33c301d49c..b9f70d3ec6 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -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.", }, ) @@ -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) @@ -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, ], @@ -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], ), ] @@ -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, diff --git a/python/uv/private/uv_repository.bzl b/python/uv/private/uv_repository.bzl index 79a6495bdc..947d392aa2 100644 --- a/python/uv/private/uv_repository.bzl +++ b/python/uv/private/uv_repository.bzl @@ -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}", diff --git a/python/uv/private/uv_toolchain.bzl b/python/uv/private/uv_toolchain.bzl index bd82e7452f..9f6856e50a 100644 --- a/python/uv/private/uv_toolchain.bzl +++ b/python/uv/private/uv_toolchain.bzl @@ -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", ), diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl index 3e15ef2053..95bb292bb2 100644 --- a/tests/uv/lock/lock_tests.bzl +++ b/tests/uv/lock/lock_tests.bzl @@ -17,7 +17,9 @@ 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") @@ -190,6 +192,46 @@ def lock_test_suite(name): }), ) + py_binary( + name = "uv_with_runfiles", + srcs = ["uv_with_runfiles.py"], + data = ["testdata/toolchain_payload.txt"], + deps = ["//python/runfiles"], + ) + + uv_toolchain( + name = "uv_with_runfiles_impl", + uv = ":uv_with_runfiles", + version = "0.0.0", + ) + + 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, @@ -199,6 +241,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", diff --git a/tests/uv/lock/testdata/toolchain_payload.txt b/tests/uv/lock/testdata/toolchain_payload.txt new file mode 100644 index 0000000000..9cfc5c3101 --- /dev/null +++ b/tests/uv/lock/testdata/toolchain_payload.txt @@ -0,0 +1 @@ +custom uv toolchain runfiles diff --git a/tests/uv/lock/toolchain_runfiles_test.py b/tests/uv/lock/toolchain_runfiles_test.py new file mode 100644 index 0000000000..2663c25b00 --- /dev/null +++ b/tests/uv/lock/toolchain_runfiles_test.py @@ -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() diff --git a/tests/uv/lock/uv_with_runfiles.py b/tests/uv/lock/uv_with_runfiles.py new file mode 100644 index 0000000000..72c9bd7a91 --- /dev/null +++ b/tests/uv/lock/uv_with_runfiles.py @@ -0,0 +1,19 @@ +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 + 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() From 41910592476982d19f63541cdaf1d6e83cf9e2e7 Mon Sep 17 00:00:00 2001 From: Kristian Hartikainen Date: Thu, 24 Sep 2026 21:28:21 -0400 Subject: [PATCH 2/3] test(uv): Cover custom toolchain runfiles --- tests/uv/lock/lock_tests.bzl | 54 ++++++++++++++++++++++++++++++- tests/uv/lock/uv_with_runfiles.py | 7 ++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl index 95bb292bb2..9df828c94f 100644 --- a/tests/uv/lock/lock_tests.bzl +++ b/tests/uv/lock/lock_tests.bzl @@ -25,6 +25,52 @@ 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( @@ -193,12 +239,18 @@ def lock_test_suite(name): ) py_binary( - name = "uv_with_runfiles", + 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", diff --git a/tests/uv/lock/uv_with_runfiles.py b/tests/uv/lock/uv_with_runfiles.py index 72c9bd7a91..757d56ed78 100644 --- a/tests/uv/lock/uv_with_runfiles.py +++ b/tests/uv/lock/uv_with_runfiles.py @@ -10,6 +10,13 @@ def main(): 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()) From e3fee62b358a13ea05f5854e336272020a6c4de9 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 27 Sep 2026 23:47:53 -0700 Subject: [PATCH 3/3] chore(uv): address PR review comments Document why allow_files and exports_files are used in the uv toolchain, clarify the _RunLockInfo.runfiles comment, simplify the test fixture to use py_binary directly with a data helper rule, and use CreateOrRaise().root() in the runfiles tests. --- python/uv/private/lock.bzl | 2 +- python/uv/private/uv_repository.bzl | 3 ++ python/uv/private/uv_toolchain.bzl | 4 ++ tests/uv/lock/lock_tests.bzl | 67 ++++++++---------------- tests/uv/lock/toolchain_runfiles_test.py | 12 ++--- tests/uv/lock/uv_with_runfiles.py | 12 ++--- 6 files changed, 40 insertions(+), 60 deletions(-) diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index b9f70d3ec6..cea7f98e4b 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -31,7 +31,7 @@ _RunLockInfo = provider( "args": "The args passed to the `uv` by default when running the runnable target.", "env": "The env passed to the execution.", # Preserve the wrapper's runtime files and symlink mappings together; - # a `srcs` `depset` cannot represent the full runfiles layout. + # a regular depset cannot represent the full runfiles layout. "runfiles": "Runtime files required by the runnable target.", "template": "The template file for writing a script.", }, diff --git a/python/uv/private/uv_repository.bzl b/python/uv/private/uv_repository.bzl index 947d392aa2..839e582a0a 100644 --- a/python/uv/private/uv_repository.bzl +++ b/python/uv/private/uv_repository.bzl @@ -24,6 +24,9 @@ UV_BUILD_TMPL = """\ # Generated by repositories.bzl load("@rules_python//python/uv:uv_toolchain.bzl", "uv_toolchain") +# Exposed for users (e.g. custom wrapper scripts), though depending on a +# specific toolchain repository's binary directly is ill-advised because +# toolchain resolution may resolve a different target. exports_files(["{binary}"], visibility = ["//visibility:public"]) uv_toolchain( diff --git a/python/uv/private/uv_toolchain.bzl b/python/uv/private/uv_toolchain.bzl index 9f6856e50a..0e5a5dc5a1 100644 --- a/python/uv/private/uv_toolchain.bzl +++ b/python/uv/private/uv_toolchain.bzl @@ -59,6 +59,10 @@ and runnable targets include the executable target's runfiles. ::: """, mandatory = True, + # allow_files = True is used instead of allow_single_file = True + # because executable rules like py_binary include additional files + # (e.g. srcs) in DefaultInfo.files. executable = True still enforces + # a single executable. allow_files = True, executable = True, cfg = "exec", diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl index 9df828c94f..f14d784ebf 100644 --- a/tests/uv/lock/lock_tests.bzl +++ b/tests/uv/lock/lock_tests.bzl @@ -25,50 +25,28 @@ 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, +def _extra_runfiles_impl(ctx): + symlink_payload = ctx.actions.declare_file( + ctx.label.name + ".symlink_payload", ) - 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") + 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, + runfiles = ctx.runfiles( + symlinks = {"uv_wrapper/symlink_payload.txt": symlink_payload}, + root_symlinks = { + "uv_wrapper/root_symlink_payload.txt": root_symlink_payload, + }, + ), )] -_uv_with_runfiles = rule( - implementation = _uv_with_runfiles_impl, - attrs = { - "binary": attr.label(executable = True, cfg = "target", mandatory = True), - }, - executable = True, +_extra_runfiles = rule( + implementation = _extra_runfiles_impl, ) def _test_reroot(env): @@ -238,17 +216,18 @@ 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"], + _extra_runfiles( + name = "uv_extra_runfiles", ) - _uv_with_runfiles( + py_binary( name = "uv_with_runfiles", - binary = ":uv_with_runfiles_main", + srcs = ["uv_with_runfiles.py"], + data = [ + "testdata/toolchain_payload.txt", + ":uv_extra_runfiles", + ], + deps = ["//python/runfiles"], ) uv_toolchain( diff --git a/tests/uv/lock/toolchain_runfiles_test.py b/tests/uv/lock/toolchain_runfiles_test.py index 2663c25b00..ae0ebda4f2 100644 --- a/tests/uv/lock/toolchain_runfiles_test.py +++ b/tests/uv/lock/toolchain_runfiles_test.py @@ -12,19 +12,17 @@ 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 + files = runfiles.CreateOrRaise() + launcher = files.root() / LAUNCHER 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] + command = [str(launcher)] + if os.name == "nt" and launcher.suffix == ".bat": + command = ["cmd.exe", "/c", str(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") diff --git a/tests/uv/lock/uv_with_runfiles.py b/tests/uv/lock/uv_with_runfiles.py index 757d56ed78..4e38f3fdbb 100644 --- a/tests/uv/lock/uv_with_runfiles.py +++ b/tests/uv/lock/uv_with_runfiles.py @@ -8,18 +8,14 @@ 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 + root = runfiles.CreateOrRaise().root() 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()) + assert (root / location).read_text() == expected, location + payload = root / "_main/tests/uv/lock/testdata/toolchain_payload.txt" + args.output_file.write_bytes(payload.read_bytes()) if __name__ == "__main__":