Skip to content

Commit 6e06c98

Browse files
authored
✨ Add public sphinx_design.testing helpers (#277)
1 parent 1bfd79d commit 6e06c98

6 files changed

Lines changed: 305 additions & 84 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- ✨ NEW: `sphinx_design.testing` module with the `normalize_doctree_xml` helper, for downstream extensions' doctree regression tests ({pr}`277`, {issue}`260`)
56
- ♻️ IMPROVE: Static assets (CSS/JS) are now served via Sphinx's standard
67
`html_static_path` mechanism, rather than being written directly into the
78
build output; non-HTML builds no longer gain a spurious

‎docs/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ furo <https://sphinx-design.readthedocs.io/en/furo-theme>
9696
:caption: Development
9797
:hidden:
9898
99+
testing
99100
changelog
100101
```
101102

‎docs/testing.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Testing utilities
2+
3+
`sphinx-design` ships a small, semi-public helper module,
4+
`sphinx_design.testing`, for downstream extensions that write
5+
[doctree](https://docutils.sourceforge.io/docs/ref/doctree.html) regression
6+
tests (for example [`sphinx-design-elements`](https://github.com/tech-writing/sphinx-design-elements)).
7+
8+
The module has **no dependency on `pytest`** or any test extras, so it can be
9+
imported with only `sphinx_design` installed.
10+
11+
## `normalize_doctree_xml`
12+
13+
```python
14+
normalize_doctree_xml(text: str, extra_attributes: Sequence[str] = ()) -> str
15+
```
16+
17+
Normalizes pretty-printed doctree XML (e.g. from `document.pformat()`) so that
18+
a single set of regression fixtures works across docutils versions: docutils
19+
0.22+ serializes boolean node attributes as `"1"`/`"0"` rather than
20+
`"True"`/`"False"`, and this rewrites the former back to the latter. On
21+
docutils < 0.22 it is a no-op.
22+
23+
Only known boolean attributes are rewritten, so non-boolean `"1"`/`"0"` values
24+
(such as text content or numeric attributes) are left untouched. Pass
25+
`extra_attributes` to also normalize boolean attributes on your own custom
26+
nodes.
27+
28+
## `SphinxBuilder`
29+
30+
The `SphinxBuilder` wrapper used by sphinx-design's own `sphinx_builder`
31+
pytest fixture is also exposed. It is a thin wrapper around a
32+
`sphinx.testing.util.SphinxTestApp` that you construct yourself (e.g. with
33+
sphinx's `make_app` pytest fixture) — it does not create the project
34+
scaffolding for you — giving convenient access to build status/warnings and
35+
doctrees with source paths normalized for regression comparison.
36+
37+
## Stability
38+
39+
This is a *semi-public* testing API:
40+
41+
- the **signatures** of the helpers are covered by the deprecation policy;
42+
- the **exact output** of `normalize_doctree_xml` is **not** guaranteed to be
43+
stable across docutils versions -- it exists only to smooth over docutils'
44+
changing serialization for regression testing.
45+
46+
## Example
47+
48+
```python
49+
from sphinx_design.testing import normalize_doctree_xml
50+
51+
# ``doctree`` is a docutils document, e.g. from a Sphinx build
52+
xml = normalize_doctree_xml(doctree.pformat())
53+
file_regression.check(xml, extension=".xml")
54+
```

‎sphinx_design/testing.py‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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

‎tests/conftest.py‎

Lines changed: 11 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,17 @@
1+
from collections.abc import Callable
12
import os
23
from pathlib import Path
3-
import re
44
from typing import Any
55

6-
from docutils import __version_info__ as docutils_version_info
7-
from docutils import nodes
86
import pytest
9-
from sphinx.testing.util import SphinxTestApp
107

11-
pytest_plugins = "sphinx.testing.fixtures"
12-
13-
14-
class SphinxBuilder:
15-
def __init__(self, app: SphinxTestApp, src_path: Path):
16-
self.app = app
17-
self._src_path = src_path
18-
19-
@property
20-
def src_path(self) -> Path:
21-
return self._src_path
22-
23-
@property
24-
def out_path(self) -> Path:
25-
return Path(self.app.outdir)
26-
27-
def build(self, assert_pass=True):
28-
self.app.build()
29-
if assert_pass:
30-
assert self.warnings == "", self.status
31-
return self
8+
from sphinx_design.testing import SphinxBuilder
9+
from sphinx_design.testing import normalize_doctree_xml as _normalize_doctree_xml
3210

33-
@property
34-
def status(self):
35-
return self.app._status.getvalue()
11+
# re-exported for tests that do ``from .conftest import SphinxBuilder``
12+
__all__ = ["SphinxBuilder"]
3613

37-
@property
38-
def warnings(self):
39-
return self.app._warning.getvalue()
40-
41-
def get_doctree(
42-
self, docname: str, post_transforms: bool = False
43-
) -> nodes.document:
44-
doctree = self.app.env.get_doctree(docname)
45-
if post_transforms:
46-
self.app.env.apply_post_transforms(doctree, docname)
47-
# make source path consistent for test comparisons
48-
for node in doctree.findall(include_self=True):
49-
if not (hasattr(node, "get") and node.get("source")):
50-
continue
51-
node["source"] = Path(node["source"]).relative_to(self.src_path).as_posix()
52-
if node["source"].endswith(".rst"):
53-
node["source"] = node["source"][:-4]
54-
elif node["source"].endswith(".md"):
55-
node["source"] = node["source"][:-3]
56-
# remove mathjax classes added by myst parser
57-
if doctree.children and isinstance(doctree.children[0], nodes.section):
58-
doctree.children[0]["classes"] = []
59-
return doctree
14+
pytest_plugins = "sphinx.testing.fixtures"
6015

6116

6217
@pytest.fixture(params=[pytest.param("html", id="html")])
@@ -85,39 +40,11 @@ def _create_project(
8540
yield _create_project
8641

8742

88-
DOCUTILS_0_22_PLUS = docutils_version_info >= (0, 22)
89-
90-
9143
@pytest.fixture
92-
def normalize_doctree_xml():
93-
"""Normalize docutils XML output for cross-version compatibility.
44+
def normalize_doctree_xml() -> Callable[..., str]:
45+
"""Return the public doctree-XML normalizer.
9446
95-
In docutils 0.22+, boolean attributes are serialized as "1"/"0"
96-
instead of "True"/"False". This function normalizes to the old format
97-
for consistent test fixtures.
47+
Thin wrapper around :func:`sphinx_design.testing.normalize_doctree_xml`,
48+
kept so existing tests keep working; see that module for details.
9849
"""
99-
100-
def _normalize(text: str) -> str:
101-
if DOCUTILS_0_22_PLUS:
102-
# Normalize new format (1/0) to old format (1/0)
103-
# Only replace when it's clearly a boolean attribute value
104-
# Pattern: attribute="1" or attribute="0"
105-
attrs = [
106-
"checked",
107-
"force",
108-
"has_title",
109-
"internal",
110-
"is_div",
111-
"linenos",
112-
"opened",
113-
"refexplicit",
114-
"refwarn",
115-
"selected",
116-
"translatable",
117-
"translated",
118-
]
119-
text = re.sub(rf' ({"|".join(attrs)})="1"', r' \1="True"', text)
120-
text = re.sub(rf' ({"|".join(attrs)})="0"', r' \1="False"', text)
121-
return text
122-
123-
return _normalize
50+
return _normalize_doctree_xml

0 commit comments

Comments
 (0)