# -*- 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 #
########################################################################################
"""
Defines the core sphinx project based test case utilities.
All project based test cases should inherit from :class:`testing.base.ExhaleTestCase`.
"""
from __future__ import unicode_literals
import os
import platform
import re
import shutil
import textwrap
import unittest
from importlib import import_module
import exhale
import pytest
import six
from six import add_metaclass
from sphinx.testing.path import path
from . import TEST_PROJECTS_ROOT, get_exhale_root
from .decorators import default_confoverrides
[docs]
def make_default_config(project):
"""
Return a default configuration for exhale.
**Parameters**
``project`` (str)
The name of the project that will be searched for in
``testing/projects/{project}``.
**Return**
``dict``
The global default testing configuration to supply to ``confoverrides``
with ``@pytest.mark.sphinx``, these are values that would ordinarily be
written in a ``conf.py``.
"""
return {
"breathe_projects": {
project: "./_doxygen/xml"
},
"breathe_default_project": project,
"exhale_args": {
# required arguments
"containmentFolder": "./api",
"rootFileName": "{0}_root.rst".format(project),
"rootFileTitle": "``{0}`` Test Project".format(project),
"doxygenStripFromPath": "..",
# additional arguments
"exhaleExecutesDoxygen": True,
"exhaleDoxygenStdin": "INPUT = ../include"
}
}
[docs]
@add_metaclass(ExhaleTestCaseMetaclass)
class ExhaleTestCase(unittest.TestCase):
"""
The primary project based test class to inherit from.
The ``__metaclass__`` is set to :class:`testing.base.ExhaleTestCaseMetaclass`.
Inherits from :class:`python:unittest.TestCase`.
**Attributes Populated by the Metaclass Fixtures for Each Test**
These attributes are populated during the setup of a test function, and then
later set to ``None`` during the test function teardown. These are **only**
available inside the method body of a testing function (a function with a name
starting with ``test_``).
``self.app`` (``sphinx.testing.util.SphinxTestApp``)
The sphinx testing application. Acquire the ``conf.py`` values (and
corresponding ``@confoverrides``) via ``self.app.config`` like any
traditional sphinx application.
``self.testroot`` (:class:`python:str`)
The ``testroot`` supplied to ``pytest.mark.sphinx``, the "docs" directory.
Its value will be
``testing/projects/{test_project}/docs_{ClassName}_{test_function_name}``.
.. todo::
This value is saved in order to be able to distinguish when a "separate
source and build" directory is being tested. At this time this is not
fully implemented, ``self.app.srcdir`` should be a subdirectory of
``self.testroot`` and ``conf.py`` / ``index.rst`` should be generated
there.
Currently, these are always generated in ``testroot``, implying that
there is no "separate source and build" directory structure. Solution
requires further investigation of the sphinx testing suite.
.. danger::
As a consequence, running tests in parallel is not and never will be
supported (e.g., when running ``tox -e py``).
"""
test_project = None
"""
The string representing the project to run Doxygen / exhale on.
This variable is used to index into ``testing/projects/{test_project}``. For
example, with ``test_project = "c_maths"``, the directory used is
``testing/projects/c_maths``. That is, this variable is joined with the path
defined by :data:`testing.TEST_PROJECTS_ROOT`.
**This class-level string variable must be set in subclasses**.
"""
[docs]
def cross_validate(self, contents, required=None, forbidden=None):
"""
Validate *all* ``required`` and *no* ``forbidden`` items found in ``contents``.
For each item in ``required`` an assertion of ``item in contents`` is made, and
for each item in ``forbidden`` an assertion of ``item not in contents`` is made.
If neither ``required`` nor ``forbidden`` are supplied, no checks are performed.
**Parameters**
``contents`` (:class:`python:str` or Iterable)
The contents to cross validate that all required items and no forbidden
items are found in.
``required`` (:data:`python:None` or Iterable)
The listing of all required entries that are required to be in ``contents``.
``forbidden`` (:data:`python:None` or Iterable)
The listing of all forbidden entries that are not allowed in ``contents``.
"""
if required:
for item in required:
self.assertTrue(
item in contents,
"Required entry [{item}] not found in:\n{contents}".format(
item=item, contents=contents
)
)
if forbidden:
for item in forbidden:
self.assertTrue(
item not in contents,
"Forbidden entry [{item}] found in:\n{contents}".format(
item=item, contents=contents
)
)
[docs]
def contents_for_node(self, node):
"""
Return the generated file contents for the specified ``node``.
**Parameters**
``node`` (:class:`exhale.graph.ExhaleNode`)
The node whose generated file contents are desired. Note that this must be
a proper :class:`~exhale.graph.ExhaleNode` instance, since
``node.file_name`` is what is used. That is, a mocked testing node should
not be used!
**Return** (:class:`python:str`)
The contents of ``node.file_name``.
**Raises**
If ``os.path.join(self.getAbsContainmentFolder(), node.file_name)`` does
not exist.
"""
node_path = os.path.join(self.getAbsContainmentFolder(), node.file_name)
with open(node_path) as f:
return f.read()
[docs]
def getAbsContainmentFolder(self):
r"""
Return the absolute path to ``"containmentFolder"``.
If ``exhale_args["containmentFolder"]`` is an absolute path, it will be returned
unchanged. Otherwise, it will be resolved against ``app.srcdir``.
**Return**
``str``
An absolute path to the ``"containmentFolder"`` where Exhale will be
generating its reStructuredText documents.
.. note::
When ``platform.system() == "Windows"``, this string will **always**
be prefixed with ``\\?\`` to deal with maximum path length issues.
This is to accommodate the somewhat long containment folders
generated by using the testing class name as well as the test name.
See :data:`~exhale.configs.MAXIMUM_WINDOWS_PATH_LENGTH` for more
information.
If this is not done, then even if ``self.app.build()`` is skipped
for the test cases that cause this (in
:class:`~testing.tests.cpp_long_names.CPPLongNames`),
:func:`python:shutil.rmtree` will crash during test teardown.
Better to just always include it.
"""
containmentFolder = self.app.config.exhale_args["containmentFolder"]
if not os.path.isabs(containmentFolder):
containmentFolder = os.path.abspath(os.path.join(
self.app.srcdir, containmentFolder
))
# Guarantee Windows can cope with this path.
if platform.system() == "Windows":
# NOTE: containmentFolder is *ALREADY* an absolute path, this prefix
# requires absolute paths! See documentation for
# configs.MAXIMUM_WINDOWS_PATH_LENGTH.
containmentFolder = "{magic}{containmentFolder}".format(
magic="{slash}{slash}?{slash}".format(slash="\\"), # \\?\ I HATE YOU WINDOWS
containmentFolder=containmentFolder
)
return containmentFolder
[docs]
def checkRequiredConfigs(self):
"""
Validate the four required configuration arguments in ``exhale_args``.
1. Checks that ``{containmentFolder}`` was created.
2. Checks that ``{containmentFolder}/{rootFileName}`` was created.
3. Checks that ``{rootFileTitle}`` is found in
``{containmentFolder}/{rootFileName}``.
.. todo::
4. identify via a ``file_*`` method that ``{doxygenStripFromPath}``
was correctly removed / wielded.
.. autotested::
"""
containmentFolder = self.getAbsContainmentFolder()
rootFileName = self.app.config.exhale_args["rootFileName"]
rootFileTitle = self.app.config.exhale_args["rootFileTitle"]
doxygenStripFromPath = self.app.config.exhale_args["doxygenStripFromPath"]
# validate that the containmentFolder was created
assert os.path.isdir(containmentFolder)
# validate that {containmentFolder}/{rootFileName} was created
assert os.path.isfile(os.path.join(containmentFolder, rootFileName))
# validate that the title was included
with open(os.path.join(containmentFolder, rootFileName), "r") as root:
root_contents = root.read()
root_heading = "{0}\n{1}".format(
rootFileTitle,
exhale.utils.heading_mark(rootFileTitle, exhale.configs.SECTION_HEADING_CHAR)
)
assert root_heading in root_contents
# TODO: validate doxygenStripFromPath
if doxygenStripFromPath: # this is only here to avoid a flake8 fail on a todo
pass
[docs]
def checkAllFilesGenerated(self):
"""
Validate that all files are actually generated.
.. autotested::
"""
root = get_exhale_root(self)
containmentFolder = self.getAbsContainmentFolder()
for node in root.all_nodes:
if node.kind in ["enumvalue", "group"]:
continue
gen_file_path = os.path.join(containmentFolder, node.file_name)
self.assertTrue(
os.path.isfile(gen_file_path),
"File for {kind} node with refid=[{refid}] not generated to [{gen_file_path}]!".format(
kind=node.kind, refid=node.refid, gen_file_path=gen_file_path
)
)
[docs]
def checkAllFilesIncluded(self):
"""
Validate that all files are actually included in the library root.
.. autotested::
"""
# TODO: config objects: this import can go away
from exhale.configs import unabridgedOrphanKinds
# build path to unabridged api document that adds all toctree directives
root = get_exhale_root(self)
containmentFolder = self.getAbsContainmentFolder()
unabridged_api_path = os.path.join(containmentFolder, "unabridged_api.rst.include")
unabridged_orphan_path = os.path.join(containmentFolder, "unabridged_orphan.rst")
# gather lines that match the indented part of the toctree
#
# .. toctree::
# :maxdepth: {toctreeMaxDepth}
#
# some_node.file_name.rst
#
# so just lazily look for the leading three spaces and ending with .rst
full_api_toctrees = []
under_toctree_re = re.compile(r"^ .+\.rst$")
with open(unabridged_api_path) as unabridged_api:
for line in unabridged_api:
if under_toctree_re.match(line):
full_api_toctrees.append(line.strip())
orphan_toctrees = []
if os.path.isfile(unabridged_orphan_path):
with open(unabridged_orphan_path) as unabridged_orphan:
for line in unabridged_orphan:
if under_toctree_re.match(line):
orphan_toctrees.append(line.strip())
# Scan all nodes and make sure they were found in the toctrees above.
doxygen_mainpage_was_used = False
for node in root.all_nodes:
if node.kind in {"enumvalue", "group"}:
continue
# When doxygen \mainpage command is used, the .. doxygenpage:: index
# is .. include::'ed in the root file document. Those checks come
# after this loop.
if node.kind == "page" and node.refid == "indexpage":
doxygen_mainpage_was_used = True
continue
if node.kind in unabridgedOrphanKinds or \
(node.kind == "class" and "struct" in unabridgedOrphanKinds) or \
(node.kind == "struct" and "class" in unabridgedOrphanKinds):
toctrees = orphan_toctrees
doc = unabridged_orphan_path
else:
toctrees = full_api_toctrees
doc = unabridged_api_path
self.assertTrue(
node.file_name in toctrees,
"Node refid=[{refid}] and basename=[{file_name}] not found in [{doc}]!".format(
refid=node.refid, file_name=node.file_name, doc=doc
)
)
# Make sure every document expected to be .. include::'ed in the library root
# has actually been included.
full_root_file_path = root.full_root_file_path
root_file_includes = []
with open(full_root_file_path, "r") as full_root_f:
for line in full_root_f:
include_mark = ".. include::"
if line.startswith(include_mark):
root_file_includes.append(line.split(include_mark)[-1].strip())
index_include = False # page_index.rst comes from doxygen \mainpage
page_hierarchy_include = False # page_view_hierarchy.rst
class_hierarchy_include = False # class_view_hierarchy.rst
file_hierarchy_include = False # file_view_hierarchy.rst
unabridged_api_include = True # unabridged_api.rst. Always included.
orphan_api_include = False # unabridged_orphan.rst. Never included.
for node in root.all_nodes:
if node.kind == "page":
if node.refid == "indexpage":
index_include = True
else:
page_hierarchy_include = True
elif node.kind in exhale.utils.CLASS_LIKE_KINDS:
class_hierarchy_include = True
elif node.kind in {"dir", "file"}:
file_hierarchy_include = True
self.assertTrue(
doxygen_mainpage_was_used == index_include,
"Mismatch: doxygen_mainpage_was_used != index_include!")
include_map = {
"page_index.rst.include": index_include,
os.path.basename(root.page_hierarchy_file): page_hierarchy_include,
os.path.basename(root.class_hierarchy_file): class_hierarchy_include,
os.path.basename(root.file_hierarchy_file): file_hierarchy_include,
os.path.basename(root.unabridged_api_file): unabridged_api_include,
os.path.basename(root.unabridged_orphan_file): orphan_api_include
}
root_file_base = os.path.basename(full_root_file_path)
for key, val in include_map.items():
if val:
check = getattr(self, "assertTrue")
msg = "*WAS* expected in {root_file_base}, but was *NOT* found!".format(
root_file_base=root_file_base
)
else:
check = getattr(self, "assertFalse")
msg = "was *NOT* expected in {root_file_base}, but *WAS* found!".format(
root_file_base=root_file_base
)
check(
key in root_file_includes,
"Page '{key}' {msg}".format(key=key, msg=msg))
# Some tests may want the toctree names afterward.
return full_api_toctrees, orphan_toctrees