| | |
| | """Monkeypatching and mocking functionality.""" |
| |
|
| | from __future__ import annotations |
| |
|
| | from contextlib import contextmanager |
| | import os |
| | import re |
| | import sys |
| | from typing import Any |
| | from typing import final |
| | from typing import Generator |
| | from typing import Mapping |
| | from typing import MutableMapping |
| | from typing import overload |
| | from typing import TypeVar |
| | import warnings |
| |
|
| | from _pytest.fixtures import fixture |
| | from _pytest.warning_types import PytestWarning |
| |
|
| |
|
| | RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$") |
| |
|
| |
|
| | K = TypeVar("K") |
| | V = TypeVar("V") |
| |
|
| |
|
| | @fixture |
| | def monkeypatch() -> Generator[MonkeyPatch]: |
| | """A convenient fixture for monkey-patching. |
| | |
| | The fixture provides these methods to modify objects, dictionaries, or |
| | :data:`os.environ`: |
| | |
| | * :meth:`monkeypatch.setattr(obj, name, value, raising=True) <pytest.MonkeyPatch.setattr>` |
| | * :meth:`monkeypatch.delattr(obj, name, raising=True) <pytest.MonkeyPatch.delattr>` |
| | * :meth:`monkeypatch.setitem(mapping, name, value) <pytest.MonkeyPatch.setitem>` |
| | * :meth:`monkeypatch.delitem(obj, name, raising=True) <pytest.MonkeyPatch.delitem>` |
| | * :meth:`monkeypatch.setenv(name, value, prepend=None) <pytest.MonkeyPatch.setenv>` |
| | * :meth:`monkeypatch.delenv(name, raising=True) <pytest.MonkeyPatch.delenv>` |
| | * :meth:`monkeypatch.syspath_prepend(path) <pytest.MonkeyPatch.syspath_prepend>` |
| | * :meth:`monkeypatch.chdir(path) <pytest.MonkeyPatch.chdir>` |
| | * :meth:`monkeypatch.context() <pytest.MonkeyPatch.context>` |
| | |
| | All modifications will be undone after the requesting test function or |
| | fixture has finished. The ``raising`` parameter determines if a :class:`KeyError` |
| | or :class:`AttributeError` will be raised if the set/deletion operation does not have the |
| | specified target. |
| | |
| | To undo modifications done by the fixture in a contained scope, |
| | use :meth:`context() <pytest.MonkeyPatch.context>`. |
| | """ |
| | mpatch = MonkeyPatch() |
| | yield mpatch |
| | mpatch.undo() |
| |
|
| |
|
| | def resolve(name: str) -> object: |
| | |
| | parts = name.split(".") |
| |
|
| | used = parts.pop(0) |
| | found: object = __import__(used) |
| | for part in parts: |
| | used += "." + part |
| | try: |
| | found = getattr(found, part) |
| | except AttributeError: |
| | pass |
| | else: |
| | continue |
| | |
| | |
| | try: |
| | __import__(used) |
| | except ImportError as ex: |
| | expected = str(ex).split()[-1] |
| | if expected == used: |
| | raise |
| | else: |
| | raise ImportError(f"import error in {used}: {ex}") from ex |
| | found = annotated_getattr(found, part, used) |
| | return found |
| |
|
| |
|
| | def annotated_getattr(obj: object, name: str, ann: str) -> object: |
| | try: |
| | obj = getattr(obj, name) |
| | except AttributeError as e: |
| | raise AttributeError( |
| | f"{type(obj).__name__!r} object at {ann} has no attribute {name!r}" |
| | ) from e |
| | return obj |
| |
|
| |
|
| | def derive_importpath(import_path: str, raising: bool) -> tuple[str, object]: |
| | if not isinstance(import_path, str) or "." not in import_path: |
| | raise TypeError(f"must be absolute import path string, not {import_path!r}") |
| | module, attr = import_path.rsplit(".", 1) |
| | target = resolve(module) |
| | if raising: |
| | annotated_getattr(target, attr, ann=module) |
| | return attr, target |
| |
|
| |
|
| | class Notset: |
| | def __repr__(self) -> str: |
| | return "<notset>" |
| |
|
| |
|
| | notset = Notset() |
| |
|
| |
|
| | @final |
| | class MonkeyPatch: |
| | """Helper to conveniently monkeypatch attributes/items/environment |
| | variables/syspath. |
| | |
| | Returned by the :fixture:`monkeypatch` fixture. |
| | |
| | .. versionchanged:: 6.2 |
| | Can now also be used directly as `pytest.MonkeyPatch()`, for when |
| | the fixture is not available. In this case, use |
| | :meth:`with MonkeyPatch.context() as mp: <context>` or remember to call |
| | :meth:`undo` explicitly. |
| | """ |
| |
|
| | def __init__(self) -> None: |
| | self._setattr: list[tuple[object, str, object]] = [] |
| | self._setitem: list[tuple[Mapping[Any, Any], object, object]] = [] |
| | self._cwd: str | None = None |
| | self._savesyspath: list[str] | None = None |
| |
|
| | @classmethod |
| | @contextmanager |
| | def context(cls) -> Generator[MonkeyPatch]: |
| | """Context manager that returns a new :class:`MonkeyPatch` object |
| | which undoes any patching done inside the ``with`` block upon exit. |
| | |
| | Example: |
| | |
| | .. code-block:: python |
| | |
| | import functools |
| | |
| | |
| | def test_partial(monkeypatch): |
| | with monkeypatch.context() as m: |
| | m.setattr(functools, "partial", 3) |
| | |
| | Useful in situations where it is desired to undo some patches before the test ends, |
| | such as mocking ``stdlib`` functions that might break pytest itself if mocked (for examples |
| | of this see :issue:`3290`). |
| | """ |
| | m = cls() |
| | try: |
| | yield m |
| | finally: |
| | m.undo() |
| |
|
| | @overload |
| | def setattr( |
| | self, |
| | target: str, |
| | name: object, |
| | value: Notset = ..., |
| | raising: bool = ..., |
| | ) -> None: ... |
| |
|
| | @overload |
| | def setattr( |
| | self, |
| | target: object, |
| | name: str, |
| | value: object, |
| | raising: bool = ..., |
| | ) -> None: ... |
| |
|
| | def setattr( |
| | self, |
| | target: str | object, |
| | name: object | str, |
| | value: object = notset, |
| | raising: bool = True, |
| | ) -> None: |
| | """ |
| | Set attribute value on target, memorizing the old value. |
| | |
| | For example: |
| | |
| | .. code-block:: python |
| | |
| | import os |
| | |
| | monkeypatch.setattr(os, "getcwd", lambda: "/") |
| | |
| | The code above replaces the :func:`os.getcwd` function by a ``lambda`` which |
| | always returns ``"/"``. |
| | |
| | For convenience, you can specify a string as ``target`` which |
| | will be interpreted as a dotted import path, with the last part |
| | being the attribute name: |
| | |
| | .. code-block:: python |
| | |
| | monkeypatch.setattr("os.getcwd", lambda: "/") |
| | |
| | Raises :class:`AttributeError` if the attribute does not exist, unless |
| | ``raising`` is set to False. |
| | |
| | **Where to patch** |
| | |
| | ``monkeypatch.setattr`` works by (temporarily) changing the object that a name points to with another one. |
| | There can be many names pointing to any individual object, so for patching to work you must ensure |
| | that you patch the name used by the system under test. |
| | |
| | See the section :ref:`Where to patch <python:where-to-patch>` in the :mod:`unittest.mock` |
| | docs for a complete explanation, which is meant for :func:`unittest.mock.patch` but |
| | applies to ``monkeypatch.setattr`` as well. |
| | """ |
| | __tracebackhide__ = True |
| | import inspect |
| |
|
| | if isinstance(value, Notset): |
| | if not isinstance(target, str): |
| | raise TypeError( |
| | "use setattr(target, name, value) or " |
| | "setattr(target, value) with target being a dotted " |
| | "import string" |
| | ) |
| | value = name |
| | name, target = derive_importpath(target, raising) |
| | else: |
| | if not isinstance(name, str): |
| | raise TypeError( |
| | "use setattr(target, name, value) with name being a string or " |
| | "setattr(target, value) with target being a dotted " |
| | "import string" |
| | ) |
| |
|
| | oldval = getattr(target, name, notset) |
| | if raising and oldval is notset: |
| | raise AttributeError(f"{target!r} has no attribute {name!r}") |
| |
|
| | |
| | if inspect.isclass(target): |
| | oldval = target.__dict__.get(name, notset) |
| | self._setattr.append((target, name, oldval)) |
| | setattr(target, name, value) |
| |
|
| | def delattr( |
| | self, |
| | target: object | str, |
| | name: str | Notset = notset, |
| | raising: bool = True, |
| | ) -> None: |
| | """Delete attribute ``name`` from ``target``. |
| | |
| | If no ``name`` is specified and ``target`` is a string |
| | it will be interpreted as a dotted import path with the |
| | last part being the attribute name. |
| | |
| | Raises AttributeError it the attribute does not exist, unless |
| | ``raising`` is set to False. |
| | """ |
| | __tracebackhide__ = True |
| | import inspect |
| |
|
| | if isinstance(name, Notset): |
| | if not isinstance(target, str): |
| | raise TypeError( |
| | "use delattr(target, name) or " |
| | "delattr(target) with target being a dotted " |
| | "import string" |
| | ) |
| | name, target = derive_importpath(target, raising) |
| |
|
| | if not hasattr(target, name): |
| | if raising: |
| | raise AttributeError(name) |
| | else: |
| | oldval = getattr(target, name, notset) |
| | |
| | if inspect.isclass(target): |
| | oldval = target.__dict__.get(name, notset) |
| | self._setattr.append((target, name, oldval)) |
| | delattr(target, name) |
| |
|
| | def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None: |
| | """Set dictionary entry ``name`` to value.""" |
| | self._setitem.append((dic, name, dic.get(name, notset))) |
| | |
| | dic[name] = value |
| |
|
| | def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None: |
| | """Delete ``name`` from dict. |
| | |
| | Raises ``KeyError`` if it doesn't exist, unless ``raising`` is set to |
| | False. |
| | """ |
| | if name not in dic: |
| | if raising: |
| | raise KeyError(name) |
| | else: |
| | self._setitem.append((dic, name, dic.get(name, notset))) |
| | |
| | del dic[name] |
| |
|
| | def setenv(self, name: str, value: str, prepend: str | None = None) -> None: |
| | """Set environment variable ``name`` to ``value``. |
| | |
| | If ``prepend`` is a character, read the current environment variable |
| | value and prepend the ``value`` adjoined with the ``prepend`` |
| | character. |
| | """ |
| | if not isinstance(value, str): |
| | warnings.warn( |
| | PytestWarning( |
| | f"Value of environment variable {name} type should be str, but got " |
| | f"{value!r} (type: {type(value).__name__}); converted to str implicitly" |
| | ), |
| | stacklevel=2, |
| | ) |
| | value = str(value) |
| | if prepend and name in os.environ: |
| | value = value + prepend + os.environ[name] |
| | self.setitem(os.environ, name, value) |
| |
|
| | def delenv(self, name: str, raising: bool = True) -> None: |
| | """Delete ``name`` from the environment. |
| | |
| | Raises ``KeyError`` if it does not exist, unless ``raising`` is set to |
| | False. |
| | """ |
| | environ: MutableMapping[str, str] = os.environ |
| | self.delitem(environ, name, raising=raising) |
| |
|
| | def syspath_prepend(self, path) -> None: |
| | """Prepend ``path`` to ``sys.path`` list of import locations.""" |
| | if self._savesyspath is None: |
| | self._savesyspath = sys.path[:] |
| | sys.path.insert(0, str(path)) |
| |
|
| | |
| | |
| | if "pkg_resources" in sys.modules: |
| | from pkg_resources import fixup_namespace_packages |
| |
|
| | fixup_namespace_packages(str(path)) |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | from importlib import invalidate_caches |
| |
|
| | invalidate_caches() |
| |
|
| | def chdir(self, path: str | os.PathLike[str]) -> None: |
| | """Change the current working directory to the specified path. |
| | |
| | :param path: |
| | The path to change into. |
| | """ |
| | if self._cwd is None: |
| | self._cwd = os.getcwd() |
| | os.chdir(path) |
| |
|
| | def undo(self) -> None: |
| | """Undo previous changes. |
| | |
| | This call consumes the undo stack. Calling it a second time has no |
| | effect unless you do more monkeypatching after the undo call. |
| | |
| | There is generally no need to call `undo()`, since it is |
| | called automatically during tear-down. |
| | |
| | .. note:: |
| | The same `monkeypatch` fixture is used across a |
| | single test function invocation. If `monkeypatch` is used both by |
| | the test function itself and one of the test fixtures, |
| | calling `undo()` will undo all of the changes made in |
| | both functions. |
| | |
| | Prefer to use :meth:`context() <pytest.MonkeyPatch.context>` instead. |
| | """ |
| | for obj, name, value in reversed(self._setattr): |
| | if value is not notset: |
| | setattr(obj, name, value) |
| | else: |
| | delattr(obj, name) |
| | self._setattr[:] = [] |
| | for dictionary, key, value in reversed(self._setitem): |
| | if value is notset: |
| | try: |
| | |
| | del dictionary[key] |
| | except KeyError: |
| | pass |
| | else: |
| | |
| | dictionary[key] = value |
| | self._setitem[:] = [] |
| | if self._savesyspath is not None: |
| | sys.path[:] = self._savesyspath |
| | self._savesyspath = None |
| |
|
| | if self._cwd is not None: |
| | os.chdir(self._cwd) |
| | self._cwd = None |
| |
|