This commit is contained in:
Ganden Schaffner 2024-06-18 16:37:07 +02:00 committed by GitHub
commit a1100c7864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 65 additions and 11 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

@ -223,7 +223,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):
@ -246,9 +245,10 @@ class MonkeyPatch:
if raising and oldval is notset: 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)

View File

@ -6,12 +6,30 @@ 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 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
@pytest.fixture @pytest.fixture
def mp() -> Generator[MonkeyPatch, None, None]: def mp() -> Generator[MonkeyPatch, None, None]:
@ -23,15 +41,21 @@ 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()
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 +69,23 @@ 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)
# Test that class/instance descriptors don't get bound and written into the target's
# dictionary.
for obj in (A, A()):
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)
class TestSetattrWithImportPath: class TestSetattrWithImportPath:
@ -97,8 +136,11 @@ class TestSetattrWithImportPath:
def test_delattr() -> None: def test_delattr() -> None:
import inspect
class A: class A:
x = 1 x = 1
y = SomeDescriptor()
monkeypatch = MonkeyPatch() monkeypatch = MonkeyPatch()
monkeypatch.delattr(A, "x") monkeypatch.delattr(A, "x")
@ -107,14 +149,22 @@ 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)
def test_setitem() -> None: def test_setitem() -> None:
d = {"x": 1} d = {"x": 1}