Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 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
2 changes: 2 additions & 0 deletions dev/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ lock(
"--universal",
"--upgrade",
],
directory = "dev",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe directory should default to package_directory()? It feels redundant to have to specify the directory the rule is in.

# NOTE @aignas 2025-08-17: here we select the lowest actively supported
# version so that the requirements file is generated to be compatible with
# Python version 3.10 or greater.
Expand All @@ -24,6 +25,7 @@ lock(
name = "uv_lock",
srcs = ["pyproject.toml"],
out = "uv.lock",
directory = "dev",
python_version = "3.10",
visibility = ["//:__subpackages__"],
)
3 changes: 3 additions & 0 deletions news/4029.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
(uv) Added {obj}`directory` attribute to {obj}`lock` to support running `uv`
commands within subdirectories when generating lock files
([#4029](https://github.com/bazel-contrib/rules_python/pull/4029)).
190 changes: 132 additions & 58 deletions python/uv/private/lock.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -37,40 +37,86 @@ _RunLockInfo = provider(
def _args(ctx):
"""A small helper to ensure that the right args are pushed to the _RunLockInfo provider"""
run_info = []
run_shell_list = []
args = ctx.actions.args()

def _add_args(arg, maybe_value = None):
run_info.append(arg)
if maybe_value:
args.add(arg, maybe_value)
run_info.append(maybe_value)
def _add(arg, maybe_value = None, format = None):
if format != None:
formatted = format % arg
run_info.append(formatted)
run_shell_list.append(formatted)
args.add(arg, format = format)
else:
run_info.append(arg)
run_shell_list.append(arg)
args.add(arg)

def _add_all(name, all_args = None, **kwargs):
if not all_args and type(name) == "list":
all_args = name
name = None

before_each = kwargs.get("before_each")
if name:
args.add_all(name, all_args, **kwargs)
run_info.append(name)
if maybe_value != None:
run_info.append(maybe_value)
run_shell_list.append(maybe_value)
args.add(maybe_value)

def _add_run_shell(arg, maybe_value = None, format = None):
if format != None:
formatted = format % arg
run_shell_list.append(formatted)
args.add(arg, format = format)
else:
args.add_all(all_args, **kwargs)
run_shell_list.append(arg)
args.add(arg)
if maybe_value != None:
run_shell_list.append(maybe_value)
args.add(maybe_value)

for arg in all_args:
if before_each:
run_info.append(before_each)
def _add_run_info(arg):
if type(arg) == "list":
run_info.extend(arg)
else:
run_info.append(arg)

return struct(
run_info = run_info,
run_shell = args,
add = _add_args,
add_all = _add_all,
run_shell_list = run_shell_list,
add = _add,
add_run_shell = _add_run_shell,
add_run_info = _add_run_info,
)

def _reroot(x, directory):
if not directory:
return x

if hasattr(x, "path"):
x = x.path
if x == directory:
return "."

if x.startswith(directory + "/"):
return x[len(directory) + 1:]

fail("File '{}' does not start with directory prefix '{}'".format(
x,
directory,
))

def _reroot_all(xs, directory):
return [
_reroot(x, directory)
for x in xs
]

def _up(x, directory, short_path = False):
if hasattr(x, "short_path") if short_path else hasattr(x, "path"):
x = x.short_path if short_path else x.path
elif hasattr(x, "path"):
x = x.path

if not directory:
return x

prefix = "/".join([".."] * len(directory.split("/")))
return "{}/{}".format(prefix, x)

def _common_lock(ctx, locker):
fname = "{}.out".format(ctx.label.name)

Expand Down Expand Up @@ -98,55 +144,58 @@ def _common_lock(ctx, locker):
# * progress_message is the same
srcs, output_filename, mnemonic, progress_message = locker(args, output)

args.add_all([
"--no-python-downloads",
"--no-cache",
])
args.add("--no-python-downloads")
args.add("--no-cache")

project = None
if ctx.attr.project:
project = ctx.attr.project
else:
project = ctx.attr.project
if not project:
# Autodetect the project based on the `pyproject.toml` location - it will be the first src that
# we see that is named "pyproject.toml"
for src in srcs:
if src.basename == "pyproject.toml":
if project == None:
project = src.dirname
elif len(project) > len(src.dirname):
if not project or len(project) > len(src.dirname):
# select the shortest match
project = src.dirname

if project == None:
project = ctx.label.package
directory = ctx.attr.directory
if directory:
args.add_run_shell("--directory={}".format(directory))
args.add_run_info("--directory={}".format(directory))

if project:
args.add_all([project], before_each = "--project")
rerooted_project = _reroot(project, directory)
if rerooted_project:
args.add(rerooted_project, format = "--project=%s")

args.add_all(ctx.attr.args)
for arg in ctx.attr.args:
args.add(arg)

exec_tools = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE].exec_tools
runtime = exec_tools.exec_interpreter[platform_common.ToolchainInfo].py3_runtime
python = runtime.interpreter or runtime.interpreter_path
python_files = runtime.files or depset()
args.add("--python", python)

# Handle python
args.add_run_shell("--python", _up(python, directory))
args.add_run_info(["--python", _up(python, directory, short_path = True)])

# These arguments does not change behaviour, but it reduces the output from
# the command, which is especially verbose in stderr.
args.add("--no-progress")
args.add("--quiet")

project_dir = project or ctx.label.package
project_lock = (
"{}/uv.lock".format(project_dir) if project_dir else "uv.lock"
)

if ctx.files.existing_output:
src_out = ctx.files.existing_output[0].path
elif output_filename:
# special case - the output filename has to be in the source tree and it has to have a
# special name, we use the project folder to determine this.

if not project:
fail("Cannot lock this if the project dir is unset or cannot be infered")

src_out = "{project}/{out_filename}".format(
project = project,
project = project_dir,
out_filename = output_filename,
)
else:
Expand All @@ -160,14 +209,19 @@ def _common_lock(ctx, locker):
path_sep = "/"
ext = ""

output_path = output.path.replace("/", path_sep) if is_windows else output.path
output_path = (
output.path.replace("/", path_sep) if is_windows else output.path
)
src_out_path = src_out.replace("/", path_sep) if is_windows else src_out
project_lock_path = (
project_lock.replace("/", path_sep) if is_windows else project_lock
)

# On Windows, all args must be embedded in the .bat script because
# arguments are not passed on the command line.
if is_windows:
args_parts = []
for i, arg in enumerate(args.run_info):
for i, arg in enumerate(args.run_shell_list):
if hasattr(arg, "path"):
arg = arg.path

Expand All @@ -177,26 +231,20 @@ def _common_lock(ctx, locker):
if i == 0:
a = arg.replace("/", "\\")
else:
a = arg
a = str(arg)
a = a.replace('"', '""')
args_parts.append('"' + a + '"')

# uv pip compile adds --output-file to run_shell (not run_info).
# For the lock case, output_filename is "uv.lock" and uv lock
# writes to the project directory without --output-file.
if not output_filename:
args_parts.append('"--output-file"')
args_parts.append('"' + output_path + '"')
windows_args = " ".join(args_parts)
else:
windows_args = " ".join([])
windows_args = ""

script = ctx.actions.declare_file(ctx.label.name + "_lock" + ext)
ctx.actions.expand_template(
template = ctx.files._template[0],
substitutions = {
'"{{args}}"': windows_args,
"{{out}}": output_path,
"{{project_lock}}": project_lock_path,
"{{src_out}}": src_out_path,
},
output = script,
Expand Down Expand Up @@ -242,7 +290,8 @@ def _common_lock(ctx, locker):

def _pip_compile_impl(ctx):
def _setup_args(args, output):
args.add_all(["pip", "compile"])
args.add("pip")
args.add("compile")
pkg = ctx.label.package
update_target = ctx.attr.update_target
args.add("--custom-compile-command", "bazel run //{}:{}".format(pkg, update_target))
Expand All @@ -252,14 +301,19 @@ def _pip_compile_impl(ctx):
if not ctx.attr.strip_extras:
args.add("--no-strip-extras")

args.add_all(ctx.files.build_constraints, before_each = "--build-constraints")
args.add_all(ctx.files.constraints, before_each = "--constraints")
directory = ctx.attr.directory

for constraint in _reroot_all(ctx.files.build_constraints, directory):
args.add("--build-constraints", constraint)
for constraint in _reroot_all(ctx.files.constraints, directory):
args.add("--constraints", constraint)

args.run_shell.add("--output-file", output)
args.add_run_shell("--output-file", _up(output, directory))
mnemonic = "PyRequirementsLockUv"
progress_message = "Creating a requirements.txt with uv: %{label}"

args.add_all(ctx.files.srcs)
for src in _reroot_all(ctx.files.srcs, directory):
args.add(src)
srcs = ctx.files.srcs + ctx.files.build_constraints + ctx.files.constraints

return srcs, None, mnemonic, progress_message
Expand All @@ -284,6 +338,12 @@ _common_attrs = {
"args": attr.string_list(
doc = "Public, see the docs in the macro.",
),
"directory": attr.string(
doc = """
Sets the --directory flag if provided. Will fail if at least one of the files
does not start with the given prefix of the directory.
""",
),
"env": attr.string_dict(
doc = "Public, see the docs in the macro.",
),
Expand Down Expand Up @@ -535,6 +595,7 @@ def lock(
env = None,
generate_hashes = True,
python_version = None,
directory = None,
project = None,
strip_extras = False,
**kwargs):
Expand Down Expand Up @@ -563,6 +624,9 @@ def lock(
All of the targets have `manual` tags as locking results cannot be cached.
:::

:::{versionadded} VERSION_NEXT_FEATURE
:::

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
Expand All @@ -574,6 +638,8 @@ def lock(
is passed as is and the environment variables are not expanded.
build_constraints: {type}`list[Label]` The list of build constraints to use.
constraints: {type}`list[Label]` The list of constraints files to use.
directory: {type}`str` The directory into which we should cd when
running the command.
generate_hashes: {type}`bool` Generate hashes for all of the
requirements. Only meaningful for `requirements.txt` style output.
Defaults to `True`.
Expand Down Expand Up @@ -636,6 +702,8 @@ def lock(
lock_target_kwargs["build_constraints"] = build_constraints
if constraints:
lock_target_kwargs["constraints"] = constraints
if directory:
lock_target_kwargs["directory"] = directory

if out.endswith(".lock"):
_lock(name = name, **lock_target_kwargs)
Expand Down Expand Up @@ -676,3 +744,9 @@ def lock(
tags = tags,
**kwargs
)

testing = struct(
reroot = _reroot,
reroot_all = _reroot_all,
up = _up,
)
18 changes: 6 additions & 12 deletions python/uv/private/template/uv_lock.bat
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,12 @@ if not defined BUILD_WORKSPACE_DIRECTORY goto :not_in_workspace
exit /b %ERRORLEVEL%

:not_in_workspace

if not exist "{{src_out}}" goto :no_src_out
copy /y "{{src_out}}" "{{out}}"
del /f "{{src_out}}"
copy /y "{{out}}" "{{src_out}}"
"{{args}}" %*
set "exit_code=%ERRORLEVEL%"
copy /y "{{src_out}}" "{{out}}"
exit /b %exit_code%

:no_src_out
if exist "{{src_out}}" copy /y "{{src_out}}" "{{out}}" >nul
if exist "{{out}}" (
for %%d in ("{{project_lock}}") do mkdir "%%~dpd" >nul 2>&1
copy /y "{{out}}" "{{project_lock}}" >nul
)
"{{args}}" %*
set "exit_code=%ERRORLEVEL%"
copy /y "{{src_out}}" "{{out}}"
if exist "{{project_lock}}" copy /y "{{project_lock}}" "{{out}}" >nul
exit /b %exit_code%
Loading