Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/monkeypatch.py: 42%
167 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
1"""Monkeypatching and mocking functionality."""
2import os
3import re
4import sys
5import warnings
6from contextlib import contextmanager
7from typing import Any
8from typing import Generator
9from typing import List
10from typing import MutableMapping
11from typing import Optional
12from typing import overload
13from typing import Tuple
14from typing import TypeVar
15from typing import Union
17from _pytest.compat import final
18from _pytest.fixtures import fixture
19from _pytest.warning_types import PytestWarning
21RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$")
24K = TypeVar("K")
25V = TypeVar("V")
28@fixture
29def monkeypatch() -> Generator["MonkeyPatch", None, None]:
30 """A convenient fixture for monkey-patching.
32 The fixture provides these methods to modify objects, dictionaries, or
33 :data:`os.environ`:
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>`
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.
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()
58def resolve(name: str) -> object:
59 # Simplified from zope.dottedname.
60 parts = name.split(".")
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
86def 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
98def 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
108class Notset:
109 def __repr__(self) -> str:
110 return "<notset>"
113notset = Notset()
116@final
117class MonkeyPatch:
118 """Helper to conveniently monkeypatch attributes/items/environment
119 variables/syspath.
121 Returned by the :fixture:`monkeypatch` fixture.
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 """
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
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.
142 Example:
144 .. code-block:: python
146 import functools
149 def test_partial(monkeypatch):
150 with monkeypatch.context() as m:
151 m.setattr(functools, "partial", 3)
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()
163 @overload
164 def setattr(
165 self,
166 target: str,
167 name: object,
168 value: Notset = ...,
169 raising: bool = ...,
170 ) -> None:
171 ...
173 @overload
174 def setattr(
175 self,
176 target: object,
177 name: str,
178 value: object,
179 raising: bool = ...,
180 ) -> None:
181 ...
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.
193 For example:
195 .. code-block:: python
197 import os
199 monkeypatch.setattr(os, "getcwd", lambda: "/")
201 The code above replaces the :func:`os.getcwd` function by a ``lambda`` which
202 always returns ``"/"``.
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:
208 .. code-block:: python
210 monkeypatch.setattr("os.getcwd", lambda: "/")
212 Raises :class:`AttributeError` if the attribute does not exist, unless
213 ``raising`` is set to False.
215 **Where to patch**
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.
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
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 )
245 oldval = getattr(target, name, notset)
246 if raising and oldval is notset:
247 raise AttributeError(f"{target!r} has no attribute {name!r}")
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)
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``.
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.
267 Raises AttributeError it the attribute does not exist, unless
268 ``raising`` is set to False.
269 """
270 __tracebackhide__ = True
271 import inspect
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)
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)
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
298 def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None:
299 """Delete ``name`` from dict.
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]
311 def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None:
312 """Set environment variable ``name`` to ``value``.
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)
333 def delenv(self, name: str, raising: bool = True) -> None:
334 """Delete ``name`` from the environment.
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)
342 def syspath_prepend(self, path) -> None:
343 """Prepend ``path`` to ``sys.path`` list of import locations."""
345 if self._savesyspath is None:
346 self._savesyspath = sys.path[:]
347 sys.path.insert(0, str(path))
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
354 fixup_namespace_packages(str(path))
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
365 invalidate_caches()
367 def chdir(self, path: Union[str, "os.PathLike[str]"]) -> None:
368 """Change the current working directory to the specified path.
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)
377 def undo(self) -> None:
378 """Undo previous changes.
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.
383 There is generally no need to call `undo()`, since it is
384 called automatically during tear-down.
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.
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
414 if self._cwd is not None:
415 os.chdir(self._cwd)
416 self._cwd = None