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