1"""Record warnings during test function execution."""

2 import re

3 import warnings

4 from pprint import pformat

5 from types import TracebackType

6 from typing import Any

7 from typing import Callable

8 from typing import Generator

9 from typing import Iterator

10 from typing import List

11 from typing import Optional

12 from typing import Pattern

13 from typing import Tuple

14 from typing import Type

15 from typing import TypeVar

16 from typing import Union

17

18 from _pytest.compat import final

19 from _pytest.compat import overload

20 from _pytest.deprecated import check_ispytest

21 from _pytest.deprecated import WARNS_NONE_ARG

22 from _pytest.fixtures import fixture

23 from _pytest.outcomes import fail

24

25

26 T = TypeVar("T")

27

28

29@fixture

30 def recwarn() -> Generator["WarningsRecorder", None, None]:

31 """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions.

32

33 See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information

34 on warning categories.

35 """

36 wrec = WarningsRecorder(_ispytest=True)

37 with wrec:

38 warnings.simplefilter("default")

39 yield wrec

40

41

42@overload

43 def deprecated_call(

44 *, match: Optional[Union[str, Pattern[str]]] = ...

45) -> "WarningsRecorder":

46 ...

47

48

49@overload

50 def deprecated_call( # noqa: F811

51 func: Callable[..., T], *args: Any, **kwargs: Any

52) -> T:

53 ...

54

55

56 def deprecated_call( # noqa: F811

57 func: Optional[Callable[..., Any]] = None, *args: Any, **kwargs: Any

58) -> Union["WarningsRecorder", Any]:

59 """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning``.

60

61 This function can be used as a context manager::

62

63 >>> import warnings

64 >>> def api_call_v2():

65 ... warnings.warn('use v3 of this api', DeprecationWarning)

66 ... return 200

67

68 >>> import pytest

69 >>> with pytest.deprecated_call():

70 ... assert api_call_v2() == 200

71

72 It can also be used by passing a function and ``*args`` and ``**kwargs``,

73 in which case it will ensure calling ``func(*args, **kwargs)`` produces one of

74 the warnings types above. The return value is the return value of the function.

75

76 In the context manager form you may use the keyword argument ``match`` to assert

77 that the warning matches a text or regex.

78

79 The context manager produces a list of :class:`warnings.WarningMessage` objects,

80 one for each warning raised.

81 """

82 __tracebackhide__ = True

83 if func is not None:

84 args = (func,) + args

85 return warns((DeprecationWarning, PendingDeprecationWarning), *args, **kwargs)

86

87

88@overload

89 def warns(

90 expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]] = ...,

91 *,

92 match: Optional[Union[str, Pattern[str]]] = ...,

93) -> "WarningsChecker":

94 ...

95

96

97@overload

98 def warns( # noqa: F811

99 expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]],

100 func: Callable[..., T],

101 *args: Any,

102 **kwargs: Any,

103) -> T:

104 ...

105

106

107 def warns( # noqa: F811

108 expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]] = Warning,

109 *args: Any,

110 match: Optional[Union[str, Pattern[str]]] = None,

111 **kwargs: Any,

112) -> Union["WarningsChecker", Any]:

113 r"""Assert that code raises a particular class of warning.

114

115 Specifically, the parameter ``expected_warning`` can be a warning class or sequence

116 of warning classes, and the code inside the ``with`` block must issue at least one

117 warning of that class or classes.

118

119 This helper produces a list of :class:`warnings.WarningMessage` objects, one for

120 each warning raised (regardless of whether it is an ``expected_warning`` or not).

121

122 This function can be used as a context manager, which will capture all the raised

123 warnings inside it::

124

125 >>> import pytest

126 >>> with pytest.warns(RuntimeWarning):

127 ... warnings.warn("my warning", RuntimeWarning)

128

129 In the context manager form you may use the keyword argument ``match`` to assert

130 that the warning matches a text or regex::

131

132 >>> with pytest.warns(UserWarning, match='must be 0 or None'):

133 ... warnings.warn("value must be 0 or None", UserWarning)

134

135 >>> with pytest.warns(UserWarning, match=r'must be \d+$'):

136 ... warnings.warn("value must be 42", UserWarning)

137

138 >>> with pytest.warns(UserWarning, match=r'must be \d+$'):

139 ... warnings.warn("this is not here", UserWarning)

140 Traceback (most recent call last):

141 ...

142 Failed: DID NOT WARN. No warnings of type ...UserWarning... were emitted...

143

144 **Using with** ``pytest.mark.parametrize``

145

146 When using :ref:`pytest.mark.parametrize ref` it is possible to parametrize tests

147 such that some runs raise a warning and others do not.

148

149 This could be achieved in the same way as with exceptions, see

150 :ref:`parametrizing_conditional_raising` for an example.

151

152 """

153 __tracebackhide__ = True

154 if not args:

155 if kwargs:

156 argnames = ", ".join(sorted(kwargs))

157 raise TypeError(

158 f"Unexpected keyword arguments passed to pytest.warns: {argnames}"

159 "\nUse context-manager form instead?"

160 )

161 return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)

162 else:

163 func = args[0]

164 if not callable(func):

165 raise TypeError(f"{func!r} object (type: {type(func)}) must be callable")

166 with WarningsChecker(expected_warning, _ispytest=True):

167 return func(*args[1:], **kwargs)

168

169

170 class WarningsRecorder(warnings.catch_warnings): # type:ignore[type-arg]

171 """A context manager to record raised warnings.

172

173 Each recorded warning is an instance of :class:`warnings.WarningMessage`.

174

175 Adapted from `warnings.catch_warnings`.

176

177 .. note::

178 ``DeprecationWarning`` and ``PendingDeprecationWarning`` are treated

179 differently; see :ref:`ensuring_function_triggers`.

180

181 """

182

183 def __init__(self, *, _ispytest: bool = False) -> None:

184 check_ispytest(_ispytest)

185 # Type ignored due to the way typeshed handles warnings.catch_warnings.

186 super().__init__(record=True) # type: ignore[call-arg]

187 self._entered = False

188 self._list: List[warnings.WarningMessage] = []

189

190 @property

191 def list(self) -> List["warnings.WarningMessage"]:

192 """The list of recorded warnings."""

193 return self._list

194

195 def __getitem__(self, i: int) -> "warnings.WarningMessage":

196 """Get a recorded warning by index."""

197 return self._list[i]

198

199 def __iter__(self) -> Iterator["warnings.WarningMessage"]:

200 """Iterate through the recorded warnings."""

201 return iter(self._list)

202

203 def __len__(self) -> int:

204 """The number of recorded warnings."""

205 return len(self._list)

206

207 def pop(self, cls: Type[Warning] = Warning) -> "warnings.WarningMessage":

208 """Pop the first recorded warning, raise exception if not exists."""

209 for i, w in enumerate(self._list):

210 if issubclass(w.category, cls):

211 return self._list.pop(i)

212 __tracebackhide__ = True

213 raise AssertionError(f"{cls!r} not found in warning list")

214

215 def clear(self) -> None:

216 """Clear the list of recorded warnings."""

217 self._list[:] = []

218

219 # Type ignored because it doesn't exactly warnings.catch_warnings.__enter__

220 # -- it returns a List but we only emulate one.

221 def __enter__(self) -> "WarningsRecorder": # type: ignore

222 if self._entered:

223 __tracebackhide__ = True

224 raise RuntimeError(f"Cannot enter {self!r} twice")

225 _list = super().__enter__()

226 # record=True means it's None.

227 assert _list is not None

228 self._list = _list

229 warnings.simplefilter("always")

230 return self

231

232 def __exit__(

233 self,

234 exc_type: Optional[Type[BaseException]],

235 exc_val: Optional[BaseException],

236 exc_tb: Optional[TracebackType],

237 ) -> None:

238 if not self._entered:

239 __tracebackhide__ = True

240 raise RuntimeError(f"Cannot exit {self!r} without entering first")

241

242 super().__exit__(exc_type, exc_val, exc_tb)

243

244 # Built-in catch_warnings does not reset entered state so we do it

245 # manually here for this context manager to become reusable.

246 self._entered = False

247

248

249@final

250 class WarningsChecker(WarningsRecorder):

251 def __init__(

252 self,

253 expected_warning: Optional[

254 Union[Type[Warning], Tuple[Type[Warning], ...]]

255 ] = Warning,

256 match_expr: Optional[Union[str, Pattern[str]]] = None,

257 *,

258 _ispytest: bool = False,

259 ) -> None:

260 check_ispytest(_ispytest)

261 super().__init__(_ispytest=True)

262

263 msg = "exceptions must be derived from Warning, not %s"

264 if expected_warning is None:

265 warnings.warn(WARNS_NONE_ARG, stacklevel=4)

266 expected_warning_tup = None

267 elif isinstance(expected_warning, tuple):

268 for exc in expected_warning:

269 if not issubclass(exc, Warning):

270 raise TypeError(msg % type(exc))

271 expected_warning_tup = expected_warning

272 elif issubclass(expected_warning, Warning):

273 expected_warning_tup = (expected_warning,)

274 else:

275 raise TypeError(msg % type(expected_warning))

276

277 self.expected_warning = expected_warning_tup

278 self.match_expr = match_expr

279

280 def __exit__(

281 self,

282 exc_type: Optional[Type[BaseException]],

283 exc_val: Optional[BaseException],

284 exc_tb: Optional[TracebackType],

285 ) -> None:

286 super().__exit__(exc_type, exc_val, exc_tb)

287

288 __tracebackhide__ = True

289

290 def found_str():

291 return pformat([record.message for record in self], indent=2)

292

293 # only check if we're not currently handling an exception

294 if exc_type is None and exc_val is None and exc_tb is None:

295 if self.expected_warning is not None:

296 if not any(issubclass(r.category, self.expected_warning) for r in self):

297 __tracebackhide__ = True

298 fail(

299 f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n"

300 f"The list of emitted warnings is: {found_str()}."

301 )

302 elif self.match_expr is not None:

303 for r in self:

304 if issubclass(r.category, self.expected_warning):

305 if re.compile(self.match_expr).search(str(r.message)):

306 break

307 else:

308 fail(

309 f"""\

310 DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.

311 Regex: {self.match_expr}

312 Emitted warnings: {found_str()}"""

313 )