Skip to content

Commit 3169b17

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.
1 parent 410b404 commit 3169b17

File tree

4 files changed

+169
-0
lines changed

4 files changed

+169
-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__` protocol support for Python 3.13's `copy.replace()`
14+
915
## 0.25.0
1016

1117
### Features

docs/pages/container.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,29 @@ 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+
>>> from copy import replace # Python 3.13+ only
191+
>>> from returns.result import Success
192+
193+
>>> value = Success(1)
194+
>>> new_value = replace(value, _inner_value=2)
195+
>>> assert new_value == Success(2)
196+
>>> assert value != new_value
197+
198+
This is particularly useful when you need to modify the inner value of a container
199+
without using the regular container methods like ``map`` or ``bind``.
200+
201+
179202
Working with multiple containers
180203
--------------------------------
181204

returns/primitives/container.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ 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, /, **changes) -> 'BaseContainer':
72+
"""Protocol for copy.replace() function (Python 3.13+)."""
73+
if not changes:
74+
return self
75+
76+
if len(changes) > 1 or '_inner_value' not in changes:
77+
raise ValueError(
78+
'Only _inner_value can be replaced in a container',
79+
)
80+
81+
return self.__class__(changes['_inner_value'])
82+
7183

7284
def container_equality(
7385
self: Kind1[_EqualType, Any],
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
14+
def _replace(container_instance: Any, /, **changes: Any) -> Any:
15+
"""Dummy replace function for type checking."""
16+
return container_instance
17+
18+
if not hasattr(copy, 'replace'):
19+
copy.replace = _replace # type: ignore
20+
21+
22+
class _CustomClass:
23+
__slots__ = ('inner_value',)
24+
25+
def __init__(self, inner_value: str) -> None:
26+
self.inner_value = inner_value
27+
28+
def __eq__(self, other: object) -> bool:
29+
if isinstance(other, _CustomClass):
30+
return self.inner_value == other.inner_value
31+
return NotImplemented
32+
33+
def __ne__(self, other: object) -> bool:
34+
if isinstance(other, _CustomClass):
35+
return self.inner_value != other.inner_value
36+
return NotImplemented
37+
38+
def __hash__(self) -> int:
39+
return hash(self.inner_value)
40+
41+
42+
@given(
43+
st.one_of(
44+
st.integers(),
45+
st.floats(allow_nan=False),
46+
st.text(),
47+
st.booleans(),
48+
st.lists(st.text()),
49+
st.dictionaries(st.text(), st.integers()),
50+
st.builds(_CustomClass, st.text()),
51+
),
52+
)
53+
@example(None)
54+
def test_replace(container_value: Any) -> None:
55+
"""Test __replace__ magic method."""
56+
container = BaseContainer(container_value)
57+
58+
new_value = 'new_value'
59+
new_container = container.__replace__(_inner_value=new_value)
60+
61+
assert new_container is not container
62+
assert new_container._inner_value == new_value # noqa: SLF001
63+
assert isinstance(new_container, BaseContainer)
64+
assert type(new_container) is type(container) # noqa: WPS516
65+
66+
67+
def test_replace_no_changes() -> None:
68+
"""Test __replace__ with no changes."""
69+
container = BaseContainer('test')
70+
result = container.__replace__() # noqa: PLC2801
71+
assert result is container
72+
73+
74+
def test_replace_invalid_attributes() -> None:
75+
"""Test __replace__ with invalid attributes."""
76+
container = BaseContainer('test')
77+
78+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
79+
container.__replace__(invalid_attr='value')
80+
81+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
82+
container.__replace__(_inner_value='new', another_attr='value')
83+
84+
85+
@pytest.mark.skipif(
86+
sys.version_info < (3, 13),
87+
reason='copy.replace requires Python 3.13+',
88+
)
89+
@given(
90+
st.one_of(
91+
st.integers(),
92+
st.floats(allow_nan=False),
93+
st.text(),
94+
st.booleans(),
95+
st.lists(st.text()),
96+
st.dictionaries(st.text(), st.integers()),
97+
st.builds(_CustomClass, st.text()),
98+
),
99+
)
100+
@example(None)
101+
def test_copy_replace(container_value: Any) -> None:
102+
"""Test copy.replace with BaseContainer."""
103+
container = BaseContainer(container_value)
104+
105+
assert copy.replace(container) is container # type: ignore[attr-defined]
106+
107+
new_value = 'new_value'
108+
new_container = copy.replace(container, _inner_value=new_value) # type: ignore[attr-defined]
109+
110+
assert new_container is not container
111+
assert new_container._inner_value == new_value # noqa: SLF001
112+
assert isinstance(new_container, BaseContainer)
113+
assert type(new_container) is type(container) # noqa: WPS516
114+
115+
116+
@pytest.mark.skipif(
117+
sys.version_info < (3, 13),
118+
reason='copy.replace requires Python 3.13+',
119+
)
120+
def test_copy_replace_invalid_attributes() -> None:
121+
"""Test copy.replace with invalid attributes."""
122+
container = BaseContainer('test')
123+
124+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
125+
copy.replace(container, invalid_attr='value') # type: ignore[attr-defined]
126+
127+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
128+
copy.replace(container, _inner_value='new', another_attr='value') # type: ignore[attr-defined]

0 commit comments

Comments
 (0)