1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
|
"""Sphinx test suite utilities"""
from __future__ import annotations
__all__ = ('SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding')
import contextlib
import os
import sys
from io import StringIO
from types import MappingProxyType
from typing import TYPE_CHECKING
from defusedxml.ElementTree import parse as xml_parse
from docutils import nodes
from docutils.parsers.rst import directives, roles
import sphinx.application
import sphinx.locale
import sphinx.pycode
from sphinx.util.console import strip_colors
from sphinx.util.docutils import additional_nodes
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from xml.etree.ElementTree import ElementTree
from docutils.nodes import Node
def assert_node(node: Node, cls: Any = None, xpath: str = "", **kwargs: Any) -> None:
if cls:
if isinstance(cls, list):
assert_node(node, cls[0], xpath=xpath, **kwargs)
if cls[1:]:
if isinstance(cls[1], tuple):
assert_node(node, cls[1], xpath=xpath, **kwargs)
else:
assert isinstance(node, nodes.Element), \
'The node%s does not have any children' % xpath
assert len(node) == 1, \
'The node%s has %d child nodes, not one' % (xpath, len(node))
assert_node(node[0], cls[1:], xpath=xpath + "[0]", **kwargs)
elif isinstance(cls, tuple):
assert isinstance(node, (list, nodes.Element)), \
'The node%s does not have any items' % xpath
assert len(node) == len(cls), \
'The node%s has %d child nodes, not %r' % (xpath, len(node), len(cls))
for i, nodecls in enumerate(cls):
path = xpath + "[%d]" % i
assert_node(node[i], nodecls, xpath=path, **kwargs)
elif isinstance(cls, str):
assert node == cls, f'The node {xpath!r} is not {cls!r}: {node!r}'
else:
assert isinstance(node, cls), \
f'The node{xpath} is not subclass of {cls!r}: {node!r}'
if kwargs:
assert isinstance(node, nodes.Element), \
'The node%s does not have any attributes' % xpath
for key, value in kwargs.items():
if key not in node:
if (key := key.replace('_', '-')) not in node:
msg = f'The node{xpath} does not have {key!r} attribute: {node!r}'
raise AssertionError(msg)
assert node[key] == value, \
f'The node{xpath}[{key}] is not {value!r}: {node[key]!r}'
# keep this to restrict the API usage and to have a correct return type
def etree_parse(path: str | os.PathLike[str]) -> ElementTree:
"""Parse a file into a (safe) XML element tree."""
return xml_parse(path)
class SphinxTestApp(sphinx.application.Sphinx):
"""A subclass of :class:`~sphinx.application.Sphinx` for tests.
The constructor uses some better default values for the initialization
parameters and supports arbitrary keywords stored in the :attr:`extras`
read-only mapping.
It is recommended to use::
@pytest.mark.sphinx('html')
def test(app):
app = ...
instead of::
def test():
app = SphinxTestApp('html', srcdir=srcdir)
In the former case, the 'app' fixture takes care of setting the source
directory, whereas in the latter, the user must provide it themselves.
"""
# see https://github.com/sphinx-doc/sphinx/pull/12089 for the
# discussion on how the signature of this class should be used
def __init__(
self,
/, # to allow 'self' as an extras
buildername: str = 'html',
srcdir: Path | None = None,
builddir: Path | None = None, # extra constructor argument
freshenv: bool = False, # argument is not in the same order as in the superclass
confoverrides: dict[str, Any] | None = None,
status: StringIO | None = None,
warning: StringIO | None = None,
tags: list[str] | None = None,
docutils_conf: str | None = None, # extra constructor argument
parallel: int = 0,
# additional arguments at the end to keep the signature
verbosity: int = 0, # argument is not in the same order as in the superclass
keep_going: bool = False,
warningiserror: bool = False, # argument is not in the same order as in the superclass
# unknown keyword arguments
**extras: Any,
) -> None:
assert srcdir is not None
if verbosity == -1:
quiet = True
verbosity = 0
else:
quiet = False
if status is None:
# ensure that :attr:`status` is a StringIO and not sys.stdout
# but allow the stream to be /dev/null by passing verbosity=-1
status = None if quiet else StringIO()
elif not isinstance(status, StringIO):
err = "%r must be an io.StringIO object, got: %s" % ('status', type(status))
raise TypeError(err)
if warning is None:
# ensure that :attr:`warning` is a StringIO and not sys.stderr
# but allow the stream to be /dev/null by passing verbosity=-1
warning = None if quiet else StringIO()
elif not isinstance(warning, StringIO):
err = '%r must be an io.StringIO object, got: %s' % ('warning', type(warning))
raise TypeError(err)
self.docutils_conf_path = srcdir / 'docutils.conf'
if docutils_conf is not None:
self.docutils_conf_path.write_text(docutils_conf, encoding='utf8')
if builddir is None:
builddir = srcdir / '_build'
confdir = srcdir
outdir = builddir.joinpath(buildername)
outdir.mkdir(parents=True, exist_ok=True)
doctreedir = builddir.joinpath('doctrees')
doctreedir.mkdir(parents=True, exist_ok=True)
if confoverrides is None:
confoverrides = {}
self._saved_path = sys.path.copy()
self.extras: Mapping[str, Any] = MappingProxyType(extras)
"""Extras keyword arguments."""
try:
super().__init__(
srcdir, confdir, outdir, doctreedir, buildername,
confoverrides=confoverrides, status=status, warning=warning,
freshenv=freshenv, warningiserror=warningiserror, tags=tags,
verbosity=verbosity, parallel=parallel, keep_going=keep_going,
pdb=False,
)
except Exception:
self.cleanup()
raise
@property
def status(self) -> StringIO:
"""The in-memory text I/O for the application status messages."""
# sphinx.application.Sphinx uses StringIO for a quiet stream
assert isinstance(self._status, StringIO)
return self._status
@property
def warning(self) -> StringIO:
"""The in-memory text I/O for the application warning messages."""
# sphinx.application.Sphinx uses StringIO for a quiet stream
assert isinstance(self._warning, StringIO)
return self._warning
def cleanup(self, doctrees: bool = False) -> None:
sys.path[:] = self._saved_path
_clean_up_global_state()
with contextlib.suppress(FileNotFoundError):
os.remove(self.docutils_conf_path)
def __repr__(self) -> str:
return f'<{self.__class__.__name__} buildername={self.builder.name!r}>'
def build(self, force_all: bool = False, filenames: list[str] | None = None) -> None:
self.env._pickled_doctree_cache.clear()
super().build(force_all, filenames)
class SphinxTestAppWrapperForSkipBuilding:
"""A wrapper for SphinxTestApp.
This class is used to speed up the test by skipping ``app.build()``
if it has already been built and there are any output files.
"""
def __init__(self, app_: SphinxTestApp) -> None:
self.app = app_
def __getattr__(self, name: str) -> Any:
return getattr(self.app, name)
def build(self, *args: Any, **kwargs: Any) -> None:
if not os.listdir(self.app.outdir):
# if listdir is empty, do build.
self.app.build(*args, **kwargs)
# otherwise, we can use built cache
def _clean_up_global_state() -> None:
# clean up Docutils global state
directives._directives.clear() # type: ignore[attr-defined]
roles._roles.clear() # type: ignore[attr-defined]
for node in additional_nodes:
delattr(nodes.GenericNodeVisitor, f'visit_{node.__name__}')
delattr(nodes.GenericNodeVisitor, f'depart_{node.__name__}')
delattr(nodes.SparseNodeVisitor, f'visit_{node.__name__}')
delattr(nodes.SparseNodeVisitor, f'depart_{node.__name__}')
additional_nodes.clear()
# clean up Sphinx global state
sphinx.locale.translators.clear()
# clean up autodoc global state
sphinx.pycode.ModuleAnalyzer.cache.clear()
# deprecated name -> (object to return, canonical path or '', removal version)
_DEPRECATED_OBJECTS: dict[str, tuple[Any, str, tuple[int, int]]] = {
'strip_escseq': (strip_colors, 'sphinx.util.console.strip_colors', (9, 0)),
}
def __getattr__(name: str) -> Any:
if name not in _DEPRECATED_OBJECTS:
msg = f'module {__name__!r} has no attribute {name!r}'
raise AttributeError(msg)
from sphinx.deprecation import _deprecation_warning
deprecated_object, canonical_name, remove = _DEPRECATED_OBJECTS[name]
_deprecation_warning(__name__, name, canonical_name, remove=remove)
return deprecated_object
|