# -*- coding: utf8 -*-
########################################################################################
# This file is part of exhale. Copyright (c) 2017-2024, Stephen McDowell. #
# Full BSD 3-Clause license available here: #
# #
# https://github.com/svenevs/exhale/blob/master/LICENSE #
########################################################################################
'''
The deploy module is responsible for two primary actions:
1. Executing Doxygen (if requested in ``exhale_args``).
2. Launching the full API generation via the :func:`~exhale.deploy.explode` function.
'''
from __future__ import unicode_literals
from . import configs
from . import utils
from .graph import ExhaleRoot
import os
import sys
import six
import re
import codecs
import tempfile
import textwrap
from subprocess import PIPE, Popen, STDOUT
[docs]
def _generate_doxygen(doxygen_input):
'''
This method executes doxygen based off of the specified input. By the time this
method is executed, it is assumed that Doxygen is intended to be run in the
**current working directory**. Search for ``returnPath`` in the implementation of
:func:`~exhale.configs.apply_sphinx_configurations` for handling of this aspect.
This method is intended to be called by :func:`~exhale.deploy.generateDoxygenXML`,
which is in turn called by :func:`~exhale.configs.apply_sphinx_configurations`.
Two versions of the
doxygen command can be executed:
1. If ``doxygen_input`` is exactly ``"Doxyfile"``, then it is assumed that a
``Doxyfile`` exists in the **current working directory**. Meaning the command
being executed is simply ``doxygen``.
2. For all other values, ``doxygen_input`` represents the arguments as to be
specified on ``stdin`` to the process.
**Parameters**
``doxygen_input`` (str)
Either the string ``"Doxyfile"`` to run vanilla ``doxygen``, or the
selection of doxygen inputs (that would ordinarily be in a ``Doxyfile``)
that will be ``communicate``d to the ``doxygen`` process on ``stdin``.
.. note::
If using Python **3**, the input **must** still be a ``str``. This
method will convert the input to ``bytes`` as follows:
.. code-block:: py
if sys.version[0] == "3":
doxygen_input = bytes(doxygen_input, "utf-8")
**Return**
``str`` or ``None``
If an error occurs, a string describing the error is returned with the
intention of the caller raising the exception. If ``None`` is returned,
then the process executed without error. Example usage:
.. code-block:: py
status = _generate_doxygen("Doxygen")
if status:
raise RuntimeError(status)
Though a little awkward, this is done to enable the intended caller of this
method to restore some state before exiting the program (namely, the working
directory before propagating an exception to ``sphinx-build``).
'''
if not isinstance(doxygen_input, six.string_types):
return "Error: the `doxygen_input` variable must be of type `str`."
doxyfile = doxygen_input == "Doxyfile"
try:
# Setup the arguments to launch doxygen
if doxyfile:
args = ["doxygen"]
kwargs = {}
else:
args = ["doxygen", "-"]
kwargs = {"stdin": PIPE}
if configs._on_rtd:
# On RTD, any capturing of Doxygen output can cause buffer overflows for
# even medium sized projects. So it is disregarded entirely to ensure the
# build will complete (otherwise, it silently fails after `cat conf.py`)
devnull_file = open(os.devnull, "w")
kwargs["stdout"] = devnull_file
kwargs["stderr"] = STDOUT
else:
# TL;DR: strictly enforce that (verbose) doxygen output doesn't cause the
# `communicate` to hang due to buffer overflows.
#
# See excellent synopsis:
# https://thraxil.org/users/anders/posts/2008/03/13/Subprocess-Hanging-PIPE-is-your-enemy/
if six.PY2:
tempfile_kwargs = {}
else:
# encoding argument introduced in python 3
tempfile_kwargs = {"encoding": "utf-8"}
tempfile_kwargs["mode"] = "r+"
tmp_out_file = tempfile.TemporaryFile(
prefix="doxygen_stdout_buff", **tempfile_kwargs
)
tmp_err_file = tempfile.TemporaryFile(
prefix="doxygen_stderr_buff", **tempfile_kwargs
)
# Write to the tempfiles over PIPE to avoid buffer overflowing
kwargs["stdout"] = tmp_out_file
kwargs["stderr"] = tmp_err_file
# Note: overload of args / kwargs, Popen is expecting a list as the first
# parameter (aka no *args, just args)!
doxygen_proc = Popen(args, **kwargs)
# Communicate can only be called once, arrange whether or not stdin has value
if not doxyfile:
# In Py3, make sure we are communicating a bytes-like object which is no
# longer interchangeable with strings (as was the case in Py2).
if sys.version[0] == "3":
doxygen_input = bytes(doxygen_input, "utf-8")
comm_kwargs = {"input": doxygen_input}
else:
comm_kwargs = {}
# Waits until doxygen has completed
doxygen_proc.communicate(**comm_kwargs)
# Print out what was written to the tmpfiles by doxygen
if not configs._on_rtd and not configs.exhaleSilentDoxygen:
# Doxygen output (some useful information, mostly just enumeration of the
# configurations you gave it {useful for debugging...})
if tmp_out_file.tell() > 0:
tmp_out_file.seek(0)
print(tmp_out_file.read())
# Doxygen error (e.g. any warnings, or invalid input)
if tmp_err_file.tell() > 0:
# Making them stick out, ideally users would reduce this output to 0 ;)
# This will print a yellow [~] before every line, but not make the
# entire line yellow because it's definitively not helpful
prefix = utils._use_color(
utils.prefix("[~]", " "), utils.AnsiColors.BOLD_YELLOW, sys.stderr
)
tmp_err_file.seek(0)
sys.stderr.write(utils.prefix(prefix, tmp_err_file.read()))
# Close the file handles opened for communication with subprocess
if configs._on_rtd:
devnull_file.close()
else:
# Delete the tmpfiles
tmp_out_file.close()
tmp_err_file.close()
# Make sure we had a valid execution of doxygen
exit_code = doxygen_proc.returncode
if exit_code != 0:
raise RuntimeError("Non-zero return code of [{0}] from 'doxygen'...".format(exit_code))
except Exception as e:
return "Unable to execute 'doxygen': {0}".format(e)
# returning None signals _success_
return None
[docs]
def _valid_config(config, required):
'''
.. todo:: add documentation of this method
``config``: doxygen input we're looking for
``required``: if ``True``, must be present. if ``False``, NOT ALLOWED to be present
'''
re_template = r"\s*{config}\s*=.*".format(config=config)
found = re.search(re_template, configs.exhaleDoxygenStdin)
if required:
return found is not None
else:
return found is None
[docs]
def generateDoxygenXML():
# If this happens, we really shouldn't be here...
if not configs.exhaleExecutesDoxygen:
return textwrap.dedent('''
`generateDoxygenXML` should *ONLY* be called internally. You should
set `exhaleExecutesDoxygen=True` in `exhale_args` in `conf.py`.
''')
# Case 1: the user has their own `Doxyfile`.
if configs.exhaleUseDoxyfile:
return _generate_doxygen("Doxyfile")
# Case 2: use stdin, with some defaults and potentially additional specs from user
else:
# There are two doxygen specs that we explicitly disallow
#
# 1. OUTPUT_DIRECTORY: this is *ALREADY* specified implicitly via breathe
# 2. STRIP_FROM_PATH: this is a *REQUIRED* config (`doxygenStripFromPath`)
#
# There is one doxygen spec that is REQUIRED to be given:
#
# 1. INPUT (where doxygen should parse).
#
# The below is a modest attempt to validate that these were / were not given.
if not isinstance(configs.exhaleDoxygenStdin, six.string_types):
return "`exhaleDoxygenStdin` config must be a string!"
if not _valid_config("OUTPUT_DIRECTORY", False):
# If we are hitting this code, these should both exist and be configured
# since this method is called **AFTER** the configuration verification code
# performed in configs.apply_sphinx_configurations
breathe_projects = configs._the_app.config.breathe_projects
breathe_default_project = configs._the_app.config.breathe_default_project
return textwrap.dedent('''
`exhaleDoxygenStdin` may *NOT* specify `OUTPUT_DIRECTORY`. Exhale does
this internally by reading what you provided to `breathe_projects` in
your `conf.py`.
Based on what you had in `conf.py`, Exhale will be using
- The `breathe_default_project`:
{default}
- The output path specified (`breathe_projects[breathe_default_project]`):
{path}
NOTE: the above path has the `xml` portion removed from what you
provided. This path is what is sent to Doxygen, Breathe
requires you include the `xml` directory path; so Exhale simply
re-uses this variable and adapts the value for our needs.
'''.format(
default=breathe_default_project,
path=breathe_projects[breathe_default_project].rsplit("{sep}xml".format(sep=os.sep), 1)[0]
))
if not _valid_config("STRIP_FROM_PATH", False):
return textwrap.dedent('''
`exhaleDoxygenStdin` may *NOT* specify `STRIP_FROM_PATH`. Exhale does
this internally by using the value you provided to `exhale_args` in
your `conf.py` for the key `doxygenStripFromPath`.
Based on what you had in `conf.py`, Exhale will be using:
{strip}
NOTE: the above is what you specified directly in `exhale_args`. Exhale
will be using an absolute path to send to Doxygen. It is:
{absolute}
'''.format(
strip=configs._the_app.config.exhale_args["doxygenStripFromPath"],
absolute=configs.doxygenStripFromPath
))
if not _valid_config("INPUT", True):
return textwrap.dedent('''
`exhaleDoxygenStdin` *MUST* specify the `INPUT` doxygen config variable.
The INPUT variable is what tells Doxygen where to look for code to
extract documentation from. For example, if you had a directory layout
project_root/
docs/
conf.py
Makefile
... etc ...
include/
my_header.hpp
src/
my_header.cpp
Then you would include the line
INPUT = ../include
in the string provided to `exhale_args["exhaleDoxygenStdin"]`.
''')
# For these, we just want to warn them of the impact but still allow an override
re_template = r"\s*{config}\s*=\s*(.*)"
for cfg in ("ALIASES", "PREDEFINED"):
found = re.search(re_template.format(config=cfg), configs.exhaleDoxygenStdin)
if found:
sys.stderr.write(utils.info(textwrap.dedent('''
You have supplied to `exhaleDoxygenStdin` a configuration of:
{cfg} = {theirs}
This has an important impact, as it overrides a default setting that
Exhale is using.
1. If you are intentionally overriding this configuration, simply
ignore this message --- what you intended will happen.
2. If you meant to _continue_ adding to the defaults Exhale provides,
you need to use a `+=` instead of a raw `=`. So do instead
{cfg} += {theirs}
'''.format(cfg=cfg, theirs=found.groups()[0])), utils.AnsiColors.BOLD_YELLOW))
# Include their custom doxygen definitions after the defaults so that they can
# override anything they want to. Populate the necessary output dir and strip path.
doxy_dir = configs._doxygen_xml_output_directory.rsplit("{sep}xml".format(sep=os.sep), 1)[0]
internal_configs = textwrap.dedent('''
# Tell doxygen to output wherever breathe is expecting things
OUTPUT_DIRECTORY = "{out}"
# Tell doxygen to strip the path names (RTD builds produce long abs paths...)
STRIP_FROM_PATH = "{strip}"
'''.format(out=doxy_dir, strip=configs.doxygenStripFromPath))
external_configs = textwrap.dedent(configs.exhaleDoxygenStdin)
# Place external configs last so that if the _valid_config method isn't actually
# catching what it should be, the internal configs will override theirs
full_input = "{base}\n{external}\n{internal}\n\n".format(base=configs.DEFAULT_DOXYGEN_STDIN_BASE,
external=external_configs,
internal=internal_configs)
# << verboseBuild
if configs.verboseBuild:
msg = "[*] The following input will be sent to Doxygen:\n"
if not configs.alwaysColorize and not sys.stderr.isatty():
sys.stderr.write(msg)
sys.stderr.write(full_input)
else:
sys.stderr.write(utils.colorize(msg, utils.AnsiColors.BOLD_CYAN))
sys.stderr.write(utils.__fancy(full_input, "make", "console"))
return _generate_doxygen(full_input)
########################################################################################
#
##
###
####
##### Primary entry point.
####
###
##
#
########################################################################################
[docs]
def explode():
'''
This method **assumes** that :func:`~exhale.configs.apply_sphinx_configurations` has
already been applied. It performs minimal sanity checking, and then performs in
order
1. Creates a :class:`~exhale.graph.ExhaleRoot` object.
2. Executes :func:`~exhale.graph.ExhaleRoot.parse` for this object.
3. Executes :func:`~exhale.graph.ExhaleRoot.generateFullAPI` for this object.
4. Executes :func:`~exhale.graph.ExhaleRoot.toConsole` for this object (which will
only produce output when :data:`~exhale.configs.verboseBuild` is ``True``).
This results in the full API being generated, and control is subsequently passed
back to Sphinx to now read in the source documents (many of which were just
generated in :data:`~exhale.configs.containmentFolder`), and proceed to writing the
final output.
'''
# Quick sanity check to make sure the bare minimum have been set in the configs
err_msg = "`configs.{config}` was `None`. Do not call `deploy.explode` directly."
if configs.containmentFolder is None:
raise RuntimeError(err_msg.format(config="containmentFolder"))
if configs.rootFileName is None:
raise RuntimeError(err_msg.format(config="rootFileName"))
if configs.doxygenStripFromPath is None:
raise RuntimeError(err_msg.format(config="doxygenStripFromPath"))
# From here on, we assume that everything else has been checked / configured.
try:
textRoot = ExhaleRoot()
except:
utils.fancyError("Unable to create an `ExhaleRoot` object:")
try:
sys.stdout.write("{0}\n".format(utils.info("Exhale: parsing Doxygen XML.")))
start = utils.get_time()
textRoot.parse()
end = utils.get_time()
sys.stdout.write("{0}\n".format(
utils.progress("Exhale: finished parsing Doxygen XML in {0}.".format(
utils.time_string(start, end)
))
))
except:
utils.fancyError("Exception caught while parsing:")
try:
sys.stdout.write("{0}\n".format(
utils.info("Exhale: generating reStructuredText documents.")
))
start = utils.get_time()
textRoot.generateFullAPI()
end = utils.get_time()
sys.stdout.write("{0}\n".format(
utils.progress("Exhale: generated reStructuredText documents in {0}.".format(
utils.time_string(start, end)
))
))
except:
utils.fancyError("Exception caught while generating:")
# << verboseBuild
# toConsole only prints if verbose mode is enabled
textRoot.toConsole()
# allow access to the result after-the-fact
configs._the_app.exhale_root = textRoot