1"""Monkeypatching and mocking functionality."""

2 import os

3 import re

4 import sys

5 import warnings

6 from contextlib import contextmanager

7 from typing import Any

8 from typing import Generator

9 from typing import List

10 from typing import MutableMapping

11 from typing import Optional

12 from typing import overload

13 from typing import Tuple

14 from typing import TypeVar

15 from typing import Union

16

17 from _pytest.compat import final

18 from _pytest.fixtures import fixture

19 from _pytest.warning_types import PytestWarning

20

21 RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$")

22

23

24 K = TypeVar("K")

25 V = TypeVar("V")

26

27

28@fixture

29 def monkeypatch() -> Generator["MonkeyPatch", None, None]:

30 """A convenient fixture for monkey-patching.

31

32 The fixture provides these methods to modify objects, dictionaries, or

33 :data:`os.environ`:

34

35 * :meth:`monkeypatch.setattr(obj, name, value, raising=True) <pytest.MonkeyPatch.setattr>`

36 * :meth:`monkeypatch.delattr(obj, name, raising=True) <pytest.MonkeyPatch.delattr>`

37 * :meth:`monkeypatch.setitem(mapping, name, value) <pytest.MonkeyPatch.setitem>`

38 * :meth:`monkeypatch.delitem(obj, name, raising=True) <pytest.MonkeyPatch.delitem>`

39 * :meth:`monkeypatch.setenv(name, value, prepend=None) <pytest.MonkeyPatch.setenv>`

40 * :meth:`monkeypatch.delenv(name, raising=True) <pytest.MonkeyPatch.delenv>`

41 * :meth:`monkeypatch.syspath_prepend(path) <pytest.MonkeyPatch.syspath_prepend>`

42 * :meth:`monkeypatch.chdir(path) <pytest.MonkeyPatch.chdir>`

43 * :meth:`monkeypatch.context() <pytest.MonkeyPatch.context>`

44

45 All modifications will be undone after the requesting test function or

46 fixture has finished. The ``raising`` parameter determines if a :class:`KeyError`

47 or :class:`AttributeError` will be raised if the set/deletion operation does not have the

48 specified target.

49

50 To undo modifications done by the fixture in a contained scope,

51 use :meth:`context() <pytest.MonkeyPatch.context>`.

52 """

53 mpatch = MonkeyPatch()

54 yield mpatch

55 mpatch.undo()

56

57

58 def resolve(name: str) -> object:

59 # Simplified from zope.dottedname.

60 parts = name.split(".")

61

62 used = parts.pop(0)

63 found: object = __import__(used)

64 for part in parts:

65 used += "." + part

66 try:

67 found = getattr(found, part)

68 except AttributeError:

69 pass

70 else:

71 continue

72 # We use explicit un-nesting of the handling block in order

73 # to avoid nested exceptions.

74 try:

75 __import__(used)

76 except ImportError as ex:

77 expected = str(ex).split()[-1]

78 if expected == used:

79 raise

80 else:

81 raise ImportError(f"import error in {used}: {ex}") from ex

82 found = annotated_getattr(found, part, used)

83 return found

84

85

86 def annotated_getattr(obj: object, name: str, ann: str) -> object:

87 try:

88 obj = getattr(obj, name)

89 except AttributeError as e:

90 raise AttributeError(

91 "{!r} object at {} has no attribute {!r}".format(

92 type(obj).__name__, ann, name

93 )

94 ) from e

95 return obj

96

97

98 def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]:

99 if not isinstance(import_path, str) or "." not in import_path:

100 raise TypeError(f"must be absolute import path string, not {import_path!r}")

101 module, attr = import_path.rsplit(".", 1)

102 target = resolve(module)

103 if raising:

104 annotated_getattr(target, attr, ann=module)

105 return attr, target

106

107

108 class Notset:

109 def __repr__(self) -> str:

110 return "<notset>"

111

112

113 notset = Notset()

114

115

116@final

117 class MonkeyPatch:

118 """Helper to conveniently monkeypatch attributes/items/environment

119 variables/syspath.

120

121 Returned by the :fixture:`monkeypatch` fixture.

122

123 .. versionchanged:: 6.2

124 Can now also be used directly as `pytest.MonkeyPatch()`, for when

125 the fixture is not available. In this case, use

126 :meth:`with MonkeyPatch.context() as mp: <context>` or remember to call

127 :meth:`undo` explicitly.

128 """

129

130 def __init__(self) -> None:

131 self._setattr: List[Tuple[object, str, object]] = []

132 self._setitem: List[Tuple[MutableMapping[Any, Any], object, object]] = []

133 self._cwd: Optional[str] = None

134 self._savesyspath: Optional[List[str]] = None

135

136 @classmethod

137 @contextmanager

138 def context(cls) -> Generator["MonkeyPatch", None, None]:

139 """Context manager that returns a new :class:`MonkeyPatch` object

140 which undoes any patching done inside the ``with`` block upon exit.

141

142 Example:

143

144 .. code-block:: python

145

146 import functools

147

148

149 def test_partial(monkeypatch):

150 with monkeypatch.context() as m:

151 m.setattr(functools, "partial", 3)

152

153 Useful in situations where it is desired to undo some patches before the test ends,

154 such as mocking ``stdlib`` functions that might break pytest itself if mocked (for examples

155 of this see :issue:`3290`).

156 """

157 m = cls()

158 try:

159 yield m

160 finally:

161 m.undo()

162

163 @overload

164 def setattr(

165 self,

166 target: str,

167 name: object,

168 value: Notset = ...,

169 raising: bool = ...,

170 ) -> None:

171 ...

172

173 @overload

174 def setattr(

175 self,

176 target: object,

177 name: str,

178 value: object,

179 raising: bool = ...,

180 ) -> None:

181 ...

182

183 def setattr(

184 self,

185 target: Union[str, object],

186 name: Union[object, str],

187 value: object = notset,

188 raising: bool = True,

189 ) -> None:

190 """

191 Set attribute value on target, memorizing the old value.

192

193 For example:

194

195 .. code-block:: python

196

197 import os

198

199 monkeypatch.setattr(os, "getcwd", lambda: "/")

200

201 The code above replaces the :func:`os.getcwd` function by a ``lambda`` which

202 always returns ``"/"``.

203

204 For convenience, you can specify a string as ``target`` which

205 will be interpreted as a dotted import path, with the last part

206 being the attribute name:

207

208 .. code-block:: python

209

210 monkeypatch.setattr("os.getcwd", lambda: "/")

211

212 Raises :class:`AttributeError` if the attribute does not exist, unless

213 ``raising`` is set to False.

214

215 **Where to patch**

216

217 ``monkeypatch.setattr`` works by (temporarily) changing the object that a name points to with another one.

218 There can be many names pointing to any individual object, so for patching to work you must ensure

219 that you patch the name used by the system under test.

220

221 See the section :ref:`Where to patch <python:where-to-patch>` in the :mod:`unittest.mock`

222 docs for a complete explanation, which is meant for :func:`unittest.mock.patch` but

223 applies to ``monkeypatch.setattr`` as well.

224 """

225 __tracebackhide__ = True

226 import inspect

227

228 if isinstance(value, Notset):

229 if not isinstance(target, str):

230 raise TypeError(

231 "use setattr(target, name, value) or "

232 "setattr(target, value) with target being a dotted "

233 "import string"

234 )

235 value = name

236 name, target = derive_importpath(target, raising)

237 else:

238 if not isinstance(name, str):

239 raise TypeError(

240 "use setattr(target, name, value) with name being a string or "

241 "setattr(target, value) with target being a dotted "

242 "import string"

243 )

244

245 oldval = getattr(target, name, notset)

246 if raising and oldval is notset:

247 raise AttributeError(f"{target!r} has no attribute {name!r}")

248

249 # avoid class descriptors like staticmethod/classmethod

250 if inspect.isclass(target):

251 oldval = target.__dict__.get(name, notset)

252 self._setattr.append((target, name, oldval))

253 setattr(target, name, value)

254

255 def delattr(

256 self,

257 target: Union[object, str],

258 name: Union[str, Notset] = notset,

259 raising: bool = True,

260 ) -> None:

261 """Delete attribute ``name`` from ``target``.

262

263 If no ``name`` is specified and ``target`` is a string

264 it will be interpreted as a dotted import path with the

265 last part being the attribute name.

266

267 Raises AttributeError it the attribute does not exist, unless

268 ``raising`` is set to False.

269 """

270 __tracebackhide__ = True

271 import inspect

272

273 if isinstance(name, Notset):

274 if not isinstance(target, str):

275 raise TypeError(

276 "use delattr(target, name) or "

277 "delattr(target) with target being a dotted "

278 "import string"

279 )

280 name, target = derive_importpath(target, raising)

281

282 if not hasattr(target, name):

283 if raising:

284 raise AttributeError(name)

285 else:

286 oldval = getattr(target, name, notset)

287 # Avoid class descriptors like staticmethod/classmethod.

288 if inspect.isclass(target):

289 oldval = target.__dict__.get(name, notset)

290 self._setattr.append((target, name, oldval))

291 delattr(target, name)

292

293 def setitem(self, dic: MutableMapping[K, V], name: K, value: V) -> None:

294 """Set dictionary entry ``name`` to value."""

295 self._setitem.append((dic, name, dic.get(name, notset)))

296 dic[name] = value

297

298 def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None:

299 """Delete ``name`` from dict.

300

301 Raises ``KeyError`` if it doesn't exist, unless ``raising`` is set to

302 False.

303 """

304 if name not in dic:

305 if raising:

306 raise KeyError(name)

307 else:

308 self._setitem.append((dic, name, dic.get(name, notset)))

309 del dic[name]

310

311 def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None:

312 """Set environment variable ``name`` to ``value``.

313

314 If ``prepend`` is a character, read the current environment variable

315 value and prepend the ``value`` adjoined with the ``prepend``

316 character.

317 """

318 if not isinstance(value, str):

319 warnings.warn( # type: ignore[unreachable]

320 PytestWarning(

321 "Value of environment variable {name} type should be str, but got "

322 "{value!r} (type: {type}); converted to str implicitly".format(

323 name=name, value=value, type=type(value).__name__

324 )

325 ),

326 stacklevel=2,

327 )

328 value = str(value)

329 if prepend and name in os.environ:

330 value = value + prepend + os.environ[name]

331 self.setitem(os.environ, name, value)

332

333 def delenv(self, name: str, raising: bool = True) -> None:

334 """Delete ``name`` from the environment.

335

336 Raises ``KeyError`` if it does not exist, unless ``raising`` is set to

337 False.

338 """

339 environ: MutableMapping[str, str] = os.environ

340 self.delitem(environ, name, raising=raising)

341

342 def syspath_prepend(self, path) -> None:

343 """Prepend ``path`` to ``sys.path`` list of import locations."""

344

345 if self._savesyspath is None:

346 self._savesyspath = sys.path[:]

347 sys.path.insert(0, str(path))

348

349 # https://github.com/pypa/setuptools/blob/d8b901bc/docs/pkg_resources.txt#L162-L171

350 # this is only needed when pkg_resources was already loaded by the namespace package

351 if "pkg_resources" in sys.modules:

352 from pkg_resources import fixup_namespace_packages

353

354 fixup_namespace_packages(str(path))

355

356 # A call to syspathinsert() usually means that the caller wants to

357 # import some dynamically created files, thus with python3 we

358 # invalidate its import caches.

359 # This is especially important when any namespace package is in use,

360 # since then the mtime based FileFinder cache (that gets created in

361 # this case already) gets not invalidated when writing the new files

362 # quickly afterwards.

363 from importlib import invalidate_caches

364

365 invalidate_caches()

366

367 def chdir(self, path: Union[str, "os.PathLike[str]"]) -> None:

368 """Change the current working directory to the specified path.

369

370 :param path:

371 The path to change into.

372 """

373 if self._cwd is None:

374 self._cwd = os.getcwd()

375 os.chdir(path)

376

377 def undo(self) -> None:

378 """Undo previous changes.

379

380 This call consumes the undo stack. Calling it a second time has no

381 effect unless you do more monkeypatching after the undo call.

382

383 There is generally no need to call `undo()`, since it is

384 called automatically during tear-down.

385

386 .. note::

387 The same `monkeypatch` fixture is used across a

388 single test function invocation. If `monkeypatch` is used both by

389 the test function itself and one of the test fixtures,

390 calling `undo()` will undo all of the changes made in

391 both functions.

392

393 Prefer to use :meth:`context() <pytest.MonkeyPatch.context>` instead.

394 """

395 for obj, name, value in reversed(self._setattr):

396 if value is not notset:

397 setattr(obj, name, value)

398 else:

399 delattr(obj, name)

400 self._setattr[:] = []

401 for dictionary, key, value in reversed(self._setitem):

402 if value is notset:

403 try:

404 del dictionary[key]

405 except KeyError:

406 pass # Was already deleted, so we have the desired state.

407 else:

408 dictionary[key] = value

409 self._setitem[:] = []

410 if self._savesyspath is not None:

411 sys.path[:] = self._savesyspath

412 self._savesyspath = None

413

414 if self._cwd is not None:

415 os.chdir(self._cwd)

416 self._cwd = None