Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ furo <https://sphinx-design.readthedocs.io/en/furo-theme>
:caption: Development
:hidden:

testing
changelog
```

Expand Down
54 changes: 54 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -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")
```
175 changes: 175 additions & 0 deletions sphinx_design/testing.py
Original file line number Diff line number Diff line change
@@ -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
95 changes: 11 additions & 84 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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")])
Expand Down Expand Up @@ -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
Loading
Loading