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

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 

16 

17from _pytest.compat import final 

18from _pytest.fixtures import fixture 

19from _pytest.warning_types import PytestWarning 

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 """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 

58def 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 

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 

96 

97 

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 

106 

107 

108class Notset: 

109 def __repr__(self) -> str: 

110 return "<notset>" 

111 

112 

113notset = Notset() 

114 

115 

116@final 

117class 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