Skip to content

Commit 76db12c

Browse files
Adrian AcalaAdrian Acala
Adrian Acala
authored and
Adrian Acala
committed
feat: Add __replace__ magic method to BaseContainer for Python 3.13 support
- Implemented __replace__ method in BaseContainer to support the copy.replace() function. - Updated CHANGELOG to reflect this new feature. - Enhanced test cases to ensure __replace__ works as expected, including handling of invalid attributes.
1 parent 3169b17 commit 76db12c

File tree

4 files changed

+102
-49
lines changed

4 files changed

+102
-49
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ See [0Ver](https://0ver.org/).
1010

1111
### Features
1212

13-
- Add `__replace__` protocol support for Python 3.13's `copy.replace()`
13+
- Add `__replace__` magic method to `BaseContainer` to support `copy.replace()` function from Python 3.13
1414

1515
## 0.25.0
1616

docs/pages/container.rst

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,19 @@ This allows creating new container instances with modified internal values:
187187

188188
.. code:: python
189189
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
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
197203
198204
This is particularly useful when you need to modify the inner value of a container
199205
without using the regular container methods like ``map`` or ``bind``.

returns/primitives/container.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,16 @@ 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-
)
71+
def __replace__(self, /, inner_value: Any) -> 'BaseContainer':
72+
"""
73+
Creates a new container with replaced inner_value.
8074
81-
return self.__class__(changes['_inner_value'])
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)
8281

8382

8483
def container_equality(

tests/test_primitives/test_container/test_base_container/test_replace.py

Lines changed: 79 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,39 @@
1010

1111
# For Python < 3.13 compatibility: copy.replace doesn't exist in older Python
1212
if TYPE_CHECKING: # pragma: no cover
13-
14-
def _replace(container_instance: Any, /, **changes: Any) -> Any:
13+
# Defining a dummy replace function for type checking
14+
def _replace(container_instance: Any, /, inner_value: Any) -> Any:
1515
"""Dummy replace function for type checking."""
1616
return container_instance
1717

18+
# Assigning it to copy.replace for type checking
1819
if not hasattr(copy, 'replace'):
1920
copy.replace = _replace # type: ignore
2021

2122

2223
class _CustomClass:
24+
"""A custom class for replace testing."""
25+
2326
__slots__ = ('inner_value',)
2427

2528
def __init__(self, inner_value: str) -> None:
29+
"""Initialize instance."""
2630
self.inner_value = inner_value
2731

2832
def __eq__(self, other: object) -> bool:
33+
"""Compare with other."""
2934
if isinstance(other, _CustomClass):
3035
return self.inner_value == other.inner_value
3136
return NotImplemented
3237

3338
def __ne__(self, other: object) -> bool:
39+
"""Not equal to other."""
3440
if isinstance(other, _CustomClass):
3541
return self.inner_value != other.inner_value
3642
return NotImplemented
3743

3844
def __hash__(self) -> int:
45+
"""Return hash of the inner value."""
3946
return hash(self.inner_value)
4047

4148

@@ -51,35 +58,50 @@ def __hash__(self) -> int:
5158
),
5259
)
5360
@example(None)
54-
def test_replace(container_value: Any) -> None:
55-
"""Test __replace__ magic method."""
61+
def test_replace_method(container_value: Any) -> None:
62+
"""Ensures __replace__ magic method works as expected."""
5663
container = BaseContainer(container_value)
5764

65+
# Test with new inner_value returns a new container
5866
new_value = 'new_value'
59-
new_container = container.__replace__(_inner_value=new_value)
67+
# Test direct call to __replace__
68+
new_container = container.__replace__(new_value) # noqa: PLC2801
6069

6170
assert new_container is not container
6271
assert new_container._inner_value == new_value # noqa: SLF001
6372
assert isinstance(new_container, BaseContainer)
6473
assert type(new_container) is type(container) # noqa: WPS516
6574

6675

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
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
7281

82+
assert new_container is not container
83+
assert isinstance(new_container, BaseContainer)
7384

74-
def test_replace_invalid_attributes() -> None:
75-
"""Test __replace__ with invalid attributes."""
76-
container = BaseContainer('test')
7785

78-
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
79-
container.__replace__(invalid_attr='value')
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
8091

81-
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
82-
container.__replace__(_inner_value='new', another_attr='value')
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]
83105

84106

85107
@pytest.mark.skipif(
@@ -99,30 +121,56 @@ def test_replace_invalid_attributes() -> None:
99121
)
100122
@example(None)
101123
def test_copy_replace(container_value: Any) -> None:
102-
"""Test copy.replace with BaseContainer."""
124+
"""Ensures copy.replace works with BaseContainer."""
103125
container = BaseContainer(container_value)
104126

105-
assert copy.replace(container) is container # type: ignore[attr-defined]
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.
106131

132+
# Test with new inner_value returns a new container using copy.replace
107133
new_value = 'new_value'
108-
new_container = copy.replace(container, _inner_value=new_value) # type: ignore[attr-defined]
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]
109136

110137
assert new_container is not container
111138
assert new_container._inner_value == new_value # noqa: SLF001
112139
assert isinstance(new_container, BaseContainer)
113140
assert type(new_container) is type(container) # noqa: WPS516
114141

115142

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')
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]
123167

124-
with pytest.raises(ValueError, match='Only _inner_value can be replaced'):
125-
copy.replace(container, invalid_attr='value') # type: ignore[attr-defined]
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]
126172

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]
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)