Skip to content

Commit 5e23f16

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 5e23f16

File tree

4 files changed

+187
-0
lines changed

4 files changed

+187
-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: 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ 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+
"""
73+
Creates a new container with replaced attribute values.
74+
75+
Implements the protocol for the `copy.replace()` function
76+
introduced in Python 3.13.
77+
78+
The only supported attribute is '_inner_value'.
79+
"""
80+
if not changes:
81+
return self
82+
83+
if len(changes) > 1 or '_inner_value' not in changes:
84+
raise ValueError(
85+
'Only _inner_value can be replaced in a container',
86+
)
87+
88+
return self.__class__(changes['_inner_value'])
89+
7190

7291
def container_equality(
7392
self: Kind1[_EqualType, Any],
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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, /, **changes: 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(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+
new_container = container.__replace__(_inner_value=new_value)
68+
69+
assert new_container is not container
70+
assert new_container._inner_value == new_value # noqa: SLF001
71+
assert isinstance(new_container, BaseContainer)
72+
assert type(new_container) is type(container) # noqa: WPS516
73+
74+
75+
def test_replace_no_changes() -> None:
76+
"""Ensures __replace__ with no changes returns the same container."""
77+
container = BaseContainer('test')
78+
# We need to call the method directly to test this functionality
79+
result = container.__replace__() # noqa: PLC2801
80+
assert result is container
81+
82+
83+
def test_replace_invalid_attributes() -> None:
84+
"""Ensures __replace__ raises ValueError for invalid attributes."""
85+
container = BaseContainer('test')
86+
87+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
88+
container.__replace__(invalid_attr='value')
89+
90+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
91+
container.__replace__(_inner_value='new', another_attr='value')
92+
93+
94+
@pytest.mark.skipif(
95+
sys.version_info < (3, 13),
96+
reason='copy.replace requires Python 3.13+',
97+
)
98+
@given(
99+
st.one_of(
100+
st.integers(),
101+
st.floats(allow_nan=False),
102+
st.text(),
103+
st.booleans(),
104+
st.lists(st.text()),
105+
st.dictionaries(st.text(), st.integers()),
106+
st.builds(_CustomClass, st.text()),
107+
),
108+
)
109+
@example(None)
110+
def test_copy_replace(container_value: Any) -> None:
111+
"""Ensures copy.replace works with BaseContainer."""
112+
container = BaseContainer(container_value)
113+
114+
# Test with no changes returns the same container
115+
assert copy.replace(container) is container # type: ignore[attr-defined]
116+
117+
# Test with new inner_value returns a new container
118+
new_value = 'new_value'
119+
new_container = copy.replace(container, _inner_value=new_value) # type: ignore[attr-defined]
120+
121+
assert new_container is not container
122+
assert new_container._inner_value == new_value # noqa: SLF001
123+
assert isinstance(new_container, BaseContainer)
124+
assert type(new_container) is type(container) # noqa: WPS516
125+
126+
127+
@pytest.mark.skipif(
128+
sys.version_info < (3, 13),
129+
reason='copy.replace requires Python 3.13+',
130+
)
131+
def test_copy_replace_invalid_attributes() -> None:
132+
"""Ensures copy.replace raises ValueError for invalid attributes."""
133+
container = BaseContainer('test')
134+
135+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
136+
copy.replace(container, invalid_attr='value') # type: ignore[attr-defined]
137+
138+
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
139+
copy.replace(container, _inner_value='new', another_attr='value') # type: ignore[attr-defined]

0 commit comments

Comments
 (0)