Add pw_docgen module
This change adds a module which builds documentation using Sphinx.
Documentation files and assets are defined using a pw_doc_group GN
template which lists out all of the sources as metadata. Another GN
template, pw_doc_gen, collects the metadata from pw_doc_group targets
into a JSON file and defines an action that runs a script to build the
HTML docs.
A docgen script is provided by the pw_docgen module. This script reads
the JSON metadata file collected by a pw_doc_gen template, copies all of
the source and resource files into a documentation tree, and runs Sphinx
on the tree to render the docs as HTML.
Change-Id: I97890f05d850d5119cbd3bbf48138cd82f22d56c
diff --git a/modules.gni b/modules.gni
index ede125f..7fe69dc 100644
--- a/modules.gni
+++ b/modules.gni
@@ -23,6 +23,7 @@
}
dir_pw_build = "$dir_pigweed/pw_build"
+dir_pw_docgen = "$dir_pigweed/pw_docgen"
dir_pw_preprocessor = "$dir_pigweed/pw_preprocessor"
dir_pw_span = "$dir_pigweed/pw_span"
dir_pw_status = "$dir_pigweed/pw_status"
diff --git a/pw_build/python_script.gni b/pw_build/python_script.gni
index ced7a10..99b20c3 100644
--- a/pw_build/python_script.gni
+++ b/pw_build/python_script.gni
@@ -58,6 +58,8 @@
# $target_out_dir:foo ../out/obj/my_module/foo.exe (toolchain-dependent)
#
template("pw_python_script") {
+ assert(defined(invoker.script), "pw_python_script requires a script to run")
+
_script_args = [
# GN root directory relative to the build directory (in which the runner
# script is invoked).
@@ -69,6 +71,16 @@
root_out_dir,
]
+ if (defined(invoker.inputs)) {
+ _inputs = invoker.inputs
+ } else {
+ _inputs = []
+ }
+
+ # List the script to run as an input so that the action is re-run when it is
+ # modified.
+ _inputs += [ invoker.script ]
+
if (defined(invoker.outputs)) {
_outputs = invoker.outputs
} else {
@@ -99,12 +111,14 @@
_ignore_vars = [
"script",
"args",
+ "inputs",
"outputs",
]
forward_variables_from(invoker, "*", _ignore_vars)
script = "$dir_pw_build/py/python_runner.py"
args = _script_args
+ inputs = _inputs
outputs = _outputs
}
}
diff --git a/pw_docgen/docs.gni b/pw_docgen/docs.gni
new file mode 100644
index 0000000..1bd5991
--- /dev/null
+++ b/pw_docgen/docs.gni
@@ -0,0 +1,115 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+
+import("$dir_pw_build/python_script.gni")
+
+# Defines a group of documentation files and assets.
+#
+# Args:
+# sources: Source files for the documentation (.rst or .md).
+# inputs: Additional resource files for the docs, such as images.
+# group_deps: Other pw_doc_group targets on which this group depends.
+# report_deps: Report card targets on which documentation depends.
+template("pw_doc_group") {
+ assert(defined(invoker.sources), "pw_doc_group requires a list of sources")
+
+ if (defined(invoker.inputs)) {
+ _inputs = invoker.inputs
+ } else {
+ _inputs = []
+ }
+
+ _all_deps = []
+ if (defined(invoker.group_deps)) {
+ _all_deps += invoker.group_deps
+ }
+ if (defined(invoker.report_deps)) {
+ _all_deps += invoker.report_deps
+ }
+
+ # Creates an action which depends on all of the source and input files so that
+ # docs are rebuilt on file modifications. The action does not do anything.
+ pw_python_script(target_name) {
+ metadata = {
+ pw_doc_sources = rebase_path(invoker.sources, root_build_dir)
+ pw_doc_inputs = rebase_path(_inputs, root_build_dir)
+ }
+ deps = _all_deps
+ script = "$dir_pw_build/py/nop.py"
+ inputs = invoker.sources + _inputs
+ stamp = true
+ }
+}
+
+# Creates a target to build HTML documentation from groups of sources.
+#
+# Args:
+# deps: List of pw_doc_group targets.
+# index: Top-level documentation index file.
+# conf: Configuration script (conf.py) for Sphinx.
+# output_directory: Path to directory to which HTML output is rendered.
+template("pw_doc_gen") {
+ assert(defined(invoker.deps),
+ "pw_doc_gen requires doc groups as dependencies")
+ assert(
+ defined(invoker.index),
+ "pw_doc_gen requires an 'index' argument pointing a top-level index.rst")
+ assert(defined(invoker.conf),
+ "pw_doc_gen requires a 'conf' argument pointing a top-level conf.py")
+ assert(defined(invoker.output_directory),
+ "pw_doc_gen requires an 'output_directory' argument")
+
+ # Collects all dependency metadata into a single JSON file.
+ _metadata_file_target = "${target_name}_metadata"
+ generated_file(_metadata_file_target) {
+ outputs = [
+ "$target_gen_dir/$target_name.json",
+ ]
+ data_keys = [
+ "pw_doc_sources",
+ "pw_doc_inputs",
+ ]
+ output_conversion = "json"
+ deps = invoker.deps
+ }
+
+ _script_args = [
+ "--gn-root",
+ "//",
+ "--sphinx-build-dir",
+ get_path_info("$target_gen_dir/pw_docgen_tree", "abspath"),
+ "--conf",
+ get_path_info(invoker.conf, "abspath"),
+ "--index",
+ get_path_info(invoker.index, "abspath"),
+ "--out-dir",
+ get_path_info(invoker.output_directory, "abspath"),
+ ]
+
+ # Metadata JSON file path.
+ _script_args +=
+ get_path_info(get_target_outputs(":$_metadata_file_target"), "abspath")
+
+ pw_python_script(target_name) {
+ script = "$dir_pw_docgen/py/docgen.py"
+ args = _script_args
+ deps = [
+ ":$_metadata_file_target",
+ ]
+ inputs = [
+ invoker.index,
+ ]
+ stamp = true
+ }
+}
diff --git a/pw_docgen/py/docgen.py b/pw_docgen/py/docgen.py
new file mode 100644
index 0000000..63f844f
--- /dev/null
+++ b/pw_docgen/py/docgen.py
@@ -0,0 +1,126 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+
+"""Renders HTML documentation using Sphinx."""
+
+# TODO(frolv): Figure out a solution for installing all library dependencies
+# to run Sphinx and build RTD docs.
+
+import argparse
+import collections
+import json
+import os
+import shutil
+import subprocess
+import sys
+
+from typing import Dict, List, Tuple
+
+SCRIPT_HEADER: str = '''
+██████╗ ██╗ ██████╗ ██╗ ██╗███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗███████╗
+██╔══██╗██║██╔════╝ ██║ ██║██╔════╝██╔════╝██╔══██╗ ██╔══██╗██╔═══██╗██╔════╝██╔════╝
+██████╔╝██║██║ ███╗██║ █╗ ██║█████╗ █████╗ ██║ ██║ ██║ ██║██║ ██║██║ ███████╗
+██╔═══╝ ██║██║ ██║██║███╗██║██╔══╝ ██╔══╝ ██║ ██║ ██║ ██║██║ ██║██║ ╚════██║
+██║ ██║╚██████╔╝╚███╔███╔╝███████╗███████╗██████╔╝ ██████╔╝╚██████╔╝╚██████╗███████║
+╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝ ╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝
+'''
+
+
+def parse_args() -> argparse.Namespace:
+ """Parses command-line arguments."""
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--sphinx-build-dir', type=str, required=True,
+ help='Directory in which to build docs')
+ parser.add_argument('--conf', type=str, required=True,
+ 'Path to conf.py file for Sphinx')
+ parser.add_argument('--gn-root', type=str, required=True,
+ 'Root of the GN build tree')
+ parser.add_argument('--index', type=str, required=True,
+ help='Path to root index.rst file')
+ parser.add_argument('--out-dir', type=str, required=True,
+ help='Output directory for rendered HTML docs')
+ parser.add_argument('metadata_file', type=argparse.FileType('r'))
+ return parser.parse_args()
+
+
+def build_docs(src_dir: str, dst_dir: str) -> int:
+ """Runs Sphinx to render HTML documentation from a doc tree."""
+
+ # TODO(frolv): Specify the Sphinx script from a prebuilts path instead of
+ # requiring it in the tree.
+ command = ['sphinx-build', '-b', 'html', '-d',
+ f'{dst_dir}/help', src_dir, f'{dst_dir}/html']
+ return subprocess.call(command)
+
+
+def mkdir(dirname: str, exist_ok: bool = False) -> None:
+ """Wrapper around os.makedirs that prints the operation."""
+ print(f'MKDIR {dirname}')
+ os.makedirs(dirname, exist_ok=exist_ok)
+
+
+def copy(src: str, dst: str) -> None:
+ """Wrapper around shutil.copy that prints the operation."""
+ print(f'COPY {src} -> {dst}')
+ shutil.copy(src, dst)
+
+
+def copy_doc_tree(args: argparse.Namespace) -> None:
+ """Copies doc source and input files into a build tree."""
+
+ def build_path(path):
+ """Converts a source path to a filename in the build directory."""
+ return f'{args.sphinx_build_dir}/{path[len(args.gn_root):]}'
+
+ source_files = json.load(args.metadata_file)
+ copy_paths = [build_path(f) for f in source_files]
+
+ mkdir(args.sphinx_build_dir)
+ copy(args.index, f'{args.sphinx_build_dir}/index.rst')
+ copy(args.conf, f'{args.sphinx_build_dir}/conf.py')
+
+ # Map of directory path to list of source and destination file paths.
+ dirs: Dict[str, List[Tuple[str, str]]] = collections.defaultdict(list)
+
+ for source_file, copy_path in zip(source_files, copy_paths):
+ dirname = os.path.dirname(copy_path)
+ dirs[dirname].append((source_file, copy_path))
+
+ for directory, file_pairs in dirs.items():
+ mkdir(directory, exist_ok=True)
+ for src, dst in file_pairs:
+ copy(src, dst)
+
+
+def main() -> int:
+ """Script entry point."""
+
+ args = parse_args()
+
+ # Clear out any existing docs for the target.
+ if os.path.exists(args.sphinx_build_dir):
+ shutil.rmtree(args.sphinx_build_dir)
+
+ print(SCRIPT_HEADER)
+ copy_doc_tree(args)
+
+ # Flush all script output before running Sphinx.
+ print('-' * 80, flush=True)
+
+ return build_docs(args.sphinx_build_dir, args.out_dir)
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/pw_preprocessor/BUILD.gn b/pw_preprocessor/BUILD.gn
index 230d65b..112ab73 100644
--- a/pw_preprocessor/BUILD.gn
+++ b/pw_preprocessor/BUILD.gn
@@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations under
# the License.
+import("$dir_pw_docgen/docs.gni")
import("$dir_pw_unit_test/test.gni")
config("default_config") {
@@ -33,6 +34,12 @@
sources = public
}
+pw_doc_group("docs") {
+ sources = [
+ "docs.rst",
+ ]
+}
+
# All pw_preprocessor test binaries.
group("unit_tests") {
deps = [