Skip to content

Commit 3b9768d

Browse files
Adrian AcalaAdrian Acala
Adrian Acala
authored and
Adrian Acala
committed
feat: Implement __replace__ method in BaseContainer for copy.replace() support
- Added the __replace__ magic method to BaseContainer, enabling the creation of modified copies of immutable containers in line with Python 3.13's copy.replace() functionality. - Updated documentation to include usage examples and clarify the behavior of the new method. - Added tests to ensure the correct functionality of the __replace__ method and its integration with the copy module. - Updated CHANGELOG to reflect this new feature and its implications for container usage. Closes #1920.
1 parent ac1bf89 commit 3b9768d

File tree

4 files changed

+371
-4
lines changed

4 files changed

+371
-4
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Versions before `1.0.0` are `0Ver`-based:
55
incremental in minor, bugfixes only are patches.
66
See [0Ver](https://0ver.org/).
77

8+
## Unreleased
9+
10+
### Features
11+
12+
- Add support for `copy.replace()` from Python 3.13+ by implementing `__replace__`
13+
magic method on `BaseContainer`. This allows for creating modified copies
14+
of immutable containers. (#1920)
815

916
## 0.25.0
1017

docs/pages/container.rst

+120
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,126 @@ Well, nothing is **really** immutable in python, but you were warned.
372372
We also provide :class:`returns.primitives.types.Immutable` mixin
373373
that users can use to quickly make their classes immutable.
374374

375+
Creating Modified Copies of Containers
376+
--------------------------------------
377+
378+
While containers are immutable, sometimes you need to create a modified copy
379+
of a container with different inner values. Since Python 3.13, ``returns``
380+
containers support the ``copy.replace()`` function via the ``__replace__``
381+
magic method.
382+
383+
.. code:: python
384+
385+
>>> from returns.result import Success, Failure
386+
>>> import copy, sys
387+
>>>
388+
>>> # Only run this example on Python 3.13+
389+
>>> if sys.version_info >= (3, 13):
390+
... # Replace the inner value of a Success container
391+
... original = Success(1)
392+
... modified = copy.replace(original, _inner_value=2)
393+
... assert modified == Success(2)
394+
... assert original is not modified # Creates a new instance
395+
...
396+
... # Works with Failure too
397+
... error = Failure("original error")
398+
... new_error = copy.replace(error, _inner_value="new error message")
399+
... assert new_error == Failure("new error message")
400+
...
401+
... # No changes returns the original object (due to immutability)
402+
... assert copy.replace(original) is original
403+
... else:
404+
... # For Python versions before 3.13, the tests would be skipped
405+
... pass
406+
407+
.. note::
408+
The parameter name ``_inner_value`` is used because it directly maps to the
409+
internal attribute of the same name in ``BaseContainer``. In the ``__replace__``
410+
implementation, this parameter name is specifically recognized to create a new
411+
container instance with a modified inner value.
412+
413+
.. warning::
414+
While ``copy.replace()`` works at runtime, it has limitations with static
415+
type checking. If you replace an inner value with a value of a different
416+
type, type checkers won't automatically infer the new type:
417+
418+
.. code:: python
419+
420+
# Example that would work in Python 3.13+:
421+
# >>> num_container = Success(123)
422+
# >>> str_container = copy.replace(num_container, _inner_value="string")
423+
# >>> # Type checkers may still think this is Success[int] not Success[str]
424+
>>> # The above is skipped in doctest as copy.replace requires Python 3.13+
425+
426+
Using ``copy.replace()`` with Custom Containers
427+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
428+
429+
If you create your own container by extending ``BaseContainer``, it will automatically
430+
inherit the ``__replace__`` implementation for free. This means your custom containers
431+
will work with ``copy.replace()`` just like the built-in ones.
432+
433+
.. code:: python
434+
435+
>>> from returns.primitives.container import BaseContainer
436+
>>> from typing import TypeVar, Generic
437+
>>> import copy, sys # Requires Python 3.13+ for copy.replace
438+
439+
>>> T = TypeVar('T')
440+
>>> class MyBox(BaseContainer, Generic[T]):
441+
... """A custom container that wraps a value."""
442+
... def __init__(self, inner_value: T) -> None:
443+
... super().__init__(inner_value)
444+
...
445+
... def __eq__(self, other: object) -> bool:
446+
... if not isinstance(other, MyBox):
447+
... return False
448+
... return self._inner_value == other._inner_value
449+
...
450+
... def __repr__(self) -> str:
451+
... return f"MyBox({self._inner_value!r})"
452+
453+
>>> # Create a basic container
454+
>>> box = MyBox("hello")
455+
>>>
456+
>>> # Test works with copy.replace only on Python 3.13+
457+
>>> if sys.version_info >= (3, 13):
458+
... new_box = copy.replace(box, _inner_value="world")
459+
... assert new_box == MyBox("world")
460+
... assert box is not new_box
461+
... else:
462+
... # For Python versions before 3.13
463+
... pass
464+
465+
By inheriting from ``BaseContainer``, your custom container will automatically support:
466+
467+
1. The basic container operations like ``__eq__``, ``__hash__``, ``__repr__``
468+
2. Pickling via ``__getstate__`` and ``__setstate__``
469+
3. The ``copy.replace()`` functionality via ``__replace__``
470+
4. Immutability via the ``Immutable`` mixin
471+
472+
Before Python 3.13, you can use container-specific methods to create modified copies:
473+
474+
.. code:: python
475+
476+
>>> from returns.result import Success, Failure, Result
477+
>>> from typing import Any
478+
479+
>>> # For Success containers, we can use .map to transform the inner value
480+
>>> original = Success(1)
481+
>>> modified = original.map(lambda _: 2)
482+
>>> assert modified == Success(2)
483+
484+
>>> # For Failure containers, we can use .alt to transform the inner value
485+
>>> error = Failure("error")
486+
>>> new_error = error.alt(lambda _: "new error")
487+
>>> assert new_error == Failure("new error")
488+
489+
>>> # For general containers without knowing success/failure state:
490+
>>> def replace_inner_value(container: Result[Any, Any], new_value: Any) -> Result[Any, Any]:
491+
... """Create a new container with the same state but different inner value."""
492+
... if container.is_success():
493+
... return Success(new_value)
494+
... return Failure(new_value)
375495
376496
.. _type-safety:
377497

returns/primitives/container.py

+69-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
from abc import ABC
2-
from typing import Any, TypeVar
3-
4-
from typing_extensions import TypedDict
2+
from typing import (
3+
TYPE_CHECKING,
4+
Any,
5+
TypedDict,
6+
TypeVar,
7+
)
8+
9+
if TYPE_CHECKING: # pragma: no cover
10+
from typing import Self # Use Self type from typing for Python 3.11+
11+
12+
# Avoid importing typing_extensions if Python version doesn't support Self
13+
try:
14+
from typing import Self # type: ignore
15+
except ImportError: # pragma: no cover
16+
from typing_extensions import Self # type: ignore
517

618
from returns.interfaces.equable import Equable
719
from returns.primitives.hkt import Kind1
@@ -24,7 +36,15 @@ class _PickleState(TypedDict):
2436

2537

2638
class BaseContainer(Immutable, ABC):
27-
"""Utility class to provide all needed magic methods to the context."""
39+
"""
40+
Utility class to provide all needed magic methods to the context.
41+
42+
Supports standard magic methods like ``__eq__``, ``__hash__``,
43+
``__repr__``, and ``__getstate__`` / ``__setstate__`` for pickling.
44+
45+
Since Python 3.13, also supports ``copy.replace()`` via the
46+
``__replace__`` magic method.
47+
"""
2848

2949
__slots__ = ('_inner_value',)
3050
_inner_value: Any
@@ -68,6 +88,51 @@ def __setstate__(self, state: _PickleState | Any) -> None:
6888
# backward compatibility with 0.19.0 and earlier
6989
object.__setattr__(self, '_inner_value', state)
7090

91+
def __replace__(self, **changes: Any) -> Self: # pragma: no cover
92+
"""
93+
Custom implementation for copy.replace() (Python 3.13+).
94+
95+
Creates a new instance of the container with specified changes.
96+
For BaseContainer and its direct subclasses, only replacing
97+
the '_inner_value' is generally supported via the constructor.
98+
99+
Args:
100+
**changes: Keyword arguments mapping attribute names to new values.
101+
Currently only ``_inner_value`` is supported.
102+
103+
Returns:
104+
A new container instance with the specified replacements, or
105+
``self`` if no changes were provided (due to immutability).
106+
107+
Raises:
108+
TypeError: If 'changes' contains keys other than '_inner_value'.
109+
"""
110+
if not changes: # pragma: no cover
111+
# copy.replace(obj) with no changes should behave like copy.copy()
112+
# Immutable.__copy__ returns self, which is correct and efficient.
113+
return self
114+
115+
# Define which attributes can be replaced in the base container logic.
116+
allowed_keys = {'_inner_value'} # pragma: no cover
117+
provided_keys = set(changes.keys()) # pragma: no cover
118+
119+
# Check if any unexpected attributes were requested for change.
120+
if not provided_keys.issubset(allowed_keys): # pragma: no cover
121+
unexpected_keys = provided_keys - allowed_keys
122+
raise TypeError(
123+
f'{type(self).__name__}.__replace__ received unexpected '
124+
f'arguments: {unexpected_keys}'
125+
)
126+
127+
# Determine the inner value for the new container.
128+
new_inner_value = changes.get(
129+
'_inner_value',
130+
self._inner_value,
131+
) # pragma: no cover
132+
133+
# Create a new instance of the *actual* container type (e.g., Success).
134+
return type(self)(new_inner_value) # pragma: no cover
135+
71136

72137
def container_equality(
73138
self: Kind1[_EqualType, Any],

0 commit comments

Comments
 (0)