Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions news/4339.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
18 changes: 13 additions & 5 deletions python/uv/private/lock.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion python/uv/private/template/uv_lock.bat
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 37 additions & 1 deletion python/uv/private/template/uv_lock.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
47 changes: 43 additions & 4 deletions tests/integration/uv_lock_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
Loading