Skip to content

Commit 507053e

Browse files
committed
feat: implement __replace__ on BaseContainer for copy.replace (#1920)
1 parent ea76fb5 commit 507053e

3 files changed

Lines changed: 137 additions & 0 deletions

File tree

‎returns/maybe.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,26 @@ def __repr__(self):
346346
"""
347347
return '<Nothing>'
348348

349+
def __replace__(self, **changes: Any) -> '_Nothing':
350+
"""
351+
Implements Python 3.13 ``copy.replace`` protocol for Nothing.
352+
353+
``Nothing`` has no inner value, so replacing its value with
354+
anything other than ``None`` raises a TypeError.
355+
"""
356+
if '_inner_value' in changes and changes['_inner_value'] is not None:
357+
raise TypeError('Cannot replace _inner_value on Nothing with a non-None value')
358+
if 'inner_value' in changes and changes['inner_value'] is not None:
359+
raise TypeError('Cannot replace inner_value on Nothing with a non-None value')
360+
changes.pop('_inner_value', None)
361+
changes.pop('inner_value', None)
362+
if changes:
363+
unexpected = next(iter(changes))
364+
raise TypeError(
365+
f'_Nothing.__replace__() got an unexpected keyword argument {unexpected!r}',
366+
)
367+
return self
368+
349369
if not TYPE_CHECKING: # noqa: WPS604 # pragma: no branch
350370

351371
def map(self, function):

‎returns/primitives/container.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from returns.primitives.types import Immutable
99

1010
_EqualType = TypeVar('_EqualType', bound=Equable)
11+
_ContainerType = TypeVar('_ContainerType', bound='BaseContainer')
1112

1213

1314
class _PickleState(TypedDict):
@@ -68,6 +69,27 @@ def __setstate__(self, state: _PickleState | Any) -> None:
6869
# backward compatibility with 0.19.0 and earlier
6970
object.__setattr__(self, '_inner_value', state)
7071

72+
def __replace__(self: _ContainerType, **changes: Any) -> _ContainerType:
73+
"""
74+
Implements Python 3.13 ``copy.replace`` protocol.
75+
76+
Allows creating a new container of the same type with replaced fields.
77+
"""
78+
if '_inner_value' in changes and 'inner_value' in changes:
79+
raise TypeError(
80+
f'{self.__class__.__name__}.__replace__() got multiple values for inner value',
81+
)
82+
inner_value = changes.pop(
83+
'_inner_value',
84+
changes.pop('inner_value', self._inner_value),
85+
)
86+
if changes:
87+
unexpected = next(iter(changes))
88+
raise TypeError(
89+
f'{self.__class__.__name__}.__replace__() got an unexpected keyword argument {unexpected!r}',
90+
)
91+
return self.__class__(inner_value)
92+
7193

7294
def container_equality(
7395
self: Kind1[_EqualType, Any],
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import copy
2+
import sys
3+
from typing import Any
4+
5+
import pytest
6+
from hypothesis import example, given
7+
from hypothesis import strategies as st
8+
9+
from returns.io import IO
10+
from returns.maybe import Maybe, Nothing, Some
11+
from returns.primitives.container import BaseContainer
12+
from returns.result import Failure, Result, Success
13+
14+
15+
@given(st.integers(), st.integers())
16+
def test_base_container_replace_inner_value(initial: int, new_val: int):
17+
"""Ensures that __replace__ updates inner_value properly."""
18+
container = BaseContainer(initial)
19+
replaced = container.__replace__(inner_value=new_val)
20+
21+
assert replaced != container or initial == new_val
22+
assert replaced._inner_value == new_val
23+
assert container._inner_value == initial
24+
assert isinstance(replaced, BaseContainer)
25+
26+
27+
@given(st.text(), st.text())
28+
def test_base_container_replace_underscore_inner_value(initial: str, new_val: str):
29+
"""Ensures that __replace__ accepts _inner_value keyword argument."""
30+
container = BaseContainer(initial)
31+
replaced = container.__replace__(_inner_value=new_val)
32+
33+
assert replaced._inner_value == new_val
34+
assert container._inner_value == initial
35+
36+
37+
def test_base_container_replace_no_args():
38+
"""Ensures that __replace__ without arguments returns an equal copy."""
39+
container = BaseContainer(42)
40+
replaced = container.__replace__()
41+
42+
assert replaced == container
43+
assert replaced is not container
44+
45+
46+
def test_base_container_replace_unexpected_kwarg():
47+
"""Ensures that unexpected kwargs raise TypeError."""
48+
container = BaseContainer(42)
49+
with pytest.raises(TypeError, match="unexpected keyword argument 'invalid'"):
50+
container.__replace__(invalid=True)
51+
52+
53+
def test_base_container_replace_multiple_values():
54+
"""Ensures providing both inner_value and _inner_value raises TypeError."""
55+
container = BaseContainer(42)
56+
with pytest.raises(TypeError, match="multiple values for inner value"):
57+
container.__replace__(inner_value=1, _inner_value=2)
58+
59+
60+
@pytest.mark.parametrize(
61+
('container', 'new_value', 'expected'),
62+
[
63+
(Success(1), 2, Success(2)),
64+
(Failure('err1'), 'err2', Failure('err2')),
65+
(Some('a'), 'b', Some('b')),
66+
(IO(100), 200, IO(200)),
67+
],
68+
)
69+
def test_concrete_containers_replace(container: Any, new_value: Any, expected: Any):
70+
"""Ensures concrete containers support __replace__."""
71+
replaced = container.__replace__(inner_value=new_value)
72+
assert replaced == expected
73+
assert isinstance(replaced, type(container))
74+
75+
76+
def test_nothing_replace():
77+
"""Ensures Nothing supports __replace__ when value remains None."""
78+
assert Nothing.__replace__() is Nothing
79+
assert Nothing.__replace__(inner_value=None) is Nothing
80+
assert Nothing.__replace__(_inner_value=None) is Nothing
81+
82+
with pytest.raises(TypeError, match="Cannot replace inner_value on Nothing"):
83+
Nothing.__replace__(inner_value=42)
84+
85+
with pytest.raises(TypeError, match="Cannot replace _inner_value on Nothing"):
86+
Nothing.__replace__(_inner_value=42)
87+
88+
89+
@pytest.mark.skipif(sys.version_info < (3, 13), reason="copy.replace was added in Python 3.13")
90+
def test_copy_replace_standard_library():
91+
"""Ensures stdlib copy.replace works seamlessly on Python 3.13+."""
92+
replace = getattr(copy, "replace")
93+
s = Success(1)
94+
replaced = replace(s, inner_value=2)
95+
assert replaced == Success(2)

0 commit comments

Comments
 (0)