Skip to content

Commit

Permalink
Implement __replace__ on 3.13 (#1383)
Browse files Browse the repository at this point in the history
* Implement __replace__ on 3.13

Fixes #1313

* Add newsfragment

Add news fragment

* fix markup

* Add pro-tip

* Explicit autodoc
  • Loading branch information
hynek authored Dec 14, 2024
1 parent 103d51f commit 62bdbf2
Show file tree
Hide file tree
Showing 10 changed files with 130 additions and 59 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ jobs:
- uses: actions/setup-python@v5
with:
# Keep in sync with tox/docs and .readthedocs.yaml.
python-version: "3.12"
python-version: "3.13"
- uses: hynek/setup-cached-uv@v2

- run: uvx --with=tox-uv tox run -e docs,changelog
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build:
os: ubuntu-lts-latest
tools:
# Keep version in sync with tox.ini/docs and ci.yml/docs.
python: "3.12"
python: "3.13"
jobs:
# Need the tags to calculate the version (sometimes).
post_checkout:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1383.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*attrs* instances now support [`copy.replace()`](https://docs.python.org/3/library/copy.html#copy.replace).
13 changes: 13 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,19 @@ C(x=1, y=3)
False
```

On Python 3.13 and later, you can also use {func}`copy.replace` from the standard library:

```{doctest}
>>> import copy
>>> @frozen
... class C:
... x: int
... y: int
>>> i = C(1, 2)
>>> copy.replace(i, y=3)
C(x=1, y=3)
```


## Other Goodies

Expand Down
3 changes: 2 additions & 1 deletion src/attr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from . import converters, exceptions, filters, setters, validators
from ._cmp import cmp_using
from ._config import get_run_validators, set_run_validators
from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types
from ._funcs import asdict, assoc, astuple, has, resolve_types
from ._make import (
NOTHING,
Attribute,
Expand All @@ -19,6 +19,7 @@
_Nothing,
attrib,
attrs,
evolve,
fields,
fields_dict,
make_class,
Expand Down
54 changes: 0 additions & 54 deletions src/attr/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,60 +394,6 @@ def assoc(inst, **changes):
return new


def evolve(*args, **changes):
"""
Create a new instance, based on the first positional argument with
*changes* applied.
Args:
inst:
Instance of a class with *attrs* attributes. *inst* must be passed
as a positional argument.
changes:
Keyword changes in the new copy.
Returns:
A copy of inst with *changes* incorporated.
Raises:
TypeError:
If *attr_name* couldn't be found in the class ``__init__``.
attrs.exceptions.NotAnAttrsClassError:
If *cls* is not an *attrs* class.
.. 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.
.. versionchanged:: 24.1.0
*inst* can't be passed as a keyword argument anymore.
"""
try:
(inst,) = args
except ValueError:
msg = (
f"evolve() takes 1 positional argument, but {len(args)} were given"
)
raise TypeError(msg) from None

cls = inst.__class__
attrs = fields(cls)
for a in attrs:
if not a.init:
continue
attr_name = a.name # To deal with private attributes.
init_name = a.alias
if init_name not in changes:
changes[init_name] = getattr(inst, attr_name)

return cls(**changes)


def resolve_types(
cls, globalns=None, localns=None, attribs=None, include_extras=True
):
Expand Down
70 changes: 69 additions & 1 deletion src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ._compat import (
PY_3_10_PLUS,
PY_3_11_PLUS,
PY_3_13_PLUS,
_AnnotationExtractor,
_get_annotations,
get_generic_base,
Expand Down Expand Up @@ -565,6 +566,64 @@ def _frozen_delattrs(self, name):
raise FrozenInstanceError


def evolve(*args, **changes):
"""
Create a new instance, based on the first positional argument with
*changes* applied.
.. tip::
On Python 3.13 and later, you can also use `copy.replace` instead.
Args:
inst:
Instance of a class with *attrs* attributes. *inst* must be passed
as a positional argument.
changes:
Keyword changes in the new copy.
Returns:
A copy of inst with *changes* incorporated.
Raises:
TypeError:
If *attr_name* couldn't be found in the class ``__init__``.
attrs.exceptions.NotAnAttrsClassError:
If *cls* is not an *attrs* class.
.. 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.
.. versionchanged:: 24.1.0
*inst* can't be passed as a keyword argument anymore.
"""
try:
(inst,) = args
except ValueError:
msg = (
f"evolve() takes 1 positional argument, but {len(args)} were given"
)
raise TypeError(msg) from None

cls = inst.__class__
attrs = fields(cls)
for a in attrs:
if not a.init:
continue
attr_name = a.name # To deal with private attributes.
init_name = a.alias
if init_name not in changes:
changes[init_name] = getattr(inst, attr_name)

return cls(**changes)


class _ClassBuilder:
"""
Iteratively build *one* class.
Expand Down Expand Up @@ -979,6 +1038,12 @@ def add_init(self):

return self

def add_replace(self):
self._cls_dict["__replace__"] = self._add_method_dunders(
lambda self, **changes: evolve(self, **changes)
)
return self

def add_match_args(self):
self._cls_dict["__match_args__"] = tuple(
field.name
Expand Down Expand Up @@ -1381,6 +1446,9 @@ def wrap(cls):
msg = "Invalid value for cache_hash. To use hash caching, init must be True."
raise TypeError(msg)

if PY_3_13_PLUS and not _has_own_attribute(cls, "__replace__"):
builder.add_replace()

if (
PY_3_10_PLUS
and match_args
Expand Down Expand Up @@ -2394,7 +2462,7 @@ def evolve(self, **changes):
Copy *self* and apply *changes*.
This works similarly to `attrs.evolve` but that function does not work
with {class}`Attribute`.
with :class:`attrs.Attribute`.
It is mainly meant to be used for `transform-fields`.
Expand Down
3 changes: 3 additions & 0 deletions src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ def define(
If a class has an *inherited* classmethod called
``__attrs_init_subclass__``, it is executed after the class is created.
.. deprecated:: 24.1.0 *hash* is deprecated in favor of *unsafe_hash*.
.. versionadded:: 24.3.0
Unless already present, a ``__replace__`` method is automatically
created for `copy.replace` (Python 3.13+ only).
.. note::
Expand Down
39 changes: 39 additions & 0 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
End-to-end tests.
"""

import copy
import inspect
import pickle

Expand All @@ -16,6 +17,7 @@

import attr

from attr._compat import PY_3_13_PLUS
from attr._make import NOTHING, Attribute
from attr.exceptions import FrozenInstanceError

Expand Down Expand Up @@ -766,3 +768,40 @@ class ToRegister(Base):
pass

assert [ToRegister] == REGISTRY


@pytest.mark.skipif(not PY_3_13_PLUS, reason="requires Python 3.13+")
class TestReplace:
def test_replaces(self):
"""
copy.replace() is added by default and works like `attrs.evolve`.
"""
inst = C1(1, 2)

assert C1(1, 42) == copy.replace(inst, y=42)
assert C1(42, 2) == copy.replace(inst, x=42)

def test_already_has_one(self):
"""
If the object already has a __replace__, it's left alone.
"""
sentinel = object()

@attr.s
class C:
x = attr.ib()

__replace__ = sentinel

assert sentinel == C.__replace__

def test_invalid_field_name(self):
"""
Invalid field names raise a TypeError.
This is consistent with dataclasses.
"""
inst = C1(1, 2)

with pytest.raises(TypeError):
copy.replace(inst, z=42)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ commands = pytest --codspeed -n auto bench/test_benchmarks.py

[testenv:docs]
# Keep base_python in-sync with ci.yml/docs and .readthedocs.yaml.
base_python = py312
base_python = py313
extras = docs
commands =
sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html
Expand Down

0 comments on commit 62bdbf2

Please sign in to comment.