Skip to content

Commit 62bdbf2

Browse files
authored
Implement __replace__ on 3.13 (#1383)
* Implement __replace__ on 3.13 Fixes #1313 * Add newsfragment Add news fragment * fix markup * Add pro-tip * Explicit autodoc
1 parent 103d51f commit 62bdbf2

File tree

10 files changed

+130
-59
lines changed

10 files changed

+130
-59
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ jobs:
185185
- uses: actions/setup-python@v5
186186
with:
187187
# Keep in sync with tox/docs and .readthedocs.yaml.
188-
python-version: "3.12"
188+
python-version: "3.13"
189189
- uses: hynek/setup-cached-uv@v2
190190

191191
- run: uvx --with=tox-uv tox run -e docs,changelog

.readthedocs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build:
55
os: ubuntu-lts-latest
66
tools:
77
# Keep version in sync with tox.ini/docs and ci.yml/docs.
8-
python: "3.12"
8+
python: "3.13"
99
jobs:
1010
# Need the tags to calculate the version (sometimes).
1111
post_checkout:

changelog.d/1383.change.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*attrs* instances now support [`copy.replace()`](https://docs.python.org/3/library/copy.html#copy.replace).

docs/examples.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,19 @@ C(x=1, y=3)
674674
False
675675
```
676676

677+
On Python 3.13 and later, you can also use {func}`copy.replace` from the standard library:
678+
679+
```{doctest}
680+
>>> import copy
681+
>>> @frozen
682+
... class C:
683+
... x: int
684+
... y: int
685+
>>> i = C(1, 2)
686+
>>> copy.replace(i, y=3)
687+
C(x=1, y=3)
688+
```
689+
677690

678691
## Other Goodies
679692

src/attr/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from . import converters, exceptions, filters, setters, validators
1111
from ._cmp import cmp_using
1212
from ._config import get_run_validators, set_run_validators
13-
from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types
13+
from ._funcs import asdict, assoc, astuple, has, resolve_types
1414
from ._make import (
1515
NOTHING,
1616
Attribute,
@@ -19,6 +19,7 @@
1919
_Nothing,
2020
attrib,
2121
attrs,
22+
evolve,
2223
fields,
2324
fields_dict,
2425
make_class,

src/attr/_funcs.py

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -394,60 +394,6 @@ def assoc(inst, **changes):
394394
return new
395395

396396

397-
def evolve(*args, **changes):
398-
"""
399-
Create a new instance, based on the first positional argument with
400-
*changes* applied.
401-
402-
Args:
403-
404-
inst:
405-
Instance of a class with *attrs* attributes. *inst* must be passed
406-
as a positional argument.
407-
408-
changes:
409-
Keyword changes in the new copy.
410-
411-
Returns:
412-
A copy of inst with *changes* incorporated.
413-
414-
Raises:
415-
TypeError:
416-
If *attr_name* couldn't be found in the class ``__init__``.
417-
418-
attrs.exceptions.NotAnAttrsClassError:
419-
If *cls* is not an *attrs* class.
420-
421-
.. versionadded:: 17.1.0
422-
.. deprecated:: 23.1.0
423-
It is now deprecated to pass the instance using the keyword argument
424-
*inst*. It will raise a warning until at least April 2024, after which
425-
it will become an error. Always pass the instance as a positional
426-
argument.
427-
.. versionchanged:: 24.1.0
428-
*inst* can't be passed as a keyword argument anymore.
429-
"""
430-
try:
431-
(inst,) = args
432-
except ValueError:
433-
msg = (
434-
f"evolve() takes 1 positional argument, but {len(args)} were given"
435-
)
436-
raise TypeError(msg) from None
437-
438-
cls = inst.__class__
439-
attrs = fields(cls)
440-
for a in attrs:
441-
if not a.init:
442-
continue
443-
attr_name = a.name # To deal with private attributes.
444-
init_name = a.alias
445-
if init_name not in changes:
446-
changes[init_name] = getattr(inst, attr_name)
447-
448-
return cls(**changes)
449-
450-
451397
def resolve_types(
452398
cls, globalns=None, localns=None, attribs=None, include_extras=True
453399
):

src/attr/_make.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from ._compat import (
2323
PY_3_10_PLUS,
2424
PY_3_11_PLUS,
25+
PY_3_13_PLUS,
2526
_AnnotationExtractor,
2627
_get_annotations,
2728
get_generic_base,
@@ -565,6 +566,64 @@ def _frozen_delattrs(self, name):
565566
raise FrozenInstanceError
566567

567568

569+
def evolve(*args, **changes):
570+
"""
571+
Create a new instance, based on the first positional argument with
572+
*changes* applied.
573+
574+
.. tip::
575+
576+
On Python 3.13 and later, you can also use `copy.replace` instead.
577+
578+
Args:
579+
580+
inst:
581+
Instance of a class with *attrs* attributes. *inst* must be passed
582+
as a positional argument.
583+
584+
changes:
585+
Keyword changes in the new copy.
586+
587+
Returns:
588+
A copy of inst with *changes* incorporated.
589+
590+
Raises:
591+
TypeError:
592+
If *attr_name* couldn't be found in the class ``__init__``.
593+
594+
attrs.exceptions.NotAnAttrsClassError:
595+
If *cls* is not an *attrs* class.
596+
597+
.. versionadded:: 17.1.0
598+
.. deprecated:: 23.1.0
599+
It is now deprecated to pass the instance using the keyword argument
600+
*inst*. It will raise a warning until at least April 2024, after which
601+
it will become an error. Always pass the instance as a positional
602+
argument.
603+
.. versionchanged:: 24.1.0
604+
*inst* can't be passed as a keyword argument anymore.
605+
"""
606+
try:
607+
(inst,) = args
608+
except ValueError:
609+
msg = (
610+
f"evolve() takes 1 positional argument, but {len(args)} were given"
611+
)
612+
raise TypeError(msg) from None
613+
614+
cls = inst.__class__
615+
attrs = fields(cls)
616+
for a in attrs:
617+
if not a.init:
618+
continue
619+
attr_name = a.name # To deal with private attributes.
620+
init_name = a.alias
621+
if init_name not in changes:
622+
changes[init_name] = getattr(inst, attr_name)
623+
624+
return cls(**changes)
625+
626+
568627
class _ClassBuilder:
569628
"""
570629
Iteratively build *one* class.
@@ -979,6 +1038,12 @@ def add_init(self):
9791038

9801039
return self
9811040

1041+
def add_replace(self):
1042+
self._cls_dict["__replace__"] = self._add_method_dunders(
1043+
lambda self, **changes: evolve(self, **changes)
1044+
)
1045+
return self
1046+
9821047
def add_match_args(self):
9831048
self._cls_dict["__match_args__"] = tuple(
9841049
field.name
@@ -1381,6 +1446,9 @@ def wrap(cls):
13811446
msg = "Invalid value for cache_hash. To use hash caching, init must be True."
13821447
raise TypeError(msg)
13831448

1449+
if PY_3_13_PLUS and not _has_own_attribute(cls, "__replace__"):
1450+
builder.add_replace()
1451+
13841452
if (
13851453
PY_3_10_PLUS
13861454
and match_args
@@ -2394,7 +2462,7 @@ def evolve(self, **changes):
23942462
Copy *self* and apply *changes*.
23952463
23962464
This works similarly to `attrs.evolve` but that function does not work
2397-
with {class}`Attribute`.
2465+
with :class:`attrs.Attribute`.
23982466
23992467
It is mainly meant to be used for `transform-fields`.
24002468

src/attr/_next_gen.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ def define(
316316
If a class has an *inherited* classmethod called
317317
``__attrs_init_subclass__``, it is executed after the class is created.
318318
.. deprecated:: 24.1.0 *hash* is deprecated in favor of *unsafe_hash*.
319+
.. versionadded:: 24.3.0
320+
Unless already present, a ``__replace__`` method is automatically
321+
created for `copy.replace` (Python 3.13+ only).
319322
320323
.. note::
321324

tests/test_functional.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
End-to-end tests.
55
"""
66

7+
import copy
78
import inspect
89
import pickle
910

@@ -16,6 +17,7 @@
1617

1718
import attr
1819

20+
from attr._compat import PY_3_13_PLUS
1921
from attr._make import NOTHING, Attribute
2022
from attr.exceptions import FrozenInstanceError
2123

@@ -766,3 +768,40 @@ class ToRegister(Base):
766768
pass
767769

768770
assert [ToRegister] == REGISTRY
771+
772+
773+
@pytest.mark.skipif(not PY_3_13_PLUS, reason="requires Python 3.13+")
774+
class TestReplace:
775+
def test_replaces(self):
776+
"""
777+
copy.replace() is added by default and works like `attrs.evolve`.
778+
"""
779+
inst = C1(1, 2)
780+
781+
assert C1(1, 42) == copy.replace(inst, y=42)
782+
assert C1(42, 2) == copy.replace(inst, x=42)
783+
784+
def test_already_has_one(self):
785+
"""
786+
If the object already has a __replace__, it's left alone.
787+
"""
788+
sentinel = object()
789+
790+
@attr.s
791+
class C:
792+
x = attr.ib()
793+
794+
__replace__ = sentinel
795+
796+
assert sentinel == C.__replace__
797+
798+
def test_invalid_field_name(self):
799+
"""
800+
Invalid field names raise a TypeError.
801+
802+
This is consistent with dataclasses.
803+
"""
804+
inst = C1(1, 2)
805+
806+
with pytest.raises(TypeError):
807+
copy.replace(inst, z=42)

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ commands = pytest --codspeed -n auto bench/test_benchmarks.py
6262

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

0 commit comments

Comments
 (0)