Skip to content

Commit f3188c3

Browse files
committed
Implement __replace__ on 3.13
Fixes #1313
1 parent d18763c commit f3188c3

File tree

9 files changed

+124
-58
lines changed

9 files changed

+124
-58
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:

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: 64 additions & 0 deletions
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,60 @@ 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+
Args:
575+
576+
inst:
577+
Instance of a class with *attrs* attributes. *inst* must be passed
578+
as a positional argument.
579+
580+
changes:
581+
Keyword changes in the new copy.
582+
583+
Returns:
584+
A copy of inst with *changes* incorporated.
585+
586+
Raises:
587+
TypeError:
588+
If *attr_name* couldn't be found in the class ``__init__``.
589+
590+
attrs.exceptions.NotAnAttrsClassError:
591+
If *cls* is not an *attrs* class.
592+
593+
.. versionadded:: 17.1.0
594+
.. deprecated:: 23.1.0
595+
It is now deprecated to pass the instance using the keyword argument
596+
*inst*. It will raise a warning until at least April 2024, after which
597+
it will become an error. Always pass the instance as a positional
598+
argument.
599+
.. versionchanged:: 24.1.0
600+
*inst* can't be passed as a keyword argument anymore.
601+
"""
602+
try:
603+
(inst,) = args
604+
except ValueError:
605+
msg = (
606+
f"evolve() takes 1 positional argument, but {len(args)} were given"
607+
)
608+
raise TypeError(msg) from None
609+
610+
cls = inst.__class__
611+
attrs = fields(cls)
612+
for a in attrs:
613+
if not a.init:
614+
continue
615+
attr_name = a.name # To deal with private attributes.
616+
init_name = a.alias
617+
if init_name not in changes:
618+
changes[init_name] = getattr(inst, attr_name)
619+
620+
return cls(**changes)
621+
622+
568623
class _ClassBuilder:
569624
"""
570625
Iteratively build *one* class.
@@ -979,6 +1034,12 @@ def add_init(self):
9791034

9801035
return self
9811036

1037+
def add_replace(self):
1038+
self._cls_dict["__replace__"] = self._add_method_dunders(
1039+
lambda self, **changes: evolve(self, **changes)
1040+
)
1041+
return self
1042+
9821043
def add_match_args(self):
9831044
self._cls_dict["__match_args__"] = tuple(
9841045
field.name
@@ -1381,6 +1442,9 @@ def wrap(cls):
13811442
msg = "Invalid value for cache_hash. To use hash caching, init must be True."
13821443
raise TypeError(msg)
13831444

1445+
if PY_3_13_PLUS and not _has_own_attribute(cls, "__replace__"):
1446+
builder.add_replace()
1447+
13841448
if (
13851449
PY_3_10_PLUS
13861450
and match_args

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)