You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

380 lines
13KB

  1. """Monkeypatching and mocking functionality."""
  2. import os
  3. import re
  4. import sys
  5. import warnings
  6. from contextlib import contextmanager
  7. from pathlib import Path
  8. from typing import Any
  9. from typing import Generator
  10. from typing import List
  11. from typing import MutableMapping
  12. from typing import Optional
  13. from typing import overload
  14. from typing import Tuple
  15. from typing import TypeVar
  16. from typing import Union
  17. from _pytest.compat import final
  18. from _pytest.fixtures import fixture
  19. from _pytest.warning_types import PytestWarning
  20. RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$")
  21. K = TypeVar("K")
  22. V = TypeVar("V")
  23. @fixture
  24. def monkeypatch() -> Generator["MonkeyPatch", None, None]:
  25. """A convenient fixture for monkey-patching.
  26. The fixture provides these methods to modify objects, dictionaries or
  27. os.environ::
  28. monkeypatch.setattr(obj, name, value, raising=True)
  29. monkeypatch.delattr(obj, name, raising=True)
  30. monkeypatch.setitem(mapping, name, value)
  31. monkeypatch.delitem(obj, name, raising=True)
  32. monkeypatch.setenv(name, value, prepend=False)
  33. monkeypatch.delenv(name, raising=True)
  34. monkeypatch.syspath_prepend(path)
  35. monkeypatch.chdir(path)
  36. All modifications will be undone after the requesting test function or
  37. fixture has finished. The ``raising`` parameter determines if a KeyError
  38. or AttributeError will be raised if the set/deletion operation has no target.
  39. """
  40. mpatch = MonkeyPatch()
  41. yield mpatch
  42. mpatch.undo()
  43. def resolve(name: str) -> object:
  44. # Simplified from zope.dottedname.
  45. parts = name.split(".")
  46. used = parts.pop(0)
  47. found = __import__(used)
  48. for part in parts:
  49. used += "." + part
  50. try:
  51. found = getattr(found, part)
  52. except AttributeError:
  53. pass
  54. else:
  55. continue
  56. # We use explicit un-nesting of the handling block in order
  57. # to avoid nested exceptions.
  58. try:
  59. __import__(used)
  60. except ImportError as ex:
  61. expected = str(ex).split()[-1]
  62. if expected == used:
  63. raise
  64. else:
  65. raise ImportError(f"import error in {used}: {ex}") from ex
  66. found = annotated_getattr(found, part, used)
  67. return found
  68. def annotated_getattr(obj: object, name: str, ann: str) -> object:
  69. try:
  70. obj = getattr(obj, name)
  71. except AttributeError as e:
  72. raise AttributeError(
  73. "{!r} object at {} has no attribute {!r}".format(
  74. type(obj).__name__, ann, name
  75. )
  76. ) from e
  77. return obj
  78. def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]:
  79. if not isinstance(import_path, str) or "." not in import_path: # type: ignore[unreachable]
  80. raise TypeError(f"must be absolute import path string, not {import_path!r}")
  81. module, attr = import_path.rsplit(".", 1)
  82. target = resolve(module)
  83. if raising:
  84. annotated_getattr(target, attr, ann=module)
  85. return attr, target
  86. class Notset:
  87. def __repr__(self) -> str:
  88. return "<notset>"
  89. notset = Notset()
  90. @final
  91. class MonkeyPatch:
  92. """Helper to conveniently monkeypatch attributes/items/environment
  93. variables/syspath.
  94. Returned by the :fixture:`monkeypatch` fixture.
  95. :versionchanged:: 6.2
  96. Can now also be used directly as `pytest.MonkeyPatch()`, for when
  97. the fixture is not available. In this case, use
  98. :meth:`with MonkeyPatch.context() as mp: <context>` or remember to call
  99. :meth:`undo` explicitly.
  100. """
  101. def __init__(self) -> None:
  102. self._setattr: List[Tuple[object, str, object]] = []
  103. self._setitem: List[Tuple[MutableMapping[Any, Any], object, object]] = ([])
  104. self._cwd: Optional[str] = None
  105. self._savesyspath: Optional[List[str]] = None
  106. @classmethod
  107. @contextmanager
  108. def context(cls) -> Generator["MonkeyPatch", None, None]:
  109. """Context manager that returns a new :class:`MonkeyPatch` object
  110. which undoes any patching done inside the ``with`` block upon exit.
  111. Example:
  112. .. code-block:: python
  113. import functools
  114. def test_partial(monkeypatch):
  115. with monkeypatch.context() as m:
  116. m.setattr(functools, "partial", 3)
  117. Useful in situations where it is desired to undo some patches before the test ends,
  118. such as mocking ``stdlib`` functions that might break pytest itself if mocked (for examples
  119. of this see `#3290 <https://github.com/pytest-dev/pytest/issues/3290>`_.
  120. """
  121. m = cls()
  122. try:
  123. yield m
  124. finally:
  125. m.undo()
  126. @overload
  127. def setattr(
  128. self, target: str, name: object, value: Notset = ..., raising: bool = ...,
  129. ) -> None:
  130. ...
  131. @overload
  132. def setattr(
  133. self, target: object, name: str, value: object, raising: bool = ...,
  134. ) -> None:
  135. ...
  136. def setattr(
  137. self,
  138. target: Union[str, object],
  139. name: Union[object, str],
  140. value: object = notset,
  141. raising: bool = True,
  142. ) -> None:
  143. """Set attribute value on target, memorizing the old value.
  144. For convenience you can specify a string as ``target`` which
  145. will be interpreted as a dotted import path, with the last part
  146. being the attribute name. For example,
  147. ``monkeypatch.setattr("os.getcwd", lambda: "/")``
  148. would set the ``getcwd`` function of the ``os`` module.
  149. Raises AttributeError if the attribute does not exist, unless
  150. ``raising`` is set to False.
  151. """
  152. __tracebackhide__ = True
  153. import inspect
  154. if isinstance(value, Notset):
  155. if not isinstance(target, str):
  156. raise TypeError(
  157. "use setattr(target, name, value) or "
  158. "setattr(target, value) with target being a dotted "
  159. "import string"
  160. )
  161. value = name
  162. name, target = derive_importpath(target, raising)
  163. else:
  164. if not isinstance(name, str):
  165. raise TypeError(
  166. "use setattr(target, name, value) with name being a string or "
  167. "setattr(target, value) with target being a dotted "
  168. "import string"
  169. )
  170. oldval = getattr(target, name, notset)
  171. if raising and oldval is notset:
  172. raise AttributeError(f"{target!r} has no attribute {name!r}")
  173. # avoid class descriptors like staticmethod/classmethod
  174. if inspect.isclass(target):
  175. oldval = target.__dict__.get(name, notset)
  176. self._setattr.append((target, name, oldval))
  177. setattr(target, name, value)
  178. def delattr(
  179. self,
  180. target: Union[object, str],
  181. name: Union[str, Notset] = notset,
  182. raising: bool = True,
  183. ) -> None:
  184. """Delete attribute ``name`` from ``target``.
  185. If no ``name`` is specified and ``target`` is a string
  186. it will be interpreted as a dotted import path with the
  187. last part being the attribute name.
  188. Raises AttributeError it the attribute does not exist, unless
  189. ``raising`` is set to False.
  190. """
  191. __tracebackhide__ = True
  192. import inspect
  193. if isinstance(name, Notset):
  194. if not isinstance(target, str):
  195. raise TypeError(
  196. "use delattr(target, name) or "
  197. "delattr(target) with target being a dotted "
  198. "import string"
  199. )
  200. name, target = derive_importpath(target, raising)
  201. if not hasattr(target, name):
  202. if raising:
  203. raise AttributeError(name)
  204. else:
  205. oldval = getattr(target, name, notset)
  206. # Avoid class descriptors like staticmethod/classmethod.
  207. if inspect.isclass(target):
  208. oldval = target.__dict__.get(name, notset)
  209. self._setattr.append((target, name, oldval))
  210. delattr(target, name)
  211. def setitem(self, dic: MutableMapping[K, V], name: K, value: V) -> None:
  212. """Set dictionary entry ``name`` to value."""
  213. self._setitem.append((dic, name, dic.get(name, notset)))
  214. dic[name] = value
  215. def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None:
  216. """Delete ``name`` from dict.
  217. Raises ``KeyError`` if it doesn't exist, unless ``raising`` is set to
  218. False.
  219. """
  220. if name not in dic:
  221. if raising:
  222. raise KeyError(name)
  223. else:
  224. self._setitem.append((dic, name, dic.get(name, notset)))
  225. del dic[name]
  226. def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None:
  227. """Set environment variable ``name`` to ``value``.
  228. If ``prepend`` is a character, read the current environment variable
  229. value and prepend the ``value`` adjoined with the ``prepend``
  230. character.
  231. """
  232. if not isinstance(value, str):
  233. warnings.warn( # type: ignore[unreachable]
  234. PytestWarning(
  235. "Value of environment variable {name} type should be str, but got "
  236. "{value!r} (type: {type}); converted to str implicitly".format(
  237. name=name, value=value, type=type(value).__name__
  238. )
  239. ),
  240. stacklevel=2,
  241. )
  242. value = str(value)
  243. if prepend and name in os.environ:
  244. value = value + prepend + os.environ[name]
  245. self.setitem(os.environ, name, value)
  246. def delenv(self, name: str, raising: bool = True) -> None:
  247. """Delete ``name`` from the environment.
  248. Raises ``KeyError`` if it does not exist, unless ``raising`` is set to
  249. False.
  250. """
  251. environ: MutableMapping[str, str] = os.environ
  252. self.delitem(environ, name, raising=raising)
  253. def syspath_prepend(self, path) -> None:
  254. """Prepend ``path`` to ``sys.path`` list of import locations."""
  255. from pkg_resources import fixup_namespace_packages
  256. if self._savesyspath is None:
  257. self._savesyspath = sys.path[:]
  258. sys.path.insert(0, str(path))
  259. # https://github.com/pypa/setuptools/blob/d8b901bc/docs/pkg_resources.txt#L162-L171
  260. fixup_namespace_packages(str(path))
  261. # A call to syspathinsert() usually means that the caller wants to
  262. # import some dynamically created files, thus with python3 we
  263. # invalidate its import caches.
  264. # This is especially important when any namespace package is in use,
  265. # since then the mtime based FileFinder cache (that gets created in
  266. # this case already) gets not invalidated when writing the new files
  267. # quickly afterwards.
  268. from importlib import invalidate_caches
  269. invalidate_caches()
  270. def chdir(self, path) -> None:
  271. """Change the current working directory to the specified path.
  272. Path can be a string or a py.path.local object.
  273. """
  274. if self._cwd is None:
  275. self._cwd = os.getcwd()
  276. if hasattr(path, "chdir"):
  277. path.chdir()
  278. elif isinstance(path, Path):
  279. # Modern python uses the fspath protocol here LEGACY
  280. os.chdir(str(path))
  281. else:
  282. os.chdir(path)
  283. def undo(self) -> None:
  284. """Undo previous changes.
  285. This call consumes the undo stack. Calling it a second time has no
  286. effect unless you do more monkeypatching after the undo call.
  287. There is generally no need to call `undo()`, since it is
  288. called automatically during tear-down.
  289. Note that the same `monkeypatch` fixture is used across a
  290. single test function invocation. If `monkeypatch` is used both by
  291. the test function itself and one of the test fixtures,
  292. calling `undo()` will undo all of the changes made in
  293. both functions.
  294. """
  295. for obj, name, value in reversed(self._setattr):
  296. if value is not notset:
  297. setattr(obj, name, value)
  298. else:
  299. delattr(obj, name)
  300. self._setattr[:] = []
  301. for dictionary, key, value in reversed(self._setitem):
  302. if value is notset:
  303. try:
  304. del dictionary[key]
  305. except KeyError:
  306. pass # Was already deleted, so we have the desired state.
  307. else:
  308. dictionary[key] = value
  309. self._setitem[:] = []
  310. if self._savesyspath is not None:
  311. sys.path[:] = self._savesyspath
  312. self._savesyspath = None
  313. if self._cwd is not None:
  314. os.chdir(self._cwd)
  315. self._cwd = None