Skip to content

Monkey patching frozen attrs instances #9419

Closed
@sscherfke

Description

@sscherfke

What's the problem this feature will solve?

It is generally good [citation needed] to use frozen attrs clases to avoid bugs related to unintended alterations of class instances.

To change frozen instances, there is the evolve() function which returns a new instances with the updated attribute(s).

However, in tests you sometimes may want to monkey patch a frozen object in place.

For example, with Typed Settings you usually end up with a frozen SETTINGS object (which is an instance of your Settings class). Other modules import this object directly:

from .settings import SETTINGS

def spam():
    print(SETTINGS.my_option)

which is more convenient than:

from . import settings

def spam():
    print(settings.SETTINGS.my_option)

Since using monkeypatch.setattr() would raise a FrozenError, you need to use evolve(). But this does not work very well because you create an enirely new instances and need to monkeypatch all modules that import SETTINGS.

Describe the solution you'd like

monkeypatch.setattr() automatically (or explicitly) handles frozen attrs instances and sets an attribute (which is technically possible, see below).

Alternative Solutions

I could bundle a stripped down version of pytests monkey patcher but that would be less convenient for users.

Additional context

You can set attributes in frozen instances using object.__setattr__(target, name, value).

The changes to pytest could look like this:

Implicit version
diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py
index 31f95a95a..e71d5ddf9 100644
--- a/src/_pytest/monkeypatch.py
+++ b/src/_pytest/monkeypatch.py
@@ -14,6 +14,8 @@ from typing import Tuple
 from typing import TypeVar
 from typing import Union
 
+from attr.exceptions import FrozenError
+
 from _pytest.compat import final
 from _pytest.fixtures import fixture
 from _pytest.warning_types import PytestWarning
@@ -221,7 +223,10 @@ class MonkeyPatch:
         if inspect.isclass(target):
             oldval = target.__dict__.get(name, notset)
         self._setattr.append((target, name, oldval))
-        setattr(target, name, value)
+        try:
+            setattr(target, name, value)
+        except FrozenError:
+            object.__setattr__(target, name, value)
 
     def delattr(
         self,
@@ -361,7 +366,10 @@ class MonkeyPatch:
         """
         for obj, name, value in reversed(self._setattr):
             if value is not notset:
-                setattr(obj, name, value)
+                try:
+                    setattr(obj, name, value)
+                except FrozenError:
+                    object.__setattr__(obj, name, value)
             else:
                 delattr(obj, name)
         self._setattr[:] = []
Explicit version
diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py
index 31f95a95a..39baa2828 100644
--- a/src/_pytest/monkeypatch.py
+++ b/src/_pytest/monkeypatch.py
@@ -14,6 +14,8 @@ from typing import Tuple
 from typing import TypeVar
 from typing import Union
 
+from attr.exceptions import FrozenError
+
 from _pytest.compat import final
 from _pytest.fixtures import fixture
 from _pytest.warning_types import PytestWarning
@@ -181,6 +183,7 @@ class MonkeyPatch:
         name: Union[object, str],
         value: object = notset,
         raising: bool = True,
+        frozen: bool = False,
     ) -> None:
         """Set attribute value on target, memorizing the old value.
 
@@ -221,7 +224,13 @@ class MonkeyPatch:
         if inspect.isclass(target):
             oldval = target.__dict__.get(name, notset)
         self._setattr.append((target, name, oldval))
-        setattr(target, name, value)
+        try:
+            setattr(target, name, value)
+        except FrozenError:
+            if frozen:
+                object.__setattr__(target, name, value)
+            else:
+                raise
 
     def delattr(
         self,
@@ -361,7 +370,12 @@ class MonkeyPatch:
         """
         for obj, name, value in reversed(self._setattr):
             if value is not notset:
-                setattr(obj, name, value)
+                try:
+                    setattr(obj, name, value)
+                except FrozenError:
+                    # If we did set an attribute on a frozen instance, we surely can undo
+                    # these changes. :)
+                    object.__setattr__(obj, name, value)
             else:
                 delattr(obj, name)
         self._setattr[:] = []

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: proposalproposal for a new feature, often to gather opinions or design the API around the new feature

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions