Skip to content

Commit 6efa7c3

Browse files
fix(uv): Reuse the uv cache in runnable lock targets (bazel-contrib#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 bazel-contrib#4179. --------- Co-authored-by: Richard Levasseur <richardlev@gmail.com>
1 parent fa8a0e8 commit 6efa7c3

7 files changed

Lines changed: 168 additions & 3 deletions

File tree

‎news/4179.fixed.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
(uv) The {obj}`lock` macro's `.run` target uses the `uv` cache and honors
2+
`UV_CACHE_DIR` and `UV_NO_CACHE`. Pass `--refresh` to refresh cached data or
3+
`--no-cache` to disable caching. Lock build actions, including those used by
4+
`.update`, disable the `uv` cache.
5+
([#4179](https://github.com/bazel-contrib/rules_python/issues/4179))

‎python/uv/private/lock.bzl‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ def _common_lock(ctx, locker):
143143
srcs, output_filename, mnemonic, progress_message = locker(args, output)
144144

145145
args.add("--no-python-downloads")
146-
args.add("--no-cache")
146+
147+
# Build actions must not depend on the host cache but `.run` may reuse it.
148+
args.add_run_shell("--no-cache")
147149

148150
project = ctx.attr.project
149151
if not project:
@@ -607,7 +609,11 @@ def lock(
607609
to the same command that would be run in the `name` action. This will
608610
update the source copy of the requirements file. You can customize the
609611
args via the command line, but it requires being able to run `uv` (and
610-
possibly `python`) directly on your host.
612+
possibly `python`) directly on your host. This target uses the `uv`
613+
cache and inherits its cache settings from the environment, including
614+
`UV_CACHE_DIR` and `UV_NO_CACHE`. Pass `--refresh` to refresh cached
615+
data or `--no-cache` to disable caching. Build actions, including those
616+
used by `name.update`, disable the `uv` cache.
611617
- `name.update`: a target that can be run to update the source-tree version
612618
of the requirements lock file. The output can be fed to the
613619
{obj}`pip.parse` bzlmod extension tag class. Note, you can use
@@ -622,6 +628,10 @@ def lock(
622628
All of the targets have `manual` tags as locking results cannot be cached.
623629
:::
624630
631+
:::{versionchanged} VERSION_NEXT_PATCH
632+
The `name.run` target uses the `uv` cache by default.
633+
:::
634+
625635
Args:
626636
name: {type}`str` The prefix of all targets created by this macro.
627637
srcs: {type}`list[Label]` The sources that will be used. Add all of the

‎tests/integration/uv_lock/BUILD.bazel‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ diff_test(
2424
file1 = ":requirements",
2525
file2 = ":requirements.txt",
2626
)
27+
28+
lock(
29+
name = "project_lock",
30+
srcs = ["pyproject.toml"],
31+
out = "uv.lock",
32+
tags = ["no-remote-exec"],
33+
)

‎tests/integration/uv_lock_test.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,55 @@ def _assert_lock_file(self, result):
185185
self.assertIn("my-local-pkg", contents)
186186
self.assertIn("--hash=sha256:", contents)
187187

188+
def _run_cached_lock(self, target, *args, check=True):
189+
return self.run_bazel(
190+
"run",
191+
target,
192+
"--",
193+
"--default-index=" + self.server_url + "/simple/",
194+
*args,
195+
check=check,
196+
)
197+
198+
def test_run_reuses_cache_offline(self):
199+
for target, filename in (
200+
("//:requirements.run", "requirements.txt"),
201+
(
202+
"//:project_lock.run",
203+
"bazel-bin/project_lock.run.runfiles/_main/uv.lock",
204+
),
205+
):
206+
with self.subTest(target=target):
207+
self.bazel_env["UV_CACHE_DIR"] = str(
208+
self.test_tmp_dir / (Path(filename).name + ".cache")
209+
)
210+
output = self.repo_root / filename
211+
212+
output.unlink(missing_ok=True)
213+
cold = self._run_cached_lock(target, "--offline", check=False)
214+
self.assertNotEqual(cold.exit_code, 0, cold.describe())
215+
216+
self._run_cached_lock(target)
217+
expected = output.read_text()
218+
self.assertIn("my-local-pkg", expected)
219+
220+
output.unlink()
221+
self._run_cached_lock(target, "--offline")
222+
self.assertEqual(expected, output.read_text())
223+
224+
output.unlink()
225+
bypass = self._run_cached_lock(
226+
target, "--offline", "--no-cache", check=False
227+
)
228+
self.assertNotEqual(bypass.exit_code, 0, bypass.describe())
229+
230+
self.bazel_env["UV_NO_CACHE"] = "true"
231+
try:
232+
bypass_env = self._run_cached_lock(target, "--offline", check=False)
233+
self.assertNotEqual(bypass_env.exit_code, 0, bypass_env.describe())
234+
finally:
235+
del self.bazel_env["UV_NO_CACHE"]
236+
188237
def test_lock_update_with_custom_index(self):
189238
self._assert_server_requires_auth()
190239
self._assert_simple_api_sha256()

‎tests/uv/lock/BUILD.bazel‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
load(":cache_tests.bzl", "cache_test_suite")
12
load(":lock_tests.bzl", "lock_test_suite")
23
load(":uv_lock_to_requirements_tests.bzl", "uv_lock_to_requirements_test_suite")
34

@@ -13,3 +14,5 @@ lock_test_suite(
1314
uv_lock_to_requirements_test_suite(
1415
name = "uv_lock_to_requirements_tests",
1516
)
17+
18+
cache_test_suite(name = "cache_tests")

‎tests/uv/lock/cache_tests.bzl‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Cache policy tests for build actions and runnable lock targets."""
2+
3+
load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
4+
load("@rules_testing//lib:test_suite.bzl", "test_suite")
5+
load("//python/uv:lock.bzl", "lock")
6+
load("//tests/support/platforms:platforms.bzl", "platform_targets")
7+
8+
def _cache_tests(name, is_windows, extension):
9+
if is_windows:
10+
platform = platform_targets.WINDOWS_X86_64
11+
else:
12+
platform = platform_targets.LINUX_X86_64
13+
config_settings = {
14+
"//command_line_option:platforms": [platform],
15+
}
16+
for no_cache in [False, True]:
17+
subject = name + ("_no_cache" if no_cache else "_default")
18+
lock(
19+
name = subject,
20+
srcs = ["testdata/pyproject.toml"],
21+
args = ["--no-cache"] if no_cache else [],
22+
out = subject + extension,
23+
)
24+
analysis_test(
25+
name = subject + "_build_test",
26+
impl = _test_windows_build_impl if is_windows else _test_build_impl,
27+
target = subject,
28+
config_settings = config_settings,
29+
)
30+
analysis_test(
31+
name = subject + "_run_test",
32+
impl = _test_no_cache_run_impl if no_cache else _test_run_impl,
33+
target = subject + ".run",
34+
config_settings = config_settings,
35+
)
36+
native.test_suite(
37+
name = name,
38+
tests = [
39+
name + mode + kind
40+
for mode in ["_default", "_no_cache"]
41+
for kind in ["_build_test", "_run_test"]
42+
],
43+
)
44+
45+
def _test_build_impl(env, target):
46+
output = target[DefaultInfo].files.to_list()[0]
47+
env.expect.that_target(target).action_generating(
48+
output.short_path,
49+
).argv().contains("--no-cache")
50+
51+
def _test_windows_build_impl(env, target):
52+
env.expect.that_target(target).action_generating(
53+
"{package}/{name}_lock.bat",
54+
).content().contains("--no-cache")
55+
56+
def _run_script(env, target):
57+
executable = target[DefaultInfo].files_to_run.executable
58+
return env.expect.that_target(target).action_generating(
59+
executable.short_path,
60+
).content()
61+
62+
def _test_run_impl(env, target):
63+
_run_script(env, target).split("--no-cache").has_size(1)
64+
65+
def _test_no_cache_run_impl(env, target):
66+
_run_script(env, target).contains("--no-cache")
67+
68+
def _test_requirements_cache(name):
69+
_cache_tests(name, is_windows = False, extension = ".txt")
70+
71+
def _test_uv_lock_cache(name):
72+
_cache_tests(name, is_windows = False, extension = ".lock")
73+
74+
def _test_windows_requirements_cache(name):
75+
_cache_tests(name, is_windows = True, extension = ".txt")
76+
77+
def _test_windows_uv_lock_cache(name):
78+
_cache_tests(name, is_windows = True, extension = ".lock")
79+
80+
def cache_test_suite(name):
81+
"""Check cache defaults and explicit `--no-cache` for both lock formats."""
82+
test_suite(
83+
name = name,
84+
tests = [
85+
_test_requirements_cache,
86+
_test_uv_lock_cache,
87+
_test_windows_requirements_cache,
88+
_test_windows_uv_lock_cache,
89+
],
90+
)

‎tests/uv/lock/lock_run_test.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class LockTests(unittest.TestCase):
5151
def _subprocess_env(self, workspace_dir: Path) -> dict[str, str]:
5252
env = {
5353
"BUILD_WORKSPACE_DIRECTORY": str(workspace_dir),
54+
"UV_CACHE_DIR": str(workspace_dir / "uv-cache"),
5455
}
5556
# Inherit specific env vars needed for finding runfiles on Windows
5657
for key in (
@@ -249,7 +250,7 @@ def test_requirements_run_script_has_expected_args(self):
249250
self.assertIn("--generate-hashes", content)
250251
self.assertIn("--no-strip-extras", content)
251252
self.assertIn("--no-python-downloads", content)
252-
self.assertIn("--no-cache", content)
253+
self.assertNotIn("--no-cache", content)
253254
self.assertIn("--no-progress", content)
254255
self.assertIn("--quiet", content)
255256
self.assertIn("--output-file", content)

0 commit comments

Comments
 (0)