Source code for doc_control

# Copyright (c) 2026 inovex GmbH
# Copyright (c) 2026 almedso GmbH
#
# SPDX-License-Identifier: Apache-2.0

import posixpath
from datetime import datetime

from docutils import nodes
from docutils.parsers.rst import Directive, directives


def parse_date(value, field_name):
    try:
        return datetime.strptime(value, "%Y-%m-%d").date()
    except Exception as exc:
        raise ValueError(
            f"Invalid date format for '{field_name}': '{value}', expected YYYY-MM-DD"
        ) from exc


[docs] class DocCtrlDirective(Directive): has_content = False option_spec = { "version": directives.unchanged_required, "owner": directives.unchanged_required, "author": directives.unchanged_required, # Injected by a CI job, extracted from git "approval_date": directives.unchanged_required, "approved_by": directives.unchanged_required, "reviewed_by": directives.unchanged_required, # Manually set "effective_date": directives.unchanged, "supersedes": directives.unchanged, # Nice to have for reviewer to know where it is derived from # Regulatory not needed "based_on_template": directives.unchanged, # "classification": directives.unchanged, } # `version` is NOT required: it defaults to the document's git-tag-derived # version (conf `version`, see scripts/docrefs.py resolve_version). Pass # `:version:` only to override. REQUIRED = [ "owner", ] def run(self): opts = self.options opts = self._validate(opts) data = self._add_missing_attributes(opts) # Stashed so the doctree-resolved handler below (which builds the # PDF-only "signature_section") can look up this document's # reviewed_by/approved_by/approval_date without re-parsing the # directive's options itself. env = self.state.document.settings.env env.doc_control_data = getattr(env, "doc_control_data", {}) env.doc_control_data[env.docname] = data return self._render(data) def _determine_doc_title(self): # The controlled DOCUMENT's title, not whatever heading (or admonition # generated title, or nothing at all) happens to precede the directive # in the fragment it sits in -- see the brief's decision 1. `project` # is available at parse time (no doctree-resolved deferral needed) and # is already this document's title everywhere else (html_title, the # LaTeX title page), so reading it here can't disagree with those. env = self.state.document.settings.env return env.config.project def _extract_document_id(self): # The document's registry id (decision 2), plumbed through as the # `zdocs_doc_id` config value by zdocs_conf.configure(). Empty when # this extension is used standalone (no zdocs_conf) -- the unit test # roots are exactly that case -- so fall back to the historical # behaviour (decision 3): the fragment's own docname basename. Do NOT # raise if this fallback looks wrong for a given document: a directive # error is a docutils system message, which yields a green build with # the entire control table silently missing -- worse than a merely # imprecise id. env = self.state.document.settings.env return env.config.zdocs_doc_id or posixpath.basename(env.docname) def _validate(self, opts): # --- required field check --- missing = [k for k in self.REQUIRED if k not in opts] if missing: raise self.error(f"Missing required fields: {missing}") # --- date validation --- if "approval_date" in opts: parse_date(opts["approval_date"], "approval_date") if "effective_date" in opts and "approval_date" in opts: eff_date = parse_date(opts["effective_date"], "effective_date") appr_date = parse_date(opts["approval_date"], "approval_date") if eff_date < appr_date: raise self.error("effective_date must be >= approval_date") if "classification" in opts: allowed = self.state.document.settings.env.config.doc_control_classifications if allowed and opts["classification"] not in allowed: raise self.error( f"Invalid classification '{opts['classification']}', " f"allowed values are: {list(allowed)}" ) return opts def _add_missing_attributes(self, data): data["document_id"] = self._extract_document_id() data["title"] = self._determine_doc_title() # Default the version to the document's git-tag-derived conf version # (same value shown in the sidebar). `:version:` overrides it. if "version" not in data: env = self.state.document.settings.env data["version"] = env.config.version or "unknown" if "author" not in data: data["author"] = "not-authored-yet" if "approval_date" not in data: data["approval_date"] = "not-approved-yet" if "approved_by" not in data: data["approved_by"] = "not-approved-yet" if "reviewed_by" not in data: data["reviewed_by"] = "will by stamped in after approval" if "supersedes" not in data: data["supersedes"] = "None" if "based_on_template" not in data: data["based_on_template"] = "None" if "classification" not in data: data["classification"] = "Unclassified" return data def _render(self, data): table = nodes.table() table["classes"].append("doc-ctrl") tgroup = nodes.tgroup(cols=2) table += tgroup tgroup += nodes.colspec(colwidth=35) tgroup += nodes.colspec(colwidth=65) thead = nodes.thead() tbody = nodes.tbody() tgroup += thead tgroup += tbody # header row header_row = nodes.row() header_row += nodes.entry("", nodes.paragraph(text="Field")) header_row += nodes.entry("", nodes.paragraph(text="Value")) thead += header_row # controlled output order order = [ "document_id", "title", "version", "owner", "classification", "based_on_template", "author", "reviewed_by", "approved_by", "approval_date", "effective_date", "supersedes", ] for key in order: if key in data: row = nodes.row() label = key.replace("_", " ").title() value = data[key] row += nodes.entry("", nodes.paragraph(text=label)) row += nodes.entry("", nodes.paragraph(text=value)) tbody += row return [table]
_LATEX_ESCAPES = { "&": r"\&", "%": r"\%", "$": r"\$", "#": r"\#", "_": r"\_", "{": r"\{", "}": r"\}", "~": r"\textasciitilde{}", "^": r"\textasciicircum{}", "\\": r"\textbackslash{}", }
[docs] def latex_escape(text): """Escape `text` for use in a LaTeX string. Public because ``zdocs_conf`` needs the same escaping for the running header and footer it builds out of consumer-supplied values (project name, copyright holder, document id). Those are the strings most likely to contain an ``&`` or an ``_``, and a second copy of this table living in the config module is how the two would stop agreeing. """ return "".join(_LATEX_ESCAPES.get(ch, ch) for ch in text)
def _signature_section_latex(data): """PDF-only sign-off block: one ruled line per role (Author is left for the signer to fill in by hand; Reviewer/Approver are pre-filled from the doc_control directive's own fields), plus the control approval_date — the one date doc_control actually tracks. """ reviewed_by = latex_escape(data.get("reviewed_by", "")) approved_by = latex_escape(data.get("approved_by", "")) approval_date = latex_escape(data.get("approval_date", "")) return rf""" \bigskip \noindent\textbf{{Signatures}}\par \bigskip \signatureline{{Author}}{{}} \signatureline{{Reviewer}}{{{reviewed_by}}} \signatureline{{Approver}}{{{approved_by}}} \noindent\textbf{{Date:}} {approval_date}\par \bigskip """ def _pick_doc_control_data(app): """The doc_control data for this project's *primary* document. The LaTeX builder assembles a project's whole toctree into one combined tree before firing ``doctree-resolved`` — always for ``docname == master_doc`` ("index"), regardless of which child document the content (and its ``.. doc_control::``) actually lives in. So this can't just look up ``env.doc_control_data[docname]``: for a single-file project the data lives under "index" itself, but for a project whose content is a separate document included via a toctree (e.g. doc-control.rst) it's filed under that document's own name. A project with several nested sub-documents also has one doc_control per included file if more than one declares the directive — those aren't the document being signed off, so an ambiguous match falls back to "whatever's there" rather than silently dropping the signature section. """ all_data = getattr(app.env, "doc_control_data", {}) if not all_data: return None master = app.config.master_doc if master in all_data: return all_data[master] top_level = {k: v for k, v in all_data.items() if "/" not in k} if len(top_level) == 1: return next(iter(top_level.values())) return next(iter(all_data.values())) def _find_doc_control_table(node): """Depth-first search for the rendered ``.. doc_control::`` table (the ``doc-ctrl``-classed table ``DocCtrlDirective._render`` builds) anywhere under ``node``. Used instead of "the first section" (a former, broken heuristic — see git history) to place the "top" signature block: docutils wraps a document's OWN title into a ``nodes.section`` that also wraps everything below it, including its own ``.. doc_control::`` table when the directive lives directly in the master document (e.g. index.rst) rather than in a separately toctree-included file. "The first ``nodes.section`` anywhere" then finds THAT wrapping section — even though its own title was already hoisted onto the PDF title page and it renders as plain body content — and inserts the signature block as its preceding sibling, i.e. before EVERYTHING including the doc_control table itself, not after it. Anchoring on the table node directly sidesteps this entirely. """ for child in node.children: if isinstance(child, nodes.table) and "doc-ctrl" in child.get("classes", []): return child found = _find_doc_control_table(child) if found is not None: return found return None def _insert_signature_section(app, doctree, docname): # HTML (and any other non-LaTeX builder) has no notion of a signature # page — a raw(format="latex") node is already a no-op there, but skip # the work entirely rather than relying on that alone. if app.builder.format != "latex": return mode = app.config.signature_section if mode not in ("top", "bottom"): return data = _pick_doc_control_data(app) if not data: return raw = nodes.raw("", _signature_section_latex(data), format="latex") if mode == "bottom": doctree.append(raw) else: # "top": right after the doc_control table itself, wherever in the # (possibly nested) tree that table actually is. table = _find_doc_control_table(doctree) if table is not None and table.parent is not None: parent = table.parent parent.insert(parent.index(table) + 1, raw) else: doctree.append(raw) def _merge_doc_control_data(app, env, docnames, other): # This build (SPHINXOPTS "-j auto" — see cmake/sphinx.cmake) reads # documents in parallel worker processes, each with its own pickled copy # of `env`; only attributes Sphinx itself knows about get merged back # into the main process's `env` automatically. `env.doc_control_data` # (set in DocCtrlDirective.run(), read by _pick_doc_control_data at # doctree-resolved time) is our own attribute, so it needs its own # merge step here — without this, the main env's doc_control_data stays # permanently empty and the signature section is silently never # inserted, no matter what `signature_section` is set to. other_data = getattr(other, "doc_control_data", None) if not other_data: return env.doc_control_data = getattr(env, "doc_control_data", {}) env.doc_control_data.update(other_data) #: Default vocabulary for `:classification:`. A reasonable QMS starting point, #: NOT a rule: it is one organisation's document taxonomy, and it was previously #: hardcoded in the validator, so a project whose documents are called anything #: else got `ERROR: Invalid classification` and — because a directive error is a #: docutils system message, not a build failure — a green build with the entire #: control table missing from the page. #: #: Override in conf.py: #: doc_control_classifications = ["Handbook", "Runbook", ...] #: or set it to an empty list to accept any value. DEFAULT_CLASSIFICATIONS = [ "SOP", "Work Instruction", "Record", "Policy", "Plan", # e.g. project plan, risk management plan "Report", # e.g. validation report, risk assessment report "Specification", # e.g. software design specification "Register", # e.g. training record register, tool register ] def setup(app): app.add_directive("doc_control", DocCtrlDirective) # The permitted `:classification:` values — a project's document taxonomy, # not the engine's. Empty list disables the check. app.add_config_value("doc_control_classifications", DEFAULT_CLASSIFICATIONS, "env") # Per-project switch (set as a plain variable in conf.py, e.g. # `signature_section = "top"`) — "none" (default), "top" or "bottom". # PDF/LaTeX only; see _insert_signature_section. app.add_config_value("signature_section", "none", "env") # The document's registry id (decision 2). zdocs_conf.configure() sets # this from the `doc_id` it already computes; a standalone document (no # zdocs_conf) leaves it at its default "", which _extract_document_id() # treats as "fall back to the docname basename" -- see decision 3. app.add_config_value("zdocs_doc_id", "", "env") # Release stage gate for `.. ifconfig:: releaselevel not in (...)` blocks. # Set as a plain variable in conf.py (see conf_common.configure()) — must # be registered here for sphinx.ext.ifconfig to see it via config. app.add_config_value("releaselevel", "next", "env") app.connect("env-merge-info", _merge_doc_control_data) app.connect("doctree-resolved", _insert_signature_section) return { "version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True, }