This commit is contained in:
Ganden Schaffner 2024-06-01 12:37:42 +08:00 committed by GitHub
commit 0d3b20b1bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 116 additions and 23 deletions

View File

@ -159,6 +159,7 @@ Floris Bruynooghe
Fraser Stark Fraser Stark
Gabriel Landau Gabriel Landau
Gabriel Reis Gabriel Reis
Ganden Schaffner
Garvit Shubham Garvit Shubham
Gene Wood Gene Wood
George Kussumoto George Kussumoto

View File

@ -0,0 +1,3 @@
`monkeypatch.setattr` no longer leaves a leftover item in the target's dictionary after cleanup when patching an inherited attribute of a non-class object. This fixes a bug where a `__get__` descriptor's dynamic lookup was blocked and replaced by a cached static lookup after `monkeypatch` teardown.
Previously `monkeypatch.setattr` avoided leaving leftover items in the target's dictionary when patching class objects but not when patching non-class objects.

View File

@ -0,0 +1,3 @@
Fix bug where `monkeypatch.setattr` and `monkeypatch.delattr` bind descriptors (possibly causing unwanted side-effects) to implement their `hasattr` checks even when their checks are disabled by `raising=False`.
Note that they still must bind descriptors to implement their checks if `raising=True`.

View File

@ -222,7 +222,6 @@ class MonkeyPatch:
applies to ``monkeypatch.setattr`` as well. applies to ``monkeypatch.setattr`` as well.
""" """
__tracebackhide__ = True __tracebackhide__ = True
import inspect
if isinstance(value, Notset): if isinstance(value, Notset):
if not isinstance(target, str): if not isinstance(target, str):
@ -241,13 +240,13 @@ class MonkeyPatch:
"import string" "import string"
) )
oldval = getattr(target, name, notset) if raising and not hasattr(target, name):
if raising and oldval is notset:
raise AttributeError(f"{target!r} has no attribute {name!r}") raise AttributeError(f"{target!r} has no attribute {name!r}")
# avoid class descriptors like staticmethod/classmethod # Prevent `undo` from polluting `vars(target)` with an object that was not in it
if inspect.isclass(target): # before monkeypatching, such as inherited attributes or the results of
oldval = target.__dict__.get(name, notset) # descriptor binding.
oldval = vars(target).get(name, notset)
self._setattr.append((target, name, oldval)) self._setattr.append((target, name, oldval))
setattr(target, name, value) setattr(target, name, value)
@ -267,7 +266,6 @@ class MonkeyPatch:
``raising`` is set to False. ``raising`` is set to False.
""" """
__tracebackhide__ = True __tracebackhide__ = True
import inspect
if isinstance(name, Notset): if isinstance(name, Notset):
if not isinstance(target, str): if not isinstance(target, str):
@ -278,16 +276,18 @@ class MonkeyPatch:
) )
name, target = derive_importpath(target, raising) name, target = derive_importpath(target, raising)
if not hasattr(target, name): if raising and not hasattr(target, name):
if raising: raise AttributeError(f"{target!r} has no attribute {name!r}")
raise AttributeError(name)
else: # Prevent `undo` from overwriting class descriptors (like
oldval = getattr(target, name, notset) # staticmethod/classmethod) with the results of descriptor binding.
# Avoid class descriptors like staticmethod/classmethod. oldval = vars(target).get(name, notset)
if inspect.isclass(target): try:
oldval = target.__dict__.get(name, notset)
self._setattr.append((target, name, oldval))
delattr(target, name) delattr(target, name)
except AttributeError:
pass
else:
self._setattr.append((target, name, oldval))
def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None: def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None:
"""Set dictionary entry ``name`` to value.""" """Set dictionary entry ``name`` to value."""

View File

@ -6,12 +6,46 @@ import sys
import textwrap import textwrap
from typing import Dict from typing import Dict
from typing import Generator from typing import Generator
from typing import NoReturn
from typing import Optional
from typing import overload
from typing import Type from typing import Type
from typing import TypeVar
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from _pytest.pytester import Pytester from _pytest.pytester import Pytester
import pytest import pytest
T = TypeVar("T")
class SomeDescriptor:
@overload
def __get__(self, instance: None, owner: type) -> int:
...
@overload
def __get__(self, instance: T, owner: Optional[Type[T]] = ...) -> int:
...
def __get__(self, instance: Optional[T], owner: Optional[Type[T]] = None) -> int:
return 1
class RaisingDescriptor:
@overload
def __get__(self, instance: None, owner: type) -> NoReturn:
...
@overload
def __get__(self, instance: T, owner: Optional[Type[T]] = ...) -> NoReturn:
...
def __get__(
self, instance: Optional[T], owner: Optional[Type[T]] = None
) -> NoReturn:
assert False, "descriptor was bound"
@pytest.fixture @pytest.fixture
def mp() -> Generator[MonkeyPatch, None, None]: def mp() -> Generator[MonkeyPatch, None, None]:
@ -23,15 +57,22 @@ def mp() -> Generator[MonkeyPatch, None, None]:
def test_setattr() -> None: def test_setattr() -> None:
import inspect
class A: class A:
x = 1 x = 1
y = SomeDescriptor()
z = RaisingDescriptor()
monkeypatch = MonkeyPatch() monkeypatch = MonkeyPatch()
pytest.raises(AttributeError, monkeypatch.setattr, A, "notexists", 2) pytest.raises(AttributeError, monkeypatch.setattr, A, "notexists", 2)
monkeypatch.setattr(A, "y", 2, raising=False) monkeypatch.setattr(A, "w", 2, raising=False)
assert A.y == 2 # type: ignore assert A.w == 2 # type: ignore
monkeypatch.undo() monkeypatch.undo()
assert not hasattr(A, "y") assert not hasattr(A, "w")
with pytest.raises(TypeError):
monkeypatch.setattr(A, "w") # type: ignore[call-overload]
monkeypatch = MonkeyPatch() monkeypatch = MonkeyPatch()
monkeypatch.setattr(A, "x", 2) monkeypatch.setattr(A, "x", 2)
@ -45,8 +86,32 @@ def test_setattr() -> None:
monkeypatch.undo() # double-undo makes no modification monkeypatch.undo() # double-undo makes no modification
assert A.x == 5 assert A.x == 5
with pytest.raises(TypeError): # Test that inherited attributes don't get written into the target's instance
monkeypatch.setattr(A, "y") # type: ignore[call-overload] # dictionary.
a = A()
monkeypatch = MonkeyPatch()
assert "x" not in vars(a)
monkeypatch.setattr(a, "x", 2)
monkeypatch.undo()
assert "x" not in vars(a)
for obj in (A, A()):
# Test that class/instance descriptors don't get bound and written into the
# target's dictionary.
monkeypatch = MonkeyPatch()
assert isinstance(inspect.getattr_static(obj, "y"), SomeDescriptor)
monkeypatch.setattr(obj, "y", 2)
monkeypatch.undo()
assert isinstance(inspect.getattr_static(obj, "y"), SomeDescriptor)
# Test that the `raising=True` check binds descriptors (to check if they raise
# AttributeError).
monkeypatch = MonkeyPatch()
with pytest.raises(AssertionError, match="descriptor was bound"):
monkeypatch.setattr(obj, "z", 2)
# Test that descriptors don't get bound if `raising=False`.
monkeypatch.setattr(obj, "z", 2, raising=False)
monkeypatch.undo()
class TestSetattrWithImportPath: class TestSetattrWithImportPath:
@ -97,8 +162,12 @@ class TestSetattrWithImportPath:
def test_delattr() -> None: def test_delattr() -> None:
import inspect
class A: class A:
x = 1 x = 1
y = SomeDescriptor()
z = RaisingDescriptor()
monkeypatch = MonkeyPatch() monkeypatch = MonkeyPatch()
monkeypatch.delattr(A, "x") monkeypatch.delattr(A, "x")
@ -107,14 +176,31 @@ def test_delattr() -> None:
assert A.x == 1 assert A.x == 1
monkeypatch = MonkeyPatch() monkeypatch = MonkeyPatch()
pytest.raises(AttributeError, monkeypatch.delattr, A, "w")
monkeypatch.delattr(A, "w", raising=False)
monkeypatch.delattr(A, "x") monkeypatch.delattr(A, "x")
pytest.raises(AttributeError, monkeypatch.delattr, A, "y")
monkeypatch.delattr(A, "y", raising=False)
monkeypatch.setattr(A, "x", 5, raising=False) monkeypatch.setattr(A, "x", 5, raising=False)
assert A.x == 5 assert A.x == 5
monkeypatch.undo() monkeypatch.undo()
assert A.x == 1 assert A.x == 1
# Test that (non-inherited) class descriptors don't get bound and written into the
# target's dictionary.
monkeypatch = MonkeyPatch()
assert isinstance(inspect.getattr_static(A, "y"), SomeDescriptor)
monkeypatch.delattr(A, "y")
monkeypatch.undo()
assert isinstance(inspect.getattr_static(A, "y"), SomeDescriptor)
# Test that the `raising=True` check binds descriptors (to check if they raise
# AttributeError).
monkeypatch = MonkeyPatch()
with pytest.raises(AssertionError, match="descriptor was bound"):
monkeypatch.delattr(A, "z")
# Test that descriptor's don't get bound if `raising=False`.
monkeypatch.delattr(A, "z", raising=False)
monkeypatch.undo()
def test_setitem() -> None: def test_setitem() -> None:
d = {"x": 1} d = {"x": 1}