diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 9d802a625..09f1ac36e 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -4,17 +4,29 @@ import re import sys import warnings from contextlib import contextmanager +from typing import Any from typing import Generator +from typing import List +from typing import MutableMapping +from typing import Optional +from typing import Tuple +from typing import TypeVar +from typing import Union import pytest +from _pytest.compat import overload from _pytest.fixtures import fixture from _pytest.pathlib import Path RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$") +K = TypeVar("K") +V = TypeVar("V") + + @fixture -def monkeypatch(): +def monkeypatch() -> Generator["MonkeyPatch", None, None]: """The returned ``monkeypatch`` fixture provides these helper methods to modify objects, dictionaries or os.environ:: @@ -37,7 +49,7 @@ def monkeypatch(): mpatch.undo() -def resolve(name): +def resolve(name: str) -> object: # simplified from zope.dottedname parts = name.split(".") @@ -66,7 +78,7 @@ def resolve(name): return found -def annotated_getattr(obj, name, ann): +def annotated_getattr(obj: object, name: str, ann: str) -> object: try: obj = getattr(obj, name) except AttributeError: @@ -78,7 +90,7 @@ def annotated_getattr(obj, name, ann): return obj -def derive_importpath(import_path, raising): +def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]: if not isinstance(import_path, str) or "." not in import_path: raise TypeError( "must be absolute import path string, not {!r}".format(import_path) @@ -91,7 +103,7 @@ def derive_importpath(import_path, raising): class Notset: - def __repr__(self): + def __repr__(self) -> str: return "" @@ -102,11 +114,13 @@ class MonkeyPatch: """ Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes. """ - def __init__(self): - self._setattr = [] - self._setitem = [] - self._cwd = None - self._savesyspath = None + def __init__(self) -> None: + self._setattr = [] # type: List[Tuple[object, str, object]] + self._setitem = ( + [] + ) # type: List[Tuple[MutableMapping[Any, Any], object, object]] + self._cwd = None # type: Optional[str] + self._savesyspath = None # type: Optional[List[str]] @contextmanager def context(self) -> Generator["MonkeyPatch", None, None]: @@ -133,7 +147,25 @@ class MonkeyPatch: finally: m.undo() - def setattr(self, target, name, value=notset, raising=True): + @overload + def setattr( + self, target: str, name: object, value: Notset = ..., raising: bool = ..., + ) -> None: + raise NotImplementedError() + + @overload # noqa: F811 + def setattr( # noqa: F811 + self, target: object, name: str, value: object, raising: bool = ..., + ) -> None: + raise NotImplementedError() + + def setattr( # noqa: F811 + self, + target: Union[str, object], + name: Union[object, str], + value: object = notset, + raising: bool = True, + ) -> None: """ Set attribute value on target, memorizing the old value. By default raise AttributeError if the attribute did not exist. @@ -150,7 +182,7 @@ class MonkeyPatch: __tracebackhide__ = True import inspect - if value is notset: + if isinstance(value, Notset): if not isinstance(target, str): raise TypeError( "use setattr(target, name, value) or " @@ -159,6 +191,13 @@ class MonkeyPatch: ) 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: @@ -170,7 +209,12 @@ class MonkeyPatch: self._setattr.append((target, name, oldval)) setattr(target, name, value) - def delattr(self, target, name=notset, raising=True): + def delattr( + self, + target: Union[object, str], + name: Union[str, Notset] = notset, + raising: bool = True, + ) -> None: """ Delete attribute ``name`` from ``target``, by default raise AttributeError it the attribute did not previously exist. @@ -184,7 +228,7 @@ class MonkeyPatch: __tracebackhide__ = True import inspect - if name is notset: + if isinstance(name, Notset): if not isinstance(target, str): raise TypeError( "use delattr(target, name) or " @@ -204,12 +248,12 @@ class MonkeyPatch: self._setattr.append((target, name, oldval)) delattr(target, name) - def setitem(self, dic, name, value): + def setitem(self, dic: MutableMapping[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, name, raising=True): + def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None: """ Delete ``name`` from dict. Raise KeyError if it doesn't exist. If ``raising`` is set to False, no exception will be raised if the @@ -222,7 +266,7 @@ class MonkeyPatch: self._setitem.append((dic, name, dic.get(name, notset))) del dic[name] - def setenv(self, name, value, prepend=None): + def setenv(self, name: str, value: str, prepend: Optional[str] = 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.""" @@ -241,16 +285,17 @@ class MonkeyPatch: value = value + prepend + os.environ[name] self.setitem(os.environ, name, value) - def delenv(self, name, raising=True): + def delenv(self, name: str, raising: bool = True) -> None: """ Delete ``name`` from the environment. Raise KeyError if it does not exist. If ``raising`` is set to False, no exception will be raised if the environment variable is missing. """ - self.delitem(os.environ, name, raising=raising) + environ = os.environ # type: MutableMapping[str, str] + self.delitem(environ, name, raising=raising) - def syspath_prepend(self, path): + def syspath_prepend(self, path) -> None: """ Prepend ``path`` to ``sys.path`` list of import locations. """ from pkg_resources import fixup_namespace_packages @@ -272,7 +317,7 @@ class MonkeyPatch: invalidate_caches() - def chdir(self, path): + def chdir(self, path) -> None: """ Change the current working directory to the specified path. Path can be a string or a py.path.local object. """ @@ -286,7 +331,7 @@ class MonkeyPatch: else: os.chdir(path) - def undo(self): + 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. @@ -306,14 +351,14 @@ class MonkeyPatch: else: delattr(obj, name) self._setattr[:] = [] - for dictionary, name, value in reversed(self._setitem): + for dictionary, key, value in reversed(self._setitem): if value is notset: try: - del dictionary[name] + del dictionary[key] except KeyError: pass # was already deleted, so we have the desired state else: - dictionary[name] = value + dictionary[key] = value self._setitem[:] = [] if self._savesyspath is not None: sys.path[:] = self._savesyspath diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 1a3afbea9..509e72599 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -5,9 +5,12 @@ import textwrap from typing import Dict from typing import Generator +import py + import pytest from _pytest.compat import TYPE_CHECKING from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Testdir if TYPE_CHECKING: from typing import Type @@ -45,9 +48,12 @@ def test_setattr() -> None: monkeypatch.undo() # double-undo makes no modification assert A.x == 5 + with pytest.raises(TypeError): + monkeypatch.setattr(A, "y") # type: ignore[call-overload] + class TestSetattrWithImportPath: - def test_string_expression(self, monkeypatch): + def test_string_expression(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("os.path.abspath", lambda x: "hello2") assert os.path.abspath("123") == "hello2" @@ -64,30 +70,31 @@ class TestSetattrWithImportPath: assert _pytest.config.Config == 42 # type: ignore monkeypatch.delattr("_pytest.config.Config") - def test_wrong_target(self, monkeypatch): - pytest.raises(TypeError, lambda: monkeypatch.setattr(None, None)) + def test_wrong_target(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(TypeError): + monkeypatch.setattr(None, None) # type: ignore[call-overload] - def test_unknown_import(self, monkeypatch): - pytest.raises(ImportError, lambda: monkeypatch.setattr("unkn123.classx", None)) + def test_unknown_import(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(ImportError): + monkeypatch.setattr("unkn123.classx", None) - def test_unknown_attr(self, monkeypatch): - pytest.raises( - AttributeError, lambda: monkeypatch.setattr("os.path.qweqwe", None) - ) + def test_unknown_attr(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(AttributeError): + monkeypatch.setattr("os.path.qweqwe", None) def test_unknown_attr_non_raising(self, monkeypatch: MonkeyPatch) -> None: # https://github.com/pytest-dev/pytest/issues/746 monkeypatch.setattr("os.path.qweqwe", 42, raising=False) assert os.path.qweqwe == 42 # type: ignore - def test_delattr(self, monkeypatch): + def test_delattr(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.delattr("os.path.abspath") assert not hasattr(os.path, "abspath") monkeypatch.undo() assert os.path.abspath -def test_delattr(): +def test_delattr() -> None: class A: x = 1 @@ -107,7 +114,7 @@ def test_delattr(): assert A.x == 1 -def test_setitem(): +def test_setitem() -> None: d = {"x": 1} monkeypatch = MonkeyPatch() monkeypatch.setitem(d, "x", 2) @@ -135,7 +142,7 @@ def test_setitem_deleted_meanwhile() -> None: @pytest.mark.parametrize("before", [True, False]) -def test_setenv_deleted_meanwhile(before): +def test_setenv_deleted_meanwhile(before: bool) -> None: key = "qwpeoip123" if before: os.environ[key] = "world" @@ -167,10 +174,10 @@ def test_delitem() -> None: assert d == {"hello": "world", "x": 1} -def test_setenv(): +def test_setenv() -> None: monkeypatch = MonkeyPatch() with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 2) + monkeypatch.setenv("XYZ123", 2) # type: ignore[arg-type] import os assert os.environ["XYZ123"] == "2" @@ -178,7 +185,7 @@ def test_setenv(): assert "XYZ123" not in os.environ -def test_delenv(): +def test_delenv() -> None: name = "xyz1234" assert name not in os.environ monkeypatch = MonkeyPatch() @@ -208,31 +215,28 @@ class TestEnvironWarnings: VAR_NAME = "PYTEST_INTERNAL_MY_VAR" - def test_setenv_non_str_warning(self, monkeypatch): + def test_setenv_non_str_warning(self, monkeypatch: MonkeyPatch) -> None: value = 2 msg = ( "Value of environment variable PYTEST_INTERNAL_MY_VAR type should be str, " "but got 2 (type: int); converted to str implicitly" ) with pytest.warns(pytest.PytestWarning, match=re.escape(msg)): - monkeypatch.setenv(str(self.VAR_NAME), value) + monkeypatch.setenv(str(self.VAR_NAME), value) # type: ignore[arg-type] -def test_setenv_prepend(): +def test_setenv_prepend() -> None: import os monkeypatch = MonkeyPatch() - with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 2, prepend="-") - assert os.environ["XYZ123"] == "2" - with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 3, prepend="-") + monkeypatch.setenv("XYZ123", "2", prepend="-") + monkeypatch.setenv("XYZ123", "3", prepend="-") assert os.environ["XYZ123"] == "3-2" monkeypatch.undo() assert "XYZ123" not in os.environ -def test_monkeypatch_plugin(testdir): +def test_monkeypatch_plugin(testdir: Testdir) -> None: reprec = testdir.inline_runsource( """ def test_method(monkeypatch): @@ -243,7 +247,7 @@ def test_monkeypatch_plugin(testdir): assert tuple(res) == (1, 0, 0), res -def test_syspath_prepend(mp: MonkeyPatch): +def test_syspath_prepend(mp: MonkeyPatch) -> None: old = list(sys.path) mp.syspath_prepend("world") mp.syspath_prepend("hello") @@ -255,7 +259,7 @@ def test_syspath_prepend(mp: MonkeyPatch): assert sys.path == old -def test_syspath_prepend_double_undo(mp: MonkeyPatch): +def test_syspath_prepend_double_undo(mp: MonkeyPatch) -> None: old_syspath = sys.path[:] try: mp.syspath_prepend("hello world") @@ -267,24 +271,24 @@ def test_syspath_prepend_double_undo(mp: MonkeyPatch): sys.path[:] = old_syspath -def test_chdir_with_path_local(mp: MonkeyPatch, tmpdir): +def test_chdir_with_path_local(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir) assert os.getcwd() == tmpdir.strpath -def test_chdir_with_str(mp: MonkeyPatch, tmpdir): +def test_chdir_with_str(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir.strpath) assert os.getcwd() == tmpdir.strpath -def test_chdir_undo(mp: MonkeyPatch, tmpdir): +def test_chdir_undo(mp: MonkeyPatch, tmpdir: py.path.local) -> None: cwd = os.getcwd() mp.chdir(tmpdir) mp.undo() assert os.getcwd() == cwd -def test_chdir_double_undo(mp: MonkeyPatch, tmpdir): +def test_chdir_double_undo(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir.strpath) mp.undo() tmpdir.chdir() @@ -292,7 +296,7 @@ def test_chdir_double_undo(mp: MonkeyPatch, tmpdir): assert os.getcwd() == tmpdir.strpath -def test_issue185_time_breaks(testdir): +def test_issue185_time_breaks(testdir: Testdir) -> None: testdir.makepyfile( """ import time @@ -310,7 +314,7 @@ def test_issue185_time_breaks(testdir): ) -def test_importerror(testdir): +def test_importerror(testdir: Testdir) -> None: p = testdir.mkpydir("package") p.join("a.py").write( textwrap.dedent( @@ -360,7 +364,7 @@ def test_issue156_undo_staticmethod(Sample: "Type[Sample]") -> None: assert Sample.hello() -def test_undo_class_descriptors_delattr(): +def test_undo_class_descriptors_delattr() -> None: class SampleParent: @classmethod def hello(_cls): @@ -387,7 +391,7 @@ def test_undo_class_descriptors_delattr(): assert original_world == SampleChild.world -def test_issue1338_name_resolving(): +def test_issue1338_name_resolving() -> None: pytest.importorskip("requests") monkeypatch = MonkeyPatch() try: @@ -396,7 +400,7 @@ def test_issue1338_name_resolving(): monkeypatch.undo() -def test_context(): +def test_context() -> None: monkeypatch = MonkeyPatch() import functools @@ -408,7 +412,9 @@ def test_context(): assert inspect.isclass(functools.partial) -def test_syspath_prepend_with_namespace_packages(testdir, monkeypatch): +def test_syspath_prepend_with_namespace_packages( + testdir: Testdir, monkeypatch: MonkeyPatch +) -> None: for dirname in "hello", "world": d = testdir.mkdir(dirname) ns = d.mkdir("ns_pkg")