11"""Sphinx test suite utilities"""
2+
23from __future__ import annotations
34
45import contextlib
56import os
67import re
78import sys
89import warnings
9- from typing import IO , TYPE_CHECKING , Any
10+ from io import StringIO
11+ from types import MappingProxyType
12+ from typing import TYPE_CHECKING
1013from xml .etree import ElementTree
1114
1215from docutils import nodes
1821from sphinx .util .docutils import additional_nodes
1922
2023if TYPE_CHECKING :
21- from io import StringIO
24+ from collections . abc import Mapping
2225 from pathlib import Path
26+ from typing import Any
2327
2428 from docutils .nodes import Node
2529
@@ -73,29 +77,74 @@ def etree_parse(path: str) -> Any:
7377
7478
7579class SphinxTestApp (sphinx .application .Sphinx ):
76- """
77- A subclass of :class:`Sphinx` that runs on the test root, with some
78- better default values for the initialization parameters.
80+ """A subclass of :class:`~sphinx.application.Sphinx` for tests.
81+
82+ The constructor uses some better default values for the initialization
83+ parameters and supports arbitrary keywords stored in the :attr:`extras`
84+ read-only mapping.
85+
86+ It is recommended to use::
87+
88+ @pytest.mark.sphinx('html')
89+ def test(app):
90+ app = ...
91+
92+ instead of::
93+
94+ def test():
95+ app = SphinxTestApp('html', srcdir=srcdir)
96+
97+ In the former case, the 'app' fixture takes care of setting the source
98+ directory, whereas in the latter, the user must provide it themselves.
7999 """
80100
81- _status : StringIO
82- _warning : StringIO
101+ # see https://github.com/sphinx-doc/sphinx/pull/12089 for the
102+ # discussion on how the signature of this class should be used
83103
84104 def __init__ (
85105 self ,
106+ / , # to allow 'self' as an extras
86107 buildername : str = 'html' ,
87108 srcdir : Path | None = None ,
88- builddir : Path | None = None ,
89- freshenv : bool = False ,
90- confoverrides : dict | None = None ,
91- status : IO | None = None ,
92- warning : IO | None = None ,
109+ builddir : Path | None = None , # extra constructor argument
110+ freshenv : bool = False , # argument is not in the same order as in the superclass
111+ confoverrides : dict [ str , Any ] | None = None ,
112+ status : StringIO | None = None ,
113+ warning : StringIO | None = None ,
93114 tags : list [str ] | None = None ,
94- docutils_conf : str | None = None ,
115+ docutils_conf : str | None = None , # extra constructor argument
95116 parallel : int = 0 ,
117+ # additional arguments at the end to keep the signature
118+ verbosity : int = 0 , # argument is not in the same order as in the superclass
119+ keep_going : bool = False ,
120+ warningiserror : bool = False , # argument is not in the same order as in the superclass
121+ # unknown keyword arguments
122+ ** extras : Any ,
96123 ) -> None :
97124 assert srcdir is not None
98125
126+ if verbosity == - 1 :
127+ quiet = True
128+ verbosity = 0
129+ else :
130+ quiet = False
131+
132+ if status is None :
133+ # ensure that :attr:`status` is a StringIO and not sys.stdout
134+ # but allow the stream to be /dev/null by passing verbosity=-1
135+ status = None if quiet else StringIO ()
136+ elif not isinstance (status , StringIO ):
137+ err = "%r must be an io.StringIO object, got: %s" % ('status' , type (status ))
138+ raise TypeError (err )
139+
140+ if warning is None :
141+ # ensure that :attr:`warning` is a StringIO and not sys.stderr
142+ # but allow the stream to be /dev/null by passing verbosity=-1
143+ warning = None if quiet else StringIO ()
144+ elif not isinstance (warning , StringIO ):
145+ err = '%r must be an io.StringIO object, got: %s' % ('warning' , type (warning ))
146+ raise TypeError (err )
147+
99148 self .docutils_conf_path = srcdir / 'docutils.conf'
100149 if docutils_conf is not None :
101150 self .docutils_conf_path .write_text (docutils_conf , encoding = 'utf8' )
@@ -112,17 +161,35 @@ def __init__(
112161 confoverrides = {}
113162
114163 self ._saved_path = sys .path .copy ()
164+ self .extras : Mapping [str , Any ] = MappingProxyType (extras )
165+ """Extras keyword arguments."""
115166
116167 try :
117168 super ().__init__ (
118- srcdir , confdir , outdir , doctreedir ,
119- buildername , confoverrides , status , warning , freshenv ,
120- warningiserror = False , tags = tags , parallel = parallel ,
169+ srcdir , confdir , outdir , doctreedir , buildername ,
170+ confoverrides = confoverrides , status = status , warning = warning ,
171+ freshenv = freshenv , warningiserror = warningiserror , tags = tags ,
172+ verbosity = verbosity , parallel = parallel , keep_going = keep_going ,
173+ pdb = False ,
121174 )
122175 except Exception :
123176 self .cleanup ()
124177 raise
125178
179+ @property
180+ def status (self ) -> StringIO :
181+ """The in-memory text I/O for the application status messages."""
182+ # sphinx.application.Sphinx uses StringIO for a quiet stream
183+ assert isinstance (self ._status , StringIO )
184+ return self ._status
185+
186+ @property
187+ def warning (self ) -> StringIO :
188+ """The in-memory text I/O for the application warning messages."""
189+ # sphinx.application.Sphinx uses StringIO for a quiet stream
190+ assert isinstance (self ._warning , StringIO )
191+ return self ._warning
192+
126193 def cleanup (self , doctrees : bool = False ) -> None :
127194 sys .path [:] = self ._saved_path
128195 _clean_up_global_state ()
0 commit comments