From 893052d932c086ce6fc2dfb8afd6b76e924eb807 Mon Sep 17 00:00:00 2001 From: Viktor Lind Date: Sat, 31 Oct 2026 15:32:45 -0700 Subject: [PATCH] monkeypatch: restore dynamic and mock child attributes on undo (#15287) When monkeypatching an attribute that does not exist in the instance dict nor on the class hierarchy (such as a MagicMock child mock or an attribute served by dynamic __getattr__), keep the looked-up value for undo() instead of recording NOTSET. Previously, undo() would call delattr() on the target, which permanently marked child mocks in MagicMock as deleted and caused subsequent accesses to raise AttributeError. Closes #15287. --- changelog/15287.bugfix.rst | 1 + src/_pytest/monkeypatch.py | 5 ++++- testing/test_monkeypatch.py | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 changelog/15287.bugfix.rst diff --git a/changelog/15287.bugfix.rst b/changelog/15287.bugfix.rst new file mode 100644 index 00000000000..a59b493f4f4 --- /dev/null +++ b/changelog/15287.bugfix.rst @@ -0,0 +1 @@ +Fixed a regression in pytest 9.2.0 where :meth:`MonkeyPatch.setattr ` ``undo()`` deleted child attributes on :class:`unittest.mock.MagicMock` (or other dynamic attributes not present on the class) instead of restoring them. diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 9b9bcd3fb95..a19861c6d49 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -266,7 +266,10 @@ def setattr( # class attribute, which permanently freezes descriptors that # resolve dynamically (#10644). target_dict = getattr(target, "__dict__", None) - if isinstance(target_dict, Mapping): + if isinstance(target_dict, Mapping) and ( + name in target_dict + or inspect.getattr_static(type(target), name, NOTSET) is not NOTSET + ): oldval = target_dict.get(name, NOTSET) setattr(target, name, value) self._setattr.append((target, name, oldval)) diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 0d07783b05b..e652eeb9af0 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -607,6 +607,44 @@ def __init__(self) -> None: assert obj.x == 1 +def test_undo_magicmock_child_attribute() -> None: + """Undoing a patch on a MagicMock's child mock must not delete it. + + See #15287. + """ + from unittest.mock import MagicMock + + shared = MagicMock() + orig_child = shared.connect # create child mock + monkeypatch = MonkeyPatch() + monkeypatch.setattr(shared, "connect", MagicMock(return_value=1)) + assert shared.connect() == 1 + + monkeypatch.undo() + assert shared.connect is orig_child + shared.connect.return_value = 2 + assert shared.connect() == 2 + + +def test_undo_dynamic_getattr_attribute() -> None: + """Undoing a patch on an attribute resolved via __getattr__ must restore it.""" + + class Dynamic: + def __getattr__(self, name: str) -> int: + if name == "dyn": + return 42 + raise AttributeError(name) + + d = Dynamic() + assert d.dyn == 42 + monkeypatch = MonkeyPatch() + monkeypatch.setattr(d, "dyn", 100) + assert d.dyn == 100 + + monkeypatch.undo() + assert d.dyn == 42 + + def test_issue1338_name_resolving() -> None: pytest.importorskip("requests") monkeypatch = MonkeyPatch()