diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5216460f9..6b025d69c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e747cb46d..9bcdc4bce 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -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: diff --git a/changelog.d/1383.change.md b/changelog.d/1383.change.md new file mode 100644 index 000000000..dbc6348e8 --- /dev/null +++ b/changelog.d/1383.change.md @@ -0,0 +1 @@ +*attrs* instances now support [`copy.replace()`](https://docs.python.org/3/library/copy.html#copy.replace). diff --git a/docs/examples.md b/docs/examples.md index bc9fa36e5..2393decf4 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 2e3b7c005..5c6e0650b 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -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, @@ -19,6 +19,7 @@ _Nothing, attrib, attrs, + evolve, fields, fields_dict, make_class, diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 355cef442..c39fb8aa5 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -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 ): diff --git a/src/attr/_make.py b/src/attr/_make.py index eac5888a5..e17e6a215 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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, @@ -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. @@ -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 @@ -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 @@ -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`. diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index e36b6d75f..9290664b2 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -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:: diff --git a/tests/test_functional.py b/tests/test_functional.py index a1c7f755d..7b0317d19 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -4,6 +4,7 @@ End-to-end tests. """ +import copy import inspect import pickle @@ -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 @@ -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) diff --git a/tox.ini b/tox.ini index bc199713a..b1250f06c 100644 --- a/tox.ini +++ b/tox.ini @@ -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