blob: 678d01bca73106dea341a2159bb977a4328eab0c [file] [log] [blame]
# Copyright 2023 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.
"""Implementation of sphinx rules."""
load("@bazel_skylib//lib:paths.bzl", "paths")
load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
load("//python:py_binary.bzl", "py_binary")
load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs") # buildifier: disable=bzl-visibility
load(":sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo")
_SPHINX_BUILD_MAIN_SRC = Label("//sphinxdocs/private:sphinx_build.py")
_SPHINX_SERVE_MAIN_SRC = Label("//sphinxdocs/private:sphinx_server.py")
_SphinxSourceTreeInfo = provider(
doc = "Information about source tree for Sphinx to build.",
fields = {
"source_dir_runfiles_path": """
:type: str
Runfiles-root relative path of the root directory for the source files.
""",
"source_root": """
:type: str
Exec-root relative path of the root directory for the source files (which are in DefaultInfo.files)
""",
},
)
_SphinxRunInfo = provider(
doc = "Information for running the underlying Sphinx command directly",
fields = {
"per_format_args": """
:type: dict[str, struct]
A dict keyed by output format name. The values are a struct with attributes:
* args: a `list[str]` of args to run this format's build
* env: a `dict[str, str]` of environment variables to set for this format's build
""",
"source_tree": """
:type: Target
Target with the source tree files
""",
"sphinx": """
:type: Target
The sphinx-build binary to run.
""",
"tools": """
:type: list[Target]
Additional tools Sphinx needs
""",
},
)
def sphinx_build_binary(name, py_binary_rule = py_binary, **kwargs):
"""Create an executable with the sphinx-build command line interface.
The `deps` must contain the sphinx library and any other extensions Sphinx
needs at runtime.
Args:
name: {type}`str` name of the target. The name "sphinx-build" is the
conventional name to match what Sphinx itself uses.
py_binary_rule: {type}`callable` A `py_binary` compatible callable
for creating the target. If not set, the regular `py_binary`
rule is used. This allows using the version-aware rules, or
other alternative implementations.
**kwargs: {type}`dict` Additional kwargs to pass onto `py_binary`. The `srcs` and
`main` attributes must not be specified.
"""
add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_build_binary")
py_binary_rule(
name = name,
srcs = [_SPHINX_BUILD_MAIN_SRC],
main = _SPHINX_BUILD_MAIN_SRC,
**kwargs
)
def sphinx_docs(
name,
*,
srcs = [],
deps = [],
renamed_srcs = {},
sphinx,
config,
formats,
strip_prefix = "",
extra_opts = [],
tools = [],
**kwargs):
"""Generate docs using Sphinx.
Generates targets:
* `<name>`: The output of this target is a directory for each
format Sphinx creates. This target also has a separate output
group for each format. e.g. `--output_group=html` will only build
the "html" format files.
* `<name>.serve`: A binary that locally serves the HTML output. This
allows previewing docs during development.
* `<name>.run`: A binary that directly runs the underlying Sphinx command
to build the docs. This is a debugging aid.
Args:
name: {type}`Name` name of the docs rule.
srcs: {type}`list[label]` The source files for Sphinx to process.
deps: {type}`list[label]` of {obj}`sphinx_docs_library` targets.
renamed_srcs: {type}`dict[label, dict]` Doc source files for Sphinx that
are renamed. This is typically used for files elsewhere, such as top
level files in the repo.
sphinx: {type}`label` the Sphinx tool to use for building
documentation. Because Sphinx supports various plugins, you must
construct your own binary with the necessary dependencies. The
{obj}`sphinx_build_binary` rule can be used to define such a binary, but
any executable supporting the `sphinx-build` command line interface
can be used (typically some `py_binary` program).
config: {type}`label` the Sphinx config file (`conf.py`) to use.
formats: (list of str) the formats (`-b` flag) to generate documentation
in. Each format will become an output group.
strip_prefix: {type}`str` A prefix to remove from the file paths of the
source files. e.g., given `//docs:foo.md`, stripping `docs/` makes
Sphinx see `foo.md` in its generated source directory. If not
specified, then {any}`native.package_name` is used.
extra_opts: {type}`list[str]` Additional options to pass onto Sphinx building.
On each provided option, a location expansion is performed.
See {any}`ctx.expand_location`.
tools: {type}`list[label]` Additional tools that are used by Sphinx and its plugins.
This just makes the tools available during Sphinx execution. To locate
them, use {obj}`extra_opts` and `$(location)`.
**kwargs: {type}`dict` Common attributes to pass onto rules.
"""
add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_docs")
common_kwargs = copy_propagating_kwargs(kwargs)
internal_name = "_{}".format(name.lstrip("_"))
_sphinx_source_tree(
name = internal_name + "/_sources",
srcs = srcs,
deps = deps,
renamed_srcs = renamed_srcs,
config = config,
strip_prefix = strip_prefix,
**common_kwargs
)
_sphinx_docs(
name = name,
sphinx = sphinx,
formats = formats,
source_tree = internal_name + "/_sources",
extra_opts = extra_opts,
tools = tools,
**kwargs
)
html_name = internal_name + "_html"
native.filegroup(
name = html_name,
srcs = [name],
output_group = "html",
**common_kwargs
)
py_binary(
name = name + ".serve",
srcs = [_SPHINX_SERVE_MAIN_SRC],
main = _SPHINX_SERVE_MAIN_SRC,
data = [html_name],
args = [
"$(execpath {})".format(html_name),
],
**common_kwargs
)
sphinx_run(
name = name + ".run",
docs = name,
)
build_test(
name = name + "_build_test",
targets = [name],
**kwargs # kwargs used to pick up target_compatible_with
)
def _sphinx_docs_impl(ctx):
source_tree_info = ctx.attr.source_tree[_SphinxSourceTreeInfo]
source_dir_path = source_tree_info.source_root
inputs = ctx.attr.source_tree[DefaultInfo].files
per_format_args = {}
outputs = {}
for format in ctx.attr.formats:
output_dir, args_env = _run_sphinx(
ctx = ctx,
format = format,
source_path = source_dir_path,
output_prefix = paths.join(ctx.label.name, "_build"),
inputs = inputs,
)
outputs[format] = output_dir
per_format_args[format] = args_env
return [
DefaultInfo(files = depset(outputs.values())),
OutputGroupInfo(**{
format: depset([output])
for format, output in outputs.items()
}),
_SphinxRunInfo(
sphinx = ctx.attr.sphinx,
source_tree = ctx.attr.source_tree,
tools = ctx.attr.tools,
per_format_args = per_format_args,
),
]
_sphinx_docs = rule(
implementation = _sphinx_docs_impl,
attrs = {
"extra_opts": attr.string_list(
doc = "Additional options to pass onto Sphinx. These are added after " +
"other options, but before the source/output args.",
),
"formats": attr.string_list(doc = "Output formats for Sphinx to create."),
"source_tree": attr.label(
doc = "Directory of files for Sphinx to process.",
providers = [_SphinxSourceTreeInfo],
),
"sphinx": attr.label(
executable = True,
cfg = "exec",
mandatory = True,
doc = "Sphinx binary to generate documentation.",
),
"tools": attr.label_list(
cfg = "exec",
doc = "Additional tools that are used by Sphinx and its plugins.",
),
"_extra_defines_flag": attr.label(default = "//sphinxdocs:extra_defines"),
"_extra_env_flag": attr.label(default = "//sphinxdocs:extra_env"),
"_quiet_flag": attr.label(default = "//sphinxdocs:quiet"),
},
)
def _run_sphinx(ctx, format, source_path, inputs, output_prefix):
output_dir = ctx.actions.declare_directory(paths.join(output_prefix, format))
run_args = [] # Copy of the args to forward along to debug runner
args = ctx.actions.args() # Args passed to the action
args.add("--show-traceback") # Full tracebacks on error
run_args.append("--show-traceback")
args.add("--builder", format)
run_args.extend(("--builder", format))
if ctx.attr._quiet_flag[BuildSettingInfo].value:
# Not added to run_args because run_args is for debugging
args.add("--quiet") # Suppress stdout informational text
# Build in parallel, if possible
# Don't add to run_args: parallel building breaks interactive debugging
args.add("--jobs", "auto")
args.add("--fresh-env") # Don't try to use cache files. Bazel can't make use of them.
run_args.append("--fresh-env")
args.add("--write-all") # Write all files; don't try to detect "changed" files
run_args.append("--write-all")
for opt in ctx.attr.extra_opts:
expanded = ctx.expand_location(opt)
args.add(expanded)
run_args.append(expanded)
extra_defines = ctx.attr._extra_defines_flag[_FlagInfo].value
args.add_all(extra_defines, before_each = "--define")
for define in extra_defines:
run_args.extend(("--define", define))
args.add(source_path)
args.add(output_dir.path)
env = dict([
v.split("=", 1)
for v in ctx.attr._extra_env_flag[_FlagInfo].value
])
tools = []
for tool in ctx.attr.tools:
tools.append(tool[DefaultInfo].files_to_run)
ctx.actions.run(
executable = ctx.executable.sphinx,
arguments = [args],
inputs = inputs,
outputs = [output_dir],
tools = tools,
mnemonic = "SphinxBuildDocs",
progress_message = "Sphinx building {} for %{{label}}".format(format),
env = env,
)
return output_dir, struct(args = run_args, env = env)
def _sphinx_source_tree_impl(ctx):
# Sphinx only accepts a single directory to read its doc sources from.
# Because plain files and generated files are in different directories,
# we need to merge the two into a single directory.
source_prefix = ctx.label.name
sphinx_source_files = []
# Materialize a file under the `_sources` dir
def _relocate(source_file, dest_path = None):
if not dest_path:
dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix)
dest_path = paths.join(source_prefix, dest_path)
if source_file.is_directory:
dest_file = ctx.actions.declare_directory(dest_path)
else:
dest_file = ctx.actions.declare_file(dest_path)
ctx.actions.symlink(
output = dest_file,
target_file = source_file,
progress_message = "Symlinking Sphinx source %{input} to %{output}",
)
sphinx_source_files.append(dest_file)
return dest_file
# Though Sphinx has a -c flag, we move the config file into the sources
# directory to make the config more intuitive because some configuration
# options are relative to the config location, not the sources directory.
source_conf_file = _relocate(ctx.file.config)
sphinx_source_dir_path = paths.dirname(source_conf_file.path)
for src in ctx.attr.srcs:
if SphinxDocsLibraryInfo in src:
fail((
"In attribute srcs: target {src} is misplaced here: " +
"sphinx_docs_library targets belong in the deps attribute."
).format(src = src))
for orig_file in ctx.files.srcs:
_relocate(orig_file)
for src_target, dest in ctx.attr.renamed_srcs.items():
src_files = src_target.files.to_list()
if len(src_files) != 1:
fail("A single file must be specified to be renamed. Target {} " +
"generate {} files: {}".format(
src_target,
len(src_files),
src_files,
))
_relocate(src_files[0], dest)
for t in ctx.attr.deps:
info = t[SphinxDocsLibraryInfo]
for entry in info.transitive.to_list():
for original in entry.files:
new_path = entry.prefix + original.short_path.removeprefix(entry.strip_prefix)
_relocate(original, new_path)
return [
DefaultInfo(
files = depset(sphinx_source_files),
),
_SphinxSourceTreeInfo(
source_root = sphinx_source_dir_path,
source_dir_runfiles_path = paths.dirname(source_conf_file.short_path),
),
]
_sphinx_source_tree = rule(
implementation = _sphinx_source_tree_impl,
attrs = {
"config": attr.label(
allow_single_file = True,
mandatory = True,
doc = "Config file for Sphinx",
),
"deps": attr.label_list(
providers = [SphinxDocsLibraryInfo],
),
"renamed_srcs": attr.label_keyed_string_dict(
allow_files = True,
doc = "Doc source files for Sphinx that are renamed. This is " +
"typically used for files elsewhere, such as top level " +
"files in the repo.",
),
"srcs": attr.label_list(
allow_files = True,
doc = "Doc source files for Sphinx.",
),
"strip_prefix": attr.string(doc = "Prefix to remove from input file paths."),
},
)
_FlagInfo = provider(
doc = "Provider for a flag value",
fields = ["value"],
)
def _repeated_string_list_flag_impl(ctx):
return _FlagInfo(value = ctx.build_setting_value)
repeated_string_list_flag = rule(
implementation = _repeated_string_list_flag_impl,
build_setting = config.string_list(flag = True, repeatable = True),
)
def sphinx_inventory(*, name, src, **kwargs):
"""Creates a compressed inventory file from an uncompressed on.
The Sphinx inventory format isn't formally documented, but is understood
to be:
```
# Sphinx inventory version 2
# Project: <project name>
# Version: <version string>
# The remainder of this file is compressed using zlib
name domain:role 1 relative-url display name
```
Where:
* `<project name>` is a string. e.g. `Rules Python`
* `<version string>` is a string e.g. `1.5.3`
And there are one or more `name domain:role ...` lines
* `name`: the name of the symbol. It can contain special characters,
but not spaces.
* `domain:role`: The `domain` is usually a language, e.g. `py` or `bzl`.
The `role` is usually the type of object, e.g. `class` or `func`. There
is no canonical meaning to the values, they are usually domain-specific.
* `1` is a number. It affects search priority.
* `relative-url` is a URL path relative to the base url in the
confg.py intersphinx config.
* `display name` is a string. It can contain spaces, or simply be
the value `-` to indicate it is the same as `name`
:::{seealso}
{bzl:obj}`//sphinxdocs/inventories` for inventories of Bazel objects.
:::
Args:
name: {type}`Name` name of the target.
src: {type}`label` Uncompressed inventory text file.
**kwargs: {type}`dict` additional kwargs of common attributes.
"""
_sphinx_inventory(name = name, src = src, **kwargs)
def _sphinx_inventory_impl(ctx):
output = ctx.actions.declare_file(ctx.label.name + ".inv")
args = ctx.actions.args()
args.add(ctx.file.src)
args.add(output)
ctx.actions.run(
executable = ctx.executable._builder,
arguments = [args],
inputs = depset([ctx.file.src]),
outputs = [output],
)
return [DefaultInfo(files = depset([output]))]
_sphinx_inventory = rule(
implementation = _sphinx_inventory_impl,
attrs = {
"src": attr.label(allow_single_file = True),
"_builder": attr.label(
default = "//sphinxdocs/private:inventory_builder",
executable = True,
cfg = "exec",
),
},
)
def _sphinx_run_impl(ctx):
run_info = ctx.attr.docs[_SphinxRunInfo]
builder = ctx.attr.builder
if builder not in run_info.per_format_args:
builder = run_info.per_format_args.keys()[0]
args_info = run_info.per_format_args.get(builder)
if not args_info:
fail("Format {} not built by {}".format(
builder,
ctx.attr.docs.label,
))
args_str = []
args_str.extend(args_info.args)
args_str = "\n".join(["args+=('{}')".format(value) for value in args_info.args])
if not args_str:
args_str = "# empty custom args"
env_str = "\n".join([
"sphinx_env+=({}='{}')".format(*item)
for item in args_info.env.items()
])
if not env_str:
env_str = "# empty custom env"
executable = ctx.actions.declare_file(ctx.label.name)
sphinx = run_info.sphinx
ctx.actions.expand_template(
template = ctx.file._template,
output = executable,
substitutions = {
"%SETUP_ARGS%": args_str,
"%SETUP_ENV%": env_str,
"%SOURCE_DIR_EXEC_PATH%": run_info.source_tree[_SphinxSourceTreeInfo].source_root,
"%SOURCE_DIR_RUNFILES_PATH%": run_info.source_tree[_SphinxSourceTreeInfo].source_dir_runfiles_path,
"%SPHINX_EXEC_PATH%": sphinx[DefaultInfo].files_to_run.executable.path,
"%SPHINX_RUNFILES_PATH%": sphinx[DefaultInfo].files_to_run.executable.short_path,
},
is_executable = True,
)
runfiles = ctx.runfiles(
transitive_files = run_info.source_tree[DefaultInfo].files,
).merge(sphinx[DefaultInfo].default_runfiles).merge_all([
tool[DefaultInfo].default_runfiles
for tool in run_info.tools
])
return [
DefaultInfo(
executable = executable,
runfiles = runfiles,
),
]
sphinx_run = rule(
implementation = _sphinx_run_impl,
doc = """
Directly run the underlying Sphinx command `sphinx_docs` uses.
This is primarily a debugging tool. It's useful for directly running the
Sphinx command so that debuggers can be attached or output more directly
inspected without Bazel interference.
""",
attrs = {
"builder": attr.string(
doc = "The output format to make runnable.",
default = "html",
),
"docs": attr.label(
doc = "The {obj}`sphinx_docs` target to make directly runnable.",
providers = [_SphinxRunInfo],
),
"_template": attr.label(
allow_single_file = True,
default = "//sphinxdocs/private:sphinx_run_template.sh",
),
},
executable = True,
)