Source code for zdocs_conf
# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0
"""Shared Sphinx configuration for zdocs documents.
Each document's ``conf.py`` is a thin shim that calls :func:`configure`, passing
its own directory. This module is named ``zdocs_conf`` to not
conflict with a docset wide `conf_common`` the user might have on the path as well.
**Environment contract** (all set by ``add_sphinx_target``):
``ZDOCS_CONF_DIR``
Where this file lives. zdocs is a separate repository, checked out wherever
the Zephyr module system put it, so no relative path from a consumer's tree
can reach it. (A consumer's ``conf.py`` *is* read from its authored
directory, so relative paths to the consumer's own files work fine.)
``ZDOCS_DOC_ID``
The document's registry key (its folder name).
``ZDOCS_PROJECT_BASE``
The consuming repository's root — the git repo whose tags date the document.
``ZDOCS_REGISTRY``
Optional path to ``documents.yaml``. Empty means a standalone document with
no cross-references, which is a supported configuration, not a degraded one.
``ZDOCS_DOC_BUILD_DIR``, ``ZDOCS_DOC_DEPLOY_DIR``, ``ZDOCS_DOC_BASE_URL``
Build tree, deploy tree, and the URL the deploy tree is served under.
"""
from __future__ import annotations
import datetime
import os
import sys
from pathlib import Path
ZDOCS_DOC_DIR = Path(__file__).resolve().parent
ZDOCS_BASE = ZDOCS_DOC_DIR.parent
# zdocs' own extensions and scripts, plus Zephyr's doc extensions (external_content
# assembles the Sphinx source tree). cmake/sphinx.cmake puts ZEPHYR_BASE into
# the environment of every Sphinx run, from the Zephyr that find_package(Zephyr)
# found. find_package itself sets only the CMake variable.
sys.path.insert(0, str(ZDOCS_DOC_DIR / "_extensions"))
sys.path.insert(0, str(ZDOCS_BASE / "scripts"))
_zephyr_base = os.environ.get("ZEPHYR_BASE")
if _zephyr_base:
sys.path.insert(0, str(Path(_zephyr_base) / "doc" / "_extensions"))
import docrefs # noqa: E402 (needs the sys.path above)
from doc_control import latex_escape # noqa: E402 (same)
#: Defines the macro ``doc_control`` emits for the PDF-only sign-off block.
#:
#: The two halves live apart for a reason that is easy to get wrong: the
#: extension decides WHAT is signed (which roles, pre-filled from which
#: directive fields) and the preamble decides what a signature line LOOKS like.
#: But they are a pair, and only one of them can fail loudly — an emitted
#: ``\signatureline`` with nothing defining it stops xelatex dead ("Undefined
#: control sequence"), whereas the macro sitting unused costs nothing. So the
#: engine always ships the definition, whatever ``signature_section`` is set to.
_SIGNATURE_LINE_MACRO = r"""
\newcommand{\signatureline}[2]{%
\noindent\textbf{#1}\par
\vspace{0.35cm}
\noindent#2\par
\vspace{0.20cm}
\noindent\makebox[\linewidth]{\rule{0pt}{0.5pt}\hrulefill}\par
\vspace{0.85cm}
}
"""
def _env_path(name):
value = os.environ.get(name)
return Path(value).resolve() if value else None
[docs]
def configure(
namespace,
doc_dir,
project=None,
author=None,
copyright_holder=None,
html_logo=None,
extensions=None,
static_path=None,
css_files=None,
templates_path=None,
needs_config=None,
):
"""Populate a document's ``conf.py`` globals with the shared configuration.
``doc_dir`` is the document's own directory. ``project``, ``author`` and
``copyright_holder`` are the consumer's identity.
``extensions`` is appended to the engine's list rather than replacing it, so
a consumer can add its own without having to restate the two-stage build's
requirements (and without being able to drop them by accident).
"""
doc_dir = Path(doc_dir)
folder = doc_dir.name
project = project or folder.replace("-", " ").title()
doc_id = os.environ.get("ZDOCS_DOC_ID", folder)
project_base = _env_path("ZDOCS_PROJECT_BASE")
registry = _env_path("ZDOCS_REGISTRY")
# -- Extensions -----------------------------------------------------------
#
# xref_builder registers the `-b xref` builder that stage 1 runs. Without it
# the stage-1 command fails outright, which is the good failure -- the bad
# one would be a build that quietly skips indexing and leaves every
# cross-document reference unresolvable.
engine_extensions = [
"xref_builder",
"sphinx.ext.intersphinx",
"sphinx_rtd_theme",
# Resolves :<prefix>:`symbol` against another document's Doxygen tag
# file. Loaded unconditionally so the extension list does not vary with
# whether a registry happened to be passed; with no registry the
# `doxylink` mapping below is simply empty.
"sphinxcontrib.doxylink",
# Structured, linkable requirements/specifications. Loaded for every
# document: the directives are inert in one that uses none, whereas a
# conditional extension list would make the two build stages' configs
# differ and invalidate the doctree cache between them.
"sphinx_needs",
# Re-reads the document when the needs TOML's content changes:
# sphinx-needs rebuilds only the HTML when its types, links or fields
# change, and an incremental build then crashes on a new link type.
# Also when the needs it imports from a peer's needs.json change:
# else a page does not show a new incoming link from the peer.
"needs_config_state",
# The `doc_control` directive: the controlled-document header (owner,
# classification, approval dates, version). Registers `signature_section`
# and `releaselevel` as config values, so it must be loaded even by
# documents that use no directive from it — sphinx.ext.ifconfig reads
# `releaselevel` through the config, and an unregistered value is an error
# rather than a default.
"doc_control",
# The `:qmsdoc:` role, for `kind: external` registry documents — ones
# this project does not build and cannot reach with :ref: or intersphinx.
"qms_ref",
# The `latexinclude` directive: content that belongs in the PDF and
# nowhere else, typically a shared glossary. Loaded for every document
# like the rest — the directive is a no-op in any non-LaTeX builder, and
# an extension list that varied by builder would make the two build
# stages' configs differ and invalidate the doctree cache between them.
"latexinclude",
]
if _zephyr_base:
# Assembles the Sphinx source tree by copying the document's own files
# into <build>/<doc>/src. The engine builds from that copy, not from the
# authored directory.
engine_extensions.append("zephyr.external_content")
all_extensions = engine_extensions + list(extensions or [])
# -- Cross-document links, if this document is part of a set ---------------
refs = None
version_scope = None
if registry and registry.is_file():
refs = docrefs.load(registry=registry, this_doc=doc_id)
version_scope = refs.version_scope
#
# The registry entry IS the opt-in: a document whose entry carries no
# `testmodule:` block gets NONE of this — not the extension, not any of its
# config values — rather than an `add_test_config(namespace)` call a
# consumer's conf.py has to remember , or unconditional
# registration in `engine_extensions` above.
testmodule = refs.testmodule if refs else None
if testmodule is not None:
all_extensions.append("test_module")
# Same opt-in shape: a `symbol_needs:` block loads the symbolneeds directive.
symbol_needs = refs.symbol_needs if refs else None
if symbol_needs is not None:
all_extensions.append("symbol_needs")
# Version, from this document's scoped git tags in the CONSUMING repository
# (or the VERSION env override) — the same resolver the Doxygen side uses, so
# the two toolchains cannot disagree about the version of one repository.
#
# Displayed by _templates/layout.html, which restores the sidebar version
# block sphinx_rtd_theme 3.1.0 dropped — without it a self-hosted document
# resolves a version perfectly and shows it nowhere.
version = docrefs.resolve_version(scope=version_scope, repo_root=project_base)
copyright_year = datetime.datetime.now().year
holder = copyright_holder or author or project
# -- LaTeX / PDF ----------------------------------------------------------
#
# LATEX_DOC=<doc>.tex is set on every sphinx-build by cmake/sphinx.cmake, and
# is the filename it then tells `latexmk` to build. It has to be repeated
# here because Sphinx otherwise names the .tex after the project TITLE
# ("ACME Handbook" -> acmehandbook.tex) — the build then does a full parse,
# writes a perfectly good .tex, and dies at the last step with "Latexmk:
# Could not find file 'handbook.tex'". The fallback is for an ad-hoc
# sphinx-build outside the CMake build; folder == document name by
# convention, which is the same convention add_sphinx_target relies on.
latex_target = os.environ.get("LATEX_DOC") or f"{folder}.tex"
# The running header and footer.
#
# A PDF leaves the deploy tree — it gets printed, mailed, filed in a binder —
# so unlike an HTML page it carries no navigation and no URL to say what it
# is. This is the only thing that does, which is why the version goes in it:
# a printed controlled document that does not state its own version is not a
# controlled document.
#
# Every value here is the CONSUMER's, and every one is escaped. These are the
# strings most likely to contain LaTeX syntax by accident — a company called
# "Smith & Co", a document id with an underscore — and unescaped they are a
# compile error at the very end of a long build, not a typo on a page.
#
# sphinxlatexstylepage.sty (pulled in by \usepackage{sphinx}) defines the
# "normal" and "plain" page styles with \fancypagestyle, and the body and ToC
# pages select those BY NAME rather than a bare "fancy". Redefining those two
# names, after \usepackage{sphinx} has run, is what actually sticks;
# \pagestyle{fancy} in the preamble does not.
page_style = rf"""
\fancyhf{{}}
\fancyhead[L]{{{latex_escape(doc_id)}}}
\fancyhead[C]{{{latex_escape(project)}}}
\fancyhead[R]{{{latex_escape(version)}}}
\fancyfoot[L]{{Copyright {copyright_year} {latex_escape(holder)}. All rights reserved}}
\fancyfoot[R]{{\thepage/\pageref{{LastPage}}}}
\renewcommand{{\headrulewidth}}{{0.4pt}}
\renewcommand{{\footrulewidth}}{{0.4pt}}
"""
latex_preamble = (
r"""
\usepackage{longtable}
\usepackage{array}
\usepackage{booktabs}
\usepackage{fancyhdr}
\usepackage{lastpage}
\usepackage{xcolor}
\usepackage{tabularx}
\usepackage{graphicx}
"""
+ _SIGNATURE_LINE_MACRO
+ rf"""
\fancypagestyle{{normal}}{{{page_style}}}
\fancypagestyle{{plain}}{{{page_style}}}
"""
+ r"""
% Top-level headings are \section, not \chapter (see latex_toplevel_sectioning):
% no "Chapter N" banner and no forced page break before each one, which suits a
% controlled document of a few dozen pages rather than a book.
%
% \thesection must then be re-anchored to \arabic{section}. In this document
% class it is \thechapter.\arabic{section}, and with no \chapter command ever
% invoked the chapter counter is never incremented — so every heading in the
% document numbers itself 0.1, 0.2, 0.3. That renders, compiles clean, and is
% only visible once someone looks at the PDF.
\renewcommand{\thesection}{\arabic{section}}
% No index, no glossary back-matter. Sphinx appends an index to a manual-class
% document by default; for a controlled document it is a page of nothing, since
% the terms that matter are in the glossary the document itself includes.
\let\printindex\relax
\let\printglossary\relax
\let\printglossaries\relax
"""
)
namespace.update(
{
# -- Project information ----------------------------------------
"project": project,
# The document's registry id, exposed to doc_control (which has
# no other engine-side way to learn it) so its `Document Id`
# field agrees with the id you type at `docctl author <id>` and
# with the LaTeX running header above, instead of guessing from
# whatever fragment the directive happens to sit in.
"zdocs_doc_id": doc_id,
"author": author or project,
"copyright": f"{copyright_year}, {holder}",
"version": version,
"release": version,
# -- General configuration --------------------------------------
"extensions": all_extensions,
"exclude_patterns": ["_build", "Thumbs.db", ".DS_Store"],
# zdocs' templates first (they extend the theme), consumer's after.
"templates_path": [str(ZDOCS_DOC_DIR / "_templates")]
+ [str(p) for p in (templates_path or [])],
# external_content copies these into the Sphinx source tree. The
# document's own folder is always included; a consumer adding more
# does so by extending this afterwards in its conf.py.
"external_content_contents": [(doc_dir, "*")],
"intersphinx_mapping": refs.intersphinx_mapping if refs else {},
# Doxygen tag files for every other `kind: doxygen` document, keyed
# by that document's registry prefix — so :acme-widget:`some_symbol`
# links into the API docs.
#
# Emitted unconditionally, like intersphinx, so the configuration is
# identical in both build stages. Unlike intersphinx the ROLE
# resolves at PARSE time, which is why every stage-1 index build
# waits on the `doc-tags` aggregate: with no tag file present when
# the document is parsed, the role degrades to plain text rather
# than failing.
"doxylink": refs.doxylink if refs else {},
# doxylink parses EVERY signature in EVERY tag file to do overload
# resolution, and warns once per signature its C++ grammar rejects —
# on every document's build. The rejected ones are a mix of artifacts
# doxygen recorded as "functions" that never were (attribute-
# decorated declarations, function-like macros) and genuine C APIs
# the grammar cannot handle. Neither is actionable in bulk, and
# suppressing them loses nothing: referencing an unparseable symbol
# still warns AT THE USE SITE, with file and line, which is the
# message worth acting on.
"doxylink_parse_error_ignore_regexes": [
r"Error reported from parser was",
],
# -- sphinx-needs -------------------------------------------------
#
# The export is a cross-document index exactly like objects.inv, and
# the engine depends on it: sphinx-needs writes needs.json from a
# `build-finished` hook whenever this is set, whatever the active
# builder, which is what makes the stage-1 `xref` build produce one
# without rendering HTML. Not a consumer preference — a document
# whose needs are not exported cannot be imported by its peers.
"needs_build_json": True,
# Imports of other documents' needs, derived from the registry.
"needs_external_needs": refs.needs_external_needs if refs else [],
# -- HTML output ------------------------------------------------
"html_title": project,
"html_show_sphinx": False,
"html_theme": "sphinx_rtd_theme",
"html_theme_options": {
"prev_next_buttons_location": "bottom",
"style_external_links": False,
"collapse_navigation": True,
"sticky_navigation": True,
"navigation_depth": 3,
"includehidden": True,
"titles_only": False,
},
# -- LaTeX / PDF output -------------------------------------------
#
# xelatex, not pdflatex: the documents this engine builds carry
# names, standards references and units that are not Latin-1, and
# pdflatex's answer to those is an inputenc error at the end of a
# long build. It also lets fontspec use a system OpenType font
# rather than a T1-encoded substitute.
"latex_engine": "xelatex",
# xindy handles non-English index sorting, which is the only reason
# to prefer it — and there is no index (see the preamble). Left on,
# it is one more binary a consumer has to install to build a PDF.
"latex_use_xindy": False,
"latex_domain_indices": False,
# Top level of the document is a section; see the preamble note on
# why that also needs \thesection re-anchored.
"latex_toplevel_sectioning": "section",
"latex_elements": {
"papersize": "a4paper",
"pointsize": "11pt",
"figure_align": "htbp",
# Both emptied because they are pdflatex's answer to encoding
# and xelatex reads UTF-8 natively — left in place they load
# inputenc, which under xelatex is an error rather than a
# no-op.
"inputenc": "",
"utf8extra": "",
# oneside: no blank verso pages, and no gutter that alternates
# sides — a controlled document is read on screen and printed
# single-sided far more often than it is bound. openany goes
# with it: no forced recto start per top-level heading.
"classoptions": "oneside,openany",
"preamble": latex_preamble,
"maketitle": r"\sphinxmaketitle",
"printindex": "",
},
# (start docname, filename, title, author, class). The filename is
# the contract with cmake — see latex_target above. "index" is
# Sphinx's own default master_doc and the convention every document
# in a zdocs set follows; a consumer that changes master_doc has to
# restate this entry, which is the honest failure (a PDF built from
# the wrong root would otherwise just be quietly incomplete).
"latex_documents": [
("index", latex_target, project, author or project, "manual"),
],
}
)
# Need types, link types and schemas are a METHODOLOGY choice — a
# medical-device QMS wants hazard/risk, a security analysis wants threat —
# and none of it belongs in a documentation engine. The engine only carries
# the path across; sphinx-needs resolves it against the conf directory, so an
# absolute path (which is what a consumer computing one from __file__ gets)
# works unchanged.
#
# PROJECT-scoped by default (ZDOCS_NEEDS_CONFIG), not per document, because
# the registry hands EVERY document an import of every needs-publishing
# document. So a set whose documents declare different types is not a
# configuration choice, it is a broken import, and the failure lands on the
# innocent document rather than the one that diverged.
#
# The per-document argument still wins, for a document that genuinely needs
# its own vocabulary and accepts being unable to import its peers'.
needs_config = needs_config or os.environ.get("ZDOCS_NEEDS_CONFIG") or None
if needs_config:
namespace["needs_from_toml"] = str(needs_config)
if html_logo:
namespace["html_logo"] = html_logo
# The engine's own static files always come first; a consumer's are appended
# so a same-named file of theirs wins. Absolute paths, because a consumer's
# conf.py is read from the copied source tree and relative ones would resolve
# against the wrong directory.
namespace["html_static_path"] = [str(ZDOCS_DOC_DIR / "_static")] + [
str(p) for p in (static_path or [])
]
namespace["html_css_files"] = ["zdocs-sphinx.css"] + list(css_files or [])
if refs is not None:
# Consumed by the cross-document navigation in the page template.
namespace["html_context"] = {"reference_groups": refs.reference_groups}
if testmodule is not None:
namespace["testmodule_xml_dir"] = testmodule["xml_dir"]
namespace["testspec_doxygen_url"] = testmodule["doxygen_url"]
namespace["api_doxygen_url"] = testmodule["api_url"]
namespace["testmodule_tag_urls"] = testmodule.get("tag_urls", {})
namespace["testspec_needs_json"] = testmodule["needs_json"]
namespace["testmodule_root"] = str(project_base) if project_base else ""
namespace["twister_output_dir"] = os.environ.get("ZDOCS_TWISTER_OUT", "")
namespace["coverage_output_dir"] = os.environ.get("ZDOCS_COVERAGE_OUT", "")
namespace["twisterinfo_project_name"] = project
namespace["twisterinfo_project_version"] = version
if symbol_needs is not None:
namespace["symbolneeds_xml_dir"] = symbol_needs["xml_dir"]
namespace["symbolneeds_doxygen_url"] = symbol_needs["doxygen_url"]