| """ |
| Doxyrunner Sphinx Plugin |
| ######################## |
| |
| Copyright (c) 2021 Nordic Semiconductor ASA |
| SPDX-License-Identifier: Apache-2.0 |
| |
| Introduction |
| ============ |
| |
| This Sphinx plugin can be used to run Doxygen build as part of the Sphinx build |
| process. It is meant to be used with other plugins such as ``breathe`` in order |
| to improve the user experience. The principal features offered by this plugin |
| are: |
| |
| - Doxygen build is run before Sphinx reads input files |
| - Doxyfile can be optionally pre-processed so that variables can be inserted |
| - Changes in the Doxygen input files are tracked so that Doxygen build is only |
| run if necessary. |
| - Synchronizes Doxygen XML output so that even if Doxygen is run only changed, |
| deleted or added files are modified. |
| |
| References: |
| |
| - https://github.com/michaeljones/breathe/issues/420 |
| |
| Configuration options |
| ===================== |
| |
| - ``doxyrunner_doxygen``: Path to the Doxygen binary. |
| - ``doxyrunner_doxyfile``: Path to Doxyfile. |
| - ``doxyrunner_outdir``: Doxygen build output directory (inserted to |
| ``OUTPUT_DIRECTORY``) |
| - ``doxyrunner_outdir_var``: Variable representing the Doxygen build output |
| directory, as used by ``OUTPUT_DIRECTORY``. This can be useful if other |
| Doxygen variables reference to the output directory. |
| - ``doxyrunner_fmt``: Flag to indicate if Doxyfile should be formatted. |
| - ``doxyrunner_fmt_vars``: Format variables dictionary (name: value). |
| - ``doxyrunner_fmt_pattern``: Format pattern. |
| - ``doxyrunner_silent``: If Doxygen output should be logged or not. Note that |
| this option may not have any effect if ``QUIET`` is set to ``YES``. |
| """ |
| |
| import filecmp |
| import hashlib |
| from pathlib import Path |
| import re |
| import shlex |
| import shutil |
| from subprocess import Popen, PIPE, STDOUT |
| import tempfile |
| from typing import List, Dict, Optional, Any |
| |
| from sphinx.application import Sphinx |
| from sphinx.environment import BuildEnvironment |
| from sphinx.util import logging |
| |
| |
| __version__ = "0.1.0" |
| |
| |
| logger = logging.getLogger(__name__) |
| |
| |
| def hash_file(file: Path) -> str: |
| """Compute the hash (SHA256) of a file in text mode. |
| |
| Args: |
| file: File to be hashed. |
| |
| Returns: |
| Hash. |
| """ |
| |
| with open(file, encoding="utf-8") as f: |
| sha256 = hashlib.sha256(f.read().encode("utf-8")) |
| |
| return sha256.hexdigest() |
| |
| def get_doxygen_option(doxyfile: str, option: str) -> List[str]: |
| """Obtain the value of a Doxygen option. |
| |
| Args: |
| doxyfile: Content of the Doxyfile. |
| option: Option to be retrieved. |
| |
| Notes: |
| Does not support appended values. |
| |
| Returns: |
| Option values. |
| """ |
| |
| option_re = re.compile(r"^\s*([A-Z0-9_]+)\s*=\s*(.*)$") |
| multiline_re = re.compile(r"^\s*(.*)$") |
| |
| values = [] |
| found = False |
| finished = False |
| for line in doxyfile.splitlines(): |
| if not found: |
| m = option_re.match(line) |
| if not m or m.group(1) != option: |
| continue |
| |
| found = True |
| value = m.group(2) |
| else: |
| m = multiline_re.match(line) |
| if not m: |
| raise ValueError(f"Unexpected line content: {line}") |
| |
| value = m.group(1) |
| |
| # check if it is a multiline value |
| finished = not value.endswith("\\") |
| |
| # strip backslash |
| if not finished: |
| value = value[:-1] |
| |
| # split values |
| values += shlex.split(value.replace("\\", "\\\\")) |
| |
| if finished: |
| break |
| |
| return values |
| |
| |
| def process_doxyfile( |
| doxyfile: str, |
| outdir: Path, |
| silent: bool, |
| fmt: bool = False, |
| fmt_pattern: Optional[str] = None, |
| fmt_vars: Optional[Dict[str, str]] = None, |
| outdir_var: Optional[str] = None, |
| ) -> str: |
| """Process Doxyfile. |
| |
| Notes: |
| OUTPUT_DIRECTORY, WARN_FORMAT and QUIET are overridden to satisfy |
| extension operation needs. |
| |
| Args: |
| doxyfile: Path to the Doxyfile. |
| outdir: Output directory of the Doxygen build. |
| silent: If Doxygen should be run in quiet mode or not. |
| fmt: If Doxyfile should be formatted. |
| fmt_pattern: Format pattern. |
| fmt_vars: Format variables. |
| outdir_var: Variable representing output directory. |
| |
| Returns: |
| Processed Doxyfile content. |
| """ |
| |
| with open(doxyfile) as f: |
| content = f.read() |
| |
| content = re.sub( |
| r"^\s*OUTPUT_DIRECTORY\s*=.*$", |
| f"OUTPUT_DIRECTORY={outdir.as_posix()}", |
| content, |
| flags=re.MULTILINE, |
| ) |
| |
| content = re.sub( |
| r"^\s*WARN_FORMAT\s*=.*$", |
| 'WARN_FORMAT="$file:$line: $text"', |
| content, |
| flags=re.MULTILINE, |
| ) |
| |
| content = re.sub( |
| r"^\s*QUIET\s*=.*$", |
| "QUIET=" + "YES" if silent else "NO", |
| content, |
| flags=re.MULTILINE, |
| ) |
| |
| if fmt: |
| if not fmt_pattern or not fmt_vars: |
| raise ValueError("Invalid formatting pattern or variables") |
| |
| if outdir_var: |
| fmt_vars = fmt_vars.copy() |
| fmt_vars[outdir_var] = outdir.as_posix() |
| |
| for var, value in fmt_vars.items(): |
| content = content.replace(fmt_pattern.format(var), value) |
| |
| return content |
| |
| |
| def doxygen_input_has_changed(env: BuildEnvironment, doxyfile: str) -> bool: |
| """Check if Doxygen input files have changed. |
| |
| Args: |
| env: Sphinx build environment instance. |
| doxyfile: Doxyfile content. |
| |
| Returns: |
| True if changed, False otherwise. |
| """ |
| |
| # obtain Doxygen input files and patterns |
| input_files = get_doxygen_option(doxyfile, "INPUT") |
| if not input: |
| raise ValueError("No INPUT set in Doxyfile") |
| |
| file_patterns = get_doxygen_option(doxyfile, "FILE_PATTERNS") |
| if not file_patterns: |
| raise ValueError("No FILE_PATTERNS set in Doxyfile") |
| |
| # build a set with input files hash |
| cache = set() |
| for file in input_files: |
| path = Path(file) |
| if path.is_file(): |
| cache.add(hash_file(path)) |
| else: |
| for pattern in file_patterns: |
| for p_file in path.glob("**/" + pattern): |
| cache.add(hash_file(p_file)) |
| |
| # check if any file has changed |
| if hasattr(env, "doxyrunner_cache") and env.doxyrunner_cache == cache: |
| return False |
| |
| # store current state |
| env.doxyrunner_cache = cache |
| |
| return True |
| |
| |
| def process_doxygen_output(line: str, silent: bool) -> None: |
| """Process a line of Doxygen program output. |
| |
| This function will map Doxygen output to the Sphinx logger output. Errors |
| and warnings will be converted to Sphinx errors and warnings. Other |
| messages, if not silent, will be mapped to the info logger channel. |
| |
| Args: |
| line: Doxygen program line. |
| silent: True if regular messages should be logged, False otherwise. |
| """ |
| |
| m = re.match(r"(.*):(\d+): ([a-z]+): (.*)", line) |
| if m: |
| type = m.group(3) |
| message = f"{m.group(1)}:{m.group(2)}: {m.group(4)}" |
| if type == "error": |
| logger.error(message) |
| elif type == "warning": |
| logger.warning(message) |
| else: |
| logger.info(message) |
| elif not silent: |
| logger.info(line) |
| |
| |
| def run_doxygen(doxygen: str, doxyfile: str, silent: bool = False) -> None: |
| """Run Doxygen build. |
| |
| Args: |
| doxygen: Path to Doxygen binary. |
| doxyfile: Doxyfile content. |
| silent: If Doxygen output should be logged or not. |
| """ |
| |
| f_doxyfile = tempfile.NamedTemporaryFile("w", delete=False) |
| f_doxyfile.write(doxyfile) |
| f_doxyfile.close() |
| |
| p = Popen([doxygen, f_doxyfile.name], stdout=PIPE, stderr=STDOUT, encoding="utf-8") |
| while True: |
| line = p.stdout.readline() # type: ignore |
| if line: |
| process_doxygen_output(line.rstrip(), silent) |
| if p.poll() is not None: |
| break |
| |
| Path(f_doxyfile.name).unlink() |
| |
| if p.returncode: |
| raise IOError(f"Doxygen process returned non-zero ({p.returncode})") |
| |
| |
| def sync_doxygen(doxyfile: str, new: Path, prev: Path) -> None: |
| """Synchronize Doxygen output with a previous build. |
| |
| This function makes sure that only new, deleted or changed files are |
| actually modified in the Doxygen XML output. Latest HTML content is just |
| moved. |
| |
| Args: |
| doxyfile: Contents of the Doxyfile. |
| new: Newest Doxygen build output directory. |
| prev: Previous Doxygen build output directory. |
| """ |
| |
| generate_html = get_doxygen_option(doxyfile, "GENERATE_HTML") |
| if generate_html[0] == "YES": |
| html_output = get_doxygen_option(doxyfile, "HTML_OUTPUT") |
| if not html_output: |
| raise ValueError("No HTML_OUTPUT set in Doxyfile") |
| |
| new_htmldir = new / html_output[0] |
| prev_htmldir = prev / html_output[0] |
| |
| if prev_htmldir.exists(): |
| shutil.rmtree(prev_htmldir) |
| new_htmldir.rename(prev_htmldir) |
| |
| xml_output = get_doxygen_option(doxyfile, "XML_OUTPUT") |
| if not xml_output: |
| raise ValueError("No XML_OUTPUT set in Doxyfile") |
| |
| new_xmldir = new / xml_output[0] |
| prev_xmldir = prev / xml_output[0] |
| |
| if prev_xmldir.exists(): |
| dcmp = filecmp.dircmp(new_xmldir, prev_xmldir) |
| |
| for file in dcmp.right_only: |
| (Path(dcmp.right) / file).unlink() |
| |
| for file in dcmp.left_only + dcmp.diff_files: |
| shutil.copy(Path(dcmp.left) / file, Path(dcmp.right) / file) |
| |
| shutil.rmtree(new_xmldir) |
| else: |
| new_xmldir.rename(prev_xmldir) |
| |
| |
| def doxygen_build(app: Sphinx) -> None: |
| """Doxyrunner entry point. |
| |
| Args: |
| app: Sphinx application instance. |
| """ |
| |
| if app.config.doxyrunner_outdir: |
| outdir = Path(app.config.doxyrunner_outdir) |
| else: |
| outdir = Path(app.outdir) / "_doxygen" |
| |
| outdir.mkdir(exist_ok=True) |
| tmp_outdir = outdir / "tmp" |
| |
| logger.info("Preparing Doxyfile...") |
| doxyfile = process_doxyfile( |
| app.config.doxyrunner_doxyfile, |
| tmp_outdir, |
| app.config.doxyrunner_silent, |
| app.config.doxyrunner_fmt, |
| app.config.doxyrunner_fmt_pattern, |
| app.config.doxyrunner_fmt_vars, |
| app.config.doxyrunner_outdir_var, |
| ) |
| |
| logger.info("Checking if Doxygen needs to be run...") |
| changed = doxygen_input_has_changed(app.env, doxyfile) |
| if not changed: |
| logger.info("Doxygen build will be skipped (no changes)!") |
| return |
| |
| logger.info("Running Doxygen...") |
| run_doxygen( |
| app.config.doxyrunner_doxygen, |
| doxyfile, |
| app.config.doxyrunner_silent, |
| ) |
| |
| logger.info("Syncing Doxygen output...") |
| sync_doxygen(doxyfile, tmp_outdir, outdir) |
| |
| shutil.rmtree(tmp_outdir) |
| |
| |
| def setup(app: Sphinx) -> Dict[str, Any]: |
| app.add_config_value("doxyrunner_doxygen", "doxygen", "env") |
| app.add_config_value("doxyrunner_doxyfile", None, "env") |
| app.add_config_value("doxyrunner_outdir", None, "env") |
| app.add_config_value("doxyrunner_outdir_var", None, "env") |
| app.add_config_value("doxyrunner_fmt", False, "env") |
| app.add_config_value("doxyrunner_fmt_vars", {}, "env") |
| app.add_config_value("doxyrunner_fmt_pattern", "@{}@", "env") |
| app.add_config_value("doxyrunner_silent", True, "") |
| |
| app.connect("builder-inited", doxygen_build) |
| |
| return { |
| "version": __version__, |
| "parallel_read_safe": True, |
| "parallel_write_safe": True, |
| } |