Description
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[:] = []