1# Copyright 2023 The Bazel Authors. All rights reserved. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Implementation of sphinx rules.""" 16 17load("@bazel_skylib//lib:paths.bzl", "paths") 18load("@bazel_skylib//rules:build_test.bzl", "build_test") 19load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") 20load("//python:py_binary.bzl", "py_binary") 21load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs") # buildifier: disable=bzl-visibility 22load(":sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") 23 24_SPHINX_BUILD_MAIN_SRC = Label("//sphinxdocs/private:sphinx_build.py") 25_SPHINX_SERVE_MAIN_SRC = Label("//sphinxdocs/private:sphinx_server.py") 26 27_SphinxSourceTreeInfo = provider( 28 doc = "Information about source tree for Sphinx to build.", 29 fields = { 30 "source_dir_runfiles_path": """ 31:type: str 32 33Runfiles-root relative path of the root directory for the source files. 34""", 35 "source_root": """ 36:type: str 37 38Exec-root relative path of the root directory for the source files (which are in DefaultInfo.files) 39""", 40 }, 41) 42 43_SphinxRunInfo = provider( 44 doc = "Information for running the underlying Sphinx command directly", 45 fields = { 46 "per_format_args": """ 47:type: dict[str, struct] 48 49A dict keyed by output format name. The values are a struct with attributes: 50* args: a `list[str]` of args to run this format's build 51* env: a `dict[str, str]` of environment variables to set for this format's build 52""", 53 "source_tree": """ 54:type: Target 55 56Target with the source tree files 57""", 58 "sphinx": """ 59:type: Target 60 61The sphinx-build binary to run. 62""", 63 "tools": """ 64:type: list[Target] 65 66Additional tools Sphinx needs 67""", 68 }, 69) 70 71def sphinx_build_binary(name, py_binary_rule = py_binary, **kwargs): 72 """Create an executable with the sphinx-build command line interface. 73 74 The `deps` must contain the sphinx library and any other extensions Sphinx 75 needs at runtime. 76 77 Args: 78 name: {type}`str` name of the target. The name "sphinx-build" is the 79 conventional name to match what Sphinx itself uses. 80 py_binary_rule: {type}`callable` A `py_binary` compatible callable 81 for creating the target. If not set, the regular `py_binary` 82 rule is used. This allows using the version-aware rules, or 83 other alternative implementations. 84 **kwargs: {type}`dict` Additional kwargs to pass onto `py_binary`. The `srcs` and 85 `main` attributes must not be specified. 86 """ 87 add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_build_binary") 88 py_binary_rule( 89 name = name, 90 srcs = [_SPHINX_BUILD_MAIN_SRC], 91 main = _SPHINX_BUILD_MAIN_SRC, 92 **kwargs 93 ) 94 95def sphinx_docs( 96 name, 97 *, 98 srcs = [], 99 deps = [], 100 renamed_srcs = {}, 101 sphinx, 102 config, 103 formats, 104 strip_prefix = "", 105 extra_opts = [], 106 tools = [], 107 **kwargs): 108 """Generate docs using Sphinx. 109 110 Generates targets: 111 * `<name>`: The output of this target is a directory for each 112 format Sphinx creates. This target also has a separate output 113 group for each format. e.g. `--output_group=html` will only build 114 the "html" format files. 115 * `<name>.serve`: A binary that locally serves the HTML output. This 116 allows previewing docs during development. 117 * `<name>.run`: A binary that directly runs the underlying Sphinx command 118 to build the docs. This is a debugging aid. 119 120 Args: 121 name: {type}`Name` name of the docs rule. 122 srcs: {type}`list[label]` The source files for Sphinx to process. 123 deps: {type}`list[label]` of {obj}`sphinx_docs_library` targets. 124 renamed_srcs: {type}`dict[label, dict]` Doc source files for Sphinx that 125 are renamed. This is typically used for files elsewhere, such as top 126 level files in the repo. 127 sphinx: {type}`label` the Sphinx tool to use for building 128 documentation. Because Sphinx supports various plugins, you must 129 construct your own binary with the necessary dependencies. The 130 {obj}`sphinx_build_binary` rule can be used to define such a binary, but 131 any executable supporting the `sphinx-build` command line interface 132 can be used (typically some `py_binary` program). 133 config: {type}`label` the Sphinx config file (`conf.py`) to use. 134 formats: (list of str) the formats (`-b` flag) to generate documentation 135 in. Each format will become an output group. 136 strip_prefix: {type}`str` A prefix to remove from the file paths of the 137 source files. e.g., given `//docs:foo.md`, stripping `docs/` makes 138 Sphinx see `foo.md` in its generated source directory. If not 139 specified, then {any}`native.package_name` is used. 140 extra_opts: {type}`list[str]` Additional options to pass onto Sphinx building. 141 On each provided option, a location expansion is performed. 142 See {any}`ctx.expand_location`. 143 tools: {type}`list[label]` Additional tools that are used by Sphinx and its plugins. 144 This just makes the tools available during Sphinx execution. To locate 145 them, use {obj}`extra_opts` and `$(location)`. 146 **kwargs: {type}`dict` Common attributes to pass onto rules. 147 """ 148 add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_docs") 149 common_kwargs = copy_propagating_kwargs(kwargs) 150 151 internal_name = "_{}".format(name.lstrip("_")) 152 153 _sphinx_source_tree( 154 name = internal_name + "/_sources", 155 srcs = srcs, 156 deps = deps, 157 renamed_srcs = renamed_srcs, 158 config = config, 159 strip_prefix = strip_prefix, 160 **common_kwargs 161 ) 162 _sphinx_docs( 163 name = name, 164 sphinx = sphinx, 165 formats = formats, 166 source_tree = internal_name + "/_sources", 167 extra_opts = extra_opts, 168 tools = tools, 169 **kwargs 170 ) 171 172 html_name = internal_name + "_html" 173 native.filegroup( 174 name = html_name, 175 srcs = [name], 176 output_group = "html", 177 **common_kwargs 178 ) 179 180 py_binary( 181 name = name + ".serve", 182 srcs = [_SPHINX_SERVE_MAIN_SRC], 183 main = _SPHINX_SERVE_MAIN_SRC, 184 data = [html_name], 185 args = [ 186 "$(execpath {})".format(html_name), 187 ], 188 **common_kwargs 189 ) 190 sphinx_run( 191 name = name + ".run", 192 docs = name, 193 **common_kwargs 194 ) 195 196 build_test( 197 name = name + "_build_test", 198 targets = [name], 199 **kwargs # kwargs used to pick up target_compatible_with 200 ) 201 202def _sphinx_docs_impl(ctx): 203 source_tree_info = ctx.attr.source_tree[_SphinxSourceTreeInfo] 204 source_dir_path = source_tree_info.source_root 205 inputs = ctx.attr.source_tree[DefaultInfo].files 206 207 per_format_args = {} 208 outputs = {} 209 for format in ctx.attr.formats: 210 output_dir, args_env = _run_sphinx( 211 ctx = ctx, 212 format = format, 213 source_path = source_dir_path, 214 output_prefix = paths.join(ctx.label.name, "_build"), 215 inputs = inputs, 216 ) 217 outputs[format] = output_dir 218 per_format_args[format] = args_env 219 return [ 220 DefaultInfo(files = depset(outputs.values())), 221 OutputGroupInfo(**{ 222 format: depset([output]) 223 for format, output in outputs.items() 224 }), 225 _SphinxRunInfo( 226 sphinx = ctx.attr.sphinx, 227 source_tree = ctx.attr.source_tree, 228 tools = ctx.attr.tools, 229 per_format_args = per_format_args, 230 ), 231 ] 232 233_sphinx_docs = rule( 234 implementation = _sphinx_docs_impl, 235 attrs = { 236 "extra_opts": attr.string_list( 237 doc = "Additional options to pass onto Sphinx. These are added after " + 238 "other options, but before the source/output args.", 239 ), 240 "formats": attr.string_list(doc = "Output formats for Sphinx to create."), 241 "source_tree": attr.label( 242 doc = "Directory of files for Sphinx to process.", 243 providers = [_SphinxSourceTreeInfo], 244 ), 245 "sphinx": attr.label( 246 executable = True, 247 cfg = "exec", 248 mandatory = True, 249 doc = "Sphinx binary to generate documentation.", 250 ), 251 "tools": attr.label_list( 252 cfg = "exec", 253 doc = "Additional tools that are used by Sphinx and its plugins.", 254 ), 255 "_extra_defines_flag": attr.label(default = "//sphinxdocs:extra_defines"), 256 "_extra_env_flag": attr.label(default = "//sphinxdocs:extra_env"), 257 "_quiet_flag": attr.label(default = "//sphinxdocs:quiet"), 258 }, 259) 260 261def _run_sphinx(ctx, format, source_path, inputs, output_prefix): 262 output_dir = ctx.actions.declare_directory(paths.join(output_prefix, format)) 263 264 run_args = [] # Copy of the args to forward along to debug runner 265 args = ctx.actions.args() # Args passed to the action 266 267 args.add("--show-traceback") # Full tracebacks on error 268 run_args.append("--show-traceback") 269 args.add("--builder", format) 270 run_args.extend(("--builder", format)) 271 272 if ctx.attr._quiet_flag[BuildSettingInfo].value: 273 # Not added to run_args because run_args is for debugging 274 args.add("--quiet") # Suppress stdout informational text 275 276 # Build in parallel, if possible 277 # Don't add to run_args: parallel building breaks interactive debugging 278 args.add("--jobs", "auto") 279 args.add("--fresh-env") # Don't try to use cache files. Bazel can't make use of them. 280 run_args.append("--fresh-env") 281 args.add("--write-all") # Write all files; don't try to detect "changed" files 282 run_args.append("--write-all") 283 284 for opt in ctx.attr.extra_opts: 285 expanded = ctx.expand_location(opt) 286 args.add(expanded) 287 run_args.append(expanded) 288 289 extra_defines = ctx.attr._extra_defines_flag[_FlagInfo].value 290 args.add_all(extra_defines, before_each = "--define") 291 for define in extra_defines: 292 run_args.extend(("--define", define)) 293 294 args.add(source_path) 295 args.add(output_dir.path) 296 297 env = dict([ 298 v.split("=", 1) 299 for v in ctx.attr._extra_env_flag[_FlagInfo].value 300 ]) 301 302 tools = [] 303 for tool in ctx.attr.tools: 304 tools.append(tool[DefaultInfo].files_to_run) 305 306 ctx.actions.run( 307 executable = ctx.executable.sphinx, 308 arguments = [args], 309 inputs = inputs, 310 outputs = [output_dir], 311 tools = tools, 312 mnemonic = "SphinxBuildDocs", 313 progress_message = "Sphinx building {} for %{{label}}".format(format), 314 env = env, 315 ) 316 return output_dir, struct(args = run_args, env = env) 317 318def _sphinx_source_tree_impl(ctx): 319 # Sphinx only accepts a single directory to read its doc sources from. 320 # Because plain files and generated files are in different directories, 321 # we need to merge the two into a single directory. 322 source_prefix = ctx.label.name 323 sphinx_source_files = [] 324 325 # Materialize a file under the `_sources` dir 326 def _relocate(source_file, dest_path = None): 327 if not dest_path: 328 dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix) 329 330 dest_path = paths.join(source_prefix, dest_path) 331 if source_file.is_directory: 332 dest_file = ctx.actions.declare_directory(dest_path) 333 else: 334 dest_file = ctx.actions.declare_file(dest_path) 335 ctx.actions.symlink( 336 output = dest_file, 337 target_file = source_file, 338 progress_message = "Symlinking Sphinx source %{input} to %{output}", 339 ) 340 sphinx_source_files.append(dest_file) 341 return dest_file 342 343 # Though Sphinx has a -c flag, we move the config file into the sources 344 # directory to make the config more intuitive because some configuration 345 # options are relative to the config location, not the sources directory. 346 source_conf_file = _relocate(ctx.file.config) 347 sphinx_source_dir_path = paths.dirname(source_conf_file.path) 348 349 for src in ctx.attr.srcs: 350 if SphinxDocsLibraryInfo in src: 351 fail(( 352 "In attribute srcs: target {src} is misplaced here: " + 353 "sphinx_docs_library targets belong in the deps attribute." 354 ).format(src = src)) 355 356 for orig_file in ctx.files.srcs: 357 _relocate(orig_file) 358 359 for src_target, dest in ctx.attr.renamed_srcs.items(): 360 src_files = src_target.files.to_list() 361 if len(src_files) != 1: 362 fail("A single file must be specified to be renamed. Target {} " + 363 "generate {} files: {}".format( 364 src_target, 365 len(src_files), 366 src_files, 367 )) 368 _relocate(src_files[0], dest) 369 370 for t in ctx.attr.deps: 371 info = t[SphinxDocsLibraryInfo] 372 for entry in info.transitive.to_list(): 373 for original in entry.files: 374 new_path = entry.prefix + original.short_path.removeprefix(entry.strip_prefix) 375 _relocate(original, new_path) 376 377 return [ 378 DefaultInfo( 379 files = depset(sphinx_source_files), 380 ), 381 _SphinxSourceTreeInfo( 382 source_root = sphinx_source_dir_path, 383 source_dir_runfiles_path = paths.dirname(source_conf_file.short_path), 384 ), 385 ] 386 387_sphinx_source_tree = rule( 388 implementation = _sphinx_source_tree_impl, 389 attrs = { 390 "config": attr.label( 391 allow_single_file = True, 392 mandatory = True, 393 doc = "Config file for Sphinx", 394 ), 395 "deps": attr.label_list( 396 providers = [SphinxDocsLibraryInfo], 397 ), 398 "renamed_srcs": attr.label_keyed_string_dict( 399 allow_files = True, 400 doc = "Doc source files for Sphinx that are renamed. This is " + 401 "typically used for files elsewhere, such as top level " + 402 "files in the repo.", 403 ), 404 "srcs": attr.label_list( 405 allow_files = True, 406 doc = "Doc source files for Sphinx.", 407 ), 408 "strip_prefix": attr.string(doc = "Prefix to remove from input file paths."), 409 }, 410) 411_FlagInfo = provider( 412 doc = "Provider for a flag value", 413 fields = ["value"], 414) 415 416def _repeated_string_list_flag_impl(ctx): 417 return _FlagInfo(value = ctx.build_setting_value) 418 419repeated_string_list_flag = rule( 420 implementation = _repeated_string_list_flag_impl, 421 build_setting = config.string_list(flag = True, repeatable = True), 422) 423 424def sphinx_inventory(*, name, src, **kwargs): 425 """Creates a compressed inventory file from an uncompressed on. 426 427 The Sphinx inventory format isn't formally documented, but is understood 428 to be: 429 430 ``` 431 # Sphinx inventory version 2 432 # Project: <project name> 433 # Version: <version string> 434 # The remainder of this file is compressed using zlib 435 name domain:role 1 relative-url display name 436 ``` 437 438 Where: 439 * `<project name>` is a string. e.g. `Rules Python` 440 * `<version string>` is a string e.g. `1.5.3` 441 442 And there are one or more `name domain:role ...` lines 443 * `name`: the name of the symbol. It can contain special characters, 444 but not spaces. 445 * `domain:role`: The `domain` is usually a language, e.g. `py` or `bzl`. 446 The `role` is usually the type of object, e.g. `class` or `func`. There 447 is no canonical meaning to the values, they are usually domain-specific. 448 * `1` is a number. It affects search priority. 449 * `relative-url` is a URL path relative to the base url in the 450 confg.py intersphinx config. 451 * `display name` is a string. It can contain spaces, or simply be 452 the value `-` to indicate it is the same as `name` 453 454 :::{seealso} 455 {bzl:obj}`//sphinxdocs/inventories` for inventories of Bazel objects. 456 ::: 457 458 Args: 459 name: {type}`Name` name of the target. 460 src: {type}`label` Uncompressed inventory text file. 461 **kwargs: {type}`dict` additional kwargs of common attributes. 462 """ 463 _sphinx_inventory(name = name, src = src, **kwargs) 464 465def _sphinx_inventory_impl(ctx): 466 output = ctx.actions.declare_file(ctx.label.name + ".inv") 467 args = ctx.actions.args() 468 args.add(ctx.file.src) 469 args.add(output) 470 ctx.actions.run( 471 executable = ctx.executable._builder, 472 arguments = [args], 473 inputs = depset([ctx.file.src]), 474 outputs = [output], 475 ) 476 return [DefaultInfo(files = depset([output]))] 477 478_sphinx_inventory = rule( 479 implementation = _sphinx_inventory_impl, 480 attrs = { 481 "src": attr.label(allow_single_file = True), 482 "_builder": attr.label( 483 default = "//sphinxdocs/private:inventory_builder", 484 executable = True, 485 cfg = "exec", 486 ), 487 }, 488) 489 490def _sphinx_run_impl(ctx): 491 run_info = ctx.attr.docs[_SphinxRunInfo] 492 493 builder = ctx.attr.builder 494 495 if builder not in run_info.per_format_args: 496 builder = run_info.per_format_args.keys()[0] 497 498 args_info = run_info.per_format_args.get(builder) 499 if not args_info: 500 fail("Format {} not built by {}".format( 501 builder, 502 ctx.attr.docs.label, 503 )) 504 505 args_str = [] 506 args_str.extend(args_info.args) 507 args_str = "\n".join(["args+=('{}')".format(value) for value in args_info.args]) 508 if not args_str: 509 args_str = "# empty custom args" 510 511 env_str = "\n".join([ 512 "sphinx_env+=({}='{}')".format(*item) 513 for item in args_info.env.items() 514 ]) 515 if not env_str: 516 env_str = "# empty custom env" 517 518 executable = ctx.actions.declare_file(ctx.label.name) 519 sphinx = run_info.sphinx 520 ctx.actions.expand_template( 521 template = ctx.file._template, 522 output = executable, 523 substitutions = { 524 "%SETUP_ARGS%": args_str, 525 "%SETUP_ENV%": env_str, 526 "%SOURCE_DIR_EXEC_PATH%": run_info.source_tree[_SphinxSourceTreeInfo].source_root, 527 "%SOURCE_DIR_RUNFILES_PATH%": run_info.source_tree[_SphinxSourceTreeInfo].source_dir_runfiles_path, 528 "%SPHINX_EXEC_PATH%": sphinx[DefaultInfo].files_to_run.executable.path, 529 "%SPHINX_RUNFILES_PATH%": sphinx[DefaultInfo].files_to_run.executable.short_path, 530 }, 531 is_executable = True, 532 ) 533 runfiles = ctx.runfiles( 534 transitive_files = run_info.source_tree[DefaultInfo].files, 535 ).merge(sphinx[DefaultInfo].default_runfiles).merge_all([ 536 tool[DefaultInfo].default_runfiles 537 for tool in run_info.tools 538 ]) 539 return [ 540 DefaultInfo( 541 executable = executable, 542 runfiles = runfiles, 543 ), 544 ] 545 546sphinx_run = rule( 547 implementation = _sphinx_run_impl, 548 doc = """ 549Directly run the underlying Sphinx command `sphinx_docs` uses. 550 551This is primarily a debugging tool. It's useful for directly running the 552Sphinx command so that debuggers can be attached or output more directly 553inspected without Bazel interference. 554""", 555 attrs = { 556 "builder": attr.string( 557 doc = "The output format to make runnable.", 558 default = "html", 559 ), 560 "docs": attr.label( 561 doc = "The {obj}`sphinx_docs` target to make directly runnable.", 562 providers = [_SphinxRunInfo], 563 ), 564 "_template": attr.label( 565 allow_single_file = True, 566 default = "//sphinxdocs/private:sphinx_run_template.sh", 567 ), 568 }, 569 executable = True, 570) 571