diff --git a/doc/BUILD.bazel b/doc/BUILD.bazel index e650cf9a3b55..dd9c70b9e27a 100644 --- a/doc/BUILD.bazel +++ b/doc/BUILD.bazel @@ -15,6 +15,15 @@ load( ) load("//tools/lint:lint.bzl", "add_lint_tests") +drake_py_library( + name = "defs", + srcs = ["defs.py"], + visibility = ["//doc:__subpackages__"], + deps = [ + "@bazel_tools//tools/python/runfiles", + ], +) + # For maximum browser compatibility these should be at the root of the # generated website and should not be renamed. filegroup( @@ -123,6 +132,7 @@ drake_py_binary( ":gen_jekyll", "//doc/doxygen_cxx:build", "//doc/pydrake:gen_sphinx", + "//doc/styleguide:build", ], tags = DEFAULT_BINARY_TAGS, deps = [ @@ -141,6 +151,7 @@ filegroup( "//doc/doxygen_cxx:build", "//doc/pydrake:gen_sphinx", "//doc/pydrake:serve_sphinx", + "//doc/styleguide:build", ], tags = ["manual"], ) @@ -153,6 +164,7 @@ test_suite( tests = [ ":gen_jekyll_test", "//doc/pydrake:gen_sphinx_test", + "//doc/styleguide:build_test", ], ) diff --git a/doc/README.txt b/doc/README.txt index ffc3bdaaeae1..9483e2c526f4 100644 --- a/doc/README.txt +++ b/doc/README.txt @@ -16,6 +16,7 @@ doc/ ├── _pages/ - Jekyll collection: Add'l root-level pages. Can render Markdown. ├── pydrake/ - Python API reference. ├── _release-notes/ - Jekyll collection: Index of versioned releases. +├── styleguide/ - Drake Style Guide external. ├── third_party/ - Third party assets. └── index.md - Home page. ``` diff --git a/doc/_pages/documentation_instructions.md b/doc/_pages/documentation_instructions.md index 07fb82690032..d56ed543a6ed 100644 --- a/doc/_pages/documentation_instructions.md +++ b/doc/_pages/documentation_instructions.md @@ -58,6 +58,18 @@ To generate the Python API documentation: $ bazel run //doc/pydrake:serve_sphinx [-- --browser=false] ``` +# Style Guide + +To locally preview the Drake Style Guide: + +``` +$ bazel run //doc/styleguide:build -- --serve +``` + +To preview a local branch of the styleguide, set the +[local_repository_override](https://github.com/RobotLocomotion/drake/blob/master/tools/workspace/README.md#exploring-github_archive-changes-from-a-local-clone) +option in ``drake/tools/workspace/styleguide/`` before running the preview. + # Continuous Integration To check that the documentation will pass Drake's Jenkins CI builds: diff --git a/doc/build.py b/doc/build.py index d1c9e7fd1161..d563bb62641a 100644 --- a/doc/build.py +++ b/doc/build.py @@ -40,19 +40,19 @@ def main(): gen_sphinx = manifest.Rlocation("drake/doc/pydrake/gen_sphinx") gen_jekyll = manifest.Rlocation("drake/doc/gen_jekyll") doxygen = manifest.Rlocation("drake/doc/doxygen_cxx/build") - for item in [gen_sphinx, gen_jekyll, doxygen]: + styleguide_build = manifest.Rlocation("drake/doc/styleguide/build") + for item in [gen_sphinx, gen_jekyll, doxygen, styleguide_build]: assert os.path.exists(item), item _check_call([gen_jekyll, f"--out_dir={out_dir}"]) _check_call([gen_sphinx, f"--out_dir={out_dir}/pydrake"]) + _check_call([styleguide_build, f"--out_dir={out_dir}/styleguide"]) doxygen_scratch = f"{out_dir}/doxygen_scratch" _check_call([doxygen, f"--out_dir={doxygen_scratch}"]) print(f"+ mv {doxygen_scratch}/html {out_dir}/doxygen_cxx") os.rename(f"{doxygen_scratch}/html", f"{out_dir}/doxygen_cxx") print(f"+ rm -rf {doxygen_scratch}") shutil.rmtree(doxygen_scratch) - # TODO(jwnimmer-tri) Incorporate the Drake styleguide publication here, - # instead of having it be a separate pipeline. _build_sitemap(out_dir) diff --git a/doc/defs.bzl b/doc/defs.bzl index e7542db97443..9ece34ac592c 100644 --- a/doc/defs.bzl +++ b/doc/defs.bzl @@ -1,7 +1,8 @@ # -*- python -*- -# This file contains constants that help define Drake's documentation targets -# (i.e., these should only be used for BUILD files within @drake//doc/...). +# This file contains build macros and constants that help define Drake's +# documentation targets (i.e., these should only be used for BUILD files +# within @drake//doc/...). # Unless `setup/ubuntu/install_prereqs.sh --with-doc-only` has been run, most # targets in //doc/... will fail to build, so by default we'll disable them. @@ -21,3 +22,38 @@ DEFAULT_TEST_TAGS = [ # ecosystems might be doing so without us being aware. "block-network", ] + +def _enumerate_filegroup_impl(ctx): + out = ctx.actions.declare_file(ctx.attr.name) + runpaths = {} + for x in ctx.attr.data: + for y in x.data_runfiles.files.to_list(): + if y.short_path.startswith("../"): + runpath = y.short_path[3:] + else: + runpath = ctx.workspace_name + "/" + y.short_path + runpaths[runpath] = True + result = sorted(runpaths.keys()) + ctx.actions.write(out, "\n".join(result) + "\n") + + # Return the new file to our caller. + return [DefaultInfo( + files = depset([out]), + data_runfiles = ctx.runfiles(files = [out]), + )] + +enumerate_filegroup = rule( + implementation = _enumerate_filegroup_impl, + doc = """ +Creates a text file listing the files incorporated into the given filegroup. + +The listing is transitive (includes both filegroup.srcs and filegroup.data). +https://docs.bazel.build/versions/master/be/general.html#filegroup +""", + attrs = { + "data": attr.label_list( + allow_empty = False, + doc = "Filegroup whose data we should enumerate", + ), + }, +) diff --git a/doc/defs.py b/doc/defs.py new file mode 100644 index 000000000000..4c1f6bf9962b --- /dev/null +++ b/doc/defs.py @@ -0,0 +1,197 @@ +"""Common library to provide a reusable main() routine for all of our +documentation generation tools. +""" + +import argparse +import functools +from http.server import SimpleHTTPRequestHandler +import os.path +from os.path import join +from socketserver import TCPServer +import shlex +import subprocess +from subprocess import PIPE, STDOUT +import tempfile + +from bazel_tools.tools.python.runfiles import runfiles + +# This global variable can be toggled by our main() function. +_verbose = False + + +def verbose(): + """Returns True iff doc builds should produce detailed console output.""" + return _verbose + + +def symlink_input(filegroup_resource_path, temp_dir, strip_prefix=None): + """Symlinks a rule's input data into a temporary directory. + + This is useful both to create a hermetic set of inputs to pass to a + documentation builder, or also in case we need to adjust the input data + before passing it along. + + Args: + filegroup_resource_path: Names a file created by enumerate_filegroup + (in defs.bzl) which contains resource paths. + temp_dir: Destination directory, which must already exist. + strip_prefix: Optional; a list[str] of candidate strings to remove + from the resource path when linking into temp_dir. The first match + wins, and it is valid for no prefixes to match. + """ + assert os.path.isdir(temp_dir) + r = runfiles.Create() + with open(r.Rlocation(filegroup_resource_path)) as f: + input_filenames = f.read().splitlines() + for name in input_filenames: + orig_name = r.Rlocation(name) + assert os.path.exists(orig_name), name + dest_name = name + for prefix in (strip_prefix or []): + if dest_name.startswith(prefix): + dest_name = dest_name[len(prefix):] + break + temp_name = join(temp_dir, dest_name) + os.makedirs(os.path.dirname(temp_name), exist_ok=True) + os.symlink(orig_name, temp_name) + + +def check_call(args, *, cwd=None): + """Runs a subprocess command, raising an exception iff the process fails. + + Obeys the command-line verbosity flag for console output: + - when in non-verbose mode, shows output only in case of an error; + - when in verbose mode, shows the command-line and live output. + + Args: + args: Passed to subprocess.run(args=...). + """ + echo = "+ " + " ".join([shlex.quote(x) for x in args]) + if verbose(): + print(echo, flush=True) + proc = subprocess.run(args, cwd=cwd, stderr=STDOUT) + else: + proc = subprocess.run(args, cwd=cwd, stderr=STDOUT, stdout=PIPE, + encoding='utf-8') + if proc.returncode != 0: + print(echo, flush=True) + print(proc.stdout, end='', flush=True) + proc.check_returncode() + + +def _call_build(*, build, out_dir): + """Calls build() into out_dir, while also supplying a temp_dir.""" + with tempfile.TemporaryDirectory( + dir=os.environ.get("TEST_TMPDIR"), + prefix="doc_builder_temp_") as temp_dir: + return build(out_dir=out_dir, temp_dir=temp_dir) + + +class _HttpHandler(SimpleHTTPRequestHandler): + """An HTTP handler without logging.""" + + def log_request(*_): + pass + + +def _do_preview(*, build, subdir, port): + """Implements the "serve" (http) mode of main(). + + Args: + build: Same as per main(). + subdir: Same as per main(). + port: Local port number to serve on, per the command line. + """ + print("Generating documentation preview ...") + with tempfile.TemporaryDirectory(prefix="doc_builder_preview_") as scratch: + if subdir: + out_dir = join(scratch, subdir) + os.mkdir(out_dir) + else: + out_dir = scratch + pages = _call_build(build=build, out_dir=out_dir) + assert len(pages) > 0 + os.chdir(scratch) + print(f"The files have temporarily been generated into {scratch}") + print() + print("Serving at the following URLs for local preview:") + print() + for page in pages: + print(f" http://127.0.0.1:{port}/{join(subdir, page)}") + print() + print("Use Ctrl-C to exit.") + TCPServer.allow_reuse_address = True + server = TCPServer(("127.0.0.1", port), _HttpHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + print() + return + + +def _do_generate(*, build, out_dir, on_error): + """Implements the "generate" (file output) mode of main(). + Args: + build: Same as per main(). + out_dir: Directory to generate into, per the command line. + on_error: Callback function to report problems with out_dir. + """ + if out_dir == "": + out_dir = join(os.environ["TEST_TMPDIR"], "_builder_out") + if not os.path.isabs(out_dir): + on_error(f"--out_dir={out_dir} is not an absolute path") + if os.path.exists(out_dir): + if len(os.listdir(out_dir)) > 0: + on_error(f"--out_dir={out_dir} is not empty") + else: + if verbose(): + print(f"+ mkdir -p {out_dir}", flush=True) + os.makedirs(out_dir) + print("Generating HTML ...") + pages = _call_build(build=build, out_dir=out_dir) + assert len(pages) > 0 + print("... done") + + +def main(*, build, subdir, description): + """Reusable main() function for documentation binaries; processes + command-line arguments and generates documentation. + + Args: + build: Callback function to compile the documentation. + subdir: A subdirectory to use when offering preview mode on a local web + server; this does NOT affect the --out_dir path. + description: Main help str for argparse; typically the caller's __doc__. + """ + parser = argparse.ArgumentParser(description=description) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--serve", action='store_true', + help="Serve the documentation on the given PORT for easy preview.") + group.add_argument( + "--out_dir", type=str, metavar="DIR", + help="Generate the documentation to the given output directory." + " The DIR must be an absolute path." + " If DIR already exists, then it must be empty." + " (For regression testing, the DIR can be the magic value ," + " in which case a $TEST_TMPDIR subdir will be used.)") + parser.add_argument( + "--port", type=int, metavar="PORT", default=8000, + help="Use a non-default PORT when serving for preview.") + parser.add_argument( + "--verbose", action="store_true", + help="Echo detailed commands, progress, etc. to the console") + args = parser.parse_args() + if args.verbose: + global _verbose + _verbose = True + if args.out_dir is None: + assert args.serve + _do_preview(build=build, subdir=subdir, port=args.port) + else: + _do_generate(build=build, out_dir=args.out_dir, + on_error=parser.error) + + +if __name__ == '__main__': + main() diff --git a/doc/styleguide/BUILD.bazel b/doc/styleguide/BUILD.bazel new file mode 100644 index 000000000000..e84defbd263f --- /dev/null +++ b/doc/styleguide/BUILD.bazel @@ -0,0 +1,54 @@ +# -*- python -*- + +package(default_visibility = ["//visibility:private"]) + +load( + "@drake//tools/skylark:drake_py.bzl", + "drake_py_binary", +) +load( + "//doc:defs.bzl", + "DEFAULT_BINARY_TAGS", + "DEFAULT_TEST_TAGS", + "enumerate_filegroup", +) +load("//tools/lint:lint.bzl", "add_lint_tests") + +filegroup( + name = "jekyll_input", + srcs = [ + ":_config.yml", + ":_layouts/default.html", + "@styleguide//:cppguide.html", + "@styleguide//:include/link.png", + "@styleguide//:include/styleguide.css", + "@styleguide//:include/styleguide.js", + "@styleguide//:pyguide.md", + ], +) + +enumerate_filegroup( + name = "jekyll_input.txt", + data = [":jekyll_input"], +) + +drake_py_binary( + name = "build", + srcs = ["build.py"], + add_test_rule = 1, + data = [ + ":jekyll_input", + ":jekyll_input.txt", + ], + tags = DEFAULT_BINARY_TAGS, + test_rule_args = ["--out_dir="], + test_rule_size = "medium", + test_rule_tags = DEFAULT_TEST_TAGS, + test_rule_timeout = "short", + visibility = ["//doc:__pkg__"], + deps = [ + "//doc:defs", + ], +) + +add_lint_tests() diff --git a/doc/styleguide/_config.yml b/doc/styleguide/_config.yml new file mode 100644 index 000000000000..b376b8a6c81a --- /dev/null +++ b/doc/styleguide/_config.yml @@ -0,0 +1,10 @@ +# For more information, see: https://jekyllrb.com/docs/configuration/ + +title: Google Style Guide for Drake +theme: minima + +defaults: + - scope: + path: "" + values: + layout: "default" diff --git a/doc/styleguide/_layouts/default.html b/doc/styleguide/_layouts/default.html new file mode 100644 index 000000000000..4cc78280ebeb --- /dev/null +++ b/doc/styleguide/_layouts/default.html @@ -0,0 +1,15 @@ + + + + + {{ page.title }} + + + + + +
+ {{ content }} +
+ + diff --git a/doc/styleguide/build.py b/doc/styleguide/build.py new file mode 100644 index 000000000000..e96dfefa34d1 --- /dev/null +++ b/doc/styleguide/build.py @@ -0,0 +1,63 @@ +"""Command-line tool to generate the style guide for Drake's website. + +For instructions, see https://drake.mit.edu/documentation_instructions.html. +""" + +import os +from os.path import join +import textwrap + +from drake.doc.defs import check_call, main, symlink_input + + +def _add_title(*, temp_dir, filename, title): + """Adds a header to a Markdown file so that we can build it with Jekyll + directly, without using the GitHub Pages infrastructure. + The original file is replaced. + """ + temp_dir_filename = join(temp_dir, filename) + with open(temp_dir_filename, "r", encoding="utf-8") as f: + data = f.read() + os.unlink(temp_dir_filename) + with open(temp_dir_filename, "w", encoding="utf-8") as f: + f.write(textwrap.dedent(f"""\ + --- + title: {title} + --- + """) + "\n") + f.write(data) + + +def _build(*, out_dir, temp_dir): + """Callback function that implements the bulk of main(). + Generates into out_dir; writes scratch files into temp_dir. + Both directories must already exist and be empty. + """ + # Create a hermetic copy of our input. This helps ensure that only files + # listed in BUILD.bazel will render onto the website. + symlink_input( + "drake/doc/styleguide/jekyll_input.txt", temp_dir, + strip_prefix=[ + "drake/doc/styleguide/", + "styleguide/", + ]) + + # Prepare the files for Jekyll. + _add_title( + temp_dir=temp_dir, + filename="pyguide.md", + title="Google Python Style Guide for Drake") + + # Run the documentation generator. + check_call([ + "/usr/bin/jekyll", "build", + "--source", temp_dir, + "--destination", out_dir, + ]) + + # The nominal pages to offer for preview. + return ["cppguide.html", "pyguide.html"] + + +if __name__ == '__main__': + main(build=_build, subdir="styleguide", description=__doc__.strip()) diff --git a/tools/workspace/styleguide/package.BUILD.bazel b/tools/workspace/styleguide/package.BUILD.bazel index 986589af3233..0e2162594fa9 100644 --- a/tools/workspace/styleguide/package.BUILD.bazel +++ b/tools/workspace/styleguide/package.BUILD.bazel @@ -6,6 +6,12 @@ licenses(["notice"]) # BSD-3-Clause package(default_visibility = ["//visibility:public"]) +# Export doc files for website publication. +exports_files(glob([ + "*", + "include/*", +])) + # We can't set name="cpplint" here because that's the directory name so the # sandbox gets confused. We'll give it a private name with a public alias. py_binary(