30b59483fb
This reverts commit 30c3ce4a92
.
Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
400 lines
11 KiB
Python
400 lines
11 KiB
Python
"""
|
|
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,
|
|
}
|