Repository: python-attrs/attrs

Problem statement:
`attr.evolve` no longer supports setting an attr object as attribute
Hi,

I don't know if this is an intentional side-effect of #759, but `attr.evolve` no longer works in the non-recursive recursive case if the argument is an attr class.

Here is a snippet to reproduce it:

```py
import attr

@attr.s
class Cls1:
    param1 = attr.ib()


@attr.s
class Cls2:
    param2 = attr.ib()


obj2a = Cls2(param2="a")
obj2b = Cls2(param2="b")

obj1a = Cls1(param1=obj2a)
obj1b = attr.evolve(obj1a, param1=obj2b)

print(obj1b)
```

Before fe6eb3120013833d1927374eb5e826b72815a775, it printed this: `Cls1(param1=Cls2(param2='b'))`

But after this commit, it raises this error:

```
Traceback (most recent call last):
  File "test.py", line 17, in <module>
    obj1b = attr.evolve(obj1a, param1=obj2b)
  File "/home/dev/.local/lib/python3.7/site-packages/attrs-21.2.0.dev0-py3.7.egg/attr/_funcs.py", line 347, in evolve
    changes[init_name] = evolve(value, **changes[init_name])
TypeError: evolve() argument after ** must be a mapping, not Cls2
```

Thanks!


Hints:
same here. Issue is in attr._funcs.evolve at:
```
    for a in attrs:
        ...
        elif has(value):
            # Evolve nested attrs classes
            changes[init_name] = evolve(value, **changes[init_name])
```
`value` - is old value (param1 from example above)
`changes[init_name]` - is new value (obj2b from above) 
but for recursive evolve it should be dict
probably the best way to make it backward compatible will be to check
`elif has(value) and isinstance(changes[init_name], Mapping):`
Yeah, we'll have to revert #759 and push out 21.2.

I'm gonna wait a bit with the release to see if there's other breakages that should be fixed.

Patch:
diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -319,8 +319,7 @@ def evolve(inst, **changes):
     Create a new instance, based on *inst* with *changes* applied.
 
     :param inst: Instance of a class with ``attrs`` attributes.
-    :param changes: Keyword changes in the new copy.  Nested ``attrs`` classes
-       can be updated by passing (nested) dicts of values.
+    :param changes: Keyword changes in the new copy.
 
     :return: A copy of inst with *changes* incorporated.
 
@@ -338,13 +337,8 @@ def evolve(inst, **changes):
             continue
         attr_name = a.name  # To deal with private attributes.
         init_name = attr_name if attr_name[0] != "_" else attr_name[1:]
-        value = getattr(inst, attr_name)
         if init_name not in changes:
-            # Add original value to changes
-            changes[init_name] = value
-        elif has(value):
-            # Evolve nested attrs classes
-            changes[init_name] = evolve(value, **changes[init_name])
+            changes[init_name] = getattr(inst, attr_name)
 
     return cls(**changes)
 


Test patch:
diff --git a/tests/test_funcs.py b/tests/test_funcs.py
--- a/tests/test_funcs.py
+++ b/tests/test_funcs.py
@@ -598,43 +598,50 @@ class C(object):
 
         assert evolve(C(1), a=2).a == 2
 
-    def test_recursive(self):
+    def test_regression_attrs_classes(self):
         """
-        evolve() recursively evolves nested attrs classes when a dict is
-        passed for an attribute.
+        evolve() can evolve fields that are instances of attrs classes.
+
+        Regression test for #804
         """
 
         @attr.s
-        class N2(object):
-            e = attr.ib(type=int)
+        class Cls1(object):
+            param1 = attr.ib()
 
         @attr.s
-        class N1(object):
-            c = attr.ib(type=N2)
-            d = attr.ib(type=int)
+        class Cls2(object):
+            param2 = attr.ib()
 
-        @attr.s
-        class C(object):
-            a = attr.ib(type=N1)
-            b = attr.ib(type=int)
+        obj2a = Cls2(param2="a")
+        obj2b = Cls2(param2="b")
 
-        c1 = C(N1(N2(1), 2), 3)
-        c2 = evolve(c1, a={"c": {"e": 23}}, b=42)
+        obj1a = Cls1(param1=obj2a)
 
-        assert c2 == C(N1(N2(23), 2), 42)
+        assert Cls1(param1=Cls2(param2="b")) == attr.evolve(
+            obj1a, param1=obj2b
+        )
 
-    def test_recursive_dict_val(self):
+    def test_dicts(self):
         """
-        evolve() only attempts recursion when the current value is an ``attrs``
-        class.  Dictionaries as values can be replaced like any other value.
+        evolve() can replace an attrs class instance with a dict.
+
+        See #806
         """
 
         @attr.s
-        class C(object):
-            a = attr.ib(type=dict)
-            b = attr.ib(type=int)
+        class Cls1(object):
+            param1 = attr.ib()
+
+        @attr.s
+        class Cls2(object):
+            param2 = attr.ib()
 
-        c1 = C({"spam": 1}, 2)
-        c2 = evolve(c1, a={"eggs": 2}, b=42)
+        obj2a = Cls2(param2="a")
+        obj2b = {"foo": 42, "param2": 42}
 
-        assert c2 == C({"eggs": 2}, 42)
+        obj1a = Cls1(param1=obj2a)
+
+        assert Cls1({"foo": 42, "param2": 42}) == attr.evolve(
+            obj1a, param1=obj2b
+        )