Skip to content

Commit

Permalink
gh-108751: Add copy.replace() function (GH-108752)
Browse files Browse the repository at this point in the history
It creates a modified copy of an object by calling the object's
__replace__() method.

It is a generalization of dataclasses.replace(), named tuple's _replace()
method and replace() methods in various classes, and supports all these
stdlib classes.
  • Loading branch information
serhiy-storchaka authored Sep 6, 2023
1 parent 9f0c0a4 commit 6f3c138
Show file tree
Hide file tree
Showing 19 changed files with 314 additions and 71 deletions.
2 changes: 2 additions & 0 deletions Doc/library/collections.rst
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,8 @@ field names, the method and attribute names start with an underscore.
>>> for partnum, record in inventory.items():
... inventory[partnum] = record._replace(price=newprices[partnum], timestamp=time.now())

Named tuples are also supported by generic function :func:`copy.replace`.

.. attribute:: somenamedtuple._fields

Tuple of strings listing the field names. Useful for introspection
Expand Down
30 changes: 26 additions & 4 deletions Doc/library/copy.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,22 @@ operations (explained below).

Interface summary:

.. function:: copy(x)
.. function:: copy(obj)

Return a shallow copy of *x*.
Return a shallow copy of *obj*.


.. function:: deepcopy(x[, memo])
.. function:: deepcopy(obj[, memo])

Return a deep copy of *x*.
Return a deep copy of *obj*.


.. function:: replace(obj, /, **changes)

Creates a new object of the same type as *obj*, replacing fields with values
from *changes*.

.. versionadded:: 3.13


.. exception:: Error
Expand Down Expand Up @@ -89,6 +97,20 @@ with the component as first argument and the memo dictionary as second argument.
The memo dictionary should be treated as an opaque object.


.. index::
single: __replace__() (replace protocol)

Function :func:`replace` is more limited than :func:`copy` and :func:`deepcopy`,
and only supports named tuples created by :func:`~collections.namedtuple`,
:mod:`dataclasses`, and other classes which define method :meth:`!__replace__`.

.. method:: __replace__(self, /, **changes)
:noindex:

:meth:`!__replace__` should create a new object of the same type,
replacing fields with values from *changes*.


.. seealso::

Module :mod:`pickle`
Expand Down
2 changes: 2 additions & 0 deletions Doc/library/dataclasses.rst
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,8 @@ Module contents
``replace()`` (or similarly named) method which handles instance
copying.

Dataclass instances are also supported by generic function :func:`copy.replace`.

.. function:: is_dataclass(obj)

Return ``True`` if its parameter is a dataclass or an instance of one,
Expand Down
9 changes: 9 additions & 0 deletions Doc/library/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,9 @@ Instance methods:
>>> d.replace(day=26)
datetime.date(2002, 12, 26)

:class:`date` objects are also supported by generic function
:func:`copy.replace`.


.. method:: date.timetuple()

Expand Down Expand Up @@ -1251,6 +1254,9 @@ Instance methods:
``tzinfo=None`` can be specified to create a naive datetime from an aware
datetime with no conversion of date and time data.

:class:`datetime` objects are also supported by generic function
:func:`copy.replace`.

.. versionadded:: 3.6
Added the ``fold`` argument.

Expand Down Expand Up @@ -1827,6 +1833,9 @@ Instance methods:
``tzinfo=None`` can be specified to create a naive :class:`.time` from an
aware :class:`.time`, without conversion of the time data.

:class:`time` objects are also supported by generic function
:func:`copy.replace`.

.. versionadded:: 3.6
Added the ``fold`` argument.

Expand Down
11 changes: 8 additions & 3 deletions Doc/library/inspect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -689,8 +689,8 @@ function.
The optional *return_annotation* argument, can be an arbitrary Python object,
is the "return" annotation of the callable.

Signature objects are *immutable*. Use :meth:`Signature.replace` to make a
modified copy.
Signature objects are *immutable*. Use :meth:`Signature.replace` or
:func:`copy.replace` to make a modified copy.

.. versionchanged:: 3.5
Signature objects are picklable and :term:`hashable`.
Expand Down Expand Up @@ -746,6 +746,9 @@ function.
>>> str(new_sig)
"(a, b) -> 'new return anno'"

Signature objects are also supported by generic function
:func:`copy.replace`.

.. classmethod:: Signature.from_callable(obj, *, follow_wrapped=True, globalns=None, localns=None)

Return a :class:`Signature` (or its subclass) object for a given callable
Expand All @@ -769,7 +772,7 @@ function.
.. class:: Parameter(name, kind, *, default=Parameter.empty, annotation=Parameter.empty)

Parameter objects are *immutable*. Instead of modifying a Parameter object,
you can use :meth:`Parameter.replace` to create a modified copy.
you can use :meth:`Parameter.replace` or :func:`copy.replace` to create a modified copy.

.. versionchanged:: 3.5
Parameter objects are picklable and :term:`hashable`.
Expand Down Expand Up @@ -892,6 +895,8 @@ function.
>>> str(param.replace(default=Parameter.empty, annotation='spam'))
"foo:'spam'"

Parameter objects are also supported by generic function :func:`copy.replace`.

.. versionchanged:: 3.4
In Python 3.3 Parameter objects were allowed to have ``name`` set
to ``None`` if their ``kind`` was set to ``POSITIONAL_ONLY``.
Expand Down
2 changes: 2 additions & 0 deletions Doc/library/types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ Standard names are defined for the following types:

Return a copy of the code object with new values for the specified fields.

Code objects are also supported by generic function :func:`copy.replace`.

.. versionadded:: 3.8

.. data:: CellType
Expand Down
12 changes: 12 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ array
It can be used instead of ``'u'`` type code, which is deprecated.
(Contributed by Inada Naoki in :gh:`80480`.)

copy
----

* Add :func:`copy.replace` function which allows to create a modified copy of
an object, which is especially usefule for immutable objects.
It supports named tuples created with the factory function
:func:`collections.namedtuple`, :class:`~dataclasses.dataclass` instances,
various :mod:`datetime` objects, :class:`~inspect.Signature` objects,
:class:`~inspect.Parameter` objects, :ref:`code object <code-objects>`, and
any user classes which define the :meth:`!__replace__` method.
(Contributed by Serhiy Storchaka in :gh:`108751`.)

dbm
---

Expand Down
6 changes: 6 additions & 0 deletions Lib/_pydatetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,8 @@ def replace(self, year=None, month=None, day=None):
day = self._day
return type(self)(year, month, day)

__replace__ = replace

# Comparisons of date objects with other.

def __eq__(self, other):
Expand Down Expand Up @@ -1637,6 +1639,8 @@ def replace(self, hour=None, minute=None, second=None, microsecond=None,
fold = self._fold
return type(self)(hour, minute, second, microsecond, tzinfo, fold=fold)

__replace__ = replace

# Pickle support.

def _getstate(self, protocol=3):
Expand Down Expand Up @@ -1983,6 +1987,8 @@ def replace(self, year=None, month=None, day=None, hour=None,
return type(self)(year, month, day, hour, minute, second,
microsecond, tzinfo, fold=fold)

__replace__ = replace

def _local_timezone(self):
if self.tzinfo is None:
ts = self._mktime()
Expand Down
1 change: 1 addition & 0 deletions Lib/collections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ def __getnewargs__(self):
'_field_defaults': field_defaults,
'__new__': __new__,
'_make': _make,
'__replace__': _replace,
'_replace': _replace,
'__repr__': __repr__,
'_asdict': _asdict,
Expand Down
13 changes: 13 additions & 0 deletions Lib/copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,16 @@ def _reconstruct(x, memo, func, args,
return y

del types, weakref


def replace(obj, /, **changes):
"""Return a new object replacing specified fields with new values.
This is especially useful for immutable objects, like named tuples or
frozen dataclasses.
"""
cls = obj.__class__
func = getattr(cls, '__replace__', None)
if func is None:
raise TypeError(f"replace() does not support {cls.__name__} objects")
return func(obj, **changes)
9 changes: 6 additions & 3 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
globals,
slots,
))
_set_new_attribute(cls, '__replace__', _replace)

# Get the fields as a list, and include only real fields. This is
# used in all of the following methods.
Expand Down Expand Up @@ -1546,13 +1547,15 @@ class C:
c1 = replace(c, x=3)
assert c1.x == 3 and c1.y == 2
"""
if not _is_dataclass_instance(obj):
raise TypeError("replace() should be called on dataclass instances")
return _replace(obj, **changes)


def _replace(obj, /, **changes):
# We're going to mutate 'changes', but that's okay because it's a
# new dict, even if called with 'replace(obj, **my_changes)'.

if not _is_dataclass_instance(obj):
raise TypeError("replace() should be called on dataclass instances")

# It's an error to have init=False fields in 'changes'.
# If a field is not in 'changes', read its value from the provided obj.

Expand Down
4 changes: 4 additions & 0 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2870,6 +2870,8 @@ def __str__(self):

return formatted

__replace__ = replace

def __repr__(self):
return '<{} "{}">'.format(self.__class__.__name__, self)

Expand Down Expand Up @@ -3130,6 +3132,8 @@ def replace(self, *, parameters=_void, return_annotation=_void):
return type(self)(parameters,
return_annotation=return_annotation)

__replace__ = replace

def _hash_basis(self):
params = tuple(param for param in self.parameters.values()
if param.kind != _KEYWORD_ONLY)
Expand Down
Loading

0 comments on commit 6f3c138

Please sign in to comment.