From 2974a20fc6c02980aac7a426bb80c66fc7eb3dd7 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 12 Jul 2026 17:24:14 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20public=20sphinx=5Fdesign.test?= =?UTF-8?q?ing=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the test-suite's doctree-XML normalizer into a new semi-public `sphinx_design.testing` module so downstream extensions can reuse it for their doctree regression tests instead of re-implementing it. The `normalize_doctree_xml` helper and the `SphinxBuilder` wrapper are now importable with only `sphinx_design` installed (no pytest/test extras); the pytest fixtures in conftest become thin re-exporting wrappers. --- CHANGELOG.md | 1 + docs/index.md | 1 + docs/testing.md | 54 +++++++++++ sphinx_design/testing.py | 175 +++++++++++++++++++++++++++++++++++ tests/conftest.py | 95 +++---------------- tests/test_testing_module.py | 63 +++++++++++++ 6 files changed, 305 insertions(+), 84 deletions(-) create mode 100644 docs/testing.md create mode 100644 sphinx_design/testing.py create mode 100644 tests/test_testing_module.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea67ad9..067c63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- ✨ NEW: `sphinx_design.testing` module with the `normalize_doctree_xml` helper, for downstream extensions' doctree regression tests ({pr}`277`, {issue}`260`) - ♻️ IMPROVE: Static assets (CSS/JS) are now served via Sphinx's standard `html_static_path` mechanism, rather than being written directly into the build output; non-HTML builds no longer gain a spurious diff --git a/docs/index.md b/docs/index.md index 35db947..ba1bbdc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -96,6 +96,7 @@ furo :caption: Development :hidden: +testing changelog ``` diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..29bab30 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,54 @@ +# Testing utilities + +`sphinx-design` ships a small, semi-public helper module, +`sphinx_design.testing`, for downstream extensions that write +[doctree](https://docutils.sourceforge.io/docs/ref/doctree.html) regression +tests (for example [`sphinx-design-elements`](https://github.com/tech-writing/sphinx-design-elements)). + +The module has **no dependency on `pytest`** or any test extras, so it can be +imported with only `sphinx_design` installed. + +## `normalize_doctree_xml` + +```python +normalize_doctree_xml(text: str, extra_attributes: Sequence[str] = ()) -> str +``` + +Normalizes pretty-printed doctree XML (e.g. from `document.pformat()`) so that +a single set of regression fixtures works across docutils versions: docutils +0.22+ serializes boolean node attributes as `"1"`/`"0"` rather than +`"True"`/`"False"`, and this rewrites the former back to the latter. On +docutils < 0.22 it is a no-op. + +Only known boolean attributes are rewritten, so non-boolean `"1"`/`"0"` values +(such as text content or numeric attributes) are left untouched. Pass +`extra_attributes` to also normalize boolean attributes on your own custom +nodes. + +## `SphinxBuilder` + +The `SphinxBuilder` wrapper used by sphinx-design's own `sphinx_builder` +pytest fixture is also exposed. It is a thin wrapper around a +`sphinx.testing.util.SphinxTestApp` that you construct yourself (e.g. with +sphinx's `make_app` pytest fixture) — it does not create the project +scaffolding for you — giving convenient access to build status/warnings and +doctrees with source paths normalized for regression comparison. + +## Stability + +This is a *semi-public* testing API: + +- the **signatures** of the helpers are covered by the deprecation policy; +- the **exact output** of `normalize_doctree_xml` is **not** guaranteed to be + stable across docutils versions -- it exists only to smooth over docutils' + changing serialization for regression testing. + +## Example + +```python +from sphinx_design.testing import normalize_doctree_xml + +# ``doctree`` is a docutils document, e.g. from a Sphinx build +xml = normalize_doctree_xml(doctree.pformat()) +file_regression.check(xml, extension=".xml") +``` diff --git a/sphinx_design/testing.py b/sphinx_design/testing.py new file mode 100644 index 0000000..47d33b3 --- /dev/null +++ b/sphinx_design/testing.py @@ -0,0 +1,175 @@ +"""Semi-public testing helpers for sphinx-design and downstream extensions. + +These utilities are used by sphinx-design's own test suite and are exposed so +that downstream extensions (such as ``sphinx-design-elements``) can reuse them +in their own doctree regression tests, rather than re-implementing them. + +Support policy +-------------- + +This is a *semi-public* testing API: + +- The **signatures** of the public helpers are covered by the project's + deprecation policy (they will not be removed or changed without a + deprecation period). +- The **exact output** of :func:`normalize_doctree_xml` is **not** guaranteed + to be stable across docutils versions. It exists purely to paper over + docutils' changing serialization of boolean attributes for regression + testing, and may change as docutils changes. + +The module has zero runtime dependencies beyond docutils and the standard +library (in particular it does **not** import ``pytest``, and imports +``sphinx.testing`` only for type checking), so it can be imported with only +``sphinx_design`` installed -- no test extras required. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from io import StringIO +from pathlib import Path +import re +from typing import TYPE_CHECKING, cast + +from docutils import __version_info__ as _docutils_version_info +from docutils import nodes + +if TYPE_CHECKING: + from sphinx.testing.util import SphinxTestApp + +__all__ = ("SphinxBuilder", "normalize_doctree_xml") + +#: Whether the installed docutils serializes boolean attributes as ``"1"``/ +#: ``"0"`` (docutils >= 0.22) rather than ``"True"``/``"False"``. +_DOCUTILS_0_22_PLUS = _docutils_version_info >= (0, 22) + +#: Node attributes whose values are booleans, and therefore need normalizing +#: back to the ``"True"``/``"False"`` serialization for stable regression +#: fixtures. Downstream extensions with custom nodes can extend this via the +#: ``extra_attributes`` argument of :func:`normalize_doctree_xml`. +_BOOL_ATTRIBUTES: tuple[str, ...] = ( + "checked", + "force", + "has_title", + "internal", + "is_div", + "linenos", + "opened", + "refexplicit", + "refwarn", + "selected", + "translatable", + "translated", +) + + +def normalize_doctree_xml(text: str, extra_attributes: Sequence[str] = ()) -> str: + """Normalize docutils XML output for cross-version compatibility. + + In docutils 0.22+, boolean node attributes are serialized as ``"1"``/ + ``"0"`` instead of ``"True"``/``"False"``. This function normalizes the + newer serialization back to the older one, so that a single set of + regression fixtures works across docutils versions. + + On docutils < 0.22 the input is returned unchanged (no-op). + + Only known boolean attributes are rewritten (see :data:`_BOOL_ATTRIBUTES`), + so non-boolean ``"1"``/``"0"`` values -- such as text content or numeric + attributes -- are left untouched. Note the match is a textual heuristic: + element *text content* that exactly mimics a known attribute assignment + (e.g. a literal containing ``force="1"``) would also be rewritten. + + :param text: The pretty-printed doctree XML (e.g. from + ``document.pformat()``). + :param extra_attributes: Additional boolean attribute names to normalize, + for downstream extensions that define custom nodes. + :return: The normalized XML text. + """ + if not _DOCUTILS_0_22_PLUS: + return text + # Normalize the new format (1/0) to the old format (True/False). + # Only replace when it is clearly a boolean attribute value, i.e. + # ``attribute="1"`` or ``attribute="0"`` for a known boolean attribute. + attributes = (*_BOOL_ATTRIBUTES, *extra_attributes) + pattern = "|".join(re.escape(attr) for attr in attributes) + text = re.sub(rf' ({pattern})="1"', r' \1="True"', text) + text = re.sub(rf' ({pattern})="0"', r' \1="False"', text) + return text + + +class SphinxBuilder: + """Wrapper around a :class:`~sphinx.testing.util.SphinxTestApp`. + + Provides convenience access to the source/output paths, build status and + warnings, and a normalized doctree for regression testing. + """ + + def __init__(self, app: SphinxTestApp, src_path: Path): + self.app = app + self._src_path = src_path + + @property + def src_path(self) -> Path: + """The source directory of the Sphinx project.""" + return self._src_path + + @property + def out_path(self) -> Path: + """The output directory of the Sphinx project.""" + return Path(self.app.outdir) + + def build(self, assert_pass: bool = True) -> SphinxBuilder: + """Build the project. + + :param assert_pass: If true, assert that the build emitted no warnings. + :return: This builder, for chaining. + """ + self.app.build() + if assert_pass: + assert self.warnings == "", self.status + return self + + @property + def status(self) -> str: + """The build status messages.""" + # the public SphinxTestApp.status property only exists on sphinx>=7.3; + # fall back to the private stream on the sphinx 7.2 floor + stream: StringIO | None = getattr(self.app, "status", None) + if stream is None: + stream = cast("StringIO", self.app._status) + return stream.getvalue() + + @property + def warnings(self) -> str: + """The build warning messages.""" + # see ``status`` regarding the sphinx 7.2 floor + stream: StringIO | None = getattr(self.app, "warning", None) + if stream is None: + stream = cast("StringIO", self.app._warning) + return stream.getvalue() + + def get_doctree( + self, docname: str, post_transforms: bool = False + ) -> nodes.document: + """Get the doctree for a document, with source paths made relative. + + :param docname: The document name. + :param post_transforms: If true, apply post-transforms to the doctree. + :return: The (possibly transformed) doctree. + """ + doctree = self.app.env.get_doctree(docname) + if post_transforms: + self.app.env.apply_post_transforms(doctree, docname) + # make source path consistent for test comparisons + for node in doctree.findall(include_self=True): + if not (hasattr(node, "get") and node.get("source")): + continue + node["source"] = Path(node["source"]).relative_to(self.src_path).as_posix() + if node["source"].endswith(".rst"): + node["source"] = node["source"][:-4] + elif node["source"].endswith(".md"): + node["source"] = node["source"][:-3] + # remove mathjax classes added by myst parser + if doctree.children and isinstance(doctree.children[0], nodes.section): + doctree.children[0]["classes"] = [] + return doctree diff --git a/tests/conftest.py b/tests/conftest.py index ad1a28e..3557e91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,62 +1,17 @@ +from collections.abc import Callable import os from pathlib import Path -import re from typing import Any -from docutils import __version_info__ as docutils_version_info -from docutils import nodes import pytest -from sphinx.testing.util import SphinxTestApp -pytest_plugins = "sphinx.testing.fixtures" - - -class SphinxBuilder: - def __init__(self, app: SphinxTestApp, src_path: Path): - self.app = app - self._src_path = src_path - - @property - def src_path(self) -> Path: - return self._src_path - - @property - def out_path(self) -> Path: - return Path(self.app.outdir) - - def build(self, assert_pass=True): - self.app.build() - if assert_pass: - assert self.warnings == "", self.status - return self +from sphinx_design.testing import SphinxBuilder +from sphinx_design.testing import normalize_doctree_xml as _normalize_doctree_xml - @property - def status(self): - return self.app._status.getvalue() +# re-exported for tests that do ``from .conftest import SphinxBuilder`` +__all__ = ["SphinxBuilder"] - @property - def warnings(self): - return self.app._warning.getvalue() - - def get_doctree( - self, docname: str, post_transforms: bool = False - ) -> nodes.document: - doctree = self.app.env.get_doctree(docname) - if post_transforms: - self.app.env.apply_post_transforms(doctree, docname) - # make source path consistent for test comparisons - for node in doctree.findall(include_self=True): - if not (hasattr(node, "get") and node.get("source")): - continue - node["source"] = Path(node["source"]).relative_to(self.src_path).as_posix() - if node["source"].endswith(".rst"): - node["source"] = node["source"][:-4] - elif node["source"].endswith(".md"): - node["source"] = node["source"][:-3] - # remove mathjax classes added by myst parser - if doctree.children and isinstance(doctree.children[0], nodes.section): - doctree.children[0]["classes"] = [] - return doctree +pytest_plugins = "sphinx.testing.fixtures" @pytest.fixture(params=[pytest.param("html", id="html")]) @@ -85,39 +40,11 @@ def _create_project( yield _create_project -DOCUTILS_0_22_PLUS = docutils_version_info >= (0, 22) - - @pytest.fixture -def normalize_doctree_xml(): - """Normalize docutils XML output for cross-version compatibility. +def normalize_doctree_xml() -> Callable[..., str]: + """Return the public doctree-XML normalizer. - In docutils 0.22+, boolean attributes are serialized as "1"/"0" - instead of "True"/"False". This function normalizes to the old format - for consistent test fixtures. + Thin wrapper around :func:`sphinx_design.testing.normalize_doctree_xml`, + kept so existing tests keep working; see that module for details. """ - - def _normalize(text: str) -> str: - if DOCUTILS_0_22_PLUS: - # Normalize new format (1/0) to old format (1/0) - # Only replace when it's clearly a boolean attribute value - # Pattern: attribute="1" or attribute="0" - attrs = [ - "checked", - "force", - "has_title", - "internal", - "is_div", - "linenos", - "opened", - "refexplicit", - "refwarn", - "selected", - "translatable", - "translated", - ] - text = re.sub(rf' ({"|".join(attrs)})="1"', r' \1="True"', text) - text = re.sub(rf' ({"|".join(attrs)})="0"', r' \1="False"', text) - return text - - return _normalize + return _normalize_doctree_xml diff --git a/tests/test_testing_module.py b/tests/test_testing_module.py new file mode 100644 index 0000000..1a5c77a --- /dev/null +++ b/tests/test_testing_module.py @@ -0,0 +1,63 @@ +"""Unit tests for the public :mod:`sphinx_design.testing` module. + +These feed literal strings and toggle the internal docutils-version flag, so +they exercise both code paths regardless of the installed docutils version. +""" + +import pytest + +from sphinx_design import testing +from sphinx_design.testing import normalize_doctree_xml + + +@pytest.fixture +def force_0_22(monkeypatch: pytest.MonkeyPatch) -> None: + """Force the >= 0.22 (normalize) code path.""" + monkeypatch.setattr(testing, "_DOCUTILS_0_22_PLUS", True) + + +@pytest.fixture +def force_pre_0_22(monkeypatch: pytest.MonkeyPatch) -> None: + """Force the < 0.22 (no-op) code path.""" + monkeypatch.setattr(testing, "_DOCUTILS_0_22_PLUS", False) + + +def test_normalize_pre_0_22_is_noop(force_pre_0_22: None) -> None: + """On docutils < 0.22 the text is returned unchanged.""" + text = '' + assert normalize_doctree_xml(text) == text + + +def test_normalize_booleans(force_0_22: None) -> None: + """On docutils >= 0.22, known boolean attributes are normalized.""" + text = '' + expected = '' + assert normalize_doctree_xml(text) == expected + + +def test_extra_attributes_respected(force_0_22: None) -> None: + """Custom boolean attributes are normalized only when passed explicitly.""" + text = '' + # unknown attributes are not normalized by default + assert normalize_doctree_xml(text) == text + # ... but are when declared via ``extra_attributes`` + assert ( + normalize_doctree_xml(text, extra_attributes=["my_flag", "other_flag"]) + == '' + ) + + +def test_non_boolean_values_untouched(force_0_22: None) -> None: + """Non-boolean "1"/"0" values are left alone.""" + # unknown attribute with a "1" value + assert normalize_doctree_xml('') == '' + # text content that happens to be "1" + assert ( + normalize_doctree_xml("1") == "1" + ) + + +def test_bool_attributes_constant() -> None: + """The module exposes the boolean-attribute list as a tuple constant.""" + assert isinstance(testing._BOOL_ATTRIBUTES, tuple) + assert "is_div" in testing._BOOL_ATTRIBUTES