Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement __replace__ on 3.13 #1383

Merged
merged 5 commits into from
Dec 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading