diff --git a/news/4339.fixed.md b/news/4339.fixed.md new file mode 100644 index 0000000000..17fa931e27 --- /dev/null +++ b/news/4339.fixed.md @@ -0,0 +1,3 @@ +(uv) Fixed {obj}`lock`'s `.run` target for `uv.lock` outputs to read and write +the lock file in the workspace source tree instead of the runfiles tree +([#4339](https://github.com/bazel-contrib/rules_python/issues/4339)). diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl index b044151299..a75c54dde2 100644 --- a/python/uv/private/lock.bzl +++ b/python/uv/private/lock.bzl @@ -37,6 +37,11 @@ The args passed to `uv` by default when running the runnable target. :type: dict[str, str] The env passed to the execution. +""", + "project_lock": """ +:type: str + +The path to the `uv.lock` file in the project directory. """, # Preserve the wrapper's runtime files and symlink mappings together; # a regular depset cannot represent the full runfiles layout. @@ -299,8 +304,9 @@ def _common_lock(ctx, locker): _RunLockInfo( args = args.run_info, env = ctx.attr.env, + project_lock = project_lock, runfiles = ctx.runfiles( - files = srcs + [uv], + files = srcs + ctx.files.existing_output + [uv], transitive_files = python_files, ).merge(uv_default_info.default_runfiles), template = ctx.files._template[0], @@ -504,16 +510,18 @@ def _run_impl(ctx): return shell.quote(arg) info = ctx.attr.lock[_RunLockInfo] + if ctx.label.package: + src_out = "{}/{}".format(ctx.label.package, ctx.attr.output) + else: + src_out = ctx.attr.output executable = ctx.actions.declare_file(ctx.label.name + ext) ctx.actions.expand_template( template = info.template, substitutions = { '"{{args}}"': " ".join([_maybe_path(arg) for arg in info.args]), - "{{src_out}}": "{}/{}".format(ctx.label.package, ctx.attr.output).replace( - "/", - path_sep, - ), + "{{project_lock}}": info.project_lock.replace("/", path_sep), + "{{src_out}}": src_out.replace("/", path_sep), }, output = executable, is_executable = True, diff --git a/python/uv/private/template/uv_lock.bat b/python/uv/private/template/uv_lock.bat index 826e775f08..8a7dcbefdb 100755 --- a/python/uv/private/template/uv_lock.bat +++ b/python/uv/private/template/uv_lock.bat @@ -1,7 +1,33 @@ @echo off if not defined BUILD_WORKSPACE_DIRECTORY goto :not_in_workspace +rem `uv lock` always reads and writes `uv.lock` in the project directory +rem (`{{project_lock}}` relative to `cd`). Stage `_main` into a writable +rem temporary directory so the runfiles directory is not modified. +set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}" +set "orig_dir=%CD%" +for %%i in ("%CD%\..") do set "runfiles_dir=%%~fi" +set "tmp_dir=%TEMP%\uv_lock_%RANDOM%_%RANDOM%" +mkdir "%tmp_dir%" >nul 2>&1 +for /d %%d in ("%runfiles_dir%\*") do ( + if /i not "%%~nxd"=="_main" mklink /J "%tmp_dir%\%%~nxd" "%%~fd" >nul +) +xcopy /E /I /Q /Y "%orig_dir%" "%tmp_dir%\_main" >nul +pushd "%tmp_dir%\_main" +if exist "%out%" ( + for %%d in ("{{project_lock}}") do mkdir "%%~dpd" >nul 2>&1 + copy /y "%out%" "{{project_lock}}" >nul +) else ( + if exist "{{project_lock}}" del /f /q "{{project_lock}}" >nul +) "{{args}}" %* -exit /b %ERRORLEVEL% +set "exit_code=%ERRORLEVEL%" +if %exit_code% equ 0 if exist "{{project_lock}}" ( + for %%d in ("%out%") do mkdir "%%~dpd" >nul 2>&1 + copy /y "{{project_lock}}" "%out%" >nul +) +popd +rmdir /s /q "%tmp_dir%" >nul 2>&1 +exit /b %exit_code% :not_in_workspace if exist "{{src_out}}" copy /y "{{src_out}}" "{{out}}" >nul diff --git a/python/uv/private/template/uv_lock.sh b/python/uv/private/template/uv_lock.sh index 10d37fd5fb..a9543f203b 100755 --- a/python/uv/private/template/uv_lock.sh +++ b/python/uv/private/template/uv_lock.sh @@ -1,8 +1,44 @@ #!/usr/bin/env bash set -euo pipefail +# Handle `bazel run` mode if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then - exec "{{args}}" "$@" + # `uv lock` has no flag to specify the lockfile path: it always reads and + # writes `uv.lock` inside the project directory (`{{project_lock}}` + # relative to `pwd`, where `pyproject.toml` resides). + # + # When `bazel run` executes this script, its `pwd` is in the runfiles + # directory. While runfiles should be treated as read-only copies of the + # underlying files, local execution is typically a symlink to the original, + # writable, file. We exploit that to avoid having to copy the entire + # directory structure to a writable location. + readonly out="${BUILD_WORKSPACE_DIRECTORY}/{{src_out}}" + if [[ -w "{{project_lock}}" && "{{project_lock}}" -ef "$out" ]]; then + exec "{{args}}" "$@" + fi + + # Handle the case when the output file isn't a writable symlink back to the + # original file (e.g. when the lockfile does not exist yet, runfiles are not + # writable symlinks, or a custom `out` filename is used) by staging the + # runfiles tree into a temporary writable directory. + readonly orig_dir="$(pwd)" + readonly runfiles_dir="$(cd .. && pwd)" + readonly tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + for entry in "$runfiles_dir"/*; do + if [[ "$(basename "$entry")" != "_main" ]]; then + ln -s "$entry" "$tmp_dir/$(basename "$entry")" + fi + done + cp -RP "$orig_dir" "$tmp_dir/_main" + find "$tmp_dir/_main" -type d -exec chmod u+w {} + + cd "$tmp_dir/_main" + rm -f "{{project_lock}}" + mkdir -p "$(dirname "{{project_lock}}")" + mkdir -p "$(dirname "$out")" + ln -s "$out" "{{project_lock}}" + "{{args}}" "$@" + exit $? fi # Build action mode diff --git a/tests/integration/uv_lock_test.py b/tests/integration/uv_lock_test.py index 8ac95ca9f1..73760cd062 100644 --- a/tests/integration/uv_lock_test.py +++ b/tests/integration/uv_lock_test.py @@ -198,10 +198,7 @@ def _run_cached_lock(self, target, *args, check=True): 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", - ), + ("//:project_lock.run", "uv.lock"), ): with self.subTest(target=target): self.bazel_env["UV_CACHE_DIR"] = str( @@ -234,6 +231,48 @@ def test_run_reuses_cache_offline(self): finally: del self.bazel_env["UV_NO_CACHE"] + def test_project_lock_run_updates_existing_lock_in_workspace(self): + packages_dir = self.docroot / "packages" + wheel_data, _, wheel_name = _create_wheel_bytes("other-local-pkg", "1.0.0") + (packages_dir / wheel_name).write_bytes(wheel_data) + + pyproject = self.repo_root / "pyproject.toml" + orig_pyproject = pyproject.read_text() + lock_file = self.repo_root / "uv.lock" + try: + pyproject.write_text( + '[project]\nname = "uv_lock_test"\nversion = "0.0.0"\n' + 'dependencies = ["my-local-pkg", "other-local-pkg"]\n' + ) + lock_file.unlink(missing_ok=True) + + # Generate the initial lockfile in the workspace with 1.0.0 of both packages. + self._run_cached_lock("//:project_lock.run") + self.assertTrue(lock_file.exists(), "uv.lock was not written to workspace") + initial_lock = lock_file.read_text() + self.assertIn('name = "my-local-pkg"\nversion = "1.0.0"', initial_lock) + self.assertIn('name = "other-local-pkg"\nversion = "1.0.0"', initial_lock) + + # Publish 2.0.0 of both packages to the index. + for name in ("my-local-pkg", "other-local-pkg"): + wheel_data, _, wheel_name = _create_wheel_bytes(name, "2.0.0") + (packages_dir / wheel_name).write_bytes(wheel_data) + + # Upgrade only my-local-pkg; other-local-pkg must stay pinned at 1.0.0. + self._run_cached_lock( + "//:project_lock.run", "--upgrade-package", "my-local-pkg" + ) + updated_lock = lock_file.read_text() + self.assertIn('name = "my-local-pkg"\nversion = "2.0.0"', updated_lock) + self.assertIn('name = "other-local-pkg"\nversion = "1.0.0"', updated_lock) + finally: + # All test methods share self.repo_root (BIT_WORKSPACE_DIR), and in + # local runs pyproject.toml is a runfiles symlink to the host source + # tree. Restore pyproject.toml and remove uv.lock so subsequent test + # methods and the host working tree are not left with test-only edits. + pyproject.write_text(orig_pyproject) + lock_file.unlink(missing_ok=True) + def test_lock_update_with_custom_index(self): self._assert_server_requires_auth() self._assert_simple_api_sha256()