[rtems-central commit] sphinxbuilder: New

Sebastian Huber sebh at rtems.org
Tue Nov 21 13:35:41 UTC 2023


Module:    rtems-central
Branch:    master
Commit:    2ddd68a9db4dc56f1d35a4a30691bc1dc987266c
Changeset: http://git.rtems.org/rtems-central/commit/?id=2ddd68a9db4dc56f1d35a4a30691bc1dc987266c

Author:    Sebastian Huber <sebastian.huber at embedded-brains.de>
Date:      Tue Nov 21 11:13:16 2023 +0100

sphinxbuilder: New

---

 rtemsspec/packagebuildfactory.py                   |   3 +
 rtemsspec/sphinxbuilder.py                         | 466 +++++++++++++++++++++
 .../tests/spec-packagebuild/glossary/term.yml      |  12 +
 .../tests/spec-packagebuild/qdp/build/doc-2.yml    |  13 +
 .../tests/spec-packagebuild/qdp/build/doc.yml      |  13 +
 .../spec-packagebuild/qdp/deployment/doc-2.yml     |  13 +
 .../tests/spec-packagebuild/qdp/deployment/doc.yml |  13 +
 .../tests/spec-packagebuild/qdp/package-build.yml  |   4 +
 .../spec-packagebuild/qdp/source/doc-section.yml   |  13 +
 .../qdp/source/doc-subsection.yml                  |  15 +
 .../tests/spec-packagebuild/qdp/source/doc.yml     |  19 +
 .../tests/spec-packagebuild/qdp/steps/doc-2.yml    |  36 ++
 .../tests/spec-packagebuild/qdp/steps/doc.yml      | 102 +++++
 rtemsspec/tests/spec-packagebuild/rtems/if.yml     |   3 +-
 .../pkg/build/src/doc/copy-and-substitute.rst      |  21 +
 .../tests/test-files/pkg/build/src/doc/copy.rst    |   5 +
 .../tests/test-files/pkg/build/src/doc/index.rst   |  19 +
 .../tests/test-files/pkg/build/src/doc/some.txt    |   1 +
 rtemsspec/tests/test_packagebuild.py               | 215 ++++++++++
 spec-qdp/spec/qdp-document-license-map.yml         |  25 ++
 spec-qdp/spec/qdp-sphinx-build.yml                 |  98 +++++
 .../qdp-sphinx-component-copy-and-substitute.yml   |  34 ++
 spec-qdp/spec/qdp-sphinx-component-copy-files.yml  |  38 ++
 spec-qdp/spec/qdp-sphinx-component-copy.yml        |  33 ++
 spec-qdp/spec/qdp-sphinx-component-generic.yml     |  52 +++
 spec-qdp/spec/qdp-sphinx-component-glossary.yml    |  32 ++
 spec-qdp/spec/qdp-sphinx-component-list.yml        |  16 +
 spec-qdp/spec/qdp-sphinx-component.yml             |  34 ++
 .../spec/qdp-sphinx-contributor-action-list.yml    |  16 +
 spec-qdp/spec/qdp-sphinx-contributor-action.yml    |  26 ++
 spec-qdp/spec/qdp-sphinx-contributor-list.yml      |  16 +
 spec-qdp/spec/qdp-sphinx-contributor.yml           |  26 ++
 spec-qdp/spec/qdp-sphinx-release-list.yml          |  16 +
 spec-qdp/spec/qdp-sphinx-release.yml               |  31 ++
 spec-qdp/spec/qdp-sphinx-section.yml               |  34 ++
 spec-qdp/spec/qdp-sphinx-types.yml                 |  23 +
 36 files changed, 1535 insertions(+), 1 deletion(-)

diff --git a/rtemsspec/packagebuildfactory.py b/rtemsspec/packagebuildfactory.py
index 0d959f91..4e7567e0 100644
--- a/rtemsspec/packagebuildfactory.py
+++ b/rtemsspec/packagebuildfactory.py
@@ -33,6 +33,7 @@ from rtemsspec.reposubset import RepositorySubset
 from rtemsspec.rtems import RTEMSItemCache
 from rtemsspec.runactions import RunActions
 from rtemsspec.runtests import RunTests, TestLog
+from rtemsspec.sphinxbuilder import SphinxBuilder, SphinxSection
 from rtemsspec.testrunner import DummyTestRunner, GRMONManualTestRunner, \
     SubprocessTestRunner
 
@@ -49,11 +50,13 @@ def create_build_item_factory() -> BuildItemFactory:
     factory.add_constructor("qdp/build-step/rtems-item-cache", RTEMSItemCache)
     factory.add_constructor("qdp/build-step/run-actions", RunActions)
     factory.add_constructor("qdp/build-step/run-tests", RunTests)
+    factory.add_constructor("qdp/build-step/sphinx/generic", SphinxBuilder)
     factory.add_constructor("qdp/directory-state/generic", DirectoryState)
     factory.add_constructor("qdp/directory-state/repository", DirectoryState)
     factory.add_constructor("qdp/directory-state/test-log", TestLog)
     factory.add_constructor("qdp/directory-state/unpacked-archive",
                             DirectoryState)
+    factory.add_constructor("qdp/sphinx-section", SphinxSection)
     factory.add_constructor("qdp/test-runner/dummy", DummyTestRunner)
     factory.add_constructor("qdp/test-runner/grmon-manual",
                             GRMONManualTestRunner)
diff --git a/rtemsspec/sphinxbuilder.py b/rtemsspec/sphinxbuilder.py
new file mode 100644
index 00000000..298bce26
--- /dev/null
+++ b/rtemsspec/sphinxbuilder.py
@@ -0,0 +1,466 @@
+# SPDX-License-Identifier: BSD-2-Clause
+""" Contains the SphinxBuilder class. """
+
+# Copyright (C) 2020, 2023 embedded brains GmbH & Co. KG
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+#    notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+#    notice, this list of conditions and the following disclaimer in the
+#    documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from contextlib import contextmanager
+import logging
+import os
+import re
+import shutil
+from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
+import yaml
+
+from rtemsspec.content import BSD_2_CLAUSE_LICENSE, Copyrights
+from rtemsspec.directorystate import DirectoryState
+from rtemsspec.packagebuild import BuildItem, BuildItemFactory, \
+    PackageBuildDirector
+from rtemsspec.items import is_enabled, Item, ItemGetValueContext, Link
+from rtemsspec import glossary
+from rtemsspec.sphinxcontent import SphinxContent
+from rtemsspec.util import run_command
+
+_BREAK = "\\break"
+
+_PUSH_ENABLED_BY = re.compile(r"^\${\.:/push-enabled-by:(.+)}$")
+
+_POP_ENABLED_BY = re.compile(r"^\${\.:/pop-enabled-by")
+
+_RST_HEADERS = re.compile(
+    r"^\.\. SPDX-License-Identifier: (.+)\n\n((\.\. Copyright \(C\).*\n)+)\n",
+    flags=re.MULTILINE)
+
+
+def _get_normal_title(ctx: ItemGetValueContext) -> Any:
+    return ctx.item["document-title"].replace(_BREAK, " ")
+
+
+def _get_sphinx_title(ctx: ItemGetValueContext) -> Any:
+    content = SphinxContent()
+    content.add_header(_get_normal_title(ctx), level=1)
+    return "\n".join(content.lines)
+
+
+def _get_release(ctx: ItemGetValueContext) -> Any:
+    return str(len(ctx.item["document-releases"]))
+
+
+def _no_action(_component: Dict[str, Any]) -> None:
+    pass
+
+
+def _sep(seps: Tuple[str, ...], maxi: Tuple[int, ...]) -> str:
+    return "+" + "+".join(f"{sep * (val + 2)}"
+                          for sep, val in zip(seps, maxi)) + "+"
+
+
+def _row(row: Tuple[str, ...], maxi: Tuple[int, ...]) -> str:
+    return "|" + "|".join(f" {cell:{width}} "
+                          for cell, width in zip(row, maxi)) + "|"
+
+
+def _get_contributors(ctx: ItemGetValueContext) -> Any:
+    rows = [("Action", "Name", "Organization", "Signature")]
+    maxi = tuple(map(len, rows[0]))
+    for action in ctx.item["document-contributors"]:
+        for contributor in action["contributors"]:
+            row = (action["action"], contributor["name"],
+                   contributor["organization"], "")
+            rows.append(row)
+            row_lengths = tuple(map(len, row))
+            maxi = tuple(map(max, zip(maxi, row_lengths)))
+    sep_0 = _sep(("-", "-", "-", "-"), maxi)
+    sep_1 = _sep(("=", "=", "=", "="), maxi)
+    sep_2 = _sep((" ", "-", "-", "-"), maxi)
+    lines = [sep_0, _row(rows[0], maxi)]
+    last_action = rows[0][0]
+    for row in rows[1:]:
+        if last_action == "Action":
+            lines.append(sep_1)
+            last_action = row[0]
+        elif last_action == row[0]:
+            lines.append(sep_2)
+            row = ("", row[1], row[2], row[3])
+        else:
+            lines.append(sep_0)
+            last_action = row[0]
+        lines.append(_row(row, maxi))
+    lines.append(sep_0)
+    content = SphinxContent()
+    with content.directive(
+            "table", options=[":class: longtable", ":widths: 16 26 30 28"]):
+        content.add(lines)
+    return "\n".join(content.lines)
+
+
+def _latex_escape(value: str) -> str:
+    return value.replace("_", "\\_").replace("&", "\\&")
+
+
+_COPYRIGHT = re.compile(r"^\s*Copyright\s+\(C\)\s+", re.IGNORECASE)
+_YEARS = re.compile(r"^[0-9, ]*")
+
+
+def _get_document_copyright(ctx: ItemGetValueContext) -> Any:
+    copyrights = ctx.item["document-copyrights"]
+    main = _COPYRIGHT.sub("", copyrights[0])
+    if len(copyrights) == 1:
+        return main
+    return f"{main} and contributors"
+
+
+def _get_document_author(ctx: ItemGetValueContext) -> Any:
+    return _YEARS.sub("", _get_document_copyright(ctx))
+
+
+def _get_document_year(ctx: ItemGetValueContext) -> Any:
+    match = _YEARS.search(_get_document_copyright(ctx))
+    assert match
+    return match.group(0).split(",")[-1]
+
+
+class SphinxBuilder(BuildItem):
+    """ Base class for Sphinx document builds. """
+
+    # pylint: disable=too-many-instance-attributes
+    @classmethod
+    def prepare_factory(cls, factory: BuildItemFactory,
+                        type_name: str) -> None:
+        BuildItem.prepare_factory(factory, type_name)
+        factory.add_get_value(f"{type_name}:/document-release", _get_release)
+        factory.add_get_value(f"{type_name}:/document-year",
+                              _get_document_year)
+
+    def __init__(self, director: PackageBuildDirector, item: Item):
+        super().__init__(director, item)
+        source = self.input("source")
+        assert isinstance(source, DirectoryState)
+
+        build = self.output("build")
+        assert isinstance(build, DirectoryState)
+
+        self._index: List[str] = []
+        self._section_level = 2
+        self.source_dir = source.directory
+        self.build_dir = build.directory
+        self.file_path = self.build_dir
+        my_type = self.item.type
+        self.mapper.add_get_value(f"{my_type}:/document-author",
+                                  _get_document_author)
+        self.mapper.add_get_value(f"{my_type}:/document-contract-html",
+                                  self._get_contract_html)
+        self.mapper.add_get_value(f"{my_type}:/document-contract-latex",
+                                  self._get_contract_latex)
+        self.mapper.add_get_value(f"{my_type}:/document-copyright",
+                                  _get_document_copyright)
+        self.mapper.add_get_value(f"{my_type}:/document-copyrights",
+                                  self._get_document_copyrights)
+        self.mapper.add_get_value(
+            f"{my_type}:/document-bsd-2-clause-copyrights",
+            self._get_document_bsd_2_clause_copyrights)
+        self.mapper.add_get_value(f"{my_type}:/document-normal-title",
+                                  _get_normal_title)
+        self.mapper.add_get_value(f"{my_type}:/document-latex-copyright",
+                                  self._get_latex_copyright)
+        self.mapper.add_get_value(f"{my_type}:/document-latex-title",
+                                  self._get_latex_title)
+        self.mapper.add_get_value(f"{my_type}:/document-title-page-title",
+                                  self._get_title_page_title)
+        self.mapper.add_get_value(f"{my_type}:/document-sphinx-title",
+                                  _get_sphinx_title)
+        self.mapper.add_get_value(f"{my_type}:/document-index",
+                                  self._get_index)
+        self.mapper.add_get_value(f"{my_type}:/document-releases",
+                                  self._get_releases)
+        self.mapper.add_get_value(f"{my_type}:/document-contributors",
+                                  _get_contributors)
+        for name in [my_type, "qdp/sphinx-section"]:
+            self.mapper.add_get_value(f"{name}:/sections", self._get_sections)
+        self._actions = {
+            "add-to-index": _no_action,
+            "copy": self._copy,
+            "copy-and-substitute": self._copy_and_substitute,
+            "copy-files": self._copy_files,
+            "glossary": self._glossary
+        }
+
+    def run(self) -> None:
+        self.mapper.copyrights_by_license.clear()
+
+        destination = self.output("destination")
+        assert isinstance(destination, DirectoryState)
+        destination.clear()
+
+        os.makedirs(os.path.join(self.build_dir, "source"), exist_ok=True)
+        status = run_command(
+            ["python3", "-msphinx", "-M", "clean", "source", "build"],
+            self.build_dir)
+        assert status == 0
+        enabled_set = self.enabled_set
+        for component in self["document-components"]:
+            if is_enabled(enabled_set, component.get("enabled-by", True)):
+                self.file_path = os.path.join(
+                    self.build_dir, component.get("destination", "."))
+                self._actions[component["action"]](component)
+                self._add_to_index(component)
+        output = self["output-pdf"]
+        if output:
+            status = run_command(
+                ["python3", "-msphinx", "-M", "latexpdf", "source", "build"],
+                self.build_dir)
+            assert status == 0
+            src_path = os.path.join(self.build_dir, "build", "latex",
+                                    "document.pdf")
+            destination.copy_file(src_path, output)
+        output = self["output-html"]
+        if output:
+            status = run_command(
+                ["python3", "-msphinx", "-M", "html", "source", "build"],
+                self.build_dir)
+            assert status == 0
+            src_path = os.path.join(self.build_dir, "build", "html")
+            destination.copy_tree(src_path, output)
+
+        my_license = self["document-license"]
+        destination["copyrights-by-license"] = dict(
+            (key, value.get_statements())
+            for key, value in self._get_copyrights().items()
+            if key != my_license)
+
+    def add_component_action(self, name: str,
+                             action: Callable[[Dict[str, Any]], None]) -> None:
+        """ Adds a component action. """
+        self._actions[name] = action
+
+    def _get_releases(self, ctx: ItemGetValueContext) -> Any:
+        content = SphinxContent()
+        releases = ctx.item["document-releases"]
+        count = len(releases)
+        for idx, release in enumerate(reversed(releases)):
+            date = release["date"]
+            status = release["status"]
+            line = f"Release: {count - idx}, Date: {date}, Status: {status}"
+            with content.directive("topic", line):
+                lines = self._push_pop_enabled_by(
+                    release["changes"].splitlines())
+                content.add(self.mapper.substitute("\n".join(lines), ctx.item))
+        return "\n".join(content.lines)
+
+    def _add_to_index(self, component: Dict[str, Any]) -> None:
+        if component.get("add-to-index", False):
+            self._index.append(
+                os.path.basename(component["destination"]).replace(".rst", ""))
+
+    def _get_index(self, ctx: ItemGetValueContext) -> Any:
+        content = SphinxContent()
+        maxdepth = f":maxdepth: {ctx.item['document-toctree-maxdepth']}"
+        with content.directive("toctree", options=[maxdepth, ":numbered:"]):
+            content.add(self._index)
+        return "\n".join(content.lines)
+
+    def _get_contract_html(self, ctx: ItemGetValueContext) -> Any:
+        contract = self.mapper.substitute(ctx.item["document-contract"])
+        dot = ". " if contract else ""
+        return f"{dot}{contract.replace(_BREAK, ' ')}"
+
+    def _get_contract_latex(self, ctx: ItemGetValueContext) -> Any:
+        return _latex_escape(
+            self.mapper.substitute(ctx.item["document-contract"]).replace(
+                _BREAK, " \\vspace{-4pt} \\break "))
+
+    def _get_latex_copyright(self, ctx: ItemGetValueContext) -> Any:
+        return _latex_escape(
+            self.mapper.substitute(_get_document_copyright(ctx)))
+
+    def _get_copyrights(self) -> Dict[str, Copyrights]:
+        my_license = self["document-license"]
+        copyrights: Dict[str, Copyrights] = {}
+        copyrights.setdefault(my_license, Copyrights()).register(
+            self["document-copyrights"])
+        license_map = self["document-license-map"]
+        for key, value in self.mapper.copyrights_by_license.items():
+            the_license = license_map.get(key, key)
+            copyrights.setdefault(the_license, Copyrights()).register(value)
+        return copyrights
+
+    def _get_document_copyrights(self, ctx: ItemGetValueContext) -> Any:
+        my_license = self["document-license"]
+        assert " OR " not in my_license
+        copyrights = self._get_copyrights()
+        prefix = ctx.args if ctx.args else ""
+        return "\n".join(copyrights[my_license].get_statements(f"{prefix}| ©"))
+
+    def _get_document_bsd_2_clause_copyrights(
+            self, _ctx: ItemGetValueContext) -> Any:
+        copyrights = self._get_copyrights()
+        the_license = "BSD-2-Clause"
+        if the_license not in copyrights:
+            return ""
+        statements = "\n".join(copyrights[the_license].get_statements("| ©"))
+        return f"""{statements}
+
+{BSD_2_CLAUSE_LICENSE}"""
+
+    def _get_latex_title(self, ctx: ItemGetValueContext) -> Any:
+        return _latex_escape(
+            self.mapper.substitute(ctx.item["document-title"].replace(
+                _BREAK, " ")))
+
+    def _get_title_page_title(self, ctx: ItemGetValueContext) -> Any:
+        return _latex_escape(
+            self.mapper.substitute(ctx.item["document-title"].replace(
+                _BREAK, " \\break \\break ")))
+
+    @contextmanager
+    def section_level(
+            self,
+            ctx: ItemGetValueContext) -> Iterator[Tuple[int, Optional[str]]]:
+        """ Opens a section level with optional additional arguments. """
+        if ctx.args:
+            colon = ctx.args.find(":")
+            if colon >= 0:
+                level_change = int(ctx.args[:colon])
+                args: Optional[str] = ctx.args[colon + 1:]
+            else:
+                level_change = int(ctx.args)
+                args = None
+        else:
+            level_change = 1
+            args = None
+        section_level = self._section_level
+        new_section_level = section_level + level_change
+        self._section_level = new_section_level
+        yield new_section_level, args
+        self._section_level = section_level
+
+    def _get_sections(self, ctx: ItemGetValueContext) -> Any:
+        with self.section_level(ctx) as (section_level, args):
+            assert args
+            content = SphinxContent(section_level)
+            build_item = self.director[ctx.item.uid]
+            for section in build_item.inputs(args):
+                with content.section(section.item["header"],
+                                     label=section.item["label"]):
+                    content.add(section.item["content"].strip())
+        return "\n".join(content.lines)
+
+    def _register_text_copyrights(self, text: str) -> None:
+        match = _RST_HEADERS.match(text)
+        assert match
+        the_license = match.group(1)
+        statements = [
+            statement[3:] for statement in match.group(2).split("\n")[:-1]
+        ]
+        logging.info("%s: register license %s with copyrights %s", self.uid,
+                     the_license, statements)
+        self.mapper.copyrights_by_license.setdefault(the_license,
+                                                     set()).update(statements)
+
+    def _do_copy(self, src_file: str, dst_file: str) -> None:
+        os.makedirs(os.path.dirname(dst_file), exist_ok=True)
+        if dst_file.endswith(".rst"):
+            logging.info("%s: read: %s", self.uid, src_file)
+            with open(src_file, "r", encoding="utf-8") as src:
+                text = src.read()
+                self._register_text_copyrights(text)
+                logging.info("%s: write: %s", self.uid, dst_file)
+                with open(dst_file, "w+", encoding="utf-8") as dst:
+                    dst.write(text)
+        else:
+            logging.info("%s: copy '%s' to '%s'", self.uid, src_file, dst_file)
+            shutil.copy2(src_file, dst_file)
+
+    def _copy(self, component: Dict[str, Any]) -> None:
+        self._do_copy(os.path.join(self.source_dir, component["source"]),
+                      os.path.join(self.build_dir, component["destination"]))
+
+    def _push_pop_enabled_by(self, lines: List[str]) -> List[str]:
+        filtered_lines: List[str] = []
+        enabled: List[bool] = [True]
+        for line in lines:
+            push_match = _PUSH_ENABLED_BY.search(line)
+            if push_match is not None:
+                data = push_match.group(1)
+                enabled_by = yaml.load(data, yaml.SafeLoader)
+                enabled.append(enabled[-1]
+                               and is_enabled(self.enabled_set, enabled_by))
+                continue
+            if _POP_ENABLED_BY.search(line) is not None:
+                enabled.pop()
+            elif enabled[-1]:
+                filtered_lines.append(line)
+        return filtered_lines
+
+    def _copy_and_substitute(self, component: Dict[str, Any]) -> None:
+        src_path = os.path.join(self.source_dir, component["source"])
+        dst_path = os.path.join(self.build_dir, component["destination"])
+        logging.info("%s: read: %s", self.uid, src_path)
+        with open(src_path, "r", encoding="utf-8") as src:
+            logging.info("%s: process push/pop enabled by", self.uid)
+            text = "".join(self._push_pop_enabled_by(src.readlines()))
+            if dst_path.endswith(".rst"):
+                self._register_text_copyrights(text)
+            logging.info("%s: substitute", self.uid)
+            text = self.mapper.substitute(text)
+            logging.info("%s: write: %s", self.uid, dst_path)
+            os.makedirs(os.path.dirname(dst_path), exist_ok=True)
+            with open(dst_path, "w+", encoding="utf-8") as dst:
+                dst.write(text)
+
+    def _copy_files(self, component: Dict[str, Any]) -> None:
+        src_dir = os.path.join(self.source_dir, component["source"])
+        dst_dir = os.path.join(self.build_dir, component["destination"])
+        for a_file in component["files"]:
+            self._do_copy(os.path.join(src_dir, a_file),
+                          os.path.join(dst_dir, a_file))
+
+    def _glossary(self, component: Dict[str, Any]) -> None:
+        config = {
+            "project-groups":
+            component["glossary-groups"],
+            "project-header":
+            None,
+            "project-target":
+            None,
+            "documents": [{
+                "header":
+                "Terms, definitions and abbreviated terms",
+                "rest-source-paths": [
+                    str(os.path.join(self.build_dir, "source")),
+                    str(os.path.join(self.build_dir, "include"))
+                ],
+                "target":
+                str(os.path.join(self.build_dir, component["destination"])),
+            }]
+        }
+        glossary.generate(config, self.item.cache, self.mapper)
+
+
+class SphinxSection(BuildItem):
+    """ This class represents a Sphinx section. """
+
+    def has_changed(self, link: Link) -> bool:
+        if self.is_build_necessary():
+            return True
+        return super().has_changed(link)
diff --git a/rtemsspec/tests/spec-packagebuild/glossary/term.yml b/rtemsspec/tests/spec-packagebuild/glossary/term.yml
new file mode 100644
index 00000000..aa4e1432
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/glossary/term.yml
@@ -0,0 +1,12 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 Alice
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+glossary-type: term
+links:
+- role: glossary-member
+  uid: ../glossary-general
+term: Term
+text: This is the term.
+type: glossary
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/build/doc-2.yml b/rtemsspec/tests/spec-packagebuild/qdp/build/doc-2.yml
new file mode 100644
index 00000000..87634f3f
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/build/doc-2.yml
@@ -0,0 +1,13 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+copyrights-by-license: {}
+directory: ${../variant:/build-directory}/doc-2
+directory-state-type: generic
+enabled-by: true
+files: []
+hash: null
+links: []
+patterns: []
+qdp-type: directory-state
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/build/doc.yml b/rtemsspec/tests/spec-packagebuild/qdp/build/doc.yml
new file mode 100644
index 00000000..ec7d11dd
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/build/doc.yml
@@ -0,0 +1,13 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+copyrights-by-license: {}
+directory: ${../variant:/build-directory}/doc
+directory-state-type: generic
+enabled-by: true
+files: []
+hash: null
+links: []
+patterns: []
+qdp-type: directory-state
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/deployment/doc-2.yml b/rtemsspec/tests/spec-packagebuild/qdp/deployment/doc-2.yml
new file mode 100644
index 00000000..a252bf6e
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/deployment/doc-2.yml
@@ -0,0 +1,13 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+copyrights-by-license: {}
+directory: ${../variant:/deployment-directory}/doc-2
+directory-state-type: generic
+enabled-by: true
+files: []
+hash: null
+links: []
+patterns: []
+qdp-type: directory-state
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/deployment/doc.yml b/rtemsspec/tests/spec-packagebuild/qdp/deployment/doc.yml
new file mode 100644
index 00000000..ca375bca
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/deployment/doc.yml
@@ -0,0 +1,13 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+copyrights-by-license: {}
+directory: ${../variant:/deployment-directory}/doc
+directory-state-type: generic
+enabled-by: true
+files: []
+hash: null
+links: []
+patterns: []
+qdp-type: directory-state
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/package-build.yml b/rtemsspec/tests/spec-packagebuild/qdp/package-build.yml
index 4686460e..4147fd6f 100644
--- a/rtemsspec/tests/spec-packagebuild/qdp/package-build.yml
+++ b/rtemsspec/tests/spec-packagebuild/qdp/package-build.yml
@@ -21,6 +21,10 @@ links:
   uid: steps/gcda-producer
 - role: build-step
   uid: steps/membench
+- role: build-step
+  uid: steps/doc
+- role: build-step
+  uid: steps/doc-2
 - role: build-step
   uid: steps/archive
 qdp-type: package-build
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/source/doc-section.yml b/rtemsspec/tests/spec-packagebuild/qdp/source/doc-section.yml
new file mode 100644
index 00000000..8dc66d45
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/source/doc-section.yml
@@ -0,0 +1,13 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+content: |
+  Section content:
+
+  ${.:/sections:2:subsection}
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+enabled-by: true
+header: Section Header
+label: SectionHeader
+links: []
+qdp-type: sphinx-section
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/source/doc-subsection.yml b/rtemsspec/tests/spec-packagebuild/qdp/source/doc-subsection.yml
new file mode 100644
index 00000000..0636dada
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/source/doc-subsection.yml
@@ -0,0 +1,15 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+content: |
+  Subsection content.
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+enabled-by: true
+header: Subsection Header
+label: SubsectionHeader
+links:
+- hash: null
+  name: spec
+  role: input
+  uid: ../steps/rtems-item-cache
+qdp-type: sphinx-section
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/source/doc.yml b/rtemsspec/tests/spec-packagebuild/qdp/source/doc.yml
new file mode 100644
index 00000000..ca2a620b
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/source/doc.yml
@@ -0,0 +1,19 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+copyrights-by-license: {}
+directory: ${../variant:/build-directory}/src/doc
+directory-state-type: generic
+enabled-by: true
+files:
+- file: copy-and-substitute.rst
+  hash: null
+- file: copy.rst
+  hash: null
+- file: index.rst
+  hash: null
+hash: null
+links: []
+patterns: []
+qdp-type: directory-state
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/steps/doc-2.yml b/rtemsspec/tests/spec-packagebuild/qdp/steps/doc-2.yml
new file mode 100644
index 00000000..910cb2fd
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/steps/doc-2.yml
@@ -0,0 +1,36 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+build-step-type: sphinx
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+description: |
+  Builds a Sphinx document 2.
+document-title: The Title
+document-toctree-maxdepth: 4
+document-html-help-base-name: Puh
+document-components: []
+document-contract: ''
+document-copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+document-key: bla
+document-releases: []
+document-contributors: []
+document-license: CC-BY-SA-4.0
+document-license-map:
+  BSD-2-Clause: CC-BY-SA-4.0 OR BSD-2-Clause
+document-type: generic
+enabled-by: sphinx-builder-2
+links:
+- hash: null
+  name: source
+  role: input
+  uid: ../source/doc
+- name: build
+  role: output
+  uid: ../build/doc-2
+- name: destination
+  role: output
+  uid: ../deployment/doc-2
+output-pdf: null
+output-html: null
+qdp-type: build-step
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/qdp/steps/doc.yml b/rtemsspec/tests/spec-packagebuild/qdp/steps/doc.yml
new file mode 100644
index 00000000..7a6c1b17
--- /dev/null
+++ b/rtemsspec/tests/spec-packagebuild/qdp/steps/doc.yml
@@ -0,0 +1,102 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+build-step-type: sphinx
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+description: |
+  Builds a Sphinx document.
+document-title: The\breakTitle
+document-toctree-maxdepth: 4
+document-html-help-base-name: Base
+document-components:
+- action: copy-and-substitute
+  add-to-index: true
+  source: copy-and-substitute.rst
+  destination: source/copy-and-substitute.rst
+- action: copy
+  add-to-index: false
+  source: copy.rst
+  destination: source/copy.rst
+- action: copy
+  add-to-index: false
+  enabled-by: false
+  source: nil.rst
+  destination: source/nada.rst
+- action: copy
+  add-to-index: false
+  source: some.txt
+  destination: source/some.txt
+- action: copy-and-substitute
+  add-to-index: false
+  source: some.txt
+  destination: source/some.txt
+- action: copy-files
+  add-to-index: false
+  source: .
+  destination: other
+  files:
+  - copy.rst
+- action: add-to-index
+  add-to-index: true
+  destination: source/glossary.rst
+- action: copy-and-substitute
+  add-to-index: false
+  source: index.rst
+  destination: source/index.rst
+- action: glossary
+  add-to-index: false
+  destination: source/glossary.rst
+  glossary-groups:
+  - /glossary-general
+document-contract: Contract
+document-copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+document-key: blub
+document-releases:
+- date: '1970-01-01'
+  status: Replaced
+  changes: Initial release.
+- date: '2020-10-26'
+  status: Draft
+  changes: |
+    * ${.:/document-copyright}
+
+    * e
+document-contributors:
+- action: Written by
+  contributors:
+  - name: John Doe
+    organization: Some Organization
+  - name: Foo
+    organization: Bár Organization
+- action: Super Action
+  contributors:
+  - name: This is a Long Name
+    organization: Short
+document-license: CC-BY-SA-4.0
+document-license-map:
+  CC-BY-SA-4.0 OR BSD-2-Clause: CC-BY-SA-4.0
+document-type: generic
+enabled-by: sphinx-builder
+links:
+- hash: null
+  name: source
+  role: input
+  uid: ../source/doc
+- hash: null
+  name: section
+  role: input
+  uid: ../source/doc-section
+- hash: null
+  name: subsection
+  role: input
+  uid: ../source/doc-subsection
+- name: build
+  role: output
+  uid: ../build/doc
+- name: destination
+  role: output
+  uid: ../deployment/doc
+output-pdf: doc.pdf
+output-html: html
+qdp-type: build-step
+type: qdp
diff --git a/rtemsspec/tests/spec-packagebuild/rtems/if.yml b/rtemsspec/tests/spec-packagebuild/rtems/if.yml
index 0e1d67fe..3e0c69d6 100644
--- a/rtemsspec/tests/spec-packagebuild/rtems/if.yml
+++ b/rtemsspec/tests/spec-packagebuild/rtems/if.yml
@@ -1,7 +1,8 @@
-SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+SPDX-License-Identifier: BSD-2-Clause
 brief: |
   Brief.
 copyrights:
+- Copyright (C) 2023 Bob
 - Copyright (C) 2023 embedded brains GmbH & Co. KG
 definition:
   default:
diff --git a/rtemsspec/tests/test-files/pkg/build/src/doc/copy-and-substitute.rst b/rtemsspec/tests/test-files/pkg/build/src/doc/copy-and-substitute.rst
new file mode 100644
index 00000000..916b6389
--- /dev/null
+++ b/rtemsspec/tests/test-files/pkg/build/src/doc/copy-and-substitute.rst
@@ -0,0 +1,21 @@
+.. SPDX-License-Identifier: CC-BY-SA-4.0
+
+.. Copyright (C) 2023 embedded brains GmbH & Co. KG
+
+${.:/push-enabled-by:and: [sphinx-builder, {not: {not: sphinx-builder}}]}
+${.:/push-enabled-by:not: sphinx-builder}
+This is disabled.
+${.:/pop-enabled-by}
+${.:/document-contract-html}
+${.:/document-contract-latex}
+${.:/document-normal-title}
+${.:/document-title-page-title}
+${.:/document-release}
+${.:/document-latex-copyright}
+${.:/document-latex-title}
+${/glossary/term:/term}
+${/glossary/term:/plural}
+${/rtems/if:/name}
+${.:/spec}
+${.:/sections:1:section}
+${.:/pop-enabled-by}
diff --git a/rtemsspec/tests/test-files/pkg/build/src/doc/copy.rst b/rtemsspec/tests/test-files/pkg/build/src/doc/copy.rst
new file mode 100644
index 00000000..f76dec5e
--- /dev/null
+++ b/rtemsspec/tests/test-files/pkg/build/src/doc/copy.rst
@@ -0,0 +1,5 @@
+.. SPDX-License-Identifier: CC-BY-SA-4.0
+
+.. Copyright (C) 2023 embedded brains GmbH & Co. KG
+
+Only copy, no ${/variable:/substitute}.
diff --git a/rtemsspec/tests/test-files/pkg/build/src/doc/index.rst b/rtemsspec/tests/test-files/pkg/build/src/doc/index.rst
new file mode 100644
index 00000000..edd05e6a
--- /dev/null
+++ b/rtemsspec/tests/test-files/pkg/build/src/doc/index.rst
@@ -0,0 +1,19 @@
+.. SPDX-License-Identifier: CC-BY-SA-4.0
+
+.. Copyright (C) 2023 embedded brains GmbH & Co. KG
+
+${.:/document-copyright}
+
+${.:/document-author}
+
+${.:/document-copyrights}
+
+${.:/document-bsd-2-clause-copyrights}
+
+${.:/document-sphinx-title}
+
+${.:/document-releases}
+
+${.:/document-contributors}
+
+${.:/document-index}
diff --git a/rtemsspec/tests/test-files/pkg/build/src/doc/some.txt b/rtemsspec/tests/test-files/pkg/build/src/doc/some.txt
new file mode 100644
index 00000000..3e715502
--- /dev/null
+++ b/rtemsspec/tests/test-files/pkg/build/src/doc/some.txt
@@ -0,0 +1 @@
+Some text.
diff --git a/rtemsspec/tests/test_packagebuild.py b/rtemsspec/tests/test_packagebuild.py
index 5ddbff41..5ad614d8 100644
--- a/rtemsspec/tests/test_packagebuild.py
+++ b/rtemsspec/tests/test_packagebuild.py
@@ -42,6 +42,7 @@ from rtemsspec.packagebuild import BuildItem, BuildItemMapper, \
 from rtemsspec.packagebuildfactory import create_build_item_factory
 from rtemsspec.rtems import RTEMSItemCache
 from rtemsspec.specverify import verify
+import rtemsspec.sphinxbuilder
 import rtemsspec.testrunner
 from rtemsspec.testrunner import Executable, Report, TestRunner
 from rtemsspec.tests.util import get_and_clear_log
@@ -130,6 +131,31 @@ def _gather_sections(item_cache, path, objdump, gdb):
     return {}
 
 
+def _sphinx_builder_run_command(args, cwd=None, stdout=None):
+    if args == ["python3", "-msphinx", "-M", "clean", "source", "build"]:
+        return 0
+    if args == ["python3", "-msphinx", "-M", "latexpdf", "source", "build"]:
+        os.makedirs(os.path.join(cwd, "build/latex"))
+        open(os.path.join(cwd, "build/latex/document.pdf"), "w+").close()
+        return 0
+    if args == ["python3", "-msphinx", "-M", "html", "source", "build"]:
+        os.makedirs(os.path.join(cwd, "build/html"))
+        open(os.path.join(cwd, "build/html/index.html"), "w+").close()
+        return 0
+    if args == ["make", "super", "clean"]:
+        return 0
+    if args == ["make"]:
+        stdout.append("example")
+        return 0
+    if "pkg-config" in args:
+        stdout.append(" ".join(args))
+        return 0
+    if args[0].endswith("verify"):
+        stdout.append("verify")
+        return 0
+    return 1
+
+
 def test_packagebuild(caplog, tmpdir, monkeypatch):
     tmp_dir = Path(tmpdir)
     item_cache = _create_item_cache(tmp_dir, Path("spec-packagebuild"))
@@ -444,3 +470,192 @@ def test_packagebuild(caplog, tmpdir, monkeypatch):
     monkeypatch.undo()
     log = get_and_clear_log(caplog)
     assert f"/qdp/steps/membench: get memory benchmarks for build-label from: arch/bsp" in log
+
+    # Test SphinxBuilder
+    variant["enabled"] = ["sphinx-builder"]
+    doc = director["/qdp/steps/doc"]
+    doc.substitute("${.:/document-author}") == "embedded brains GmbH & Co. KG"
+    doc.substitute("${.:/document-year}") == "2020"
+    doc.substitute(
+        "${.:/document-copyright}") == "2020 embedded brains GmbH & Co. KG"
+    doc.item["document-copyrights"].append("Copyright (C) 2023 John Doe")
+    doc.substitute("${.:/document-author}"
+                   ) == "embedded brains GmbH & Co. KG and contributors"
+    doc.substitute("${.:/document-copyright}"
+                   ) == "2020 embedded brains GmbH & Co. KG and contributors"
+    doc.item["document-copyrights"].pop()
+    doc_src = director["/qdp/source/doc"]
+    doc_src.load()
+    doc_build = Path(director["/qdp/build/doc"].directory)
+    assert not (doc_build / "source" / "copy.rst").exists()
+    assert not (doc_build / "other" / "copy.rst").exists()
+    monkeypatch.setattr(rtemsspec.sphinxbuilder, "run_command",
+                        _sphinx_builder_run_command)
+    director.build_package(None, None)
+    monkeypatch.undo()
+    assert (doc_build / "source" / "copy.rst").is_file()
+    assert (doc_build / "other" / "copy.rst").is_file()
+    doc_result = doc_build / "source" / "copy-and-substitute.rst"
+    with open(doc_result, "r", encoding="utf-8") as src:
+        assert src.read() == """.. SPDX-License-Identifier: CC-BY-SA-4.0
+
+.. Copyright (C) 2023 embedded brains GmbH & Co. KG
+
+. Contract
+Contract
+The Title
+The \\break \\break Title
+2
+2020 embedded brains GmbH \\& Co. KG
+The Title
+:term:`Term`
+:term:`Terms <Term>`
+:c:func:`identity`
+spec:/qdp/steps/doc
+.. _SectionHeader:
+
+Section Header
+--------------
+
+Section content:
+
+.. _SubsectionHeader:
+
+Subsection Header
+^^^^^^^^^^^^^^^^^
+
+Subsection content.
+"""
+    doc_index = doc_build / "source" / "index.rst"
+    with open(doc_index, "r", encoding="utf-8") as src:
+        assert src.read() == """.. SPDX-License-Identifier: CC-BY-SA-4.0
+
+.. Copyright (C) 2023 embedded brains GmbH & Co. KG
+
+2020 embedded brains GmbH & Co. KG
+
+embedded brains GmbH & Co. KG
+
+| © 2023 Alice
+| © 2020, 2023 embedded brains GmbH & Co. KG
+
+| © 2023 Bob
+| © 2023 embedded brains GmbH & Co. KG
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+The Title
+*********
+
+.. topic:: Release: 2, Date: 2020-10-26, Status: Draft
+
+    * 2020 embedded brains GmbH & Co. KG
+
+    * e
+
+.. topic:: Release: 1, Date: 1970-01-01, Status: Replaced
+
+    Initial release.
+
+.. table::
+    :class: longtable
+    :widths: 16 26 30 28
+
+    +--------------+---------------------+-------------------+-----------+
+    | Action       | Name                | Organization      | Signature |
+    +==============+=====================+===================+===========+
+    | Written by   | John Doe            | Some Organization |           |
+    +              +---------------------+-------------------+-----------+
+    |              | Foo                 | Bár Organization  |           |
+    +--------------+---------------------+-------------------+-----------+
+    | Super Action | This is a Long Name | Short             |           |
+    +--------------+---------------------+-------------------+-----------+
+
+.. toctree::
+    :maxdepth: 4
+    :numbered:
+
+    copy-and-substitute
+    glossary
+"""
+    doc_glossary = doc_build / "source" / "glossary.rst"
+    with open(doc_glossary, "r", encoding="utf-8") as src:
+        assert src.read() == """.. SPDX-License-Identifier: CC-BY-SA-4.0
+
+.. Copyright (C) 2023 Alice
+.. Copyright (C) 2020 embedded brains GmbH & Co. KG
+
+Terms, definitions and abbreviated terms
+****************************************
+
+.. glossary::
+    :sorted:
+
+    Term
+        This is the term.
+"""
+    doc_deployment = director["/qdp/deployment/doc"]
+    assert doc_deployment["copyrights-by-license"] == {
+        "BSD-2-Clause": [
+            "Copyright (C) 2023 Bob",
+            "Copyright (C) 2023 embedded brains GmbH & Co. KG"
+        ]
+    }
+
+    variant["enabled"] = ["sphinx-builder-2"]
+    doc_2 = director["/qdp/steps/doc-2"]
+    doc_2.substitute("${.:/document-bsd-2-clause-copyrights}") == "\n"
+
+    with pytest.raises(NotImplementedError):
+        doc_2.mapper.get_link(doc_2.mapper.item)
+
+    with doc_2.section_level(
+            ItemGetValueContext(doc_2.item, "", "", "", -1,
+                                "")) as (section_level, args):
+        assert section_level == 3
+        assert args == None
+    with doc_2.section_level(
+            ItemGetValueContext(doc_2.item, "", "", "", -1,
+                                "-1")) as (section_level, args):
+        assert section_level == 1
+        assert args == None
+    with doc_2.section_level(
+            ItemGetValueContext(doc_2.item, "", "", "", -1,
+                                "2:mo:re")) as (section_level, args):
+        assert section_level == 4
+        assert args == "mo:re"
+
+    doc_2.item["document-components"].append({
+        "action": "foobar",
+        "add-to-index": False,
+        "value": 123
+    })
+    action_run = 0
+
+    def action(component):
+        nonlocal action_run
+        action_run += 1
+        assert component["value"] == 123
+
+    doc_2.add_component_action("foobar", action)
+    director.build_package(None, None)
+    assert action_run == 1
diff --git a/spec-qdp/spec/qdp-document-license-map.yml b/spec-qdp/spec/qdp-document-license-map.yml
new file mode 100644
index 00000000..6f8d3959
--- /dev/null
+++ b/spec-qdp/spec/qdp-document-license-map.yml
@@ -0,0 +1,25 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes: {}
+    description: |
+      This set of attributes specifies how SPDX license identifiers are mapped
+      to another SPDX license identifier.  For example, it may be used to map
+      "CC-BY-SA-4.0 OR BSD-2-Clause" to "CC-BY-SA-4.0" for a particular
+      document.
+    generic-attributes:
+      description: null
+      key-spec-type: spdx-license-identifier
+      value-spec-type: spdx-license-identifier
+    mandatory-attributes: all
+spec-name: Document License Map
+spec-type: qdp-document-license-map
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-build.yml b/spec-qdp/spec/qdp-sphinx-build.yml
new file mode 100644
index 00000000..666f5de5
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-build.yml
@@ -0,0 +1,98 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020, 2023 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: build-step-type
+  spec-value: sphinx
+  uid: qdp-build-step
+spec-description: |
+  Items of this type shall have the following links:
+
+  * There shall be exactly one link to a ${qdp-directory-state:/spec-name} item
+    with the ${qdp-input-role:/spec-name} and the name ``"source"``.  The link
+    target directory state item defines the source directory of the file copy
+    operation and the list of files to copy.
+
+  * There shall be exactly one link to a ${qdp-directory-state:/spec-name} item
+    with the ${qdp-output-role:/spec-name} and the name ``"build"``.  The link
+    target directory state item defines the build directory.
+
+  * There shall be exactly one link to a ${qdp-directory-state:/spec-name} item
+    with the ${qdp-output-role:/spec-name} and the name ``"destination"``.  The
+    link target directory state item defines the destination directory of the
+    generated output.
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      document-components:
+        description: null
+        spec-type: qdp-sphinx-component-list
+      document-contract:
+        description: |
+          It shall be the document contract reference.
+        spec-type: str
+      document-contributors:
+        description: null
+        spec-type: qdp-sphinx-contributor-action-list
+      document-copyrights:
+        description: |
+          It shall be the list of copyright statements of explicit contributors
+          to the document.  The first contributor on the list is the main
+          contributor.
+        spec-type: copyrights
+      document-html-help-base-name:
+        description: |
+          It shall be the HTML help base name.
+        spec-type: str
+      document-key:
+        description: |
+          It shall be the document key, for example "icd".
+        spec-type: name
+      document-license:
+        description: |
+          It shall be the document license.
+        spec-type: spdx-license-identifier
+      document-license-map:
+        description: |
+          It shall be the document license map.
+        spec-type: qdp-document-license-map
+      document-releases:
+        description: null
+        spec-type: qdp-sphinx-release-list
+      document-title:
+        description: |
+          It shall be the document title.  Use ``$${break}`` to add break hints
+          between words.
+        spec-type: str
+      document-toctree-maxdepth:
+        description: |
+          It shall be the maximum depth of the document table of contents tree.
+        spec-type: int
+      document-type:
+        description: |
+          It shall be the document type.
+        spec-type: str
+      output-html:
+        description: |
+          If the value is present, then it shall be the path to the generated
+          HTML document directory relative to the base directory of the
+          directory state production.
+        spec-type: optional-str
+      output-pdf:
+        description: |
+          If the value is present, then it shall be the path to the generated
+          PDF document relative to the base directory of the directory state
+          production.
+        spec-type: optional-str
+    description: |
+      This set of attributes specifies a document using the Sphinx
+      documentation framework.
+    mandatory-attributes: all
+spec-name: Sphinx Document Build Item Type
+spec-type: qdp-sphinx-build
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component-copy-and-substitute.yml b/spec-qdp/spec/qdp-sphinx-component-copy-and-substitute.yml
new file mode 100644
index 00000000..a6fbb4c9
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component-copy-and-substitute.yml
@@ -0,0 +1,34 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: action
+  spec-value: copy-and-substitute
+  uid: qdp-sphinx-component
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      destination:
+        description: |
+          It shall be the destination path of a Sphinx source file relative to
+          the build directory.
+        spec-type: str
+      source:
+        description: |
+          It shall be the source path of a Sphinx source file relative to the
+          base directory of the directory state dependency.
+        spec-type: str
+    description: |
+      This set of attributes specifies a file to copy for a document using the
+      Sphinx documentation framework.  A variable substitution is performed on
+      the source file.
+    mandatory-attributes: all
+spec-name: Sphinx Document Component Copy and Substitute Action
+spec-type: qdp-sphinx-component-copy-and-substitute
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component-copy-files.yml b/spec-qdp/spec/qdp-sphinx-component-copy-files.yml
new file mode 100644
index 00000000..af8e8c8d
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component-copy-files.yml
@@ -0,0 +1,38 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020, 2023 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: action
+  spec-value: copy-files
+  uid: qdp-sphinx-component
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      destination:
+        description: |
+          It shall be the path to the destination directory.
+        spec-type: str
+      files:
+        description: |
+          It shall be the list of the files to copy from the source directory
+          to the destination directory.  The files may be specified by a path
+          relative to the source directory.  This relative path is preserved in
+          the destination directory.
+        spec-type: list-str
+      source:
+        description: |
+          It shall be the path to the source directory.
+        spec-type: str
+    description: |
+      This set of attributes specifies a list of files to copy from the source
+      directory to the destination directory.
+    mandatory-attributes: all
+spec-name: Sphinx Document Component Copy Files Action
+spec-type: qdp-sphinx-component-copy-files
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component-copy.yml b/spec-qdp/spec/qdp-sphinx-component-copy.yml
new file mode 100644
index 00000000..76f86554
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component-copy.yml
@@ -0,0 +1,33 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: action
+  spec-value: copy
+  uid: qdp-sphinx-component
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      destination:
+        description: |
+          It shall be the destination path of a Sphinx source file relative to
+          the build directory.
+        spec-type: str
+      source:
+        description: |
+          It shall be the source path of a Sphinx source file relative to the
+          base directory of the directory state dependency.
+        spec-type: str
+    description: |
+      This set of attributes specifies a file to copy for a document using the
+      Sphinx documentation framework.
+    mandatory-attributes: all
+spec-name: Sphinx Document Component Copy Action
+spec-type: qdp-sphinx-component-copy
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component-generic.yml b/spec-qdp/spec/qdp-sphinx-component-generic.yml
new file mode 100644
index 00000000..edd9474f
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component-generic.yml
@@ -0,0 +1,52 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020, 2022 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: action
+  spec-value: add-to-index
+  uid: qdp-sphinx-component
+- role: spec-refinement
+  spec-key: action
+  spec-value: known-problems
+  uid: qdp-sphinx-component
+- role: spec-refinement
+  spec-key: action
+  spec-value: requirements
+  uid: qdp-sphinx-component
+- role: spec-refinement
+  spec-key: action
+  spec-value: requirements-and-design
+  uid: qdp-sphinx-component
+- role: spec-refinement
+  spec-key: action
+  spec-value: test-cases
+  uid: qdp-sphinx-component
+- role: spec-refinement
+  spec-key: action
+  spec-value: traceability
+  uid: qdp-sphinx-component
+- role: spec-refinement
+  spec-key: action
+  spec-value: validation
+  uid: qdp-sphinx-component
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      destination:
+        description: |
+          It shall be the destination path of a Sphinx source file relative to
+          the build directory.
+        spec-type: str
+    description: |
+      This set of attributes specifies that a generic action which produces a
+      Sphinx source file for a document using Sphinx documentation framework.
+    mandatory-attributes: all
+spec-name: Sphinx Document Component Generic
+spec-type: qdp-sphinx-component-generic
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component-glossary.yml b/spec-qdp/spec/qdp-sphinx-component-glossary.yml
new file mode 100644
index 00000000..ad60b1d9
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component-glossary.yml
@@ -0,0 +1,32 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020, 2022 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: action
+  spec-value: glossary
+  uid: qdp-sphinx-component
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      destination:
+        description: |
+          It shall be the destination path of a Sphinx source file relative to
+          the build directory.
+        spec-type: str
+      glossary-groups:
+        description: |
+          It shall be the list of Glossary group identifiers.
+        spec-type: list-uid
+    description: |
+      This set of attributes specifies that a file which should contain the
+      glossary of a document using Sphinx documentation framework.
+    mandatory-attributes: all
+spec-name: Sphinx Document Component Glossary
+spec-type: qdp-sphinx-component-glossary
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component-list.yml b/spec-qdp/spec/qdp-sphinx-component-list.yml
new file mode 100644
index 00000000..9dbcd269
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component-list.yml
@@ -0,0 +1,16 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  list:
+    description: null
+    spec-type: qdp-sphinx-component
+spec-name: Sphinx Document Component List
+spec-type: qdp-sphinx-component-list
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-component.yml b/spec-qdp/spec/qdp-sphinx-component.yml
new file mode 100644
index 00000000..d0c1748b
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-component.yml
@@ -0,0 +1,34 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      action:
+        description: |
+          It shall be the Sphinx component action.
+        spec-type: str
+      add-to-index:
+        description: |
+          It set to true, then the file will be added to the index, otherwise
+          it will not be added to the index.
+        spec-type: bool
+      enabled-by:
+        description: |
+          It shall define the conditions under which the action is enabled.
+        spec-type: enabled-by
+    description: |
+      This set of attributes specifies a component of a document using the
+      Sphinx documentation framework.
+    mandatory-attributes:
+    - action
+    - add-to-index
+spec-name: Sphinx Document Component
+spec-type: qdp-sphinx-component
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-contributor-action-list.yml b/spec-qdp/spec/qdp-sphinx-contributor-action-list.yml
new file mode 100644
index 00000000..4dee8dbd
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-contributor-action-list.yml
@@ -0,0 +1,16 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  list:
+    description: null
+    spec-type: qdp-sphinx-contributor-action
+spec-name: Sphinx Document Contributors by Action List
+spec-type: qdp-sphinx-contributor-action-list
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-contributor-action.yml b/spec-qdp/spec/qdp-sphinx-contributor-action.yml
new file mode 100644
index 00000000..f398eb36
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-contributor-action.yml
@@ -0,0 +1,26 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      action:
+        description: |
+          It shall be the action performed by the contributors.
+        spec-type: str
+      contributors:
+        description: null
+        spec-type: qdp-sphinx-contributor-list
+    description: |
+      This set of attributes specifies document contributors which performed a
+      common action.
+    mandatory-attributes: all
+spec-name: Sphinx Document Contributors by Action
+spec-type: qdp-sphinx-contributor-action
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-contributor-list.yml b/spec-qdp/spec/qdp-sphinx-contributor-list.yml
new file mode 100644
index 00000000..4bd4c2d9
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-contributor-list.yml
@@ -0,0 +1,16 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  list:
+    description: null
+    spec-type: qdp-sphinx-contributor
+spec-name: Sphinx Document Contributor List
+spec-type: qdp-sphinx-contributor-list
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-contributor.yml b/spec-qdp/spec/qdp-sphinx-contributor.yml
new file mode 100644
index 00000000..cc10bd48
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-contributor.yml
@@ -0,0 +1,26 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      name:
+        description: |
+          It shall be the name of the contributor.
+        spec-type: str
+      organization:
+        description: |
+          It shall be the organization of the contributor.
+        spec-type: str
+    description: |
+      This set of attributes specifies a document contributor.
+    mandatory-attributes: all
+spec-name: Sphinx Document Contributor
+spec-type: qdp-sphinx-contributor
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-release-list.yml b/spec-qdp/spec/qdp-sphinx-release-list.yml
new file mode 100644
index 00000000..76414718
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-release-list.yml
@@ -0,0 +1,16 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  list:
+    description: null
+    spec-type: qdp-sphinx-release
+spec-name: Sphinx Document Release List
+spec-type: qdp-sphinx-release-list
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-release.yml b/spec-qdp/spec/qdp-sphinx-release.yml
new file mode 100644
index 00000000..498a3bd1
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-release.yml
@@ -0,0 +1,31 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      changes:
+        description: |
+          It shall be a summary of the document changes for this release in
+          Sphinx format.  A variable substitution is performed.
+        spec-type: str
+      date:
+        description: |
+          It shall be a document release date.
+        spec-type: str
+      status:
+        description: |
+          It shall be a document release status.
+        spec-type: str
+    description: |
+      This set of attributes specifies a document release.
+    mandatory-attributes: all
+spec-name: Sphinx Document Release
+spec-type: qdp-sphinx-release
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-section.yml b/spec-qdp/spec/qdp-sphinx-section.yml
new file mode 100644
index 00000000..bc6ff494
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-section.yml
@@ -0,0 +1,34 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2023 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: qdp-type
+  spec-value: sphinx-section
+  uid: qdp-root
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes:
+      content:
+        description: |
+          It shall be the section content.
+        spec-type: str
+      header:
+        description: |
+          It shall be the section header.
+        spec-type: str
+      label:
+        description: |
+          If the value is present, then it shall be the section label.
+        spec-type: optional-str
+    description: |
+      This set of attributes specifies a Sphinx section.
+    mandatory-attributes: all
+spec-name: Sphinx Section Item Type
+spec-type: qdp-sphinx-section
+type: spec
diff --git a/spec-qdp/spec/qdp-sphinx-types.yml b/spec-qdp/spec/qdp-sphinx-types.yml
new file mode 100644
index 00000000..dfa7c2f8
--- /dev/null
+++ b/spec-qdp/spec/qdp-sphinx-types.yml
@@ -0,0 +1,23 @@
+SPDX-License-Identifier: CC-BY-SA-4.0 OR BSD-2-Clause
+copyrights:
+- Copyright (C) 2020 embedded brains GmbH & Co. KG
+enabled-by: true
+links:
+- role: spec-member
+  uid: root
+- role: spec-refinement
+  spec-key: document-type
+  spec-value: generic
+  uid: qdp-sphinx-build
+spec-description: null
+spec-example: null
+spec-info:
+  dict:
+    attributes: {}
+    description: |
+      This empty set of attributes is used for refined Sphinx document types
+      which do not need extra attributes.
+    mandatory-attributes: all
+spec-name: Build Sphinx Simple Document Item Types
+spec-type: qdp-sphinx-types
+type: spec



More information about the vc mailing list