diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index f3d6d27..7a6df84 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1 +1,5 @@ ce25647921a8f82c3b5009bdd07a620545b91a0c +8762f2d8834a9ba391b73ca4ac36f3bb19b169ed +04f3a94bdb9fc8c402286ebdc3ff9cb688c1e4b6 +81b7b35c4e07551459a98b7be17b6b6400d12e3a +ac69349085bb8450d760cf33a5ee46701eadd4b9 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/_static/css/custom.css b/_static/css/custom.css new file mode 100644 index 0000000..10abb45 --- /dev/null +++ b/_static/css/custom.css @@ -0,0 +1,17 @@ +/* Make equation numbers float to the right */ +.eqno { + margin-left: 5px; + float: right; +} +/* Hide the link... */ +.math .headerlink { + display: none; + visibility: hidden; +} +/* ...unless the equation is hovered */ +.math:hover .headerlink { + display: inline-block; + visibility: visible; + /* Place link in margin and keep equation number aligned with boundary */ + margin-right: -0.7em; +} diff --git a/_templates/footer.html b/_templates/footer.html new file mode 100644 index 0000000..a7c22a3 --- /dev/null +++ b/_templates/footer.html @@ -0,0 +1,5 @@ +{% extends "!footer.html" %} +{% block extrafooter %} + {{ super() }} + +{% endblock %} diff --git a/_templates/landing.index.html b/_templates/landing.index.html new file mode 100644 index 0000000..1d2002e --- /dev/null +++ b/_templates/landing.index.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/_templates/versions.html b/_templates/versions.html new file mode 100644 index 0000000..d219003 --- /dev/null +++ b/_templates/versions.html @@ -0,0 +1,26 @@ +
+ + Version: {{ current_version }} + + +
+ {% if languages|length >= 1 %} +
+
{{ _('Languages') }}
+ {% for the_language, url in languages %} +
{{ the_language }}
+ {% endfor %} +
+ {% endif %} + {% if versions|length >= 1 %} +
+
{{ _('Versions') }}
+ {% for the_version, url in versions %} +
{{ the_version }}
+ {% endfor %} +
+ {% endif %} +
+ +
+
diff --git a/build_docs b/build_docs index 3e0db1f..f05599c 100755 --- a/build_docs +++ b/build_docs @@ -8,7 +8,7 @@ This should be run from the directory that contains the Makefile for building the documentation. """ -from doc_builder import build_docs +from doc_builder import build_docs # pylint: disable=import-error if __name__ == "__main__": build_docs.main() diff --git a/build_docs_to_publish b/build_docs_to_publish new file mode 100755 index 0000000..37693c6 --- /dev/null +++ b/build_docs_to_publish @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +""" +Loop through all versions of the documentation, building each and moving it to a directory for +publication. + +Adapted from https://www.codingwiththomas.com/blog/my-sphinx-best-practice-for-a-multiversion- +documentation-in-different-languages +(last visited 2025-05-20) +""" + +import sys +import os +import subprocess +import argparse + +# pylint: disable=import-error,no-name-in-module +from doc_builder.build_docs import ( + main as build_docs, +) +from doc_builder.build_docs_shared_args import main as build_docs_shared_args +from doc_builder.sys_utils import get_git_head_or_branch, check_permanent_file + +# Change to the parent director of doc-builder and add to Python path +os.chdir(os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.getcwd()) + +# Import our definitions of each documentation version. +# pylint: disable=wrong-import-position +from version_list import ( + LATEST_REF, + VERSION_LIST, +) + + +# Path to certain important files +SOURCE = "source" +VERSIONS_PY = os.path.join("version_list.py") +MAKEFILE = "Makefile" + + +def checkout_and_build(version, args): + """ + Check out docs for a version and build + """ + + # Get the current branch, or SHA if detached HEAD + orig_ref = get_git_head_or_branch() + + # Some files/directories/submodules must stay the same for all builds. We list these in + # the permanent_files list. + permanent_files = [VERSIONS_PY, "doc-builder", MAKEFILE] + + # Check some things about "permanent" files before checkout + for filename in permanent_files: + check_permanent_file(filename) + + # Check out the git reference of this version (branch name, tag, or commit SHA) + subprocess.check_output("git checkout " + version.ref, shell=True) + + # Check out LATEST_REF version of permanent files + for filename in permanent_files: + subprocess.check_output(f"git checkout {LATEST_REF} -- {filename}", shell=True) + + # Build the docs for this version + build_args = [ + "-r", + args.repo_root, + "-v", + version.short_name, + "--version-display-name", + version.display_name, + "--versions", + "--site-root", + args.site_root, + "--clean", + ] + if args.build_with_docker: + build_args += ["-d"] + if args.conf_py_path: + build_args += ["--conf-py-path", args.conf_py_path] + if args.static_path: + build_args += ["--static-path", args.static_path] + if args.templates_path: + build_args += ["--templates-path", args.templates_path] + print(" ".join(build_args)) + build_docs(build_args) + + # Go back to original git status. + # 1. Get the current ref's version of doc-builder to avoid "would be overwritten by checkout" + # errors. + subprocess.check_output("git submodule update --checkout doc-builder", shell=True) + # 2. Check out the original git ref (branch or commit SHA) + subprocess.check_output("git checkout " + orig_ref, shell=True) + # 3. Restore the current version's doc-builder + subprocess.check_output("git submodule update --checkout doc-builder", shell=True) + + +def check_version_list(): + """ + Check version list for problems + """ + has_default = False + for version in VERSION_LIST: + # Expect at most one version with landing_version True + if version.landing_version: + if has_default: + raise RuntimeError("Expected at most one version with landing_version True") + has_default = True + + +def main(): + """ + Loop through all versions of the documentation, building each and moving it to a directory for + publication. + """ + # Set up parser + parser = argparse.ArgumentParser() + + # Arguments shared with build_docs + parser = build_docs_shared_args(parser) + + # Custom arguments for build_docs_to_publish + parser.add_argument( + "--publish-dir", + default="_publish", + help="Where the docs should be moved after being built", + ) + + # Parse arguments + args = parser.parse_args() + + # Check version list for problems + check_version_list() + + # Loop over all documentation versions + for version in VERSION_LIST: + # Build this version + checkout_and_build(version, args) + + # Copy this version to the publication directory + src = os.path.join(args.repo_root, "versions", version.short_name, "html") + if version.landing_version: + dst = args.publish_dir + else: + dst = os.path.join(args.publish_dir, version.short_name) + os.makedirs(dst) + subprocess.check_output(f"mv '{src}'/* '{dst}'/", shell=True) + + +if __name__ == "__main__": + main() diff --git a/conf.py b/conf.py new file mode 100644 index 0000000..cce38a3 --- /dev/null +++ b/conf.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +import sphinx_rtd_theme + +# Assumes substitutions.py and version_list.py are in the parent dir of doc-builder +# pylint: disable=wrong-import-position +dir2add = os.path.join(os.path.dirname(__file__), os.pardir) +sys.path.insert(0, dir2add) +import substitutions as subs # pylint: disable=import-error +from version_list import VERSION_LIST # pylint: disable=import-error + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.intersphinx', + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.githubpages', + 'sphinx_mdinclude', + ] + +# Add any paths that contain templates here, relative to this directory. +if os.environ["templates_path"]: + templates_path = [os.environ["templates_path"]] + if not all(os.path.isdir(x) for x in templates_path): + raise RuntimeError(f"Some member of templates_path does not exist: {templates_path}") + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +source_suffix = ['.rst', '.md'] +# source_suffix = '.rst' + +# The master toctree document. +source_start_file = 'index' + +# Save standard Sphinx substitution vars separately +project = subs.project +copyright = subs.copyright # pylint: disable=redefined-builtin +author = subs.author +version = subs.version +release = subs.release + +# version_label is not a standard sphinx variable, so we need some custom rst to allow +# pages to use it. We need a separate replacement for the bolded version because it +# doesn't work to have variable replacements within formatting. +rst_epilog = """ +.. |version_label| replace:: {version_label} +.. |version_label_bold| replace:: **{version_label}** +""".format(version_label=subs.version_label) + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [os.environ["html_static_path"]] + + +# -- Options for HTMLHelp output ------------------------------------------ + +if getattr(subs, "htmlhelp", False): + htmlhelp_basename = subs.htmlhelp["basename"] + + +# -- Options for LaTeX output --------------------------------------------- +if getattr(subs, "latex", False): + + latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + 'preamble': '\\usepackage{hyperref}', + + 'fncychap': '\\usepackage[Conny]{fncychap}', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', + } + + # Grouping the document tree into LaTeX files. List of tuples + # (source start file, target name, title, + # author, documentclass [howto, manual, or own class]). + latex_documents = [( + source_start_file, + subs.latex["target_name"], + subs.latex["title"], + author, + subs.latex["category"], + )] + + +# Options for manual page and Texinfo output +if getattr(subs, "mantex", False): + + # One entry per manual page. List of tuples + # (source start file, name, title, authors, manual section). + man_pages = [ + (source_start_file, subs.mantex["name"], subs.mantex["title"], [author], 1), + ] + + if getattr(subs, "tex", False): + # Grouping the document tree into Texinfo files. List of tuples + # (source start file, target name, title, author, + # dir menu entry, description, category) + texinfo_documents = [( + source_start_file, + subs.mantex["name"], + subs.mantex["title"], + author, + subs.tex["dirmenu_entry"], + subs.tex["description"], + subs.tex["category"]), + ] + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'python': ('https://docs.python.org/', None)} + +numfig = True +numfig_format = {'figure': 'Figure %s', + 'table': 'Table %s', + 'code-block': 'Code %s', + 'section': '%s', + } +numfig_secnum_depth = 2 + +def setup(app): + app.add_css_file('css/custom.css') + +try: + html_context +except NameError: + html_context = dict() + +html_context["display_lower_left"] = True + +# Whether to show the version dropdown. If not set as environment variable, or environment variable +# is Python-falsey, do not show it. +version_dropdown = os.environ.get("version_dropdown") + +if version_dropdown: + html_context["current_version"] = os.environ["version_display_name"] + + html_context["versions"] = [] + pages_root = os.environ["pages_root"] + for this_version in VERSION_LIST: + html_context["versions"].append([ + this_version.display_name, + os.path.join(pages_root, this_version.subdir()), + ]) diff --git a/doc_builder/build_commands.py b/doc_builder/build_commands.py index fdbdd99..750e89d 100644 --- a/doc_builder/build_commands.py +++ b/doc_builder/build_commands.py @@ -4,7 +4,7 @@ import os import pathlib -from doc_builder import sys_utils +from doc_builder import sys_utils # pylint: disable=import-error DEFAULT_DOCKER_IMAGE = "ghcr.io/escomp/ctsm/ctsm-docs:v1.0.1" @@ -65,6 +65,7 @@ def get_build_dir(build_dir=None, repo_root=None, version=None): def get_build_command( + *, build_dir, run_from_dir, build_target, @@ -73,6 +74,7 @@ def get_build_command( docker_name=None, warnings_as_warnings=False, docker_image=DEFAULT_DOCKER_IMAGE, + conf_py_path=None, ): # pylint: disable=too-many-arguments,too-many-locals """Return a string giving the build command. @@ -93,6 +95,7 @@ def get_build_command( build_target=build_target, num_make_jobs=num_make_jobs, warnings_as_warnings=warnings_as_warnings, + conf_py_path=conf_py_path, ) # But if we're using Docker, we have more work to do to create the command.... @@ -128,6 +131,7 @@ def get_build_command( build_target=build_target, num_make_jobs=num_make_jobs, warnings_as_warnings=warnings_as_warnings, + conf_py_path=conf_py_path, ) docker_command = [ @@ -150,7 +154,7 @@ def get_build_command( return docker_command -def _get_make_command(build_dir, build_target, num_make_jobs, warnings_as_warnings): +def _get_make_command(build_dir, build_target, num_make_jobs, warnings_as_warnings, conf_py_path): """Return the make command to run (as a list) Args: @@ -161,7 +165,14 @@ def _get_make_command(build_dir, build_target, num_make_jobs, warnings_as_warnin builddir_arg = f"BUILDDIR={build_dir}" sphinxopts = "SPHINXOPTS=" if not warnings_as_warnings: - sphinxopts += "-W --keep-going" + sphinxopts += "-W --keep-going " + if conf_py_path: + if not os.path.exists(conf_py_path): + raise FileNotFoundError(f"--conf-py-path not found: '{conf_py_path}'") + if not os.path.isdir(conf_py_path): + conf_py_path = os.path.dirname(conf_py_path) + sphinxopts += f"-c '{conf_py_path}' " + sphinxopts = sphinxopts.rstrip() return ["make", sphinxopts, builddir_arg, "-j", str(num_make_jobs), build_target] diff --git a/doc_builder/build_docs.py b/doc_builder/build_docs.py index ef4b450..bb9b9b0 100644 --- a/doc_builder/build_docs.py +++ b/doc_builder/build_docs.py @@ -8,12 +8,30 @@ import random import string import sys +from urllib.parse import urlparse import signal + +# pylint: disable=import-error,no-name-in-module from doc_builder.build_commands import ( get_build_dir, get_build_command, DEFAULT_DOCKER_IMAGE, ) +from doc_builder.build_docs_shared_args import bd_dir_group, bd_parser + + +def is_web_url(url_string): + """ + Checks if a string is a valid web URL. + + Args: + url_string: The string to check. + + Returns: + True if the string is a valid web URL, False otherwise. + """ + result = urlparse(url_string) + return all([result.scheme, result.netloc]) def commandline_options(cmdline_args=None): @@ -73,14 +91,11 @@ def commandline_options(cmdline_args=None): help="Full path to the directory in which the doc build should go.", ) - dir_group.add_argument( - "-r", - "--repo-root", - default=None, - help="Root directory of the repository holding documentation builds.\n" - "(If there are other path elements between the true repo root and\n" - "the 'versions' directory, those should be included in this path.)", - ) + # Add argument(s) to dir_group that are also in build_docs_to_publish's parser + dir_group = bd_dir_group(dir_group) + + # Add argument(s) to parser that are also in build_docs_to_publish's parser + parser = bd_parser(parser) parser.add_argument( "-v", @@ -95,24 +110,13 @@ def commandline_options(cmdline_args=None): ) parser.add_argument( - "-c", "--clean", action="store_true", help="Before building, run 'make clean'." + "--version-display-name", + default=None, + help="Version name for display in dropdown menu. If absent, uses -v/--version.", ) parser.add_argument( - "-d", - "--build-with-docker", - action="store_true", - help="Use a Docker container to build the documentation,\n" - "rather than relying on locally-installed versions of Sphinx, etc.\n" - "This assumes that Docker is installed and running on your system.\n" - "\n" - "NOTE: This mounts your home directory in the Docker image.\n" - "Therefore, both the current directory (containing the Makefile for\n" - "building the documentation) and the documentation build directory\n" - "must reside somewhere within your home directory." - "\n" - f"Default image: {DEFAULT_DOCKER_IMAGE}\n" - "This can be changed with -i/--docker-image.", + "-c", "--clean", action="store_true", help="Before building, run 'make clean'." ) parser.add_argument( @@ -136,6 +140,12 @@ def commandline_options(cmdline_args=None): help="Number of parallel jobs to use for the make process.\n" "Default is 4.", ) + parser.add_argument( + "--versions", + action="store_true", + help="Build multiple versions of the docs, with drop-down switcher menu.", + ) + parser.add_argument( "-w", "--warnings-as-warnings", @@ -145,6 +155,16 @@ def commandline_options(cmdline_args=None): options = parser.parse_args(cmdline_args) + print(f"options: {options}") + + if options.versions: + if not options.site_root: + raise RuntimeError("--site-root must be provided when --versions is enabled") + if not is_web_url(options.site_root) and not os.path.isabs(options.site_root): + raise RuntimeError( + f"--site-root is neither a web URL nor an absolute path: '{options.site_root}'" + ) + if options.docker_image: options.docker_image = options.docker_image.lower() options.build_with_docker = True @@ -154,12 +174,61 @@ def commandline_options(cmdline_args=None): return options -def run_build_command(build_command, version): +def setup_env_var(build_command, env, env_var, value, docker): + """ + Set up an environment variable, depending on whether using Docker or not + """ + if docker: + # Need to pass to Docker via the build command + build_command.insert(-3, "-e") + build_command.insert(-3, f"{env_var}={value}") + else: + env[env_var] = value + return build_command, env + + +def run_build_command(build_command, version, options): """Echo and then run the given build command""" - build_command_str = " ".join(build_command) - print(build_command_str) env = os.environ.copy() - env["current_version"] = version + + # Set version display name (in drop-down menu) + if options.version_display_name: + value = options.version_display_name + else: + value = version + build_command, env = setup_env_var( + build_command, env, "version_display_name", value, options.build_with_docker + ) + + # Set paths to certain directories + build_command, env = setup_env_var( + build_command, env, "html_static_path", options.static_path, options.build_with_docker + ) + build_command, env = setup_env_var( + build_command, env, "templates_path", options.templates_path, options.build_with_docker + ) + + # Things to do/set based on whether including version dropdown + if options.versions: + version_dropdown = "True" + build_command, env = setup_env_var( + build_command, + env, + "pages_root", + options.site_root, + options.build_with_docker, + ) + else: + version_dropdown = "" + build_command, env = setup_env_var( + build_command, + env, + "version_dropdown", + version_dropdown, + options.build_with_docker, + ) + + print(" ".join(build_command)) subprocess.check_call(build_command, env=env) @@ -227,7 +296,7 @@ def main(cmdline_args=None): docker_name=docker_name, docker_image=opts.docker_image, ) - run_build_command(build_command=clean_command, version=version) + run_build_command(build_command=clean_command, version=version, options=opts) build_command = get_build_command( build_dir=build_dir, @@ -238,5 +307,6 @@ def main(cmdline_args=None): docker_name=docker_name, docker_image=opts.docker_image, warnings_as_warnings=opts.warnings_as_warnings, + conf_py_path=opts.conf_py_path, ) - run_build_command(build_command=build_command, version=version) + run_build_command(build_command=build_command, version=version, options=opts) diff --git a/doc_builder/build_docs_shared_args.py b/doc_builder/build_docs_shared_args.py new file mode 100644 index 0000000..3d86f17 --- /dev/null +++ b/doc_builder/build_docs_shared_args.py @@ -0,0 +1,82 @@ +""" +build_docs and build_docs_to_publish share some args. This module adds them to a parser or parser +group. +""" + +# pylint: disable=import-error,no-name-in-module +from .build_commands import DEFAULT_DOCKER_IMAGE + + +def bd_parser(parser, site_root_required=False): + """ + Add arguments that build_docs has in its overall parser. + + # site_root_required: Should be True from build_docs_to_publish, False from build_docs + """ + parser.add_argument( + "--site-root", + required=site_root_required, + help="URL or absolute file path that should contain the top-level index.html", + ) + parser.add_argument( + "-d", + "--build-with-docker", + action="store_true", + help="Use a Docker container to build the documentation,\n" + "rather than relying on locally-installed versions of Sphinx, etc.\n" + "This assumes that Docker is installed and running on your system.\n" + "\n" + "NOTE: This mounts your home directory in the Docker image.\n" + "Therefore, both the current directory (containing the Makefile for\n" + "building the documentation) and the documentation build directory\n" + "must reside somewhere within your home directory." + "\n" + f"Default image: {DEFAULT_DOCKER_IMAGE}\n" + "This can be changed with -i/--docker-image.", + ) + parser.add_argument( + "--conf-py-path", + help="Path to conf.py", + default=None, + ) + parser.add_argument( + "--static-path", + help="Path to _static/. If relative, must be relative to conf.py.", + default="_static", + ) + parser.add_argument( + "--templates-path", + help="Path to _templates/. If relative, must be relative to conf.py.", + default="_templates", + ) + return parser + + +def bd_dir_group(parser_or_group, repo_root_default=None): + """ + Add arguments that build_docs has in its dir_group + """ + parser_or_group.add_argument( + "-r", + "--repo-root", + default=repo_root_default, + help="Root directory of the repository holding documentation builds.\n" + "(If there are other path elements between the true repo root and\n" + "the 'versions' directory, those should be included in this path.)", + ) + return parser_or_group + + +def main(parser): + """ + Add all arguments to parser, even if build_docs has them in dir_group + """ + + # Settings for build_docs_to_publish, because main() should only ever be called from there + site_root_required = True + repo_root_default = "_build" + + parser = bd_parser(parser, site_root_required) + parser = bd_dir_group(parser, repo_root_default) + + return parser diff --git a/doc_builder/docs_version.py b/doc_builder/docs_version.py new file mode 100644 index 0000000..fd03895 --- /dev/null +++ b/doc_builder/docs_version.py @@ -0,0 +1,38 @@ +""" +A class defining characteristics of a documentation version +""" + + +class DocsVersion: + """ + A class defining characteristics of a documentation version + """ + + # pylint: disable=too-few-public-methods,too-many-arguments + def __init__( + self, + *, + short_name, + display_name, + ref, + landing_version=False, + ): + # The name of this version in file/URL paths + self.short_name = short_name + + # What gets shown in the dropdown menu + self.display_name = display_name + + # Whether this version should be the one on the landing page (i.e., default version) + self.landing_version = landing_version + + # Branch, tag, or commit SHA + self.ref = ref + + def subdir(self): + """ + Get the subdirectory under --publish-dir where this version's HTML will be moved + """ + if self.landing_version: + return "" + return self.short_name diff --git a/doc_builder/sys_utils.py b/doc_builder/sys_utils.py index c562bce..6668467 100644 --- a/doc_builder/sys_utils.py +++ b/doc_builder/sys_utils.py @@ -6,6 +6,55 @@ import os +def check_permanent_file(filename): + """ + Check a "permanent" file (one that we don't want to change between doc version builds) + """ + + # Ensure file exists + if not os.path.exists(filename): + raise FileNotFoundError(filename) + + # Error if file contains uncommitted changes + cmd = f"git add . && git diff --quiet {filename} && git diff --cached --quiet {filename}" + try: + subprocess.check_output(cmd, shell=True) + except subprocess.CalledProcessError as exception: + subprocess.check_output("git reset", shell=True) # Unstage files staged by `git add` + msg = f"Important file/submodule may contain uncommitted changes: '{filename}'" + raise RuntimeError(msg) from exception + + +def get_git_head_or_branch(): + """ + Get the name of the current branch. If detached HEAD, get current commit SHA. + """ + try: + result = subprocess.run( + ["git", "symbolic-ref", "--short", "-q", "HEAD"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=True, + ) + output = result.stdout.strip() + except subprocess.CalledProcessError: + output = "" + + if not output: + # Fallback to commit SHA + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=True, + ) + output = result.stdout.strip() + + return output + + def git_current_branch(): """Determines the name of the current git branch diff --git a/test/Makefile b/test/Makefile index c329f75..aca6a89 100644 --- a/test/Makefile +++ b/test/Makefile @@ -35,7 +35,7 @@ PYLINT_ARGS=-j 2 --rcfile=.pylint.rc # source files SRC = \ - ../build_docs \ + ../build_docs* \ ../doc_builder/*.py TEST_DIR = . diff --git a/test/conf.py b/test/conf.py new file mode 100644 index 0000000..baa9d2d --- /dev/null +++ b/test/conf.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# +# A copy of ../conf.py but with some lines changed to make new relpaths work: +# dir2add = ... +# templates_path = ... +# html_static_path = ... + +# +import os +import sys +import sphinx_rtd_theme + +# Assumes substitutions.py and version_list.py are in the parent dir of doc-builder +# pylint: disable=wrong-import-position +dir2add = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) +sys.path.insert(0, dir2add) +import substitutions as subs # pylint: disable=import-error +from version_list import VERSION_LIST # pylint: disable=import-error + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.intersphinx', + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.githubpages', + 'sphinx_mdinclude', + ] + +# Add any paths that contain templates here, relative to this directory. +if os.environ["templates_path"]: + templates_path = [os.environ["templates_path"]] + if not all(os.path.isdir(x) for x in templates_path): + raise RuntimeError(f"Some member of templates_path does not exist: {templates_path}") + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +source_suffix = ['.rst', '.md'] +# source_suffix = '.rst' + +# The master toctree document. +source_start_file = 'index' + +# Save standard Sphinx substitution vars separately +project = subs.project +copyright = subs.copyright # pylint: disable=redefined-builtin +author = subs.author +version = subs.version +release = subs.release + +# version_label is not a standard sphinx variable, so we need some custom rst to allow +# pages to use it. We need a separate replacement for the bolded version because it +# doesn't work to have variable replacements within formatting. +rst_epilog = """ +.. |version_label| replace:: {version_label} +.. |version_label_bold| replace:: **{version_label}** +""".format(version_label=subs.version_label) + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [os.environ["html_static_path"]] + + +# -- Options for HTMLHelp output ------------------------------------------ + +if getattr(subs, "htmlhelp", False): + htmlhelp_basename = subs.htmlhelp["basename"] + + +# -- Options for LaTeX output --------------------------------------------- +if getattr(subs, "latex", False): + + latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + 'preamble': '\\usepackage{hyperref}', + + 'fncychap': '\\usepackage[Conny]{fncychap}', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', + } + + # Grouping the document tree into LaTeX files. List of tuples + # (source start file, target name, title, + # author, documentclass [howto, manual, or own class]). + latex_documents = [( + source_start_file, + subs.latex["target_name"], + subs.latex["title"], + author, + subs.latex["category"], + )] + + +# Options for manual page and Texinfo output +if getattr(subs, "mantex", False): + + # One entry per manual page. List of tuples + # (source start file, name, title, authors, manual section). + man_pages = [ + (source_start_file, subs.mantex["name"], subs.mantex["title"], [author], 1), + ] + + if getattr(subs, "tex", False): + # Grouping the document tree into Texinfo files. List of tuples + # (source start file, target name, title, author, + # dir menu entry, description, category) + texinfo_documents = [( + source_start_file, + subs.mantex["name"], + subs.mantex["title"], + author, + subs.tex["dirmenu_entry"], + subs.tex["description"], + subs.tex["category"]), + ] + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'python': ('https://docs.python.org/', None)} + +numfig = True +numfig_format = {'figure': 'Figure %s', + 'table': 'Table %s', + 'code-block': 'Code %s', + 'section': '%s', + } +numfig_secnum_depth = 2 + +def setup(app): + app.add_css_file('css/custom.css') + +try: + html_context +except NameError: + html_context = dict() + +html_context["display_lower_left"] = True + +# Whether to show the version dropdown. If not set as environment variable, or environment variable +# is Python-falsey, do not show it. +version_dropdown = os.environ.get("version_dropdown") + +if version_dropdown: + html_context["current_version"] = os.environ["version_display_name"] + + html_context["versions"] = [] + pages_root = os.environ["pages_root"] + for this_version in VERSION_LIST: + html_context["versions"].append([ + this_version.display_name, + os.path.join(pages_root, this_version.subdir()), + ]) diff --git a/test/test_sys_build_docs.py b/test/test_sys_build_docs.py index f1c5d97..aee5dd2 100644 --- a/test/test_sys_build_docs.py +++ b/test/test_sys_build_docs.py @@ -7,6 +7,8 @@ import tempfile import shutil import os + +# pylint: disable=import-error,no-name-in-module from test.test_utils.git_helpers import ( make_git_repo, add_git_commit, diff --git a/test/test_sys_git_current_branch.py b/test/test_sys_git_current_branch.py index 6305725..8a7c414 100644 --- a/test/test_sys_git_current_branch.py +++ b/test/test_sys_git_current_branch.py @@ -9,6 +9,8 @@ import tempfile import shutil import os + +# pylint: disable=import-error,no-name-in-module from test.test_utils.git_helpers import ( make_git_repo, add_git_commit, diff --git a/test/test_unit_cmdline_args.py b/test/test_unit_cmdline_args.py new file mode 100644 index 0000000..b5db50e --- /dev/null +++ b/test/test_unit_cmdline_args.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 + +"""Unit test driver for command-line arg parsing""" + +import unittest + +import os +from doc_builder.build_docs import is_web_url, commandline_options # pylint: disable=import-error + + +class TestCmdlineArgs(unittest.TestCase): + """Test the command-line arguments and parsing""" + + # Allow long method names + # pylint: disable=invalid-name + + def setUp(self): + """Run this before each test""" + self.fake_builddir = ["-b", "abc"] + self.fake_abspath = os.path.sep + os.path.join("some", "abs", "path") + self.fake_relpath = os.path.join(os.pardir, "some", "rel", "path") + self.fake_url = "https://www.google.com" + + def test_is_web_url(self): + """Ensure that all these are valid web URLs, even if they don't exist""" + urls = [ + "https://www.google.com", + "http://example.com", + "ftp://fileserver.com", + "https://www.google.com/path/to/resource?query=string#fragment", + "https://user:password@www.example.com:8080/path/to/resource?query=string#fragment", + ] + for url in urls: + print(url) + self.assertTrue(is_web_url(url)) + + def test_isnt_web_url(self): + """Ensure that all these are NOT valid web URLs""" + urls = [ + "www.example.com", + "invalid url", + self.fake_abspath, + self.fake_relpath, + ] + + for url in urls: + print(url) + self.assertFalse(is_web_url(url)) + + def test_no_versions_no_siteroot(self): + """Ensure no error when you don't provide --versions or --siteroot""" + commandline_options(self.fake_builddir) + + def test_versions_and_siteroot_abs(self): + """Ensure no error when you provide --versions and an absolute path for --site-root""" + commandline_options(self.fake_builddir + ["--versions", "--site-root", self.fake_abspath]) + + def test_versions_and_siteroot_url(self): + """Ensure no error when you provide --versions and a URL for --site-root""" + commandline_options(self.fake_builddir + ["--versions", "--site-root", self.fake_url]) + + def test_versions_and_siteroot_rel_error(self): + """Ensure error when you provide --versions and a valid relative path for --site-root""" + msg = "--site-root is neither a web URL nor an absolute path" + with self.assertRaisesRegex(RuntimeError, msg): + commandline_options( + self.fake_builddir + ["--versions", "--site-root", self.fake_relpath] + ) + + def test_versions_and_siteroot_neither_error(self): + """Ensure error when you provide --versions and just some string for --site-root""" + msg = "--site-root is neither a web URL nor an absolute path" + with self.assertRaisesRegex(RuntimeError, msg): + commandline_options(self.fake_builddir + ["--versions", "--site-root", "abc123"]) + + def test_versions_but_no_siteroot_error(self): + """Ensure error when you provide --versions but not --site-root""" + msg = "--site-root must be provided when --versions is enabled" + with self.assertRaisesRegex(RuntimeError, msg): + commandline_options(self.fake_builddir + ["--versions"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_unit_get_build_command.py b/test/test_unit_get_build_command.py index f0cea6e..6cb0482 100644 --- a/test/test_unit_get_build_command.py +++ b/test/test_unit_get_build_command.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 -"""Unit test driver for get_build_command function -""" +"""Unit test driver for get_build_command function""" import os import unittest from unittest.mock import patch -from doc_builder.build_commands import get_build_command +from doc_builder.build_commands import get_build_command # pylint: disable=import-error # Allow names that pylint doesn't like, because otherwise I find it hard # to make readable unit test names @@ -46,10 +45,55 @@ def test_basic(self): ] self.assertEqual(expected, build_command) + def test_custom_conf_py_path(self): + """Tests usage with --conf-py-path as file""" + conf_py_path = os.path.join(os.path.dirname(__file__), "conf.py") + build_command = get_build_command( + build_dir="/path/to/foo", + run_from_dir="/irrelevant/path", + build_target="html", + num_make_jobs=4, + docker_name=None, + version="None", + conf_py_path=conf_py_path, + ) + expected = [ + "make", + f"SPHINXOPTS=-W --keep-going -c '{os.path.dirname(conf_py_path)}'", + "BUILDDIR=/path/to/foo", + "-j", + "4", + "html", + ] + self.assertEqual(expected, build_command) + + def test_custom_conf_py_path_dir(self): + """Tests usage with --conf-py-path as directory""" + conf_py_path = os.path.dirname(__file__) + build_command = get_build_command( + build_dir="/path/to/foo", + run_from_dir="/irrelevant/path", + build_target="html", + num_make_jobs=4, + docker_name=None, + version="None", + conf_py_path=conf_py_path, + ) + expected = [ + "make", + f"SPHINXOPTS=-W --keep-going -c '{conf_py_path}'", + "BUILDDIR=/path/to/foo", + "-j", + "4", + "html", + ] + self.assertEqual(expected, build_command) + @patch("os.path.expanduser") def test_docker(self, mock_expanduser): """Tests usage with use_docker=True""" mock_expanduser.return_value = "/path/to/username" + conf_py_path = os.path.join(os.path.dirname(__file__), "conf.py") build_command = get_build_command( build_dir="/path/to/username/foorepos/foodocs/versions/main", run_from_dir="/path/to/username/foorepos/foocode/doc", @@ -57,6 +101,7 @@ def test_docker(self, mock_expanduser): num_make_jobs=4, docker_name="foo", version="None", + conf_py_path=conf_py_path, ) expected = [ "docker", @@ -75,7 +120,7 @@ def test_docker(self, mock_expanduser): "current_version=None", "ghcr.io/escomp/ctsm/ctsm-docs:v1.0.1", "make", - "SPHINXOPTS=-W --keep-going", + f"SPHINXOPTS=-W --keep-going -c '{os.path.dirname(conf_py_path)}'", "BUILDDIR=/home/user/mounted_home/foorepos/foodocs/versions/main", "-j", "4", diff --git a/test/test_unit_get_build_dir.py b/test/test_unit_get_build_dir.py index 247fa16..b0e181f 100644 --- a/test/test_unit_get_build_dir.py +++ b/test/test_unit_get_build_dir.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 -"""Unit test driver for get_build_dir function -""" +"""Unit test driver for get_build_dir function""" import shutil import unittest @@ -13,7 +12,11 @@ # For python3 from unittest import mock import os -from test.test_utils.sys_utils_fake import make_fake_isdir + +# pylint: disable=import-error,no-name-in-module +from test.test_utils.sys_utils_fake import ( + make_fake_isdir, +) from doc_builder.build_commands import get_build_dir