Skip to content

Commit 0ed959b

Browse files
Adrian AcalaAdrian Acala
authored andcommitted
Add __replace__ magic method to BaseContainer for copy.replace() support
- Implemented the __replace__ method in BaseContainer to allow for the creation of new container instances with modified internal values, in line with the copy.replace() function introduced in Python 3.13. - Updated documentation to reflect this new feature and provided usage examples. - 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.
1 parent 3f49f35 commit 0ed959b

File tree

4 files changed

+222
-0
lines changed

4 files changed

+222
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ incremental in minor, bugfixes only are patches.
66
See [0Ver](https://0ver.org/).
77

88

9+
## UNRELEASED
10+
11+
### Features
12+
13+
- Add `__replace__` magic method to `BaseContainer` to support `copy.replace()` function from Python 3.13
14+
915
## 0.25.0
1016

1117
### Features

docs/pages/container.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,35 @@ There are many other constructors!
176176
Check out concrete types and their interfaces.
177177

178178

179+
Replacing values in a container
180+
-------------------------------
181+
182+
Starting from Python 3.13, the standard library provides
183+
a ``copy.replace()`` function that works with objects that implement
184+
the ``__replace__`` protocol. All containers in ``returns`` implement this protocol.
185+
186+
This allows creating new container instances with modified internal values:
187+
188+
.. code:: python
189+
190+
>>> import sys
191+
>>> # The following example requires Python 3.13+
192+
>>> if sys.version_info >= (3, 13):
193+
... from copy import replace
194+
... from returns.result import Success
195+
...
196+
... value = Success(1)
197+
... new_value = replace(value, _inner_value=2)
198+
... assert new_value == Success(2)
199+
... assert value != new_value
200+
... else:
201+
... # Skip this example for Python < 3.13
202+
... pass
203+
204+
This is particularly useful when you need to modify the inner value of a container
205+
without using the regular container methods like ``map`` or ``bind``.
206+
207+
179208
Working with multiple containers
180209
--------------------------------
181210

returns/primitives/container.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ def __setstate__(self, state: _PickleState | Any) -> None:
6868
# backward compatibility with 0.19.0 and earlier
6969
object.__setattr__(self, '_inner_value', state)
7070

71+
def __replace__(self, /, inner_value: Any) -> 'BaseContainer':
72+
"""
73+
Creates a new container with replaced inner_value.
74+
75+
Implements the protocol for the `copy.replace()` function
76+
introduced in Python 3.13.
77+
78+
The only supported argument is 'inner_value'.
79+
"""
80+
return self.__class__(inner_value)
81+
7182

7283
def container_equality(
7384
self: Kind1[_EqualType, Any],
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import copy
2+
import sys
3+
from typing import TYPE_CHECKING, Any
4+
5+
import pytest
6+
from hypothesis import example, given
7+
from hypothesis import strategies as st
8+
9+
from returns.primitives.container import BaseContainer
10+
11+
# For Python < 3.13 compatibility: copy.replace doesn't exist in older Python
12+
if TYPE_CHECKING: # pragma: no cover
13+
# Defining a dummy replace function for type checking
14+
def _replace(container_instance: Any, /, inner_value: Any) -> Any:
15+
"""Dummy replace function for type checking."""
16+
return container_instance
17+
18+
# Assigning it to copy.replace for type checking
19+
if not hasattr(copy, 'replace'):
20+
copy.replace = _replace # type: ignore
21+
22+
23+
class _CustomClass:
24+
"""A custom class for replace testing."""
25+
26+
__slots__ = ('inner_value',)
27+
28+
def __init__(self, inner_value: str) -> None:
29+
"""Initialize instance."""
30+
self.inner_value = inner_value
31+
32+
def __eq__(self, other: object) -> bool:
33+
"""Compare with other."""
34+
if isinstance(other, _CustomClass):
35+
return self.inner_value == other.inner_value
36+
return NotImplemented
37+
38+
def __ne__(self, other: object) -> bool:
39+
"""Not equal to other."""
40+
if isinstance(other, _CustomClass):
41+
return self.inner_value != other.inner_value
42+
return NotImplemented
43+
44+
def __hash__(self) -> int:
45+
"""Return hash of the inner value."""
46+
return hash(self.inner_value)
47+
48+
49+
@given(
50+
st.one_of(
51+
st.integers(),
52+
st.floats(allow_nan=False),
53+
st.text(),
54+
st.booleans(),
55+
st.lists(st.text()),
56+
st.dictionaries(st.text(), st.integers()),
57+
st.builds(_CustomClass, st.text()),
58+
),
59+
)
60+
@example(None)
61+
def test_replace_method(container_value: Any) -> None:
62+
"""Ensures __replace__ magic method works as expected."""
63+
container = BaseContainer(container_value)
64+
65+
# Test with new inner_value returns a new container
66+
new_value = 'new_value'
67+
# Test direct call to __replace__
68+
new_container = container.__replace__(new_value) # noqa: PLC2801
69+
70+
assert new_container is not container
71+
assert new_container._inner_value == new_value # noqa: SLF001
72+
assert isinstance(new_container, BaseContainer)
73+
assert type(new_container) is type(container) # noqa: WPS516
74+
75+
76+
def test_base_container_replace_direct_call(container):
77+
"""Test direct call to the __replace__ method."""
78+
new_value = 'new_value'
79+
# Test direct call to __replace__
80+
new_container = container.__replace__(new_value) # noqa: PLC2801
81+
82+
assert new_container is not container
83+
assert isinstance(new_container, BaseContainer)
84+
85+
86+
def test_base_container_replace_direct_call_invalid_args(container):
87+
"""Test direct call with invalid arguments."""
88+
# Direct call with no args should fail
89+
with pytest.raises(TypeError):
90+
container.__replace__() # noqa: PLC2801
91+
92+
# Direct call with keyword args matching the name is allowed by Python,
93+
# even with /.
94+
# If uncommented, it should pass as Python allows this.
95+
# Removing commented test case for
96+
# `container.__replace__(inner_value='new')`
97+
98+
# Direct call with extra positional args should fail
99+
with pytest.raises(TypeError):
100+
container.__replace__('new', 'extra') # noqa: PLC2801
101+
102+
# Direct call with unexpected keyword args should fail
103+
with pytest.raises(TypeError):
104+
container.__replace__(other_kwarg='value') # type: ignore[attr-defined]
105+
106+
107+
@pytest.mark.skipif(
108+
sys.version_info < (3, 13),
109+
reason='copy.replace requires Python 3.13+',
110+
)
111+
@given(
112+
st.one_of(
113+
st.integers(),
114+
st.floats(allow_nan=False),
115+
st.text(),
116+
st.booleans(),
117+
st.lists(st.text()),
118+
st.dictionaries(st.text(), st.integers()),
119+
st.builds(_CustomClass, st.text()),
120+
),
121+
)
122+
@example(None)
123+
def test_copy_replace(container_value: Any) -> None:
124+
"""Ensures copy.replace works with BaseContainer."""
125+
container = BaseContainer(container_value)
126+
127+
# Test with no changes is not directly possible via copy.replace with this
128+
# __replace__ implementation.
129+
# The copy.replace function itself handles the no-change case if the
130+
# object supports it, but our __replace__ requires a value.
131+
132+
# Test with new inner_value returns a new container using copy.replace
133+
new_value = 'new_value'
134+
# copy.replace calls __replace__ with the new value as a positional arg
135+
new_container = copy.replace(container, new_value) # type: ignore[attr-defined]
136+
137+
assert new_container is not container
138+
assert new_container._inner_value == new_value # noqa: SLF001
139+
assert isinstance(new_container, BaseContainer)
140+
assert type(new_container) is type(container) # noqa: WPS516
141+
142+
143+
def test_base_container_replace_via_copy_no_changes(container_value):
144+
"""Test copy.replace with no actual change in value."""
145+
container = BaseContainer(container_value)
146+
147+
# Test with no changes is not directly possible via copy.replace with this
148+
# __replace__ implementation.
149+
# The copy.replace function itself handles the no-change case if the
150+
# object supports it, but our __replace__ requires a value.
151+
# If copy.replace is called with the same value, it should work.
152+
new_container = copy.replace(container, inner_value=container_value)
153+
154+
assert new_container is not container # A new instance should be created
155+
156+
157+
def test_base_container_replace_via_copy_invalid_args(container):
158+
"""Test copy.replace with invalid arguments."""
159+
# copy.replace converts the keyword 'inner_value' to a positional arg
160+
# for __replace__(self, /, inner_value), so this is valid.
161+
# Removing commented out test case for copy.replace with inner_value kwarg
162+
163+
# However, passing other keyword arguments will fail because __replace__
164+
# doesn't accept them.
165+
with pytest.raises(TypeError):
166+
copy.replace(container, other_kwarg='value') # type: ignore[attr-defined]
167+
168+
# copy.replace should raise TypeError if extra positional arguments
169+
# are passed.
170+
with pytest.raises(TypeError):
171+
copy.replace(container, 'new', 'extra') # type: ignore[attr-defined]
172+
173+
# copy.replace should raise TypeError if no value is passed
174+
# (our __replace__ requires one).
175+
with pytest.raises(TypeError):
176+
copy.replace(container) # type: ignore[attr-defined]

0 commit comments

Comments
 (0)