From 03e6cc70473d06c9a65129a366b3ec896312f3d9 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 2 Aug 2026 12:49:57 +0200 Subject: [PATCH 1/2] Stop evolve dunders from being modified --- changelog.d/1606.change.md | 1 + src/attr/_make.py | 6 +++++- tests/test_functional.py | 12 ++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1606.change.md diff --git a/changelog.d/1606.change.md b/changelog.d/1606.change.md new file mode 100644 index 000000000..517ac446f --- /dev/null +++ b/changelog.d/1606.change.md @@ -0,0 +1 @@ +The dunder methods of `attrs.evolve()` aren't mangled anymore. diff --git a/src/attr/_make.py b/src/attr/_make.py index c7cf3642e..db38cf994 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1108,7 +1108,11 @@ def _attach_init(cls_dict, globs): return self def add_replace(self): - self._cls_dict["__replace__"] = self._add_method_dunders(evolve) + # We create a local `evolve` proxy because it gets modified in place. + def evolve_(*args, **changes): + return evolve(*args, **changes) + + self._cls_dict["__replace__"] = self._add_method_dunders(evolve_) return self def add_match_args(self): diff --git a/tests/test_functional.py b/tests/test_functional.py index b8dfa4593..a5abc23b0 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -816,3 +816,15 @@ def test_invalid_field_name(self): with pytest.raises(TypeError): copy.replace(inst, z=42) + + def test_is_not_evolve(self): + """ + __replace__ is not the same as attrs.evolve. + + Regression test for #1606. + """ + inst = C1(1, 2) + + assert attr.evolve != inst.__replace__ + assert attr.evolve is not inst.__replace__ + assert str(attr.evolve).startswith(" Date: Sun, 2 Aug 2026 13:38:17 +0200 Subject: [PATCH 2/2] Better name, test both ways --- src/attr/_make.py | 4 ++-- tests/test_functional.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index db38cf994..afbca4635 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1109,10 +1109,10 @@ def _attach_init(cls_dict, globs): def add_replace(self): # We create a local `evolve` proxy because it gets modified in place. - def evolve_(*args, **changes): + def __replace__(*args, **changes): return evolve(*args, **changes) - self._cls_dict["__replace__"] = self._add_method_dunders(evolve_) + self._cls_dict["__replace__"] = self._add_method_dunders(__replace__) return self def add_match_args(self): diff --git a/tests/test_functional.py b/tests/test_functional.py index a5abc23b0..9a7a1fd86 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -825,6 +825,7 @@ def test_is_not_evolve(self): """ inst = C1(1, 2) - assert attr.evolve != inst.__replace__ - assert attr.evolve is not inst.__replace__ + assert "" == str( + inst.__replace__ + ) assert str(attr.evolve).startswith("