From 1ab4682c6e32fc40859a0c67be0b6b267913761f Mon Sep 17 00:00:00 2001 From: Kristian Hartikainen Date: Mon, 21 Sep 2026 09:56:10 -0400 Subject: [PATCH 1/2] Allow `uv` lock runners to reuse the cache Keep `--no-cache` on Bazel build actions while allowing `.run` targets to reuse the persistent `uv` cache. Document the cache controls and test both lock formats, cache opt-outs, and POSIX and Windows script generation. The integration regression resolves against a local authenticated package index, deletes the output, and regenerates the lockfile offline. It fails for both formats when `.run` receives `--no-cache` by default. Related to [#4164](https://github.com/bazel-contrib/rules_python/issues/4164). This applies the same separation between build-action flags and `.run` defaults to caching. Output verbosity remains unchanged. --- news/4179.fixed.md | 5 ++ python/uv/private/lock.bzl | 14 ++++- tests/integration/uv_lock/BUILD.bazel | 7 +++ tests/integration/uv_lock_test.py | 44 ++++++++++++++ tests/uv/lock/BUILD.bazel | 3 + tests/uv/lock/cache_tests.bzl | 84 +++++++++++++++++++++++++++ tests/uv/lock/lock_run_test.py | 3 +- 7 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 news/4179.fixed.md create mode 100644 tests/uv/lock/cache_tests.bzl diff --git a/news/4179.fixed.md b/news/4179.fixed.md new file mode 100644 index 0000000000..c486fc6067 --- /dev/null +++ b/news/4179.fixed.md @@ -0,0 +1,5 @@ +The `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 33c301d49c..21e1e58a34 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -143,7 +143,9 @@ def _common_lock(ctx, locker): 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 @@ def lock( 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 @@ -659,6 +665,10 @@ def lock( when locking the requirements. Defaults to the default python version configured by the {obj}`python` module extension. **kwargs: common kwargs passed to rules. + + :::{versionchanged} VERSION_NEXT_PATCH + The `name.run` target uses the `uv` cache by default. + ::: """ update_target = "{}.update".format(name) locker_target = "{}.run".format(name) diff --git a/tests/integration/uv_lock/BUILD.bazel b/tests/integration/uv_lock/BUILD.bazel index 01c90d356b..27ee012709 100644 --- a/tests/integration/uv_lock/BUILD.bazel +++ b/tests/integration/uv_lock/BUILD.bazel @@ -24,3 +24,10 @@ diff_test( 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 8efd3cb84f..b6e8c434a6 100644 --- a/tests/integration/uv_lock_test.py +++ b/tests/integration/uv_lock_test.py @@ -185,6 +185,50 @@ def _assert_lock_file(self, result): 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 60d680bde8..156b9dbe6f 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 @@ lock_test_suite( 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 0000000000..bdd30b89d6 --- /dev/null +++ b/tests/uv/lock/cache_tests.bzl @@ -0,0 +1,84 @@ +"""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): + config_settings = { + "//command_line_option:platforms": [ + platform_targets.WINDOWS_X86_64 if is_windows else platform_targets.LINUX_X86_64, + ], + } + 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 139d8bb2c8..d2d18e69f7 100644 --- a/tests/uv/lock/lock_run_test.py +++ b/tests/uv/lock/lock_run_test.py @@ -51,6 +51,7 @@ class LockTests(unittest.TestCase): 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 @@ def test_requirements_run_script_has_expected_args(self): 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) From 85ed68346cdd24beb3536bf68660f204c0ad710c Mon Sep 17 00:00:00 2001 From: Kristian Hartikainen Date: Thu, 24 Sep 2026 21:28:18 -0400 Subject: [PATCH 2/2] fix(uv): Place cache note before arguments Stardoc requires the description and version note before `Args:`. --- python/uv/private/lock.bzl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index 21e1e58a34..3d39788d9d 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -628,6 +628,10 @@ def lock( 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 @@ -665,10 +669,6 @@ def lock( when locking the requirements. Defaults to the default python version configured by the {obj}`python` module extension. **kwargs: common kwargs passed to rules. - - :::{versionchanged} VERSION_NEXT_PATCH - The `name.run` target uses the `uv` cache by default. - ::: """ update_target = "{}.update".format(name) locker_target = "{}.run".format(name)