blob: 695591e924099793c0654ca9f98a97e4c75a15bf [file]
# Copyright 2024 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""An implementation for a simple macro to lock the requirements.
"""
load("@bazel_skylib//lib:shell.bzl", "shell")
load("//python:py_binary.bzl", "py_binary")
load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility
load("//python/private:common_labels.bzl", "labels")
load("//python/private:toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE") # buildifier: disable=bzl-visibility
load(":toolchain_types.bzl", "UV_TOOLCHAIN_TYPE")
visibility(["//..."])
_RunLockInfo = provider(
doc = "",
fields = {
"args": "The args passed to the `uv` by default when running the runnable target.",
"env": "The env passed to the execution.",
"srcs": "Source files required to run the runnable target.",
"template": "The template file for writing a script.",
},
)
def _args(ctx):
"""A small helper to ensure that the right args are pushed to the _RunLockInfo provider"""
run_info = []
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)
else:
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)
else:
args.add_all(all_args, **kwargs)
for arg in all_args:
if before_each:
run_info.append(before_each)
run_info.append(arg)
return struct(
run_info = run_info,
run_shell = args,
add = _add_args,
add_all = _add_all,
)
def _common_lock(ctx, locker):
fname = "{}.out".format(ctx.label.name)
# TODO @aignas 2026-06-21: do not append python_version for uv.lock as it should work for all
# python versions
python_version = ctx.attr.python_version
if python_version:
fname = "{}.{}.out".format(
ctx.label.name,
python_version.replace(".", "_"),
)
output = ctx.actions.declare_file(fname)
toolchain_info = ctx.toolchains[UV_TOOLCHAIN_TYPE]
uv = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable
args = _args(ctx)
args.add(uv)
# The output params are:
# * srcs are the srcs for the locking command action inputs tracking
# * output_filename if set is to ensure that we can do special prep for uv.lock versus
# requirements.txt
# * mnemonic is for the action mnemonic
# * progress_message is the same
srcs, output_filename, mnemonic, progress_message = locker(args, output)
args.add_all([
"--no-python-downloads",
"--no-cache",
])
project = None
if ctx.attr.project:
project = ctx.attr.project
else:
# 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):
# select the shortest match
project = src.dirname
if project == None:
project = ctx.label.package
if project:
args.add_all([project], before_each = "--project")
args.add_all(ctx.attr.args)
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)
# 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")
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,
out_filename = output_filename,
)
else:
src_out = ""
is_windows = ctx.attr.is_windows
if is_windows:
path_sep = "\\"
ext = ".bat"
else:
path_sep = "/"
ext = ""
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
# 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):
if hasattr(arg, "path"):
arg = arg.path
# Only use backslashes for the executable itself (first arg)
# to ensure CMD can run it, but keep forward slashes for arguments
# so that uv writes consistent paths in comments.
if i == 0:
a = arg.replace("/", "\\")
else:
a = 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([])
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,
"{{src_out}}": src_out_path,
},
output = script,
is_executable = True,
)
ctx.actions.run(
executable = script,
mnemonic = mnemonic,
inputs = srcs + ctx.files.existing_output,
outputs = [output],
# On Windows, the command line is embedded directly in the .bat
# script (with backslash paths). On POSIX, args are forwarded via
# exec "$@" in the .sh script.
arguments = [args.run_shell] if not ctx.attr.is_windows else [],
tools = [
uv,
python_files,
script,
],
# User reported being unable to add `--action_env` and get it to work.
# Without this flag.
#
# Ref: https://app.slack.com/client/TA4K1KQ87/CA306CEV6
use_default_shell_env = True,
progress_message = progress_message,
env = ctx.attr.env,
)
return [
DefaultInfo(files = depset([output])),
_RunLockInfo(
args = args.run_info,
env = ctx.attr.env,
srcs = depset(
srcs + [uv],
transitive = [python_files],
),
template = ctx.files._template[0],
),
]
def _pip_compile_impl(ctx):
def _setup_args(args, output):
args.add_all(["pip", "compile"])
pkg = ctx.label.package
update_target = ctx.attr.update_target
args.add("--custom-compile-command", "bazel run //{}:{}".format(pkg, update_target))
if ctx.attr.generate_hashes:
args.add("--generate-hashes")
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")
args.run_shell.add("--output-file", output)
mnemonic = "PyRequirementsLockUv"
progress_message = "Creating a requirements.txt with uv: %{label}"
args.add_all(ctx.files.srcs)
srcs = ctx.files.srcs + ctx.files.build_constraints + ctx.files.constraints
return srcs, None, mnemonic, progress_message
return _common_lock(ctx, _setup_args)
def _transition_impl(input_settings, attr):
settings = {
labels.PYTHON_VERSION: input_settings[labels.PYTHON_VERSION],
}
if attr.python_version:
settings[labels.PYTHON_VERSION] = attr.python_version
return settings
_python_version_transition = transition(
implementation = _transition_impl,
inputs = [labels.PYTHON_VERSION],
outputs = [labels.PYTHON_VERSION],
)
_common_attrs = {
"args": attr.string_list(
doc = "Public, see the docs in the macro.",
),
"env": attr.string_dict(
doc = "Public, see the docs in the macro.",
),
"existing_output": attr.label(
mandatory = False,
allow_single_file = True,
doc = """\
An already existing output file that is used as a basis for further
modifications and the locking is not done from scratch.
""",
),
"is_windows": attr.bool(mandatory = True),
"output": attr.string(
doc = "Public, see the docs in the macro.",
mandatory = True,
),
"project": attr.string(
doc = """\
Overrides the `--project` directory passed to `uv pip compile`.
If not set, the project directory is auto-detected: when
`pyproject.toml` files are in {obj}`lock.srcs`, the one with the
shortest directory path is selected. This makes `uv` read
`[tool.uv]` settings (e.g. `no-build-isolation`,
`exclude-dependencies`) from that `pyproject.toml`.
""",
),
"python_version": attr.string(
doc = "Public, see the docs in the macro.",
),
"srcs": attr.label_list(
mandatory = True,
allow_files = True,
doc = "Public, see the docs in the macro.",
),
"_allowlist_function_transition": attr.label(
default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
),
}
_pip_compile = rule(
implementation = _pip_compile_impl,
doc = """\
The lock rule that does the locking in a build action (that makes it possible
to use RBE) and also prepares information for a `bazel run` executable rule.
:::{versionchanged} 2.1.0
Added the {attr}`project` to configure the project setting if autodetection fails.
:::
""",
attrs = {
"build_constraints": attr.label_list(
allow_files = True,
doc = "Public, see the docs in the macro.",
),
"constraints": attr.label_list(
allow_files = True,
doc = "Public, see the docs in the macro.",
),
"generate_hashes": attr.bool(
doc = "Public, see the docs in the macro.",
default = True,
),
"strip_extras": attr.bool(
doc = "Public, see the docs in the macro.",
default = False,
),
"update_target": attr.string(
mandatory = True,
doc = """\
The string to input for the 'uv pip compile'.
""",
),
"_template": attr.label(
default = "//python/uv/private:uv_pip_compile_template",
doc = """\
The template to be used for 'uv pip compile'. This is either .bat or bash
script depending on what the target platform is executed on.
""",
),
} | _common_attrs,
toolchains = [
EXEC_TOOLS_TOOLCHAIN_TYPE,
UV_TOOLCHAIN_TYPE,
],
cfg = _python_version_transition,
)
def _lock_impl(ctx):
def _setup_args(args, _output):
args.add("lock")
mnemonic = "PyUvLock"
progress_message = "Creating a uv.lock with uv: %{label}"
return ctx.files.srcs, "uv.lock", mnemonic, progress_message
return _common_lock(ctx, _setup_args)
_lock = rule(
implementation = _lock_impl,
doc = """\
The lock rule that does the locking in a build action and also prepares information for a `bazel
run` executable rule.
:::{versionadded} 2.2.0
:::
""",
attrs = _common_attrs | {
"_template": attr.label(
default = "//python/uv/private:uv_lock_template",
doc = """\
The template to be used for 'uv lock'. Used when output ends with '.lock'.
""",
),
},
toolchains = [
EXEC_TOOLS_TOOLCHAIN_TYPE,
UV_TOOLCHAIN_TYPE,
],
cfg = _python_version_transition,
)
def _run_impl(ctx):
if ctx.attr.is_windows:
path_sep = "\\"
ext = ".bat"
else:
path_sep = "/"
ext = ""
def _maybe_path(arg):
if hasattr(arg, "short_path"):
arg = arg.short_path
arg = arg.replace("/", path_sep)
if ctx.attr.is_windows:
# On Windows, CMD uses double quotes for quoting, and internal
# double quotes are escaped by doubling them.
return '"' + arg.replace('"', '""') + '"'
return shell.quote(arg)
info = ctx.attr.lock[_RunLockInfo]
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,
),
},
output = executable,
is_executable = True,
)
return [
DefaultInfo(
executable = executable,
runfiles = ctx.runfiles(transitive_files = info.srcs),
),
RunEnvironmentInfo(
environment = info.env,
),
]
_run_locker = rule(
implementation = _run_impl,
doc = """\
""",
attrs = {
"is_windows": attr.bool(mandatory = True),
"lock": attr.label(
doc = "The lock target that is doing locking in a build action.",
providers = [_RunLockInfo],
cfg = "exec",
),
"output": attr.string(
doc = """\
The output that we would be updated, relative to the package the macro is used in.
""",
),
},
executable = True,
)
def _maybe_file(path):
"""A small function to return a list of existing outputs.
If the file referenced by the input argument exists, then it will return
it, otherwise it will return an empty list. This is useful to for programs
like pip-compile which behave differently if the output file exists and
update the output file in place.
The API of the function ensures that path is not a glob itself.
Args:
path: {type}`str` the file name.
"""
for p in native.glob([path], allow_empty = True):
if path != p:
continue
return p
return None
def _expand_template_impl(ctx):
pkg = ctx.label.package
update_src = ctx.actions.declare_file(ctx.attr.update_target + ".py")
# Fix the path construction to avoid absolute paths
# If package is empty (root), don't add a leading slash
dst = "{}/{}".format(pkg, ctx.attr.output) if pkg else ctx.attr.output
ctx.actions.expand_template(
template = ctx.files._lock_copier_template[0],
substitutions = {
"{{dst}}": dst,
"{{src}}": "{}".format(ctx.files.src[0].short_path),
"{{update_target}}": "//{}:{}".format(pkg, ctx.attr.update_target),
},
output = update_src,
)
return DefaultInfo(files = depset([update_src]))
_expand_template = rule(
implementation = _expand_template_impl,
attrs = {
"output": attr.string(mandatory = True),
"src": attr.label(mandatory = True),
"update_target": attr.string(mandatory = True),
"_lock_copier_template": attr.label(
default = "//python/uv/private:lock_copier_template",
allow_single_file = True,
),
},
doc = "Expand the template for the update script allowing us to use `select` statements in the {attr}`output` attribute.",
)
def lock(
*,
name,
srcs,
out,
args = [],
build_constraints = [],
constraints = [],
env = None,
generate_hashes = True,
python_version = None,
project = None,
strip_extras = False,
**kwargs):
"""Pin the requirements based on the src files.
This macro creates the following targets:
- `name`: the target that creates the requirements.txt file in a build
action. This target will have `no-cache` and `requires-network` added
to its tags.
- `name.run`: a runnable target that can be used to pass extra parameters
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.
- `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
`native_test` to wrap this target to make a test. You can't customize the
args via command line, but you can use RBE to generate requirements
(offload execution and run for different platforms). Note, that for RBE
to be usable, one needs to ensure that the nodes running the action have
internet connectivity or the indexes are provided in a different way for
a fully offline operation.
:::{note}
All of the targets have `manual` tags as locking results cannot be cached.
:::
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
files that would be passed as srcs to the `uv pip compile` command.
out: {type}`str` The output file relative to the package.
args: {type}`list[str]` The list of args to pass to uv. Note, these are
written into the runnable `name.run` target.
env: {type}`dict[str, str]` the environment variables to set. Note, this
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.
generate_hashes: {type}`bool` Generate hashes for all of the
requirements. Only meaningful for `requirements.txt` style output.
Defaults to `True`.
strip_extras: {type}`bool` whether to strip extras from the output.
Currently `rules_python` requires `--no-strip-extras` to properly
function, but sometimes one may want to not have the extras if you
are compiling the requirements file for using it as a constraints
file. Defaults to `False`.
project: {type}`str | None` overrides the `--project` directory
passed to `uv pip compile`. By default the project directory
is auto-detected: when {obj}`lock.srcs` contains
`pyproject.toml` files, the one with the shortest directory
path is selected. This causes `uv` to read `[tool.uv]`
settings such as `no-build-isolation` and
`exclude-dependencies` from that `pyproject.toml`. If no
`pyproject.toml` is in `srcs` and no `project` is given, the
Bazel package directory is used as fallback.
{versionadded} 2.1.0
python_version: {type}`str | None` the python_version to transition to
when locking the requirements. Defaults to the default python version
configured by the {obj}`python` module extension.
**kwargs: common kwargs passed to rules.
"""
update_target = "{}.update".format(name)
locker_target = "{}.run".format(name)
# Check if the output file already exists, if yes, first copy it to the
# output file location in order to make `uv` not change the requirements if
# we are just running the command.
maybe_out = _maybe_file(out)
tags = ["manual"] + kwargs.pop("tags", [])
if not BZLMOD_ENABLED:
kwargs["target_compatible_with"] = ["@platforms//:incompatible"]
uv_kwargs = {
"is_windows": select({
"@platforms//os:windows": True,
"//conditions:default": False,
}),
"output": out,
} | kwargs
lock_target_kwargs = {
"args": args,
"env": env,
"existing_output": maybe_out,
"project": project,
"python_version": python_version,
"srcs": srcs,
"tags": [
"no-cache",
"requires-network",
] + tags,
} | uv_kwargs
# NOTE @aignas 2026-06-20: if the user passes these args the command will fail
# with an error message instead of silently ignoring the args
if build_constraints:
lock_target_kwargs["build_constraints"] = build_constraints
if constraints:
lock_target_kwargs["constraints"] = constraints
if out.endswith(".lock"):
_lock(name = name, **lock_target_kwargs)
else:
_pip_compile(
name = name,
generate_hashes = generate_hashes,
strip_extras = strip_extras,
update_target = update_target,
**lock_target_kwargs
)
# A target for updating the in-tree version directly by skipping the in-action
# uv pip compile or uv lock, depending what is defined for the locker_target.
_run_locker(
name = locker_target,
lock = name,
tags = tags,
**uv_kwargs
)
# FIXME @aignas 2025-03-20: is it possible to extend `py_binary` so that the
# srcs are generated before `py_binary` is run? I found that
# `ctx.files.srcs` usage in the base implementation is making it difficult.
template_target = "_{}_gen".format(name)
_expand_template(
name = template_target,
src = name,
output = out,
update_target = update_target,
tags = tags,
)
py_binary(
name = update_target,
srcs = [template_target],
data = [name] + ([maybe_out] if maybe_out else []),
tags = tags,
**kwargs
)