fix(uv): Reuse the `uv` cache in runnable lock targets (#4180)

This PR lets `.run` targets reuse the caller's `uv` cache by moving
`--no-cache` from the shared arguments to the build-action arguments.
Repeated runs can reuse registry metadata, Git checkouts, and build
metadata. Build actions retain cache isolation, and callers can still
pass `--no-cache`.

The lock tests use a sandbox-writable `UV_CACHE_DIR` and verify that
`.run` creates it.

Closes #4179.

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/news/4179.fixed.md b/news/4179.fixed.md
new file mode 100644
index 0000000..3225cfb
--- /dev/null
+++ b/news/4179.fixed.md
@@ -0,0 +1,5 @@
+(uv) The {obj}`lock` macro's `.run` target uses the `uv` cache and honors
+`UV_CACHE_DIR` and `UV_NO_CACHE`. Pass `--refresh` to refresh cached data or
+`--no-cache` to disable caching. Lock build actions, including those used by
+`.update`, disable the `uv` cache.
+([#4179](https://github.com/bazel-contrib/rules_python/issues/4179))
diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl
index 33c301d..3d39788 100644
--- a/python/uv/private/lock.bzl
+++ b/python/uv/private/lock.bzl
@@ -143,7 +143,9 @@
     srcs, output_filename, mnemonic, progress_message = locker(args, output)
 
     args.add("--no-python-downloads")
-    args.add("--no-cache")
+
+    # Build actions must not depend on the host cache but `.run` may reuse it.
+    args.add_run_shell("--no-cache")
 
     project = ctx.attr.project
     if not project:
@@ -607,7 +609,11 @@
       to the same command that would be run in the `name` action. This will
       update the source copy of the requirements file. You can customize the
       args via the command line, but it requires being able to run `uv` (and
-      possibly `python`) directly on your host.
+      possibly `python`) directly on your host. This target uses the `uv`
+      cache and inherits its cache settings from the environment, including
+      `UV_CACHE_DIR` and `UV_NO_CACHE`. Pass `--refresh` to refresh cached
+      data or `--no-cache` to disable caching. Build actions, including those
+      used by `name.update`, disable the `uv` cache.
     - `name.update`: a target that can be run to update the source-tree version
       of the requirements lock file. The output can be fed to the
       {obj}`pip.parse` bzlmod extension tag class. Note, you can use
@@ -622,6 +628,10 @@
     All of the targets have `manual` tags as locking results cannot be cached.
     :::
 
+    :::{versionchanged} VERSION_NEXT_PATCH
+    The `name.run` target uses the `uv` cache by default.
+    :::
+
     Args:
         name: {type}`str` The prefix of all targets created by this macro.
         srcs: {type}`list[Label]` The sources that will be used. Add all of the
diff --git a/tests/integration/uv_lock/BUILD.bazel b/tests/integration/uv_lock/BUILD.bazel
index 01c90d3..27ee012 100644
--- a/tests/integration/uv_lock/BUILD.bazel
+++ b/tests/integration/uv_lock/BUILD.bazel
@@ -24,3 +24,10 @@
     file1 = ":requirements",
     file2 = ":requirements.txt",
 )
+
+lock(
+    name = "project_lock",
+    srcs = ["pyproject.toml"],
+    out = "uv.lock",
+    tags = ["no-remote-exec"],
+)
diff --git a/tests/integration/uv_lock_test.py b/tests/integration/uv_lock_test.py
index 8efd3cb..8ac95ca 100644
--- a/tests/integration/uv_lock_test.py
+++ b/tests/integration/uv_lock_test.py
@@ -185,6 +185,55 @@
         self.assertIn("my-local-pkg", contents)
         self.assertIn("--hash=sha256:", contents)
 
+    def _run_cached_lock(self, target, *args, check=True):
+        return self.run_bazel(
+            "run",
+            target,
+            "--",
+            "--default-index=" + self.server_url + "/simple/",
+            *args,
+            check=check,
+        )
+
+    def test_run_reuses_cache_offline(self):
+        for target, filename in (
+            ("//:requirements.run", "requirements.txt"),
+            (
+                "//:project_lock.run",
+                "bazel-bin/project_lock.run.runfiles/_main/uv.lock",
+            ),
+        ):
+            with self.subTest(target=target):
+                self.bazel_env["UV_CACHE_DIR"] = str(
+                    self.test_tmp_dir / (Path(filename).name + ".cache")
+                )
+                output = self.repo_root / filename
+
+                output.unlink(missing_ok=True)
+                cold = self._run_cached_lock(target, "--offline", check=False)
+                self.assertNotEqual(cold.exit_code, 0, cold.describe())
+
+                self._run_cached_lock(target)
+                expected = output.read_text()
+                self.assertIn("my-local-pkg", expected)
+
+                output.unlink()
+                self._run_cached_lock(target, "--offline")
+                self.assertEqual(expected, output.read_text())
+
+                output.unlink()
+                bypass = self._run_cached_lock(
+                    target, "--offline", "--no-cache", check=False
+                )
+                self.assertNotEqual(bypass.exit_code, 0, bypass.describe())
+
+                self.bazel_env["UV_NO_CACHE"] = "true"
+                try:
+                    bypass_env = self._run_cached_lock(target, "--offline", check=False)
+                    self.assertNotEqual(bypass_env.exit_code, 0, bypass_env.describe())
+                finally:
+                    del self.bazel_env["UV_NO_CACHE"]
+
     def test_lock_update_with_custom_index(self):
         self._assert_server_requires_auth()
         self._assert_simple_api_sha256()
diff --git a/tests/uv/lock/BUILD.bazel b/tests/uv/lock/BUILD.bazel
index 60d680b..156b9db 100644
--- a/tests/uv/lock/BUILD.bazel
+++ b/tests/uv/lock/BUILD.bazel
@@ -1,3 +1,4 @@
+load(":cache_tests.bzl", "cache_test_suite")
 load(":lock_tests.bzl", "lock_test_suite")
 load(":uv_lock_to_requirements_tests.bzl", "uv_lock_to_requirements_test_suite")
 
@@ -13,3 +14,5 @@
 uv_lock_to_requirements_test_suite(
     name = "uv_lock_to_requirements_tests",
 )
+
+cache_test_suite(name = "cache_tests")
diff --git a/tests/uv/lock/cache_tests.bzl b/tests/uv/lock/cache_tests.bzl
new file mode 100644
index 0000000..a1e528d
--- /dev/null
+++ b/tests/uv/lock/cache_tests.bzl
@@ -0,0 +1,90 @@
+"""Cache policy tests for build actions and runnable lock targets."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("//python/uv:lock.bzl", "lock")
+load("//tests/support/platforms:platforms.bzl", "platform_targets")
+
+def _cache_tests(name, is_windows, extension):
+    if is_windows:
+        platform = platform_targets.WINDOWS_X86_64
+    else:
+        platform = platform_targets.LINUX_X86_64
+    config_settings = {
+        "//command_line_option:platforms": [platform],
+    }
+    for no_cache in [False, True]:
+        subject = name + ("_no_cache" if no_cache else "_default")
+        lock(
+            name = subject,
+            srcs = ["testdata/pyproject.toml"],
+            args = ["--no-cache"] if no_cache else [],
+            out = subject + extension,
+        )
+        analysis_test(
+            name = subject + "_build_test",
+            impl = _test_windows_build_impl if is_windows else _test_build_impl,
+            target = subject,
+            config_settings = config_settings,
+        )
+        analysis_test(
+            name = subject + "_run_test",
+            impl = _test_no_cache_run_impl if no_cache else _test_run_impl,
+            target = subject + ".run",
+            config_settings = config_settings,
+        )
+    native.test_suite(
+        name = name,
+        tests = [
+            name + mode + kind
+            for mode in ["_default", "_no_cache"]
+            for kind in ["_build_test", "_run_test"]
+        ],
+    )
+
+def _test_build_impl(env, target):
+    output = target[DefaultInfo].files.to_list()[0]
+    env.expect.that_target(target).action_generating(
+        output.short_path,
+    ).argv().contains("--no-cache")
+
+def _test_windows_build_impl(env, target):
+    env.expect.that_target(target).action_generating(
+        "{package}/{name}_lock.bat",
+    ).content().contains("--no-cache")
+
+def _run_script(env, target):
+    executable = target[DefaultInfo].files_to_run.executable
+    return env.expect.that_target(target).action_generating(
+        executable.short_path,
+    ).content()
+
+def _test_run_impl(env, target):
+    _run_script(env, target).split("--no-cache").has_size(1)
+
+def _test_no_cache_run_impl(env, target):
+    _run_script(env, target).contains("--no-cache")
+
+def _test_requirements_cache(name):
+    _cache_tests(name, is_windows = False, extension = ".txt")
+
+def _test_uv_lock_cache(name):
+    _cache_tests(name, is_windows = False, extension = ".lock")
+
+def _test_windows_requirements_cache(name):
+    _cache_tests(name, is_windows = True, extension = ".txt")
+
+def _test_windows_uv_lock_cache(name):
+    _cache_tests(name, is_windows = True, extension = ".lock")
+
+def cache_test_suite(name):
+    """Check cache defaults and explicit `--no-cache` for both lock formats."""
+    test_suite(
+        name = name,
+        tests = [
+            _test_requirements_cache,
+            _test_uv_lock_cache,
+            _test_windows_requirements_cache,
+            _test_windows_uv_lock_cache,
+        ],
+    )
diff --git a/tests/uv/lock/lock_run_test.py b/tests/uv/lock/lock_run_test.py
index 139d8bb..d2d18e6 100644
--- a/tests/uv/lock/lock_run_test.py
+++ b/tests/uv/lock/lock_run_test.py
@@ -51,6 +51,7 @@
     def _subprocess_env(self, workspace_dir: Path) -> dict[str, str]:
         env = {
             "BUILD_WORKSPACE_DIRECTORY": str(workspace_dir),
+            "UV_CACHE_DIR": str(workspace_dir / "uv-cache"),
         }
         # Inherit specific env vars needed for finding runfiles on Windows
         for key in (
@@ -249,7 +250,7 @@
         self.assertIn("--generate-hashes", content)
         self.assertIn("--no-strip-extras", content)
         self.assertIn("--no-python-downloads", content)
-        self.assertIn("--no-cache", content)
+        self.assertNotIn("--no-cache", content)
         self.assertIn("--no-progress", content)
         self.assertIn("--quiet", content)
         self.assertIn("--output-file", content)