Skip to content

Commit 288301e

Browse files
Adrian AcalaAdrian Acala
Adrian Acala
authored and
Adrian Acala
committed
feat: Add __replace__ protocol support for Python 3.13's copy.replace()
This commit introduces support for the `__replace__` protocol in the `returns` library, which allows creating new container instances with modified internal values using Python 3.13's `copy.replace()` function. Key changes: - Implemented `__replace__` method in `BaseContainer` - Added a typed `replace()` function to support type-preserving replacements - Updated documentation to explain the new replacement functionality - Added comprehensive test coverage for the new feature The implementation ensures: - Type safety when replacing container values - Compatibility with Python 3.13's `copy.replace()` - Backward compatibility for earlier Python versions - Preservation of container type information during replacements
1 parent 3f49f35 commit 288301e

File tree

6 files changed

+516
-2
lines changed

6 files changed

+516
-2
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: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
1+
import copy
12
from abc import ABC
2-
from typing import Any, TypeVar
3+
from typing import Any, TypeVar, overload
34

45
from typing_extensions import TypedDict
56

67
from returns.interfaces.equable import Equable
7-
from returns.primitives.hkt import Kind1
8+
from returns.primitives.hkt import (
9+
Kind1,
10+
SupportsKind1,
11+
SupportsKind2,
12+
SupportsKind3,
13+
)
814
from returns.primitives.types import Immutable
915

1016
_EqualType = TypeVar('_EqualType', bound=Equable)
17+
_ContainerType = TypeVar('_ContainerType', bound='BaseContainer')
18+
_ValueTypeVar = TypeVar('_ValueTypeVar')
19+
_ErrorTypeVar = TypeVar('_ErrorTypeVar')
20+
_ThirdTypeVar = TypeVar('_ThirdTypeVar')
21+
_NewValueType = TypeVar('_NewValueType')
1122

1223

1324
class _PickleState(TypedDict):
@@ -68,6 +79,13 @@ def __setstate__(self, state: _PickleState | Any) -> None:
6879
# backward compatibility with 0.19.0 and earlier
6980
object.__setattr__(self, '_inner_value', state)
7081

82+
def __replace__(self, /, _inner_value=None) -> 'BaseContainer':
83+
"""Protocol for copy.replace() function (Python 3.13+)."""
84+
if _inner_value is None:
85+
return self
86+
87+
return self.__class__(_inner_value)
88+
7189

7290
def container_equality(
7391
self: Kind1[_EqualType, Any],
@@ -83,3 +101,85 @@ def container_equality(
83101
return bool(
84102
self._inner_value == other._inner_value, # type: ignore # noqa: SLF001
85103
)
104+
105+
106+
@overload
107+
def replace(
108+
container: SupportsKind1[_ContainerType, _ValueTypeVar],
109+
*,
110+
_inner_value: _NewValueType,
111+
) -> SupportsKind1[_ContainerType, _NewValueType]: ...
112+
113+
114+
@overload
115+
def replace(
116+
container: SupportsKind2[_ContainerType, _ValueTypeVar, _ErrorTypeVar],
117+
*,
118+
_inner_value: _NewValueType,
119+
) -> SupportsKind2[_ContainerType, _NewValueType, _ErrorTypeVar]: ...
120+
121+
122+
@overload
123+
def replace(
124+
container: SupportsKind3[
125+
_ContainerType, _ValueTypeVar, _ErrorTypeVar, _ThirdTypeVar
126+
],
127+
*,
128+
_inner_value: _NewValueType,
129+
) -> SupportsKind3[
130+
_ContainerType, _NewValueType, _ErrorTypeVar, _ThirdTypeVar
131+
]: ...
132+
133+
134+
@overload
135+
def replace(
136+
container: _ContainerType,
137+
) -> _ContainerType: ...
138+
139+
140+
def replace(
141+
container: BaseContainer,
142+
**kwargs: Any,
143+
) -> BaseContainer:
144+
"""
145+
A typed wrapper around copy.replace for container types.
146+
147+
Preserves the proper typing information for all container types
148+
in the returns library. Leverages the HKT system to ensure that
149+
type information is correctly maintained when replacing the inner value.
150+
151+
This function addresses the typing issue with the built-in copy.replace,
152+
which doesn't properly track type changes on container replacements.
153+
154+
Examples:
155+
>>> from returns.primitives.container import replace
156+
>>> container = Success(1) # Result[int, Any]
157+
>>> new_container = replace(container, _inner_value='a')
158+
>>> # Result[str, Any]
159+
160+
Args:
161+
container: Any container based on BaseContainer
162+
**kwargs: Keyword arguments to pass to copy.replace.
163+
Currently only _inner_value is supported.
164+
165+
Returns:
166+
A new container with the replaced value and preserved type information.
167+
168+
Raises:
169+
ValueError: If any attribute other than _inner_value is specified
170+
or multiple attributes.
171+
"""
172+
# Validate the kwargs - only allow _inner_value
173+
if kwargs and set(kwargs.keys()) != {'_inner_value'}:
174+
raise ValueError('Only _inner_value can be replaced')
175+
176+
# If no changes, return the original
177+
if not kwargs:
178+
return container
179+
180+
# Use copy.replace if available (Python 3.13+)
181+
if hasattr(copy, 'replace'):
182+
return copy.replace(container, **kwargs) # type: ignore
183+
184+
# For Python < 3.13, call __replace__ directly
185+
return container.__replace__(**kwargs) # type: ignore
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
# Skip the whole module if Python < 3.13
22+
pytestmark = pytest.mark.skipif(
23+
sys.version_info < (3, 13),
24+
reason='copy.replace requires Python 3.13+',
25+
)
26+
27+
28+
class _CustomClass:
29+
__slots__ = ('inner_value',)
30+
31+
def __init__(self, inner_value: str) -> None:
32+
self.inner_value = inner_value
33+
34+
def __eq__(self, other: object) -> bool:
35+
if isinstance(other, _CustomClass):
36+
return self.inner_value == other.inner_value
37+
return NotImplemented
38+
39+
def __ne__(self, other: object) -> bool:
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(self.inner_value)
46+
47+
48+
@given(
49+
st.one_of(
50+
st.integers(),
51+
st.floats(allow_nan=False),
52+
st.text(),
53+
st.booleans(),
54+
st.lists(st.text()),
55+
st.dictionaries(st.text(), st.integers()),
56+
st.builds(_CustomClass, st.text()),
57+
),
58+
)
59+
@example(None)
60+
def test_replace(container_value: Any) -> None:
61+
"""Test __replace__ magic method."""
62+
container = BaseContainer(container_value)
63+
64+
new_value = 'new_value'
65+
new_container = container.__replace__(_inner_value=new_value)
66+
67+
assert new_container is not container
68+
assert new_container._inner_value == new_value # noqa: SLF001
69+
assert isinstance(new_container, BaseContainer)
70+
assert type(new_container) is type(container) # noqa: WPS516
71+
72+
73+
def test_replace_no_changes() -> None:
74+
"""Test __replace__ with no changes."""
75+
container = BaseContainer('test')
76+
result = container.__replace__() # noqa: PLC2801
77+
assert result is container
78+
79+
80+
def test_replace_invalid_attributes() -> None:
81+
"""Test __replace__ with invalid attributes."""
82+
container = BaseContainer('test')
83+
84+
with pytest.raises(TypeError, match='got an unexpected keyword argument'):
85+
container.__replace__(invalid_attr='value')
86+
87+
with pytest.raises(TypeError, match='got an unexpected keyword argument'):
88+
container.__replace__(_inner_value='new', another_attr='value')
89+
90+
91+
@given(
92+
st.one_of(
93+
st.integers(),
94+
st.floats(allow_nan=False),
95+
st.text(),
96+
st.booleans(),
97+
st.lists(st.text()),
98+
st.dictionaries(st.text(), st.integers()),
99+
st.builds(_CustomClass, st.text()),
100+
),
101+
)
102+
@example(None)
103+
def test_copy_replace(container_value: Any) -> None:
104+
"""Test copy.replace with BaseContainer."""
105+
container = BaseContainer(container_value)
106+
107+
assert copy.replace(container) is container # type: ignore[attr-defined]
108+
109+
new_value = 'new_value'
110+
new_container = copy.replace(container, _inner_value=new_value) # type: ignore[attr-defined]
111+
112+
assert new_container is not container
113+
assert new_container._inner_value == new_value # noqa: SLF001
114+
assert isinstance(new_container, BaseContainer)
115+
assert type(new_container) is type(container) # noqa: WPS516
116+
117+
118+
def test_copy_replace_invalid_attributes() -> None:
119+
"""Test copy.replace with invalid attributes."""
120+
container = BaseContainer('test')
121+
122+
with pytest.raises(TypeError, match='got an unexpected keyword argument'):
123+
copy.replace(container, invalid_attr='value') # type: ignore[attr-defined]
124+
125+
with pytest.raises(TypeError, match='got an unexpected keyword argument'):
126+
copy.replace(container, _inner_value='new', another_attr='value') # type: ignore[attr-defined]

0 commit comments

Comments
 (0)