Skip to content

Commit 1d4485f

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 dry-python#1920.
1 parent ac1bf89 commit 1d4485f

File tree

3 files changed

+364
-4
lines changed

3 files changed

+364
-4
lines changed

docs/pages/container.rst

Lines changed: 120 additions & 0 deletions
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

Lines changed: 69 additions & 4 deletions
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],

tests/test_primitives/test_replace.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import sys
2+
3+
import pytest
4+
5+
# Skip all tests if Python version is less than 3.13
6+
if sys.version_info >= (3, 13):
7+
import copy
8+
9+
from typing import Generic, TypeVar
10+
11+
from returns.io import IO
12+
from returns.maybe import Nothing, Some
13+
from returns.primitives.container import BaseContainer
14+
from returns.result import Failure, Success
15+
16+
17+
@pytest.mark.skipif(
18+
sys.version_info < (3, 13),
19+
reason='copy.replace requires Python 3.13+',
20+
)
21+
class TestCopyReplace:
22+
"""Tests for copy.replace functionality added in Python 3.13."""
23+
24+
def test_success_copy_replace(self):
25+
"""Tests copy.replace() on Success container."""
26+
original = Success(42)
27+
replaced = copy.replace(original, _inner_value=100)
28+
29+
assert replaced == Success(100)
30+
assert replaced is not original
31+
assert replaced._inner_value == 100 # noqa: SLF001
32+
33+
# No changes should return the same object (or equivalent)
34+
copied = copy.replace(original)
35+
assert copied == original
36+
# Unchanged objects return self per immutability principle
37+
assert copied is original
38+
39+
def test_failure_copy_replace(self):
40+
"""Tests copy.replace() on Failure container."""
41+
original = Failure('error')
42+
replaced = copy.replace(original, _inner_value='new error')
43+
44+
assert replaced == Failure('new error')
45+
assert replaced is not original
46+
assert replaced._inner_value == 'new error' # noqa: SLF001
47+
48+
# No changes should return an equivalent object
49+
copied = copy.replace(original)
50+
assert copied == original
51+
# Unchanged objects return self per immutability principle
52+
assert copied is original
53+
54+
def test_some_copy_replace(self):
55+
"""Tests copy.replace() on Some container."""
56+
original = Some('value')
57+
replaced = copy.replace(original, _inner_value='new value')
58+
59+
assert replaced == Some('new value')
60+
assert replaced is not original
61+
assert replaced._inner_value == 'new value' # noqa: SLF001
62+
63+
# No changes should return an equivalent object
64+
copied = copy.replace(original)
65+
assert copied == original
66+
# Unchanged objects return self per immutability principle
67+
assert copied is original
68+
69+
def test_nothing_copy_replace(self):
70+
"""Tests copy.replace() on Nothing singleton."""
71+
# Note: Nothing is a singleton, and its constructor ignores the
72+
# passed value. _Nothing will just return Nothing in any case
73+
replaced = copy.replace(Nothing, _inner_value='something')
74+
75+
assert replaced is Nothing
76+
assert replaced._inner_value is None # noqa: SLF001
77+
78+
def test_io_copy_replace(self):
79+
"""Tests copy.replace() on IO container."""
80+
original = IO('data')
81+
replaced = copy.replace(original, _inner_value='new data')
82+
83+
assert replaced == IO('new data')
84+
assert replaced is not original
85+
assert replaced._inner_value == 'new data' # noqa: SLF001
86+
87+
def test_type_change_with_replace(self):
88+
"""Tests copy.replace() when replacing with a different type."""
89+
# Test with Success container
90+
int_success = Success(42)
91+
str_success = copy.replace(int_success, _inner_value='forty-two')
92+
93+
assert str_success == Success('forty-two')
94+
assert str_success is not int_success
95+
assert isinstance(str_success._inner_value, str) # noqa: SLF001
96+
97+
# Test with Failure container
98+
str_failure = Failure('error')
99+
int_failure = copy.replace(str_failure, _inner_value=404)
100+
101+
assert int_failure == Failure(404)
102+
assert int_failure is not str_failure
103+
assert isinstance(int_failure._inner_value, int) # noqa: SLF001
104+
105+
# Test with IO container
106+
list_io = IO([1, 2, 3])
107+
dict_io = copy.replace(list_io, _inner_value={'a': 1, 'b': 2})
108+
109+
assert dict_io == IO({'a': 1, 'b': 2})
110+
assert dict_io is not list_io
111+
assert isinstance(dict_io._inner_value, dict) # noqa: SLF001
112+
113+
def test_invalid_arguments_in_replace(self):
114+
"""Tests that invalid arguments to copy.replace() raise TypeError."""
115+
original = Success(42)
116+
117+
with pytest.raises(TypeError) as exc_info:
118+
# The only valid argument for BaseContainer is _inner_value
119+
copy.replace(original, invalid_arg=True)
120+
121+
error_message = str(exc_info.value)
122+
assert 'received unexpected arguments' in error_message
123+
assert 'invalid_arg' in error_message
124+
125+
# Test with multiple invalid arguments
126+
with pytest.raises(TypeError):
127+
copy.replace(original, invalid_arg1=True, invalid_arg2='test')
128+
129+
# Valid argument should work normally
130+
assert copy.replace(original, _inner_value=100) == Success(100)
131+
132+
def test_custom_container(self):
133+
"""Tests copy.replace() works with custom user-defined containers."""
134+
T = TypeVar('T')
135+
136+
# Define a simple custom container extending BaseContainer
137+
class CustomBox(BaseContainer, Generic[T]):
138+
"""A simple box container for testing copy.replace()."""
139+
140+
def __init__(self, inner_value: T) -> None:
141+
"""Initialize with a value."""
142+
super().__init__(inner_value)
143+
144+
def __eq__(self, other: object) -> bool:
145+
"""Check equality based on type and inner value."""
146+
if not isinstance(other, CustomBox):
147+
return False
148+
return self._inner_value == other._inner_value
149+
150+
def __hash__(self) -> int:
151+
"""Hash based on the inner value."""
152+
return hash(self._inner_value)
153+
154+
def __repr__(self) -> str:
155+
"""String representation."""
156+
return f'CustomBox({self._inner_value!r})'
157+
158+
# Test basic replacement works
159+
original = CustomBox('hello')
160+
replaced = copy.replace(original, _inner_value='world')
161+
162+
assert replaced == CustomBox('world')
163+
assert replaced is not original
164+
assert replaced._inner_value == 'world' # noqa: SLF001
165+
assert isinstance(replaced, CustomBox) # Preserves exact type
166+
167+
# Test with no changes
168+
copied = copy.replace(original)
169+
assert copied == original
170+
# Unchanged objects return self per immutability principle
171+
assert copied is original
172+
173+
# Test with invalid arguments
174+
with pytest.raises(TypeError):
175+
copy.replace(original, invalid_arg=True)

0 commit comments

Comments
 (0)