|
| 1 | +"""Semi-public testing helpers for sphinx-design and downstream extensions. |
| 2 | +
|
| 3 | +These utilities are used by sphinx-design's own test suite and are exposed so |
| 4 | +that downstream extensions (such as ``sphinx-design-elements``) can reuse them |
| 5 | +in their own doctree regression tests, rather than re-implementing them. |
| 6 | +
|
| 7 | +Support policy |
| 8 | +-------------- |
| 9 | +
|
| 10 | +This is a *semi-public* testing API: |
| 11 | +
|
| 12 | +- The **signatures** of the public helpers are covered by the project's |
| 13 | + deprecation policy (they will not be removed or changed without a |
| 14 | + deprecation period). |
| 15 | +- The **exact output** of :func:`normalize_doctree_xml` is **not** guaranteed |
| 16 | + to be stable across docutils versions. It exists purely to paper over |
| 17 | + docutils' changing serialization of boolean attributes for regression |
| 18 | + testing, and may change as docutils changes. |
| 19 | +
|
| 20 | +The module has zero runtime dependencies beyond docutils and the standard |
| 21 | +library (in particular it does **not** import ``pytest``, and imports |
| 22 | +``sphinx.testing`` only for type checking), so it can be imported with only |
| 23 | +``sphinx_design`` installed -- no test extras required. |
| 24 | +""" |
| 25 | + |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +from collections.abc import Sequence |
| 29 | +from io import StringIO |
| 30 | +from pathlib import Path |
| 31 | +import re |
| 32 | +from typing import TYPE_CHECKING, cast |
| 33 | + |
| 34 | +from docutils import __version_info__ as _docutils_version_info |
| 35 | +from docutils import nodes |
| 36 | + |
| 37 | +if TYPE_CHECKING: |
| 38 | + from sphinx.testing.util import SphinxTestApp |
| 39 | + |
| 40 | +__all__ = ("SphinxBuilder", "normalize_doctree_xml") |
| 41 | + |
| 42 | +#: Whether the installed docutils serializes boolean attributes as ``"1"``/ |
| 43 | +#: ``"0"`` (docutils >= 0.22) rather than ``"True"``/``"False"``. |
| 44 | +_DOCUTILS_0_22_PLUS = _docutils_version_info >= (0, 22) |
| 45 | + |
| 46 | +#: Node attributes whose values are booleans, and therefore need normalizing |
| 47 | +#: back to the ``"True"``/``"False"`` serialization for stable regression |
| 48 | +#: fixtures. Downstream extensions with custom nodes can extend this via the |
| 49 | +#: ``extra_attributes`` argument of :func:`normalize_doctree_xml`. |
| 50 | +_BOOL_ATTRIBUTES: tuple[str, ...] = ( |
| 51 | + "checked", |
| 52 | + "force", |
| 53 | + "has_title", |
| 54 | + "internal", |
| 55 | + "is_div", |
| 56 | + "linenos", |
| 57 | + "opened", |
| 58 | + "refexplicit", |
| 59 | + "refwarn", |
| 60 | + "selected", |
| 61 | + "translatable", |
| 62 | + "translated", |
| 63 | +) |
| 64 | + |
| 65 | + |
| 66 | +def normalize_doctree_xml(text: str, extra_attributes: Sequence[str] = ()) -> str: |
| 67 | + """Normalize docutils XML output for cross-version compatibility. |
| 68 | +
|
| 69 | + In docutils 0.22+, boolean node attributes are serialized as ``"1"``/ |
| 70 | + ``"0"`` instead of ``"True"``/``"False"``. This function normalizes the |
| 71 | + newer serialization back to the older one, so that a single set of |
| 72 | + regression fixtures works across docutils versions. |
| 73 | +
|
| 74 | + On docutils < 0.22 the input is returned unchanged (no-op). |
| 75 | +
|
| 76 | + Only known boolean attributes are rewritten (see :data:`_BOOL_ATTRIBUTES`), |
| 77 | + so non-boolean ``"1"``/``"0"`` values -- such as text content or numeric |
| 78 | + attributes -- are left untouched. Note the match is a textual heuristic: |
| 79 | + element *text content* that exactly mimics a known attribute assignment |
| 80 | + (e.g. a literal containing ``force="1"``) would also be rewritten. |
| 81 | +
|
| 82 | + :param text: The pretty-printed doctree XML (e.g. from |
| 83 | + ``document.pformat()``). |
| 84 | + :param extra_attributes: Additional boolean attribute names to normalize, |
| 85 | + for downstream extensions that define custom nodes. |
| 86 | + :return: The normalized XML text. |
| 87 | + """ |
| 88 | + if not _DOCUTILS_0_22_PLUS: |
| 89 | + return text |
| 90 | + # Normalize the new format (1/0) to the old format (True/False). |
| 91 | + # Only replace when it is clearly a boolean attribute value, i.e. |
| 92 | + # ``attribute="1"`` or ``attribute="0"`` for a known boolean attribute. |
| 93 | + attributes = (*_BOOL_ATTRIBUTES, *extra_attributes) |
| 94 | + pattern = "|".join(re.escape(attr) for attr in attributes) |
| 95 | + text = re.sub(rf' ({pattern})="1"', r' \1="True"', text) |
| 96 | + text = re.sub(rf' ({pattern})="0"', r' \1="False"', text) |
| 97 | + return text |
| 98 | + |
| 99 | + |
| 100 | +class SphinxBuilder: |
| 101 | + """Wrapper around a :class:`~sphinx.testing.util.SphinxTestApp`. |
| 102 | +
|
| 103 | + Provides convenience access to the source/output paths, build status and |
| 104 | + warnings, and a normalized doctree for regression testing. |
| 105 | + """ |
| 106 | + |
| 107 | + def __init__(self, app: SphinxTestApp, src_path: Path): |
| 108 | + self.app = app |
| 109 | + self._src_path = src_path |
| 110 | + |
| 111 | + @property |
| 112 | + def src_path(self) -> Path: |
| 113 | + """The source directory of the Sphinx project.""" |
| 114 | + return self._src_path |
| 115 | + |
| 116 | + @property |
| 117 | + def out_path(self) -> Path: |
| 118 | + """The output directory of the Sphinx project.""" |
| 119 | + return Path(self.app.outdir) |
| 120 | + |
| 121 | + def build(self, assert_pass: bool = True) -> SphinxBuilder: |
| 122 | + """Build the project. |
| 123 | +
|
| 124 | + :param assert_pass: If true, assert that the build emitted no warnings. |
| 125 | + :return: This builder, for chaining. |
| 126 | + """ |
| 127 | + self.app.build() |
| 128 | + if assert_pass: |
| 129 | + assert self.warnings == "", self.status |
| 130 | + return self |
| 131 | + |
| 132 | + @property |
| 133 | + def status(self) -> str: |
| 134 | + """The build status messages.""" |
| 135 | + # the public SphinxTestApp.status property only exists on sphinx>=7.3; |
| 136 | + # fall back to the private stream on the sphinx 7.2 floor |
| 137 | + stream: StringIO | None = getattr(self.app, "status", None) |
| 138 | + if stream is None: |
| 139 | + stream = cast("StringIO", self.app._status) |
| 140 | + return stream.getvalue() |
| 141 | + |
| 142 | + @property |
| 143 | + def warnings(self) -> str: |
| 144 | + """The build warning messages.""" |
| 145 | + # see ``status`` regarding the sphinx 7.2 floor |
| 146 | + stream: StringIO | None = getattr(self.app, "warning", None) |
| 147 | + if stream is None: |
| 148 | + stream = cast("StringIO", self.app._warning) |
| 149 | + return stream.getvalue() |
| 150 | + |
| 151 | + def get_doctree( |
| 152 | + self, docname: str, post_transforms: bool = False |
| 153 | + ) -> nodes.document: |
| 154 | + """Get the doctree for a document, with source paths made relative. |
| 155 | +
|
| 156 | + :param docname: The document name. |
| 157 | + :param post_transforms: If true, apply post-transforms to the doctree. |
| 158 | + :return: The (possibly transformed) doctree. |
| 159 | + """ |
| 160 | + doctree = self.app.env.get_doctree(docname) |
| 161 | + if post_transforms: |
| 162 | + self.app.env.apply_post_transforms(doctree, docname) |
| 163 | + # make source path consistent for test comparisons |
| 164 | + for node in doctree.findall(include_self=True): |
| 165 | + if not (hasattr(node, "get") and node.get("source")): |
| 166 | + continue |
| 167 | + node["source"] = Path(node["source"]).relative_to(self.src_path).as_posix() |
| 168 | + if node["source"].endswith(".rst"): |
| 169 | + node["source"] = node["source"][:-4] |
| 170 | + elif node["source"].endswith(".md"): |
| 171 | + node["source"] = node["source"][:-3] |
| 172 | + # remove mathjax classes added by myst parser |
| 173 | + if doctree.children and isinstance(doctree.children[0], nodes.section): |
| 174 | + doctree.children[0]["classes"] = [] |
| 175 | + return doctree |
0 commit comments