Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 Tuple 

13from typing import TypeVar 

14from typing import Union 

15 

16import pytest 

17from _pytest.compat import overload 

18from _pytest.fixtures import fixture 

19from _pytest.pathlib import Path 

20 

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

22 

23 

24K = TypeVar("K") 

25V = TypeVar("V") 

26 

27 

28@fixture 

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

30 """The returned ``monkeypatch`` fixture provides these 

31 helper methods to modify objects, dictionaries or os.environ:: 

32 

33 monkeypatch.setattr(obj, name, value, raising=True) 

34 monkeypatch.delattr(obj, name, raising=True) 

35 monkeypatch.setitem(mapping, name, value) 

36 monkeypatch.delitem(obj, name, raising=True) 

37 monkeypatch.setenv(name, value, prepend=False) 

38 monkeypatch.delenv(name, raising=True) 

39 monkeypatch.syspath_prepend(path) 

40 monkeypatch.chdir(path) 

41 

42 All modifications will be undone after the requesting 

43 test function or fixture has finished. The ``raising`` 

44 parameter determines if a KeyError or AttributeError 

45 will be raised if the set/deletion operation has no target. 

46 """ 

47 mpatch = MonkeyPatch() 

48 yield mpatch 

49 mpatch.undo() 

50 

51 

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

53 # simplified from zope.dottedname 

54 parts = name.split(".") 

55 

56 used = parts.pop(0) 

57 found = __import__(used) 

58 for part in parts: 

59 used += "." + part 

60 try: 

61 found = getattr(found, part) 

62 except AttributeError: 

63 pass 

64 else: 

65 continue 

66 # we use explicit un-nesting of the handling block in order 

67 # to avoid nested exceptions on python 3 

68 try: 

69 __import__(used) 

70 except ImportError as ex: 

71 # str is used for py2 vs py3 

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

73 if expected == used: 

74 raise 

75 else: 

76 raise ImportError("import error in {}: {}".format(used, ex)) from ex 

77 found = annotated_getattr(found, part, used) 

78 return found 

79 

80 

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

82 try: 

83 obj = getattr(obj, name) 

84 except AttributeError as e: 

85 raise AttributeError( 

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

87 type(obj).__name__, ann, name 

88 ) 

89 ) from e 

90 return obj 

91 

92 

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

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

95 raise TypeError( 

96 "must be absolute import path string, not {!r}".format(import_path) 

97 ) 

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

99 target = resolve(module) 

100 if raising: 

101 annotated_getattr(target, attr, ann=module) 

102 return attr, target 

103 

104 

105class Notset: 

106 def __repr__(self) -> str: 

107 return "<notset>" 

108 

109 

110notset = Notset() 

111 

112 

113class MonkeyPatch: 

114 """ Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes. 

115 """ 

116 

117 def __init__(self) -> None: 

118 self._setattr = [] # type: List[Tuple[object, str, object]] 

119 self._setitem = ( 

120 [] 

121 ) # type: List[Tuple[MutableMapping[Any, Any], object, object]] 

122 self._cwd = None # type: Optional[str] 

123 self._savesyspath = None # type: Optional[List[str]] 

124 

125 @contextmanager 

126 def context(self) -> Generator["MonkeyPatch", None, None]: 

127 """ 

128 Context manager that returns a new :class:`MonkeyPatch` object which 

129 undoes any patching done inside the ``with`` block upon exit: 

130 

131 .. code-block:: python 

132 

133 import functools 

134 

135 

136 def test_partial(monkeypatch): 

137 with monkeypatch.context() as m: 

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

139 

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

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

142 of this see `#3290 <https://github.com/pytest-dev/pytest/issues/3290>`_. 

143 """ 

144 m = MonkeyPatch() 

145 try: 

146 yield m 

147 finally: 

148 m.undo() 

149 

150 @overload 

151 def setattr( 

152 self, target: str, name: object, value: Notset = ..., raising: bool = ..., 

153 ) -> None: 

154 raise NotImplementedError() 

155 

156 @overload # noqa: F811 

157 def setattr( # noqa: F811 

158 self, target: object, name: str, value: object, raising: bool = ..., 

159 ) -> None: 

160 raise NotImplementedError() 

161 

162 def setattr( # noqa: F811 

163 self, 

164 target: Union[str, object], 

165 name: Union[object, str], 

166 value: object = notset, 

167 raising: bool = True, 

168 ) -> None: 

169 """ Set attribute value on target, memorizing the old value. 

170 By default raise AttributeError if the attribute did not exist. 

171 

172 For convenience you can specify a string as ``target`` which 

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

174 being the attribute name. Example: 

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

176 would set the ``getcwd`` function of the ``os`` module. 

177 

178 The ``raising`` value determines if the setattr should fail 

179 if the attribute is not already present (defaults to True 

180 which means it will raise). 

181 """ 

182 __tracebackhide__ = True 

183 import inspect 

184 

185 if isinstance(value, Notset): 

186 if not isinstance(target, str): 

187 raise TypeError( 

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

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

190 "import string" 

191 ) 

192 value = name 

193 name, target = derive_importpath(target, raising) 

194 else: 

195 if not isinstance(name, str): 

196 raise TypeError( 

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

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

199 "import string" 

200 ) 

201 

202 oldval = getattr(target, name, notset) 

203 if raising and oldval is notset: 

204 raise AttributeError("{!r} has no attribute {!r}".format(target, name)) 

205 

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 setattr(target, name, value) 

211 

212 def delattr( 

213 self, 

214 target: Union[object, str], 

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

216 raising: bool = True, 

217 ) -> None: 

218 """ Delete attribute ``name`` from ``target``, by default raise 

219 AttributeError it the attribute did not previously exist. 

220 

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

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

223 last part being the attribute name. 

224 

225 If ``raising`` is set to False, no exception will be raised if the 

226 attribute is missing. 

227 """ 

228 __tracebackhide__ = True 

229 import inspect 

230 

231 if isinstance(name, Notset): 

232 if not isinstance(target, str): 

233 raise TypeError( 

234 "use delattr(target, name) or " 

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

236 "import string" 

237 ) 

238 name, target = derive_importpath(target, raising) 

239 

240 if not hasattr(target, name): 

241 if raising: 

242 raise AttributeError(name) 

243 else: 

244 oldval = getattr(target, name, notset) 

245 # Avoid class descriptors like staticmethod/classmethod. 

246 if inspect.isclass(target): 

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

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

249 delattr(target, name) 

250 

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

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

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

254 dic[name] = value 

255 

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

257 """ Delete ``name`` from dict. Raise KeyError if it doesn't exist. 

258 

259 If ``raising`` is set to False, no exception will be raised if the 

260 key is missing. 

261 """ 

262 if name not in dic: 

263 if raising: 

264 raise KeyError(name) 

265 else: 

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

267 del dic[name] 

268 

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

270 """ Set environment variable ``name`` to ``value``. If ``prepend`` 

271 is a character, read the current environment variable value 

272 and prepend the ``value`` adjoined with the ``prepend`` character.""" 

273 if not isinstance(value, str): 

274 warnings.warn( 

275 pytest.PytestWarning( 

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

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

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

279 ) 

280 ), 

281 stacklevel=2, 

282 ) 

283 value = str(value) 

284 if prepend and name in os.environ: 

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

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

287 

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

289 """ Delete ``name`` from the environment. Raise KeyError if it does 

290 not exist. 

291 

292 If ``raising`` is set to False, no exception will be raised if the 

293 environment variable is missing. 

294 """ 

295 environ = os.environ # type: MutableMapping[str, str] 

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

297 

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

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

300 from pkg_resources import fixup_namespace_packages 

301 

302 if self._savesyspath is None: 

303 self._savesyspath = sys.path[:] 

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

305 

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

307 fixup_namespace_packages(str(path)) 

308 

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

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

311 # invalidate its import caches. 

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

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

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

315 # quickly afterwards. 

316 from importlib import invalidate_caches 

317 

318 invalidate_caches() 

319 

320 def chdir(self, path) -> None: 

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

322 Path can be a string or a py.path.local object. 

323 """ 

324 if self._cwd is None: 

325 self._cwd = os.getcwd() 

326 if hasattr(path, "chdir"): 

327 path.chdir() 

328 elif isinstance(path, Path): 

329 # modern python uses the fspath protocol here LEGACY 

330 os.chdir(str(path)) 

331 else: 

332 os.chdir(path) 

333 

334 def undo(self) -> None: 

335 """ Undo previous changes. This call consumes the 

336 undo stack. Calling it a second time has no effect unless 

337 you do more monkeypatching after the undo call. 

338 

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

340 called automatically during tear-down. 

341 

342 Note that the same `monkeypatch` fixture is used across a 

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

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

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

346 both functions. 

347 """ 

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

349 if value is not notset: 

350 setattr(obj, name, value) 

351 else: 

352 delattr(obj, name) 

353 self._setattr[:] = [] 

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

355 if value is notset: 

356 try: 

357 del dictionary[key] 

358 except KeyError: 

359 pass # was already deleted, so we have the desired state 

360 else: 

361 dictionary[key] = value 

362 self._setitem[:] = [] 

363 if self._savesyspath is not None: 

364 sys.path[:] = self._savesyspath 

365 self._savesyspath = None 

366 

367 if self._cwd is not None: 

368 os.chdir(self._cwd) 

369 self._cwd = None