Repository: python-attrs/attrs (python)

Issue:
Make evolve 1st arg positional-only
It's arbitrary that the 1st argument is named `inst` and could conflict with a field.

Since PEP-570 positional-only arguments require Python 3.8 and next major version will be ≥ Python 3.7, we can do it the old-fashion way in the next breaking version (as this is an API change), i.e.
```python
def evolve(*args, **changes):
  (inst,) = args
  ...
```

Pull request:
Raise a deprecation warning when evolve receives insta as a kw arg
this looking ok @ikonst?

Fixes #1109


Patch:
diff --git a/changelog.d/1117.change.md b/changelog.d/1117.change.md
new file mode 100644
index 0000000..8e9f5d5
--- /dev/null
+++ b/changelog.d/1117.change.md
@@ -0,0 +1,3 @@
+It is now possible for `attrs.evolve()` (and `attr.evolve()`) to change fields named `inst` if the instance is passed as a positional argument.
+
+Passing the instance using the `inst` keyword argument is now deprecated and will be removed in, or after, April 2024.
diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py
index 7ee1c91..7f5d961 100644
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -351,9 +351,10 @@ def assoc(inst, **changes):
     return new
 
 
-def evolve(inst, **changes):
+def evolve(*args, **changes):
     """
-    Create a new instance, based on *inst* with *changes* applied.
+    Create a new instance, based on the first positional argument with
+    *changes* applied.
 
     :param inst: Instance of a class with *attrs* attributes.
     :param changes: Keyword changes in the new copy.
@@ -365,8 +366,40 @@ def evolve(inst, **changes):
     :raise attrs.exceptions.NotAnAttrsClassError: If *cls* is not an *attrs*
         class.
 
-    ..  versionadded:: 17.1.0
+    .. versionadded:: 17.1.0
+    .. deprecated:: 23.1.0
+       It is now deprecated to pass the instance using the keyword argument
+       *inst*. It will raise a warning until at least April 2024, after which
+       it will become an error. Always pass the instance as a positional
+       argument.
     """
+    # Try to get instance by positional argument first.
+    # Use changes otherwise and warn it'll break.
+    if args:
+        try:
+            (inst,) = args
+        except ValueError:
+            raise TypeError(
+                f"evolve() takes 1 positional argument, but {len(args)} "
+                "were given"
+            ) from None
+    else:
+        try:
+            inst = changes.pop("inst")
+        except KeyError:
+            raise TypeError(
+                "evolve() missing 1 required positional argument: 'inst'"
+            ) from None
+
+        import warnings
+
+        warnings.warn(
+            "Passing the instance per keyword argument is deprecated and "
+            "will stop working in, or after, April 2024.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+
     cls = inst.__class__
     attrs = fields(cls)
     for a in attrs:


Test patch:
diff --git a/tests/test_funcs.py b/tests/test_funcs.py
index bee3670..82f0f64 100644
--- a/tests/test_funcs.py
+++ b/tests/test_funcs.py
@@ -689,3 +689,47 @@ class TestEvolve:
         assert Cls1({"foo": 42, "param2": 42}) == attr.evolve(
             obj1a, param1=obj2b
         )
+
+    def test_inst_kw(self):
+        """
+        If `inst` is passed per kw argument, a warning is raised.
+        See #1109
+        """
+
+        @attr.s
+        class C:
+            pass
+
+        with pytest.warns(DeprecationWarning) as wi:
+            evolve(inst=C())
+
+        assert __file__ == wi.list[0].filename
+
+    def test_no_inst(self):
+        """
+        Missing inst argument raises a TypeError like Python would.
+        """
+        with pytest.raises(TypeError, match=r"evolve\(\) missing 1"):
+            evolve(x=1)
+
+    def test_too_many_pos_args(self):
+        """
+        More than one positional argument raises a TypeError like Python would.
+        """
+        with pytest.raises(
+            TypeError,
+            match=r"evolve\(\) takes 1 positional argument, but 2 were given",
+        ):
+            evolve(1, 2)
+
+    def test_can_change_inst(self):
+        """
+        If the instance is passed by positional argument, a field named `inst`
+        can be changed.
+        """
+
+        @attr.define
+        class C:
+            inst: int
+
+        assert C(42) == evolve(C(23), inst=42)