fix(uv): Preserve `uv` wrapper runfiles in lock targets (#4182)

(Done with help from an agent.)

This PR lets `uv_toolchain` accept executable wrappers with multiple
outputs and preserves their runfiles in lock actions and `.run` targets.
This supports wrappers that obtain credentials before invoking `uv`.

Lock actions receive the executable target's `FilesToRunProvider`.
Runnable targets merge its default runfiles, preserving runtime data,
ordinary symlinks, and root symlinks. The downloaded `uv` binary has
public visibility so wrappers can declare it as a runtime dependency.

The regression test covers a wrapper with multiple outputs, runtime
data, and both kinds of runfiles symlinks.

Closes #4181.

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/news/4181.fixed.md b/news/4181.fixed.md
new file mode 100644
index 0000000..1323770
--- /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 3d39788..93742a2 100644
--- a/python/uv/private/lock.bzl
+++ b/python/uv/private/lock.bzl
@@ -30,7 +30,9 @@
     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 regular 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 @@
 
     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)
@@ -261,7 +264,7 @@
         # 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,
         ],
@@ -280,10 +283,10 @@
         _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],
         ),
     ]
@@ -503,7 +506,7 @@
     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 79a6495..839e582 100644
--- a/python/uv/private/uv_repository.bzl
+++ b/python/uv/private/uv_repository.bzl
@@ -24,6 +24,11 @@
 # 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(
     name = "uv_toolchain",
     uv = "{binary}",
diff --git a/python/uv/private/uv_toolchain.bzl b/python/uv/private/uv_toolchain.bzl
index bd82e74..0e5a5dc 100644
--- a/python/uv/private/uv_toolchain.bzl
+++ b/python/uv/private/uv_toolchain.bzl
@@ -49,9 +49,21 @@
     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 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 3e15ef2..f14d784 100644
--- a/tests/uv/lock/lock_tests.bzl
+++ b/tests/uv/lock/lock_tests.bzl
@@ -17,12 +17,38 @@
 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 _extra_runfiles_impl(ctx):
+    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")
+
+    # Keep the payloads out of ordinary runfiles to require their symlink mappings.
+    return [DefaultInfo(
+        runfiles = ctx.runfiles(
+            symlinks = {"uv_wrapper/symlink_payload.txt": symlink_payload},
+            root_symlinks = {
+                "uv_wrapper/root_symlink_payload.txt": root_symlink_payload,
+            },
+        ),
+    )]
+
+_extra_runfiles = rule(
+    implementation = _extra_runfiles_impl,
+)
+
 def _test_reroot(env):
     reroot = lock_testing.reroot
     env.expect.that_str(
@@ -190,6 +216,53 @@
         }),
     )
 
+    _extra_runfiles(
+        name = "uv_extra_runfiles",
+    )
+
+    py_binary(
+        name = "uv_with_runfiles",
+        srcs = ["uv_with_runfiles.py"],
+        data = [
+            "testdata/toolchain_payload.txt",
+            ":uv_extra_runfiles",
+        ],
+        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 +272,8 @@
         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 0000000..9cfc5c3
--- /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 0000000..ae0ebda
--- /dev/null
+++ b/tests/uv/lock/toolchain_runfiles_test.py
@@ -0,0 +1,32 @@
+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.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 = [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")
+
+
+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 0000000..4e38f3f
--- /dev/null
+++ b/tests/uv/lock/uv_with_runfiles.py
@@ -0,0 +1,22 @@
+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()
+    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"),
+    ]:
+        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__":
+    main()