Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/14927.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixes MonkeyPatch so failed mutations are not recorded for undo.
8 changes: 5 additions & 3 deletions src/_pytest/monkeypatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,15 @@ def delattr(
# Avoid class descriptors like staticmethod/classmethod.
if inspect.isclass(target):
oldval = target.__dict__.get(name, NOTSET)
self._setattr.append((target, name, oldval))
delattr(target, name)
self._setattr.append((target, name, oldval))

def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None:
"""Set dictionary entry ``name`` to value."""
self._setitem.append((dic, name, dic.get(name, NOTSET)))
oldval = dic.get(name, NOTSET)
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
dic[name] = value # type: ignore[index]
self._setitem.append((dic, name, oldval))

def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None:
"""Delete ``name`` from dict.
Expand All @@ -301,9 +302,10 @@ def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None:
if raising:
raise KeyError(name)
else:
self._setitem.append((dic, name, dic.get(name, NOTSET)))
oldval = dic.get(name, NOTSET)
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
del dic[name] # type: ignore[attr-defined]
self._setitem.append((dic, name, oldval))

def setenv(self, name: str, value: str, prepend: str | None = None) -> None:
"""Set environment variable ``name`` to ``value``.
Expand Down
50 changes: 50 additions & 0 deletions testing/test_monkeypatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,22 @@ class A:
assert A.x == 1


def test_delattr_does_not_record_failed_mutation() -> None:
class BrokenDelete:
value = 1

def __delattr__(self, name: str) -> None:
raise RuntimeError("delete failed")

monkeypatch = MonkeyPatch()
obj = BrokenDelete()

with pytest.raises(RuntimeError, match="delete failed"):
monkeypatch.delattr(obj, "value")

monkeypatch.undo()


def test_setitem() -> None:
d = {"x": 1}
monkeypatch = MonkeyPatch()
Expand Down Expand Up @@ -162,6 +178,23 @@ def test_setitem_deleted_meanwhile() -> None:
assert not d


def test_setitem_does_not_record_failed_mutation() -> None:
class BrokenDict(dict[str, object]):
def __setitem__(self, key: str, value: object) -> None:
raise RuntimeError("set failed")

def __delitem__(self, key: str) -> None:
raise RuntimeError("delete failed")

monkeypatch = MonkeyPatch()
d = BrokenDict()

with pytest.raises(RuntimeError, match="set failed"):
monkeypatch.setitem(d, "x", 1)

monkeypatch.undo()


@pytest.mark.parametrize("before", [True, False])
def test_setenv_deleted_meanwhile(before: bool) -> None:
key = "qwpeoip123"
Expand Down Expand Up @@ -196,6 +229,23 @@ def test_delitem() -> None:
assert d == {"hello": "world", "x": 1}


def test_delitem_does_not_record_failed_mutation() -> None:
class BrokenDict(dict[str, object]):
def __setitem__(self, key: str, value: object) -> None:
raise RuntimeError("set failed")

def __delitem__(self, key: str) -> None:
raise RuntimeError("delete failed")

monkeypatch = MonkeyPatch()
d = BrokenDict({"x": 1})

with pytest.raises(RuntimeError, match="delete failed"):
monkeypatch.delitem(d, "x")

monkeypatch.undo()


def test_setenv() -> None:
monkeypatch = MonkeyPatch()
with pytest.warns(pytest.PytestWarning):
Expand Down