From 507053eb370fa6a8dda40c6d28a975f94f89d152 Mon Sep 17 00:00:00 2001 From: Muhammad Hamid Date: Thu, 29 Oct 2026 08:07:25 +0500 Subject: [PATCH 1/2] feat: implement __replace__ on BaseContainer for copy.replace (#1920) --- returns/maybe.py | 20 ++++ returns/primitives/container.py | 22 +++++ .../test_base_container/test_replace.py | 95 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/test_primitives/test_container/test_base_container/test_replace.py diff --git a/returns/maybe.py b/returns/maybe.py index 2e1abc4d5..4c6de2965 100644 --- a/returns/maybe.py +++ b/returns/maybe.py @@ -346,6 +346,26 @@ def __repr__(self): """ return '' + def __replace__(self, **changes: Any) -> '_Nothing': + """ + Implements Python 3.13 ``copy.replace`` protocol for Nothing. + + ``Nothing`` has no inner value, so replacing its value with + anything other than ``None`` raises a TypeError. + """ + if '_inner_value' in changes and changes['_inner_value'] is not None: + raise TypeError('Cannot replace _inner_value on Nothing with a non-None value') + if 'inner_value' in changes and changes['inner_value'] is not None: + raise TypeError('Cannot replace inner_value on Nothing with a non-None value') + changes.pop('_inner_value', None) + changes.pop('inner_value', None) + if changes: + unexpected = next(iter(changes)) + raise TypeError( + f'_Nothing.__replace__() got an unexpected keyword argument {unexpected!r}', + ) + return self + if not TYPE_CHECKING: # noqa: WPS604 # pragma: no branch def map(self, function): diff --git a/returns/primitives/container.py b/returns/primitives/container.py index e19b117ea..823fb63fc 100644 --- a/returns/primitives/container.py +++ b/returns/primitives/container.py @@ -8,6 +8,7 @@ from returns.primitives.types import Immutable _EqualType = TypeVar('_EqualType', bound=Equable) +_ContainerType = TypeVar('_ContainerType', bound='BaseContainer') class _PickleState(TypedDict): @@ -68,6 +69,27 @@ def __setstate__(self, state: _PickleState | Any) -> None: # backward compatibility with 0.19.0 and earlier object.__setattr__(self, '_inner_value', state) + def __replace__(self: _ContainerType, **changes: Any) -> _ContainerType: + """ + Implements Python 3.13 ``copy.replace`` protocol. + + Allows creating a new container of the same type with replaced fields. + """ + if '_inner_value' in changes and 'inner_value' in changes: + raise TypeError( + f'{self.__class__.__name__}.__replace__() got multiple values for inner value', + ) + inner_value = changes.pop( + '_inner_value', + changes.pop('inner_value', self._inner_value), + ) + if changes: + unexpected = next(iter(changes)) + raise TypeError( + f'{self.__class__.__name__}.__replace__() got an unexpected keyword argument {unexpected!r}', + ) + return self.__class__(inner_value) + def container_equality( self: Kind1[_EqualType, Any], diff --git a/tests/test_primitives/test_container/test_base_container/test_replace.py b/tests/test_primitives/test_container/test_base_container/test_replace.py new file mode 100644 index 000000000..f38a8f0c3 --- /dev/null +++ b/tests/test_primitives/test_container/test_base_container/test_replace.py @@ -0,0 +1,95 @@ +import copy +import sys +from typing import Any + +import pytest +from hypothesis import example, given +from hypothesis import strategies as st + +from returns.io import IO +from returns.maybe import Maybe, Nothing, Some +from returns.primitives.container import BaseContainer +from returns.result import Failure, Result, Success + + +@given(st.integers(), st.integers()) +def test_base_container_replace_inner_value(initial: int, new_val: int): + """Ensures that __replace__ updates inner_value properly.""" + container = BaseContainer(initial) + replaced = container.__replace__(inner_value=new_val) + + assert replaced != container or initial == new_val + assert replaced._inner_value == new_val + assert container._inner_value == initial + assert isinstance(replaced, BaseContainer) + + +@given(st.text(), st.text()) +def test_base_container_replace_underscore_inner_value(initial: str, new_val: str): + """Ensures that __replace__ accepts _inner_value keyword argument.""" + container = BaseContainer(initial) + replaced = container.__replace__(_inner_value=new_val) + + assert replaced._inner_value == new_val + assert container._inner_value == initial + + +def test_base_container_replace_no_args(): + """Ensures that __replace__ without arguments returns an equal copy.""" + container = BaseContainer(42) + replaced = container.__replace__() + + assert replaced == container + assert replaced is not container + + +def test_base_container_replace_unexpected_kwarg(): + """Ensures that unexpected kwargs raise TypeError.""" + container = BaseContainer(42) + with pytest.raises(TypeError, match="unexpected keyword argument 'invalid'"): + container.__replace__(invalid=True) + + +def test_base_container_replace_multiple_values(): + """Ensures providing both inner_value and _inner_value raises TypeError.""" + container = BaseContainer(42) + with pytest.raises(TypeError, match="multiple values for inner value"): + container.__replace__(inner_value=1, _inner_value=2) + + +@pytest.mark.parametrize( + ('container', 'new_value', 'expected'), + [ + (Success(1), 2, Success(2)), + (Failure('err1'), 'err2', Failure('err2')), + (Some('a'), 'b', Some('b')), + (IO(100), 200, IO(200)), + ], +) +def test_concrete_containers_replace(container: Any, new_value: Any, expected: Any): + """Ensures concrete containers support __replace__.""" + replaced = container.__replace__(inner_value=new_value) + assert replaced == expected + assert isinstance(replaced, type(container)) + + +def test_nothing_replace(): + """Ensures Nothing supports __replace__ when value remains None.""" + assert Nothing.__replace__() is Nothing + assert Nothing.__replace__(inner_value=None) is Nothing + assert Nothing.__replace__(_inner_value=None) is Nothing + + with pytest.raises(TypeError, match="Cannot replace inner_value on Nothing"): + Nothing.__replace__(inner_value=42) + + with pytest.raises(TypeError, match="Cannot replace _inner_value on Nothing"): + Nothing.__replace__(_inner_value=42) + + +@pytest.mark.skipif(sys.version_info < (3, 13), reason="copy.replace was added in Python 3.13") +def test_copy_replace_standard_library(): + """Ensures stdlib copy.replace works seamlessly on Python 3.13+.""" + replace = getattr(copy, "replace") + s = Success(1) + replaced = replace(s, inner_value=2) + assert replaced == Success(2) From 9664961f2891b2153149f625d6d701d9c97d3bce Mon Sep 17 00:00:00 2001 From: Muhammad Hamid Date: Thu, 29 Oct 2026 15:22:03 +0500 Subject: [PATCH 2/2] fix: resolve ruff lint issues in copy.replace implementation --- returns/maybe.py | 11 +++-- returns/primitives/container.py | 11 +++-- .../test_base_container/test_replace.py | 47 ++++++++++++------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/returns/maybe.py b/returns/maybe.py index 4c6de2965..2d6402d4d 100644 --- a/returns/maybe.py +++ b/returns/maybe.py @@ -354,15 +354,20 @@ def __replace__(self, **changes: Any) -> '_Nothing': anything other than ``None`` raises a TypeError. """ if '_inner_value' in changes and changes['_inner_value'] is not None: - raise TypeError('Cannot replace _inner_value on Nothing with a non-None value') + raise TypeError( + 'Cannot replace _inner_value on Nothing with a non-None value', + ) if 'inner_value' in changes and changes['inner_value'] is not None: - raise TypeError('Cannot replace inner_value on Nothing with a non-None value') + raise TypeError( + 'Cannot replace inner_value on Nothing with a non-None value', + ) changes.pop('_inner_value', None) changes.pop('inner_value', None) if changes: unexpected = next(iter(changes)) raise TypeError( - f'_Nothing.__replace__() got an unexpected keyword argument {unexpected!r}', + f'_Nothing.__replace__() got an unexpected keyword ' + f'argument {unexpected!r}', ) return self diff --git a/returns/primitives/container.py b/returns/primitives/container.py index 823fb63fc..42dacba41 100644 --- a/returns/primitives/container.py +++ b/returns/primitives/container.py @@ -1,5 +1,5 @@ from abc import ABC -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from typing_extensions import TypedDict @@ -8,7 +8,6 @@ from returns.primitives.types import Immutable _EqualType = TypeVar('_EqualType', bound=Equable) -_ContainerType = TypeVar('_ContainerType', bound='BaseContainer') class _PickleState(TypedDict): @@ -69,7 +68,7 @@ def __setstate__(self, state: _PickleState | Any) -> None: # backward compatibility with 0.19.0 and earlier object.__setattr__(self, '_inner_value', state) - def __replace__(self: _ContainerType, **changes: Any) -> _ContainerType: + def __replace__(self, **changes: Any) -> Self: """ Implements Python 3.13 ``copy.replace`` protocol. @@ -77,7 +76,8 @@ def __replace__(self: _ContainerType, **changes: Any) -> _ContainerType: """ if '_inner_value' in changes and 'inner_value' in changes: raise TypeError( - f'{self.__class__.__name__}.__replace__() got multiple values for inner value', + f'{self.__class__.__name__}.__replace__() got ' + f'multiple values for inner value', ) inner_value = changes.pop( '_inner_value', @@ -86,7 +86,8 @@ def __replace__(self: _ContainerType, **changes: Any) -> _ContainerType: if changes: unexpected = next(iter(changes)) raise TypeError( - f'{self.__class__.__name__}.__replace__() got an unexpected keyword argument {unexpected!r}', + f'{self.__class__.__name__}.__replace__() got an ' + f'unexpected keyword argument {unexpected!r}', ) return self.__class__(inner_value) diff --git a/tests/test_primitives/test_container/test_base_container/test_replace.py b/tests/test_primitives/test_container/test_base_container/test_replace.py index f38a8f0c3..5a52e44fe 100644 --- a/tests/test_primitives/test_container/test_base_container/test_replace.py +++ b/tests/test_primitives/test_container/test_base_container/test_replace.py @@ -1,15 +1,14 @@ import copy import sys -from typing import Any import pytest -from hypothesis import example, given +from hypothesis import given from hypothesis import strategies as st from returns.io import IO -from returns.maybe import Maybe, Nothing, Some +from returns.maybe import Nothing, Some from returns.primitives.container import BaseContainer -from returns.result import Failure, Result, Success +from returns.result import Failure, Success @given(st.integers(), st.integers()) @@ -19,25 +18,25 @@ def test_base_container_replace_inner_value(initial: int, new_val: int): replaced = container.__replace__(inner_value=new_val) assert replaced != container or initial == new_val - assert replaced._inner_value == new_val - assert container._inner_value == initial + assert replaced == BaseContainer(new_val) + assert container == BaseContainer(initial) assert isinstance(replaced, BaseContainer) @given(st.text(), st.text()) -def test_base_container_replace_underscore_inner_value(initial: str, new_val: str): +def test_replace_underscore_inner_value(initial: str, new_val: str): """Ensures that __replace__ accepts _inner_value keyword argument.""" container = BaseContainer(initial) replaced = container.__replace__(_inner_value=new_val) - assert replaced._inner_value == new_val - assert container._inner_value == initial + assert replaced == BaseContainer(new_val) + assert container == BaseContainer(initial) def test_base_container_replace_no_args(): """Ensures that __replace__ without arguments returns an equal copy.""" container = BaseContainer(42) - replaced = container.__replace__() + replaced = container.__replace__() # noqa: PLC2801 - testing the dunder assert replaced == container assert replaced is not container @@ -46,14 +45,17 @@ def test_base_container_replace_no_args(): def test_base_container_replace_unexpected_kwarg(): """Ensures that unexpected kwargs raise TypeError.""" container = BaseContainer(42) - with pytest.raises(TypeError, match="unexpected keyword argument 'invalid'"): + with pytest.raises( + TypeError, + match="unexpected keyword argument 'invalid'", + ): container.__replace__(invalid=True) def test_base_container_replace_multiple_values(): """Ensures providing both inner_value and _inner_value raises TypeError.""" container = BaseContainer(42) - with pytest.raises(TypeError, match="multiple values for inner value"): + with pytest.raises(TypeError, match='multiple values for inner value'): container.__replace__(inner_value=1, _inner_value=2) @@ -66,7 +68,7 @@ def test_base_container_replace_multiple_values(): (IO(100), 200, IO(200)), ], ) -def test_concrete_containers_replace(container: Any, new_value: Any, expected: Any): +def test_concrete_containers_replace(container, new_value, expected): """Ensures concrete containers support __replace__.""" replaced = container.__replace__(inner_value=new_value) assert replaced == expected @@ -75,21 +77,30 @@ def test_concrete_containers_replace(container: Any, new_value: Any, expected: A def test_nothing_replace(): """Ensures Nothing supports __replace__ when value remains None.""" - assert Nothing.__replace__() is Nothing + assert Nothing.__replace__() is Nothing # noqa: PLC2801 - testing the dunder assert Nothing.__replace__(inner_value=None) is Nothing assert Nothing.__replace__(_inner_value=None) is Nothing - with pytest.raises(TypeError, match="Cannot replace inner_value on Nothing"): + with pytest.raises( + TypeError, + match='Cannot replace inner_value on Nothing', + ): Nothing.__replace__(inner_value=42) - with pytest.raises(TypeError, match="Cannot replace _inner_value on Nothing"): + with pytest.raises( + TypeError, + match='Cannot replace _inner_value on Nothing', + ): Nothing.__replace__(_inner_value=42) -@pytest.mark.skipif(sys.version_info < (3, 13), reason="copy.replace was added in Python 3.13") +@pytest.mark.skipif( + sys.version_info < (3, 13), + reason='copy.replace was added in Python 3.13', +) def test_copy_replace_standard_library(): """Ensures stdlib copy.replace works seamlessly on Python 3.13+.""" - replace = getattr(copy, "replace") + replace = copy.replace s = Success(1) replaced = replace(s, inner_value=2) assert replaced == Success(2)