diff --git a/news/4179.fixed.md b/news/4179.fixed.md new file mode 100644 index 0000000000..3225cfb421 --- /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 33c301d49c..3d39788d9d 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 @@ -622,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 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..8ac95ca9f1 100644 --- a/tests/integration/uv_lock_test.py +++ b/tests/integration/uv_lock_test.py @@ -185,6 +185,55 @@ 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..a1e528d5dc --- /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 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)