diff --git a/README.rst b/README.rst index 540643d..6af392c 100644 --- a/README.rst +++ b/README.rst @@ -133,7 +133,7 @@ Exhale Version Compatibility with Python, Sphinx, and Breathe +----------------+----------------+----------------+-----------------+ | Exhale Version | Python Version | Sphinx Version | Breathe Version | +================+================+================+=================+ -| 0.3.2 -- 0.3.4 | >=3.7 | >=3.0,<5 | >=4.32.0 | +| 0.3.2 -- 0.3.6 | >=3.7 | >=3.0,<5 | >=4.32.0 | +----------------+----------------+----------------+-----------------+ | 0.3.0 | >=3.6 | >=3.0,<5 | >=4.32.0 | +----------------+----------------+----------------+-----------------+ diff --git a/docs/changelog.rst b/docs/changelog.rst index 2b61c59..71719be 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,12 @@ Changelog :local: :backlinks: none +v0.3.6 +---------------------------------------------------------------------------------------- + +- Enable the root document to be excluded, and additional manual indexing control + (:issue:`176`, :pr:`177`). + v0.3.5 ---------------------------------------------------------------------------------------- diff --git a/docs/reference/configs.rst b/docs/reference/configs.rst index 7c7785c..5aa417c 100644 --- a/docs/reference/configs.rst +++ b/docs/reference/configs.rst @@ -242,6 +242,55 @@ that you will link to from your documentation is laid out as follows: .. autodata:: exhale.configs.unabridgedOrphanKinds +.. _manual_indexing: + +Manual Indexing +**************************************************************************************** + +To compose the different sections of the root document, exhale generates some additional +files to ``.. include::`` them. Depending on the compounds documented in doxygen as +well as other settings different entries in the example below may or may not be +generated. + +.. code-block:: rst + + ======================= + Some Title You Provided + ======================= + + .. include:: page_index.rst.include + + .. include:: page_view_hierarchy.rst.include + + .. include:: class_view_hierarchy.rst.include + + .. include:: file_view_hierarchy.rst.include + + .. include:: unabridged_api.rst.include + +These should **not** use a file extension found in :confval:`sphinx:source_suffix`, +since the file ``class_view_hierarchy.rst`` for example will also be processed into +html. This results in warnings between duplicate labels being found in both the root +document and the document being included (`more information here`__). + +__ https://github.com/sphinx-doc/sphinx/issues/1668 + +To manually index through the generated exhale documents, you can set +:data:`~exhale.configs.rootFileName` to ``"EXCLUDE"``. With the root file out of the +picture, you may seek to ``.. include::`` (or add to a ``.. toctree::``) one of the +documents above. In the ``.. toctree::`` scenario, you will want the file extension to +be ``.rst`` so that sphinx will process it. + +.. autodata:: exhale.configs.classHierarchyFilename + +.. autodata:: exhale.configs.fileHierarchyFilename + +.. autodata:: exhale.configs.pageHierarchyFilename + +.. autodata:: exhale.configs.unabridgedApiFilename + +.. autodata:: exhale.configs.unabridgedOrphanFilename + Clickable Hierarchies **************************************************************************************** diff --git a/exhale/__init__.py b/exhale/__init__.py index 83b7362..bd51469 100644 --- a/exhale/__init__.py +++ b/exhale/__init__.py @@ -8,7 +8,7 @@ from __future__ import unicode_literals -__version__ = "0.3.6.dev" +__version__ = "0.3.6" def environment_ready(app): diff --git a/exhale/configs.py b/exhale/configs.py index 02602de..9ccda98 100644 --- a/exhale/configs.py +++ b/exhale/configs.py @@ -104,6 +104,11 @@ documents. Do **not** include the ``containmentFolder`` path in this file name, Exhale will create the file ``"{contaimentFolder}/{rootFileName}"`` for you. + .. note:: + + If the value provided is exactly ``"EXCLUDE"`` then the root file will not be + generated. See :ref:`manual_indexing` for more information. + **Value in** ``exhale_args`` (str) The value of key ``"rootFileName"`` should be a string representing the name of the file you will be including in your top-level ``toctree`` directive. In order @@ -490,6 +495,40 @@ class and file hierarchies. will present classes and structs together. """ +######################################################################################## +# Manual Indexing # +######################################################################################## +classHierarchyFilename = "class_view_hierarchy.rst.include" +''' +The name of the file the class hierarchy is generated in saved to +``{containmentFolder}/{classHierarchyFilename}``. +''' + +fileHierarchyFilename = "file_view_hierarchy.rst.include" +''' +The name of the file the class hierarchy is generated in saved to +``{containmentFolder}/{fileHierarchyFilename}``. +''' + +pageHierarchyFilename = "page_view_hierarchy.rst.include" +''' +The name of the file the class hierarchy is generated in saved to +``{containmentFolder}/{pageHierarchyFilename}``. +''' + +unabridgedApiFilename = "unabridged_api.rst.include" +''' +The name of the file the class hierarchy is generated in saved to +``{containmentFolder}/{unabridgedApiFilename}``. +''' + +unabridgedOrphanFilename = "unabridged_orphan.rst" +''' +The name of the file the class hierarchy is generated in saved to +``{containmentFolder}/{unabridgedOrphanFilename}``. See also: +:data:`~exhale.configs.unabridgedOrphanKinds`. +''' + ######################################################################################## # Clickable Hierarchies <3 # ######################################################################################## @@ -1412,11 +1451,11 @@ def apply_sphinx_configurations(app): # We *ONLY* generate reStructuredText, make sure Sphinx is expecting this as well as # the to-be-generated library root file is correctly suffixed. if not rootFileName.endswith(".rst"): - raise ConfigError( - "The given `rootFileName` ({0}) did not end with '.rst'; Exhale is reStructuredText only.".format( - rootFileName + if rootFileName != "EXCLUDE": + raise ConfigError( + f"The given `rootFileName` ({rootFileName}) did not end with '.rst'; " + "Exhale is reStructuredText only." ) - ) if ".rst" not in app.config.source_suffix: raise ConfigError( "Exhale is reStructuredText only, but '.rst' was not found in `source_suffix` list of `conf.py`." @@ -1447,6 +1486,12 @@ def apply_sphinx_configurations(app): ("fullToctreeMaxDepth", int), ("listingExclude", list), ("unabridgedOrphanKinds", (list, set)), + # Manual Indexing + ("classHierarchyFilename", six.string_types), + ("fileHierarchyFilename", six.string_types), + ("pageHierarchyFilename", six.string_types), + ("unabridgedApiFilename", six.string_types), + ("unabridgedOrphanFilename", six.string_types), # Clickable Hierarchies <3 ("createTreeView", bool), ("minifyTreeView", bool), diff --git a/exhale/graph.py b/exhale/graph.py index f14be4a..ffd284f 100644 --- a/exhale/graph.py +++ b/exhale/graph.py @@ -1061,24 +1061,12 @@ def __init__(self): self.root_directory = configs.containmentFolder self.root_file_name = configs.rootFileName self.full_root_file_path = os.path.join(self.root_directory, self.root_file_name) - # The {page,class,file}_view_hierarchy files are all `.. include::`ed in the - # root library document. Though we are generating rst, we will want to use a - # file extension `.rst.include` to bypass the fact that the sphinx builder will - # process them separately if we leave them as .rst (via the source_suffix - # configuration of the sphinx app). If users are getting warnings about it - # then we can actually check for `.include` in app.config.source_suffix, but - # it is very unlikely this is going to be a problem. - # See https://github.com/sphinx-doc/sphinx/issues/1668 - self.page_hierarchy_file = os.path.join(self.root_directory, "page_view_hierarchy.rst.include") - self.class_hierarchy_file = os.path.join(self.root_directory, "class_view_hierarchy.rst.include") - self.file_hierarchy_file = os.path.join(self.root_directory, "file_view_hierarchy.rst.include") - self.unabridged_api_file = os.path.join(self.root_directory, "unabridged_api.rst.include") - # NOTE: do *NOT* do .rst.include for the unabridged orphan kinds, the purpose of - # that document is to have it be processed by sphinx with its corresponding - # .. toctree:: calls to kinds that the user has asked to be excluded. Sphinx - # processing this document directly is desired (it is also marked :orphan: to - # avoid a warning on the fact that it is *NOT* included in any exhale toctree). - self.unabridged_orphan_file = os.path.join(self.root_directory, "unabridged_orphan.rst") + # These documents are all included in the root file document. + self.page_hierarchy_file = os.path.join(self.root_directory, configs.pageHierarchyFilename) + self.class_hierarchy_file = os.path.join(self.root_directory, configs.classHierarchyFilename) + self.file_hierarchy_file = os.path.join(self.root_directory, configs.fileHierarchyFilename) + self.unabridged_api_file = os.path.join(self.root_directory, configs.unabridgedApiFilename) + self.unabridged_orphan_file = os.path.join(self.root_directory, configs.unabridgedOrphanFilename) # whether or not we should generate the raw html tree view self.use_tree_view = configs.createTreeView @@ -2287,18 +2275,6 @@ def generateFullAPI(self): 2. :func:`~exhale.graph.ExhaleRoot.generateNodeDocuments` 3. :func:`~exhale.graph.ExhaleRoot.generateAPIRootBody` ''' - self.generateAPIRootHeader() - self.generateNodeDocuments() - self.generateAPIRootBody() - - def generateAPIRootHeader(self): - ''' - This method creates the root library api file that will include all of the - different hierarchy views and full api listing. If ``self.root_directory`` is - not a current directory, it is created first. Afterward, the root API file is - created and its title is written, as well as the value of - ``configs.afterTitleDescription``. - ''' try: # TODO: update to pathlib everywhere... root_directory_path = Path(self.root_directory) @@ -2307,6 +2283,26 @@ def generateAPIRootHeader(self): utils.fancyError( "Cannot create the directory {0} {1}".format(self.root_directory, e) ) + # TODO: API root body does not need to be separate, but it does need to happen + # after node documents are generated due to bad design (link names and other + # items get initialized). Or at least that's what I remember. + skip_root = self.root_file_name == "EXCLUDE" + if not skip_root: + self.generateAPIRootHeader() + self.generateNodeDocuments() + self.gerrymanderNodeFilenames() + self.generateViewHierarchies() + self.generateUnabridgedAPI() + if not skip_root: + self.generateAPIRootBody() + + def generateAPIRootHeader(self): + ''' + This method creates the root library api file that will include all of the + different hierarchy views and full api listing. The root API file is created + and its title is written, as well as the value of + ``configs.afterTitleDescription``. + ''' try: with codecs.open(self.full_root_file_path, "w", "utf-8") as generated_index: # Add the metadata if they requested it @@ -3613,9 +3609,7 @@ def generateAPIRootBody(self): conditionally use a ``toctree`` if you really need it. ''' try: - self.gerrymanderNodeFilenames() - self.generateViewHierarchies() - self.generateUnabridgedAPI() + with codecs.open(self.full_root_file_path, "a", "utf-8") as generated_index: # Include index page, if present for page in self.pages: diff --git a/testing/tests/configs.py b/testing/tests/configs.py index 5f5093d..e8776ce 100644 --- a/testing/tests/configs.py +++ b/testing/tests/configs.py @@ -11,6 +11,7 @@ from __future__ import unicode_literals import re import textwrap +from pathlib import Path import pytest from sphinx.errors import ConfigError @@ -392,3 +393,166 @@ def test_orphan_typedef(self): def test_orphan_file(self): """Verify excluding ``file`` behaves as expected.""" self._validate_diff_for_kinds(1, "file") + + +@confoverrides(exhale_args={ + "exhaleDoxygenStdin": textwrap.dedent("""\ + INPUT = ../include + EXCLUDE_PATTERNS = */page_town_rock*.hpp + """)}) +class ManualIndexingTests(ExhaleTestCase): + """ + Tests for :ref:`manual_indexing`. + + Includes checks for: + + - :data:`~exhale.configs.rootFileName` + - :data:`~exhale.configs.classHierarchyFilename` + - :data:`~exhale.configs.fileHierarchyFilename` + - :data:`~exhale.configs.pageHierarchyFilename` + - :data:`~exhale.configs.unabridgedApiFilename` + - :data:`~exhale.configs.unabridgedOrphanFilename` + """ + + test_project = "cpp_nesting" + """.. testproject:: cpp_nesting""" + + def root_file_path(self) -> Path: + """Return the path to the ``rootFileName``.""" + root_file_name = self.app.config.exhale_args["rootFileName"] + return Path(self.getAbsContainmentFolder()) / root_file_name + + def verify_expected_files( + self, + *, + page_default: str = "page_view_hierarchy.rst.include", + class_default: str = "class_view_hierarchy.rst.include", + file_default: str = "file_view_hierarchy.rst.include", + u_api_default: str = "unabridged_api.rst.include", + u_orphan_default: str = "unabridged_orphan.rst" + ): + """Verify the expected files were generated and included.""" + # The files that may or may not be generated by exhale. + containment_folder = Path(self.getAbsContainmentFolder()) + root_path = Path(self.app.exhale_root.full_root_file_path) + page_path = Path(self.app.exhale_root.page_hierarchy_file) + class_path = Path(self.app.exhale_root.class_hierarchy_file) + file_path = Path(self.app.exhale_root.file_hierarchy_file) + u_api_path = Path(self.app.exhale_root.unabridged_api_file) + u_orphan_path = Path(self.app.exhale_root.unabridged_orphan_file) + + # Calling tests change the exhale_args and corresponding default parameter, + # forcing manual effort deliberately for test correctness. + def verify_name(name: str, actual: Path): + # NOTE: doing direct path comparisons doesn't work on Windows, exhale (for + # better or worse) currently puts the magic windows prefix + # AssertionError: WindowsPath('//?/D:/a/exhale/...') != + # WindowsPath('D:/a/exhale/...') + # So instead just compare names and parent directory name and quit since I + # don't want to deal with this. + self.assertEqual( + name, + actual.name, + f"Expected {name} and {actual.name} to be the same ({str(actual)})." + ) + self.assertEqual( + containment_folder.name, + actual.parent.name, + f"Expected parent '{actual.parent.name}' from '{str(actual.parent)}' " + f"to be '{containment_folder.name}' from '{str(containment_folder)}'." + ) + + verify_name(page_default, page_path) + verify_name(class_default, class_path) + verify_name(file_default, file_path) + verify_name(u_api_default, u_api_path) + verify_name(u_orphan_default, u_orphan_path) + + # Make sure that everything is still getting included in the root document. + if root_path.is_file(): + with open(root_path) as f: + root_contents = f.read() + + def verify_include_directive(f: Path): + if f.is_file(): + condition = f".. include:: {f.name}" in root_contents + prefix = f"Expected '{f.name}' to be `.. included::`ed in" + else: + condition = f".. include:: {f.name}" not in root_contents + prefix = f"'{f.name}' should *NOT* have been `.. include::`ed in" + self.assertTrue( + condition, + f"{prefix} '{root_path.name}':\n{root_contents}" + ) + + verify_include_directive(page_path) + verify_include_directive(class_path) + verify_include_directive(file_path) + verify_include_directive(u_api_path) + + @pytest.mark.setup_raises( + exception=ConfigError, + match=r"The given `rootFileName` \(no_good\) did not end with '.rst'.*" + ) + @confoverrides(exhale_args={"rootFileName": "no_good"}) + def test_invalid_root_filename(self): + """ + Test that non-'.rst' suffix file is rejected, the root document is rst only. + """ + pass + + @confoverrides(exhale_args={"rootFileName": "EXCLUDE"}) + def test_no_root_generated(self): + """Test that ``rootFileName=EXCLUDE`` does not generate a file.""" + self.assertFalse( + Path(self.app.exhale_root.full_root_file_path).exists(), + "The rootFileName should not have been generated." + ) + self.verify_expected_files() + + @confoverrides(exhale_args={"classHierarchyFilename": "classes.rst"}) + def test_class_hierarchy_filename(self): + """Test changing :data:`~exhale.configs.classHierarchyFilename` works.""" + self.verify_expected_files(class_default="classes.rst") + + @confoverrides(exhale_args={"fileHierarchyFilename": "filez.rst"}) + def test_file_hierarchy_filename(self): + """Test changing :data:`~exhale.configs.fileHierarchyFilename` works.""" + self.verify_expected_files(file_default="filez.rst") + + @confoverrides(exhale_args={ + # Setup for getting pages. + "rootFileTitle": "", + "exhaleDoxygenStdin": textwrap.dedent("""\ + INPUT = ../include + EXCLUDE_PATTERNS = */page_town_rock_alt.hpp + """), + "pageHierarchyFilename": "pagez.rst" + }) + def test_page_hierarchy_filename(self): + """Test changing :data:`~exhale.configs.pageHierarchyFilename` works.""" + page_path = Path(self.app.exhale_root.page_hierarchy_file) + self.assertTrue(page_path.is_file(), f"Expected '{page_path.name}' to exist.") + self.assertEqual( + len(self.app.exhale_root.pages), + 1, + "Internal error: incorrect number of pages found." + ) + self.verify_expected_files(page_default="pagez.rst") + + @confoverrides(exhale_args={"unabridgedApiFilename": "ub_api.rst"}) + def test_unabridged_api_filename(self): + """Test changing :data:`~exhale.configs.unabridgedApiFilename` works.""" + u_api_path = Path(self.app.exhale_root.unabridged_api_file) + self.assertTrue(u_api_path.is_file(), f"Expected '{u_api_path.name}' to exist.") + self.verify_expected_files(u_api_default="ub_api.rst") + + @confoverrides(exhale_args={"unabridgedOrphanFilename": "ub_orphan.rst"}) + def test_unabridged_orphan_filename(self): + """Test changing :data:`~exhale.configs.unabridgedOrphanFilename` works.""" + u_orphan_path = Path(self.app.exhale_root.unabridged_api_file) + self.assertTrue( + u_orphan_path.is_file(), + f"Expected '{u_orphan_path.name}' to exist." + ) + self.verify_expected_files(u_orphan_default="ub_orphan.rst")