monkeypatch: add type annotations

This commit is contained in:
Ran Benita 2020-06-07 12:44:11 +03:00
parent f84ffd9747
commit b4f046b777
2 changed files with 113 additions and 62 deletions

View File

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

View File

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