blob: 8bd10e63a97c97a3ffbde4a10268ca24881789e7 [file] [log] [blame]
"""
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,
}