• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

domdfcoding / sphinx-toolbox / 24310628048

08 Apr 2026 07:49AM UTC coverage: 91.455% (-0.1%) from 91.59%
24310628048

push

github

web-flow
[repo-helper] Configuration Update (#210)

Co-authored-by: repo-helper[bot] <74742576+repo-helper[bot]@users.noreply.github.com>

4185 of 4576 relevant lines covered (91.46%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.69
/sphinx_toolbox/testing.py
1
#!/usr/bin/env python3
2
#
3
#  testing.py
4
r"""
5
Functions for testing Sphinx extensions.
6

7
.. extras-require:: testing
8
        :pyproject:
9

10
.. seealso:: Sphinx's own ``testing`` library: https://github.com/sphinx-doc/sphinx/tree/3.x/sphinx/testing
11

12
.. latex:vspace:: 10px
13

14
.. _pytest-regressions: https://pypi.org/project/pytest-regressions/
15
"""
16
#
17
#  Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
18
#
19
#  Permission is hereby granted, free of charge, to any person obtaining a copy
20
#  of this software and associated documentation files (the "Software"), to deal
21
#  in the Software without restriction, including without limitation the rights
22
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23
#  copies of the Software, and to permit persons to whom the Software is
24
#  furnished to do so, subject to the following conditions:
25
#
26
#  The above copyright notice and this permission notice shall be included in all
27
#  copies or substantial portions of the Software.
28
#
29
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
30
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
31
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
32
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
33
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
34
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
35
#  OR OTHER DEALINGS IN THE SOFTWARE.
36
#
37
#  Based on Sphinx
38
#  Copyright (c) 2007-2020 by the Sphinx team.
39
#  |  All rights reserved.
40
#  |
41
#  |  Redistribution and use in source and binary forms, with or without
42
#  |  modification, are permitted provided that the following conditions are
43
#  |  met:
44
#  |
45
#  |  * Redistributions of source code must retain the above copyright
46
#  |    notice, this list of conditions and the following disclaimer.
47
#  |
48
#  |  * Redistributions in binary form must reproduce the above copyright
49
#  |    notice, this list of conditions and the following disclaimer in the
50
#  |    documentation and/or other materials provided with the distribution.
51
#  |
52
#  |  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
53
#  |  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
54
#  |  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
55
#  |  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
56
#  |  HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
57
#  |  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
58
#  |  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
59
#  |  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
60
#  |  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
61
#  |  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
62
#  |  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
63
#
64

65
# stdlib
66
import copy
1✔
67
import re
1✔
68
import sys
1✔
69
import tempfile
1✔
70
from functools import partial
1✔
71
from operator import attrgetter
1✔
72
from types import SimpleNamespace
1✔
73
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union, cast
1✔
74

75
# 3rd party
76
import pytest  # nodep
1✔
77
import sphinx.application
1✔
78
from bs4 import BeautifulSoup
1✔
79
from coincidence.regressions import (  # nodep
1✔
80
                AdvancedFileRegressionFixture,
81
                check_file_output,
82
                check_file_regression
83
                )
84
from docutils import __version_info__ as docutils_version
1✔
85
from docutils import nodes
1✔
86
from docutils.parsers.rst import Directive, roles
1✔
87
from docutils.transforms import Transform
1✔
88
from domdf_python_tools.doctools import prettify_docstrings
1✔
89
from domdf_python_tools.paths import PathPlus
1✔
90
from domdf_python_tools.stringlist import StringList
1✔
91
from domdf_python_tools.typing import PathLike
1✔
92
from jinja2 import Template  # nodep
1✔
93
from pygments.lexer import Lexer  # type: ignore[import-untyped]  # nodep
1✔
94
from pytest_regressions.common import check_text_files  # nodep
1✔
95
from pytest_regressions.file_regression import FileRegressionFixture  # nodep
1✔
96
from sphinx.builders import Builder
1✔
97
from sphinx.domains import Domain, Index
1✔
98
from sphinx.domains.python import PythonDomain
1✔
99
from sphinx.environment.collectors import EnvironmentCollector
1✔
100
from sphinx.events import EventListener
1✔
101
from sphinx.events import EventManager as BaseEventManager
1✔
102
from sphinx.ext.autodoc.directive import AutodocDirective
1✔
103
from sphinx.highlighting import lexer_classes
1✔
104
from sphinx.registry import SphinxComponentRegistry
1✔
105
from sphinx.roles import XRefRole
1✔
106
from sphinx.util import docutils
1✔
107
from sphinx.util.typing import RoleFunction, TitleGetter
1✔
108

109
# this package
110
from sphinx_toolbox.utils import Config, SphinxExtMetadata
1✔
111

112
# _ModuleWrapper unwrapping BS due to
113
# https://github.com/sphinx-doc/sphinx/commit/8866adeacfb045c97302cc9c7e3b60dec5ca38fd
114

115
try:
1✔
116
        # 3rd party
117
        from sphinx.deprecation import _ModuleWrapper  # type: ignore[attr-defined]
1✔
118
        if isinstance(docutils, _ModuleWrapper):
1✔
119
                docutils = docutils._module
1✔
120
except ImportError:
1✔
121
        # Unnecessary if unimportable
122
        pass
1✔
123

124
__all__ = (
1✔
125
                "Sphinx",
126
                "run_setup",
127
                "RunSetupOutput",
128
                "remove_html_footer",
129
                "check_html_regression",
130
                "remove_html_link_tags",
131
                "check_asset_copy",
132
                "HTMLRegressionFixture",
133
                "html_regression",
134
                "LaTeXRegressionFixture",
135
                "latex_regression",
136
                )
137

138

139
class FakeBuilder(Builder):
1✔
140
        pass
1✔
141

142

143
class EventManager(BaseEventManager):
1✔
144

145
        def connect(self, name: str, callback: Callable, priority: int) -> int:  # noqa: PRM002
1✔
146
                """
147
                Connect a handler to specific event.
148
                """
149

150
                listener_id = self.next_listener_id
1✔
151
                self.next_listener_id += 1
1✔
152
                self.listeners[name].append(EventListener(listener_id, callback, priority))
1✔
153
                return listener_id
1✔
154

155

156
class Sphinx:
1✔
157
        """
158
        A class that pretends to be :class:`sphinx.application.Sphinx` but that is stripped
159
        back to allow the internals to be inspected. This can be used in tests to ensure the
160
        nodes, roles etc. being registered in an extension's ``setup()`` function are actually
161
        being registered.
162
        """  # noqa: D400
163

164
        registry: SphinxComponentRegistry  #: Instance of :class:`sphinx.registry.SphinxComponentRegistry`
1✔
165
        config: Config  #: Instance of :class:`sphinx.config.Config`
1✔
166
        events: EventManager  #: Instance of :class:`sphinx.events.EventManager`
1✔
167
        html_themes: Dict[str, str]  #: Mapping of HTML theme names to filesystem paths.
1✔
168

169
        # builder: Builder  #: Instance of :class:`sphinx.builder.Builder`
170

171
        def __init__(self):  # , buildername: str = "html"
1✔
172
                self.registry = SphinxComponentRegistry()
1✔
173
                self.config = Config({}, {})
1✔
174
                self.events = EventManager(self)  # type: ignore[arg-type]
1✔
175
                self.html_themes: Dict[str, str] = {}
1✔
176
                # self.builder = self.registry.create_builder(self, buildername)
177

178
        def add_builder(self, builder: Type[Builder], override: bool = False) -> None:  # noqa: PRM002
1✔
179
                r"""
180
                Register a new builder.
181

182
                The registered values are stored in the ``app.registry.builders`` dictionary
183
                (:class:`typing.Dict`\[:class:`str`\, :class:`typing.Type`\[:class:`sphinx.builders.Builder`\]]).
184
                """
185

186
                self.registry.add_builder(builder, override=override)
1✔
187

188
        def add_config_value(  # noqa: PRM002
1✔
189
                self,
190
                name: str,
191
                default: Any,
192
                rebuild: Union[bool, str],
193
                types: Any = (),
194
        ) -> None:
195
                r"""
196
                Register a configuration value.
197

198
                The registered values are stored in the ``app.config.values`` dictionary
199
                (:class:`typing.Dict`\[:class:`str`\, :class:`typing.Tuple`]).
200
                """
201

202
                if rebuild in {False, True}:
1✔
203
                        rebuild = "env" if rebuild else ''
1✔
204

205
                self.config.add(name, default, rebuild, types)
1✔
206

207
        def add_event(self, name: str) -> None:  # noqa: PRM002
1✔
208
                r"""
209
                Register an event called ``name``.
210

211
                The registered values are stored in the ``app.events.events`` dictionary
212
                (:class:`typing.Dict`\[:class:`str`\, :class:`str`\]).
213
                """
214

215
                self.events.add(name)
1✔
216

217
        def set_translator(  # noqa: PRM002
1✔
218
                self,
219
                name: str,
220
                translator_class: Type[nodes.NodeVisitor],
221
                override: bool = False,
222
        ) -> None:
223
                r"""
224
                Register or override a Docutils translator class.
225

226
                The registered values are stored in the ``app.registry.translators`` dictionary.
227
                (:class:`typing.Dict`\[:class:`str`\, :class:`typing.Type`\[:class:`docutils.nodes.NodeVisitor`\]]).
228

229
                .. clearpage::
230
                """
231

232
                self.registry.add_translator(name, translator_class, override=override)
1✔
233

234
        def add_node(  # noqa: PRM002
1✔
235
                self,
236
                node: Type[nodes.Element],
237
                override: bool = False,
238
                **kwargs: Tuple[Callable, Callable],
239
        ) -> None:
240
                r"""
241
                Register a Docutils node class.
242

243
                The registered values are stored in the ``additional_nodes`` set returned by
244
                :func:`~sphinx_toolbox.testing.run_setup`
245
                (:class:`typing.Set`\[:class:`typing.Type`\[:class:`docutils.nodes.Node`\]]).
246
                """
247

248
                if not override and docutils.is_node_registered(node):
1✔
249
                        msg = f"node class {node.__name__!r} is already registered, its visitors will be overridden"
1✔
250
                        raise ValueError(msg)
1✔
251

252
                docutils.register_node(node)
1✔
253
                self.registry.add_translation_handlers(node, **kwargs)
1✔
254

255
        def add_enumerable_node(  # noqa: PRM002
1✔
256
                self,
257
                node: Type[nodes.Element],
258
                figtype: str,
259
                title_getter: Optional[TitleGetter] = None,
260
                override: bool = False,
261
                **kwargs: Tuple[Callable, Callable],
262
        ) -> None:
263
                """
264
                Register a Docutils node class as a numfig target.
265
                """
266

267
                # Sphinx's signature is wrong WRT Optional
268
                self.registry.add_enumerable_node(
×
269
                                node,
270
                                figtype,
271
                                title_getter,
272
                                override=override,
273
                                )
274
                self.add_node(node, override=override, **kwargs)
×
275

276
        def add_directive(self, name: str, cls: Type[Directive], override: bool = False) -> None:  # noqa: PRM002
1✔
277
                """
278
                Register a Docutils directive.
279
                """
280

281
                if not override and docutils.is_directive_registered(name):
1✔
282
                        raise ValueError(f"directive {name!r} is already registered, it will be overridden")
×
283

284
                docutils.register_directive(name, cls)
1✔
285

286
        def add_role(self, name: str, role: Any, override: bool = False) -> None:  # noqa: PRM002
1✔
287
                r"""
288
                Register a Docutils role.
289

290
                The registered values are stored in the ``roles`` dictionary returned by
291
                :func:`~sphinx_toolbox.testing.run_setup`.
292
                (:class:`typing.Dict`\[:class:`str`\, :class:`typing.Callable`\]).
293
                """
294

295
                if not override and docutils.is_role_registered(name):
1✔
296
                        raise ValueError(f"role {name!r} is already registered, it will be overridden")
1✔
297

298
                docutils.register_role(name, role)
1✔
299

300
        def add_generic_role(self, name: str, nodeclass: Any, override: bool = False) -> None:  # noqa: PRM002
1✔
301
                """
302
                Register a generic Docutils role.
303
                """
304

305
                if not override and docutils.is_role_registered(name):
×
306
                        raise ValueError(f"role {name!r} is already registered, it will be overridden")
×
307

308
                role = roles.GenericRole(name, nodeclass)
×
309

310
                docutils.register_role(name, role)
×
311

312
        def add_domain(  # noqa: PRM002
1✔
313
                        self,
314
                        domain: Type[Domain],
315
                        override: bool = False,
316
                        ) -> None:
317
                """
318
                Register a domain.
319
                """
320

321
                self.registry.add_domain(domain, override=override)
1✔
322

323
        def add_directive_to_domain(  # noqa: PRM002
1✔
324
                self,
325
                domain: str,
326
                name: str,
327
                cls: Type[Directive],
328
                override: bool = False,
329
        ) -> None:
330
                """
331
                Register a Docutils directive in a domain.
332
                """
333

334
                self.registry.add_directive_to_domain(domain, name, cls, override=override)
1✔
335

336
        def add_role_to_domain(  # noqa: PRM002
1✔
337
                self,
338
                domain: str,
339
                name: str,
340
                role: Union[RoleFunction, XRefRole],
341
                override: bool = False,
342
        ) -> None:
343
                """
344
                Register a Docutils role in a domain.
345
                """
346

347
                self.registry.add_role_to_domain(domain, name, role, override=override)
1✔
348

349
        def add_index_to_domain(  # noqa: PRM002
1✔
350
                self,
351
                domain: str,
352
                index: Type[Index],
353
                override: bool = False,
354
        ) -> None:
355
                """
356
                Register a custom index for a domain.
357
                """
358

359
                self.registry.add_index_to_domain(domain, index)
×
360

361
        def add_object_type(  # noqa: PRM002
1✔
362
                self,
363
                directivename: str,
364
                rolename: str,
365
                indextemplate: str = '',
366
                parse_node: Optional[Callable] = None,
367
                ref_nodeclass: Optional[Type[nodes.TextElement]] = None,
368
                objname: str = '',
369
                doc_field_types: List = [],
370
                override: bool = False,
371
        ) -> None:
372
                """
373
                Register a new object type.
374
                """
375

376
                # Sphinx's signature is wrong WRT Optional
377
                self.registry.add_object_type(
×
378
                                directivename,
379
                                rolename,
380
                                indextemplate,
381
                                parse_node,
382
                                ref_nodeclass,
383
                                objname,
384
                                doc_field_types,
385
                                override=override,
386
                                )
387

388
        def add_crossref_type(  # noqa: PRM002
1✔
389
                self,
390
                directivename: str,
391
                rolename: str,
392
                indextemplate: str = '',
393
                ref_nodeclass: Optional[Type[nodes.TextElement]] = None,
394
                objname: str = '',
395
                override: bool = False,
396
        ) -> None:
397
                """
398
                Register a new crossref object type.
399
                """
400

401
                # Sphinx's signature is wrong WRT Optional
402
                self.registry.add_crossref_type(
×
403
                                directivename,
404
                                rolename,
405
                                indextemplate,
406
                                ref_nodeclass,
407
                                objname,
408
                                override=override,
409
                                )
410

411
        def add_transform(self, transform: Type[Transform]) -> None:  # noqa: PRM002
1✔
412
                """
413
                Register a Docutils transform to be applied after parsing.
414
                """
415

416
                self.registry.add_transform(transform)
1✔
417

418
        def add_post_transform(self, transform: Type[Transform]) -> None:  # noqa: PRM002
1✔
419
                """
420
                Register a Docutils transform to be applied before writing.
421
                """
422

423
                self.registry.add_post_transform(transform)
1✔
424

425
        def add_js_file(self, filename: str, **kwargs: str) -> None:  # noqa: PRM002
1✔
426
                """
427
                Register a JavaScript file to include in the HTML output.
428

429
                .. versionadded:: 2.8.0
430
                """
431

432
                self.registry.add_js_file(filename, **kwargs)
1✔
433

434
        #         if hasattr(self.builder, 'add_js_file'):
435
        #                 self.builder.add_js_file(filename, **kwargs)
436
        #
437

438
        def add_css_file(self, filename: str, **kwargs: str) -> None:  # noqa: PRM002
1✔
439
                """
440
                Register a stylesheet to include in the HTML output.
441

442
                .. versionadded:: 2.7.0
443
                """
444

445
                self.registry.add_css_files(filename, **kwargs)
1✔
446

447
        #         if hasattr(self.builder, 'add_css_file'):
448
        #                 self.builder.add_css_file(filename, **kwargs)
449

450
        def add_latex_package(  # noqa: PRM002
1✔
451
                self,
452
                packagename: str,
453
                options: Optional[str] = None,
454
                after_hyperref: bool = False,
455
        ) -> None:
456
                """
457
                Register a package to include in the LaTeX source code.
458
                """
459

460
                # Sphinx's signature is wrong WRT Optional
461
                self.registry.add_latex_package(packagename, cast(str, options), after_hyperref)
1✔
462

463
        def add_lexer(self, alias: str, lexer: Type[Lexer]) -> None:  # noqa: PRM002
1✔
464
                """
465
                Register a new lexer for source code.
466
                """
467

468
                if isinstance(lexer, Lexer):
1✔
469
                        raise TypeError("app.add_lexer() API changed; Please give lexer class instead instance")
1✔
470
                else:
471
                        lexer_classes[alias] = lexer
1✔
472

473
        def add_autodocumenter(self, cls: Any, override: bool = False) -> None:  # noqa: PRM002
1✔
474
                """
475
                Register a new documenter class for the autodoc extension.
476
                """
477

478
                self.registry.add_documenter(cls.objtype, cls)
1✔
479
                self.add_directive("auto" + cls.objtype, AutodocDirective, override=override)
1✔
480

481
        def add_autodoc_attrgetter(  # noqa: PRM002
1✔
482
                self,
483
                typ: Type,
484
                getter: Callable[[Any, str, Any], Any],
485
        ) -> None:
486
                """
487
                Register a new ``getattr``-like function for the autodoc extension.
488
                """
489

490
                self.registry.add_autodoc_attrgetter(typ, getter)
1✔
491

492
        def add_source_suffix(self, suffix: str, filetype: str, override: bool = False) -> None:  # noqa: PRM002
1✔
493
                """
494
                Register a suffix of source files.
495
                """
496

497
                self.registry.add_source_suffix(suffix, filetype, override=override)
1✔
498

499
        def add_source_parser(self, *args: Any, **kwargs: Any) -> None:  # noqa: PRM002
1✔
500
                """
501
                Register a parser class.
502
                """
503

504
                self.registry.add_source_parser(*args, **kwargs)
1✔
505

506
        def add_env_collector(self, collector: Type[EnvironmentCollector]) -> None:  # noqa: PRM002
1✔
507
                """
508
                No-op for now.
509

510
                .. TODO:: Make this do something
511
                """
512

513
        # def add_env_collector(self, collector: Type[EnvironmentCollector]) -> None:
514
        #         """
515
        #         Register an environment collector class.
516
        #         """
517
        #
518
        #         collector().enable(self)
519

520
        def add_html_theme(self, name: str, theme_path: str) -> None:  # noqa: PRM002
1✔
521
                """
522
                Register an HTML Theme.
523
                """
524

525
                self.html_themes[name] = theme_path
1✔
526

527
        def add_html_math_renderer(  # noqa: PRM002
1✔
528
                self,
529
                name: str,
530
                inline_renderers: Optional[Tuple[Callable, Callable]] = None,
531
                block_renderers: Optional[Tuple[Callable, Callable]] = None,
532
        ) -> None:
533
                """
534
                Register a math renderer for HTML.
535
                """
536

537
                self.registry.add_html_math_renderer(name, inline_renderers, block_renderers)
×
538

539
        def setup_extension(self, extname: str) -> None:  # noqa: PRM002
1✔
540
                """
541
                Import and setup a Sphinx extension module.
542

543
                .. TODO:: implement this
544
                """
545

546
                # self.registry.load_extension(self, extname)
547

548
        def require_sphinx(self, version: str) -> None:  # noqa: PRM002
1✔
549
                """
550
                Check the Sphinx version if requested.
551

552
                No-op when testing
553
                """
554

555
        # event interface
556
        def connect(self, event: str, callback: Callable, priority: int = 500) -> int:  # noqa: PRM002
1✔
557
                """
558
                Register *callback* to be called when *event* is emitted.
559
                """
560

561
                listener_id = self.events.connect(event, callback, priority)
1✔
562
                return listener_id
1✔
563

564

565
@prettify_docstrings
1✔
566
class RunSetupOutput(NamedTuple):
1✔
567
        """
568
        :class:`~typing.NamedTuple` representing the output from :func:`~sphinx_toolbox.testing.run_setup`.
569
        """
570

571
        setup_ret: Union[None, Dict[str, Any], "SphinxExtMetadata"]  #: The output from the ``setup()`` function.
1✔
572
        directives: Dict[str, Callable]  #: Mapping of directive names to directive functions.
1✔
573
        roles: Dict[str, Callable]  #: Mapping of role names to role functions.
1✔
574
        additional_nodes: Set[Type[Any]]  #: Set of custom docutils nodes registered in ``setup()``.
1✔
575
        app: Sphinx  #: Instance of :class:`sphinx_toolbox.testing.Sphinx`.
1✔
576

577

578
_sphinx_dict_setup = Callable[[sphinx.application.Sphinx], Optional[Dict[str, Any]]]
1✔
579
_sphinx_metadata_setup = Callable[[sphinx.application.Sphinx], Optional["SphinxExtMetadata"]]
1✔
580
_fake_dict_setup = Callable[[Sphinx], Optional[Dict[str, Any]]]
1✔
581
_fake_metadata_setup = Callable[[Sphinx], Optional["SphinxExtMetadata"]]
1✔
582
_setup_func_type = Union[_sphinx_dict_setup, _sphinx_metadata_setup, _fake_dict_setup, _fake_metadata_setup]
1✔
583

584

585
class GenericNodeVisitor(nodes.NodeVisitor):
1✔
586
        pass
1✔
587

588

589
def run_setup(
1✔
590
                setup_func: _setup_func_type,
591
                call_config_events: bool = False,  # buildername: str = "html",
592
                ) -> RunSetupOutput:
593
        """
594
        Function for running an extension's ``setup()`` function for testing.
595

596
        :param setup_func: The ``setup()`` function under test.
597
        :param call_config_events: Call event handlers for the ``config-inited`` event.
598

599
        :returns: 5-element namedtuple
600

601
        .. versionchanged:: 4.2.0  Added ``call_config_events`` option.
602
        """
603

604
        app = Sphinx()  # buildername
1✔
605

606
        app.add_domain(PythonDomain)
1✔
607

608
        _additional_nodes = copy.copy(docutils.additional_nodes)
1✔
609
        orig_gnv = nodes.GenericNodeVisitor
1✔
610

611
        try:
1✔
612
                nodes.GenericNodeVisitor = GenericNodeVisitor  # type: ignore[misc,assignment]
1✔
613
                docutils.additional_nodes = set()
1✔
614

615
                with docutils.docutils_namespace():
1✔
616
                        setup_ret = setup_func(app)  # type: ignore[arg-type]
1✔
617

618
                        if call_config_events:
1✔
619
                                for listener in sorted(app.events.listeners["config-inited"], key=attrgetter("priority")):
1✔
620
                                        listener.handler(app, app.config)
1✔
621

622
                        directives = copy.copy(docutils.directives._directives)  # type: ignore[attr-defined]
1✔
623
                        roles = copy.copy(docutils.roles._roles)  # type: ignore[attr-defined]
1✔
624
                        additional_nodes = copy.copy(docutils.additional_nodes)
1✔
625
        finally:
626
                docutils.additional_nodes = _additional_nodes
1✔
627
                nodes.GenericNodeVisitor = orig_gnv  # type: ignore[misc]
1✔
628

629
        return RunSetupOutput(setup_ret, directives, roles, additional_nodes, app)
1✔
630

631

632
def remove_html_footer(page: BeautifulSoup) -> BeautifulSoup:
1✔
633
        """
634
        Remove the Sphinx footer from HTML pages.
635

636
        The footer contains the Sphinx and theme versions and therefore changes between versions.
637
        This can cause unwanted, false positive test failures.
638

639
        :param page: The page to remove the footer from.
640

641
        :return: The page without the footer.
642
        """
643

644
        for div in page.select("div.footer"):
1✔
645
                div.extract()
1✔
646

647
        return page
1✔
648

649

650
def remove_html_link_tags(page: BeautifulSoup) -> BeautifulSoup:
1✔
651
        """
652
        Remove link tags from HTML pages.
653

654
        These may vary between different versions of Sphinx and its extensions.
655
        This can cause unwanted, false positive test failures.
656

657
        :param page: The page to remove the link tags from.
658

659
        :return: The page without the link tags.
660
        """
661

662
        for div in page.select("head link"):
1✔
663
                div.extract()
1✔
664

665
        return page
1✔
666

667

668
def check_html_regression(page: BeautifulSoup, file_regression: FileRegressionFixture) -> None:
1✔
669
        """
670
        Check an HTML page generated by Sphinx for regressions, using `pytest-regressions`_.
671

672
        :param page: The page to test.
673
        :param file_regression: The file regression fixture.
674

675
        **Example usage**
676

677
        .. code-block:: python
678

679
                @pytest.mark.parametrize("page", ["index.html"], indirect=True)
680
                def test_page(page: BeautifulSoup, file_regression: FileRegressionFixture):
681
                        check_html_regression(page, file_regression)
682
        """  # noqa: RST306
683

684
        __tracebackhide__ = True
×
685

686
        page = remove_html_footer(page)
×
687
        page = remove_html_link_tags(page)
×
688

689
        for div in page.select("script"):
×
690
                if "_static/language_data.js" in str(div):
×
691
                        div.extract()
×
692

693
        for div in page.select("div.sphinxsidebar"):
×
694
                div.extract()
×
695

696
        check_file_regression(
×
697
                        StringList(page.prettify()),
698
                        file_regression,
699
                        extension=".html",
700
                        )
701

702

703
class HTMLRegressionFixture(FileRegressionFixture):
1✔
704
        """
705
        Subclass of :class:`pytest_regressions.file_regression.FileRegressionFixture` for checking HTML files.
706

707
        .. versionadded:: 2.0.0
708
        """
709

710
        def check(  # type: ignore[override]
1✔
711
                self,
712
                page: BeautifulSoup,
713
                *,
714
                extension: str = ".html",
715
                jinja2: bool = False,
716
                jinja2_namespace: Optional[Dict[str, Any]] = None,
717
                **kwargs,
718
        ) -> None:
719
                r"""
720
                Check an HTML page generated by Sphinx for regressions, using `pytest-regressions`_.
721

722
                :param page: The page to test.
723
                :param extension: File extension to use for the reference file.
724
                :param jinja2: Whether to render the reference file as a jinja2 template.
725
                :param jinja2_namespace: If ``jinja2`` is :py:obj:`True`,
726
                        a mapping of variable names to values to make available in the jinja2 template.
727
                :param \*\*kwargs: Additional keyword arguments passed to
728
                        :meth:`pytest_regressions.file_regression.FileRegressionFixture.check`.
729

730
                .. versionchanged:: 2.14.0  Added the ``jinja2`` keyword argument.
731
                .. versionchanged:: 2.17.0  Added the ``jinja2_namespace`` keyword argument.
732

733
                .. latex:clearpage::
734

735
                When ``jinja2`` is :py:obj:`True`, the reference file will be rendered as a jinja2 template.
736
                The template is passed the following variables:
737

738
                * ``sphinx_version`` -- the Sphinx version number, as a tuple of integers.
739
                * ``python_version`` -- the Python version number, in the form returned by :data:`sys.version_info`.
740
                * ``docutils_version`` -- the docutils version number, as a tuple of integers (*New in version 2.16.0*).
741

742
                **Example usage**
743

744
                .. code-block:: python
745

746
                        @pytest.mark.parametrize("page", ["index.html"], indirect=True)
747
                        def test_page(page: BeautifulSoup, html_regression: HTMLRegressionFixture):
748
                                html_regression.check(page, file_regression)
749
                """  # noqa: RST306
750

751
                __tracebackhide__ = True
1✔
752

753
                page = remove_html_footer(page)
1✔
754
                page = remove_html_link_tags(page)
1✔
755

756
                for div in page.select("script"):
1✔
757
                        if "_static/language_data.js" in str(div):
1✔
758
                                div.extract()
×
759

760
                for div in page.select("div.sphinxsidebar"):
1✔
761
                        div.extract()
1✔
762

763
                for div in page.select("div.related"):
1✔
764
                        if div["aria-label"] == "Related":
1✔
765
                                div.extract()
1✔
766

767
                if sphinx.version_info >= (4, 3):  # pragma: no cover
768
                        for div in page.select("dt.sig em.property span.k"):
769
                                div.replace_with_children()
770
                        for div in page.select("span.w"):
771
                                div.extract()
772
                        for div in page.select("span.p"):
773
                                if div.string == '=':
774
                                        sibling = div.next_sibling
775
                                        assert sibling is not None
776
                                        div.replace_with('')
777
                                        sibling.replace_with(f"= {sibling.text}")
778

779
                kwargs.pop("encoding", None)
1✔
780
                kwargs.pop("extension", None)
1✔
781

782
                if jinja2:
1✔
783

784
                        def check_fn(obtained_filename: PathPlus, expected_filename: PathPlus):  # noqa: MAN002
1✔
785
                                __tracebackhide__ = True
1✔
786

787
                                expected_filename = PathPlus(expected_filename)
1✔
788
                                template = Template(expected_filename.read_text())
1✔
789

790
                                expected_filename.write_text(
1✔
791
                                                template.render(
792
                                                                sphinx_version=sphinx.version_info,
793
                                                                python_version=sys.version_info,
794
                                                                docutils_version=docutils_version,
795
                                                                **jinja2_namespace or {},
796
                                                                ),
797
                                                )
798

799
                                return check_text_files(obtained_filename, expected_filename, encoding="UTF-8")
1✔
800

801
                else:
802
                        check_fn = partial(check_text_files, encoding="UTF-8")
×
803

804
                super().check(
1✔
805
                                str(StringList(page.prettify())),
806
                                encoding="UTF-8",
807
                                extension=extension,
808
                                check_fn=check_fn,
809
                                )
810

811

812
@pytest.fixture()
1✔
813
def html_regression(datadir, original_datadir, request) -> HTMLRegressionFixture:  # noqa: MAN001
1✔
814
        """
815
        Returns an :class:`~.HTMLRegressionFixture` scoped to the test function.
816

817
        .. versionadded:: 2.0.0
818
        """
819

820
        return HTMLRegressionFixture(datadir, original_datadir, request)
1✔
821

822

823
def check_asset_copy(
1✔
824
                func: Callable[[sphinx.application.Sphinx, Exception], Any],
825
                *asset_files: PathLike,
826
                file_regression: FileRegressionFixture,
827
                ) -> None:
828
        r"""
829
        Helper to test functions which respond to Sphinx ``build-finished`` events and copy asset files.
830

831
        .. versionadded:: 2.0.0
832

833
        :param func: The function to test.
834
        :param \*asset_files: The paths of asset files copied by the function, relative to the Sphinx output directory.
835
        :param file_regression:
836
        """
837

838
        __tracebackhide__ = True
1✔
839

840
        with tempfile.TemporaryDirectory() as tmpdir:
1✔
841
                tmp_pathplus = PathPlus(tmpdir)
1✔
842

843
                fake_app = SimpleNamespace()
1✔
844
                fake_app.builder = SimpleNamespace()
1✔
845
                fake_app.builder.format = "html"
1✔
846
                fake_app.outdir = fake_app.builder.outdir = tmp_pathplus
1✔
847

848
                func(fake_app, None)  # type: ignore[arg-type]
1✔
849

850
                for filename in asset_files:
1✔
851
                        filename = tmp_pathplus / filename
1✔
852

853
                        check_file_output(filename, file_regression, extension=f"_{filename.stem}{filename.suffix}")
1✔
854

855

856
_latex_date_re = re.compile(r"\\date{.*}")
1✔
857

858

859
class LaTeXRegressionFixture(AdvancedFileRegressionFixture):
1✔
860
        """
861
        Subclass of :class:`coincidence.regressions.AdvancedFileRegressionFixture` for checking LaTeX files.
862

863
        .. versionadded:: 2.17.0
864
        """
865

866
        def check(  # type: ignore[override]  # noqa: MAN002
1✔
867
                self,
868
                contents: Union[str, StringList],
869
                *,
870
                extension: str = ".html",
871
                jinja2: bool = False,
872
                jinja2_namespace: Optional[Dict[str, Any]] = None,
873
                **kwargs,
874
        ):
875
                r"""
876
                Check a LaTeX file generated by Sphinx for regressions,
877
                using `pytest-regressions <https://pypi.org/project/pytest-regressions/>`__
878

879
                :param contents:
880
                :param extension: Not used.
881
                :param jinja2: Whether to render the reference file as a jinja2 template.
882
                :param jinja2_namespace: If ``jinja2`` is :py:obj:`True`,
883
                        a mapping of variable names to values to make available in the jinja2 template.
884
                :param \*\*kwargs: Additional keyword arguments passed to
885
                        :meth:`pytest_regressions.file_regression.FileRegressionFixture.check`.
886

887
                When ``jinja2`` is :py:obj:`True`, the reference file will be rendered as a jinja2 template.
888
                The template is passed the following variables:
889

890
                * ``sphinx_version`` -- the Sphinx version number, as a tuple of integers.
891
                * ``python_version`` -- the Python version number, in the form returned by :data:`sys.version_info`.
892
                * ``docutils_version`` -- the docutils version number, as a tuple of integers (*New in version 2.16.0*).
893

894
                .. note::
895

896
                        Unlike standard HTML jinja2 templates,
897
                        this class expects the use of ``<`` and ``>`` rather than ``{`` and ``}``.
898

899
                        For example::
900

901
                                <% if foo %>
902
                                <# This should only happen on Tuesdays #>
903
                                << foo.upper() >>
904
                                <% endif %>
905

906
                **Example usage**
907

908
                .. code-block:: python
909

910
                        @pytest.mark.sphinx("latex")
911
                        def test_latex_output(app: Sphinx, latex_regression: LaTeXRegressionFixture):
912
                                app.build()
913
                                output_file = app.outdir / "python.tex"
914
                                latex_regression.check(output_file.read_text())
915

916
                """  # noqa: D400
917

918
                __tracebackhide__ = True
1✔
919

920
                if jinja2:
1✔
921

922
                        def check_fn(obtained_filename: PathPlus, expected_filename: PathLike):  # noqa: MAN002
1✔
923
                                __tracebackhide__ = True
1✔
924

925
                                expected_filename = PathPlus(expected_filename)
1✔
926

927
                                template = Template(
1✔
928
                                                expected_filename.read_text(),
929
                                                block_start_string="<%",
930
                                                block_end_string="%>",
931
                                                variable_start_string="<<",
932
                                                variable_end_string=">>",
933
                                                comment_start_string="<#",
934
                                                comment_end_string="#>",
935
                                                )
936

937
                                expected_filename.write_text(
1✔
938
                                                template.render(
939
                                                                sphinx_version=sphinx.version_info,
940
                                                                python_version=sys.version_info,
941
                                                                docutils_version=docutils_version,
942
                                                                **jinja2_namespace or {},
943
                                                                ),
944
                                                )
945

946
                                return check_text_files(obtained_filename, expected_filename, encoding="UTF-8")
1✔
947

948
                else:
949
                        check_fn = partial(check_text_files, encoding="UTF-8")
×
950

951
                new_contents = _latex_date_re.sub(
1✔
952
                                r"\\date{Mar 11, 2021}",
953
                                str(contents).replace("\\sphinxAtStartPar\n", ''),
954
                                )
955
                new_contents = new_contents.replace("%% let collapsible ", "%% let collapsable ")  # changed in Sphinx 4.2
1✔
956

957
                return super().check(
1✔
958
                                new_contents,
959
                                extension=".tex",
960
                                check_fn=check_fn,
961
                                )
962

963

964
@pytest.fixture()
1✔
965
def latex_regression(datadir, original_datadir, request) -> LaTeXRegressionFixture:  # noqa: MAN001
1✔
966
        """
967
        Returns a :class:`~.LaTeXRegressionFixture` scoped to the test function.
968

969
        .. versionadded:: 2.17.0
970
        """
971

972
        return LaTeXRegressionFixture(datadir, original_datadir, request)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc