• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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