feat: Add `__replace__` protocol support for Python 3.13's `copy.repl… · dry-python/returns@77b5252 · GitHub
Skip to content

Commit 77b5252

Browse files
Adrian AcalaAdrian Acala
authored andcommitted
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 77b5252

6 files changed

Lines changed: 571 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions

‎docs/pages/container.rst‎

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

‎returns/primitives/container.py‎

Lines changed: 112 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,95 @@ 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: Any,
142+
**kwargs: Any,
143+
) -> Any:
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+
>>> from returns.result import Success
157+
>>> container = Success(1) # Result[int, Any]
158+
>>> new_container = replace(container, _inner_value='a')
159+
>>> # Result[str, Any]
160+
>>> # Example showing unchanged container when no arguments provided
161+
>>> same_container = replace(container)
162+
>>> assert same_container == container
163+
>>> # Example of invalid replacement attribute
164+
>>> try:
165+
... replace(container, invalid_attr=1)
166+
... except ValueError:
167+
... print("Got expected ValueError")
168+
Got expected ValueError
169+
170+
Args:
171+
container: Any container based on BaseContainer
172+
**kwargs: Keyword arguments to pass to copy.replace.
173+
Currently only _inner_value is supported.
174+
175+
Returns:
176+
A new container with the replaced value and preserved type information.
177+
178+
Raises:
179+
ValueError: If any attribute other than _inner_value is specified
180+
or multiple attributes.
181+
"""
182+
# Validate the kwargs - only allow _inner_value
183+
if kwargs and set(kwargs.keys()) != {'_inner_value'}:
184+
raise ValueError('Only _inner_value can be replaced')
185+
186+
# If no changes, return the original
187+
if not kwargs:
188+
return container
189+
190+
# Use copy.replace if available (Python 3.13+)
191+
if hasattr(copy, 'replace'): # pragma: no cover-py-lt-313
192+
return copy.replace(container, **kwargs) # pragma: no cover-py-lt-313
193+
194+
# For Python < 3.13, call __replace__ directly
195+
return container.__replace__(**kwargs)
Lines changed: 126 additions & 0 deletions

0 commit comments

Comments
 (0)