diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c474b2..a750425c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Versions before `1.0.0` are `0Ver`-based: incremental in minor, bugfixes only are patches. See [0Ver](https://0ver.org/). +## Unreleased + +### Features + +- Add support for `copy.replace()` from Python 3.13+ by implementing `__replace__` + magic method on `BaseContainer`. This allows for creating modified copies + of immutable containers. (#1920) ## 0.25.0 diff --git a/docs/pages/container.rst b/docs/pages/container.rst index 0203887a..c1b5ef32 100644 --- a/docs/pages/container.rst +++ b/docs/pages/container.rst @@ -372,6 +372,126 @@ Well, nothing is **really** immutable in python, but you were warned. We also provide :class:`returns.primitives.types.Immutable` mixin that users can use to quickly make their classes immutable. +Creating Modified Copies of Containers +-------------------------------------- + +While containers are immutable, sometimes you need to create a modified copy +of a container with different inner values. Since Python 3.13, ``returns`` +containers support the ``copy.replace()`` function via the ``__replace__`` +magic method. + +.. code:: python + + >>> from returns.result import Success, Failure + >>> import copy, sys + >>> + >>> # Only run this example on Python 3.13+ + >>> if sys.version_info >= (3, 13): + ... # Replace the inner value of a Success container + ... original = Success(1) + ... modified = copy.replace(original, _inner_value=2) + ... assert modified == Success(2) + ... assert original is not modified # Creates a new instance + ... + ... # Works with Failure too + ... error = Failure("original error") + ... new_error = copy.replace(error, _inner_value="new error message") + ... assert new_error == Failure("new error message") + ... + ... # No changes returns the original object (due to immutability) + ... assert copy.replace(original) is original + ... else: + ... # For Python versions before 3.13, the tests would be skipped + ... pass + +.. note:: + The parameter name ``_inner_value`` is used because it directly maps to the + internal attribute of the same name in ``BaseContainer``. In the ``__replace__`` + implementation, this parameter name is specifically recognized to create a new + container instance with a modified inner value. + +.. warning:: + While ``copy.replace()`` works at runtime, it has limitations with static + type checking. If you replace an inner value with a value of a different + type, type checkers won't automatically infer the new type: + + .. code:: python + + # Example that would work in Python 3.13+: + # >>> num_container = Success(123) + # >>> str_container = copy.replace(num_container, _inner_value="string") + # >>> # Type checkers may still think this is Success[int] not Success[str] + >>> # The above is skipped in doctest as copy.replace requires Python 3.13+ + +Using ``copy.replace()`` with Custom Containers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you create your own container by extending ``BaseContainer``, it will automatically +inherit the ``__replace__`` implementation for free. This means your custom containers +will work with ``copy.replace()`` just like the built-in ones. + +.. code:: python + + >>> from returns.primitives.container import BaseContainer + >>> from typing import TypeVar, Generic + >>> import copy, sys # Requires Python 3.13+ for copy.replace + + >>> T = TypeVar('T') + >>> class MyBox(BaseContainer, Generic[T]): + ... """A custom container that wraps a value.""" + ... def __init__(self, inner_value: T) -> None: + ... super().__init__(inner_value) + ... + ... def __eq__(self, other: object) -> bool: + ... if not isinstance(other, MyBox): + ... return False + ... return self._inner_value == other._inner_value + ... + ... def __repr__(self) -> str: + ... return f"MyBox({self._inner_value!r})" + + >>> # Create a basic container + >>> box = MyBox("hello") + >>> + >>> # Test works with copy.replace only on Python 3.13+ + >>> if sys.version_info >= (3, 13): + ... new_box = copy.replace(box, _inner_value="world") + ... assert new_box == MyBox("world") + ... assert box is not new_box + ... else: + ... # For Python versions before 3.13 + ... pass + +By inheriting from ``BaseContainer``, your custom container will automatically support: + +1. The basic container operations like ``__eq__``, ``__hash__``, ``__repr__`` +2. Pickling via ``__getstate__`` and ``__setstate__`` +3. The ``copy.replace()`` functionality via ``__replace__`` +4. Immutability via the ``Immutable`` mixin + +Before Python 3.13, you can use container-specific methods to create modified copies: + +.. code:: python + + >>> from returns.result import Success, Failure, Result + >>> from typing import Any + + >>> # For Success containers, we can use .map to transform the inner value + >>> original = Success(1) + >>> modified = original.map(lambda _: 2) + >>> assert modified == Success(2) + + >>> # For Failure containers, we can use .alt to transform the inner value + >>> error = Failure("error") + >>> new_error = error.alt(lambda _: "new error") + >>> assert new_error == Failure("new error") + + >>> # For general containers without knowing success/failure state: + >>> def replace_inner_value(container: Result[Any, Any], new_value: Any) -> Result[Any, Any]: + ... """Create a new container with the same state but different inner value.""" + ... if container.is_success(): + ... return Success(new_value) + ... return Failure(new_value) .. _type-safety: diff --git a/returns/primitives/container.py b/returns/primitives/container.py index a95568ae..fc6c5d65 100644 --- a/returns/primitives/container.py +++ b/returns/primitives/container.py @@ -1,7 +1,20 @@ +import sys from abc import ABC -from typing import Any, TypeVar - -from typing_extensions import TypedDict +from typing import ( + TYPE_CHECKING, + Any, + TypedDict, + TypeVar, +) + +# Use typing_extensions for Self if Python < 3.11 OR if just type checking +# (safer for Mypy compatibility across different check versions) +if sys.version_info >= (3, 11) and not TYPE_CHECKING: + from typing import Self # pragma: py-lt-311 +else: + # This branch is taken at runtime for Py < 3.11 + # AND during static analysis (TYPE_CHECKING=True) + from typing_extensions import Self # pragma: py-gte-311 from returns.interfaces.equable import Equable from returns.primitives.hkt import Kind1 @@ -24,7 +37,15 @@ class _PickleState(TypedDict): class BaseContainer(Immutable, ABC): - """Utility class to provide all needed magic methods to the context.""" + """ + Utility class to provide all needed magic methods to the context. + + Supports standard magic methods like ``__eq__``, ``__hash__``, + ``__repr__``, and ``__getstate__`` / ``__setstate__`` for pickling. + + Since Python 3.13, also supports ``copy.replace()`` via the + ``__replace__`` magic method. + """ __slots__ = ('_inner_value',) _inner_value: Any @@ -68,6 +89,50 @@ def __setstate__(self, state: _PickleState | Any) -> None: # backward compatibility with 0.19.0 and earlier object.__setattr__(self, '_inner_value', state) + def __replace__(self, **changes: Any) -> Self: + """ + Custom implementation for copy.replace() (Python 3.13+). + + Creates a new instance of the container with specified changes. + For BaseContainer and its direct subclasses, only replacing + the '_inner_value' is generally supported via the constructor. + + Args: + **changes: Keyword arguments mapping attribute names to new values. + Currently only ``_inner_value`` is supported. + + Returns: + A new container instance with the specified replacements, or + ``self`` if no changes were provided (due to immutability). + + Raises: + TypeError: If 'changes' contains keys other than '_inner_value'. + """ + # If no changes, return self (immutability principle) + if not changes: + return self + + # Define which attributes can be replaced in the base container logic + allowed_keys = {'_inner_value'} + provided_keys = set(changes.keys()) + + # Check if any unexpected attributes were requested for change + if not provided_keys.issubset(allowed_keys): + unexpected_keys = provided_keys - allowed_keys + raise TypeError( + f'{type(self).__name__}.__replace__ received unexpected ' + f'arguments: {unexpected_keys}' + ) + + # Determine the inner value for the new container + new_inner_value = changes.get( + '_inner_value', + self._inner_value, + ) + + # Create a new instance of the *actual* container type (e.g., Success) + return type(self)(new_inner_value) + def container_equality( self: Kind1[_EqualType, Any], diff --git a/tests/test_primitives/test_replace.py b/tests/test_primitives/test_replace.py new file mode 100644 index 00000000..06a8c14d --- /dev/null +++ b/tests/test_primitives/test_replace.py @@ -0,0 +1,231 @@ +import copy +import sys +from typing import Generic, TypeVar + +import pytest + +from returns.io import IO +from returns.maybe import Nothing, Some +from returns.primitives.container import BaseContainer +from returns.result import Failure, Success + + +# Mock version of copy.replace function for Python versions below 3.13 +def _replace_mock(container, **kwargs): + """Mock replacement for copy.replace in testing.""" + if hasattr(container, '__replace__'): + return container.__replace__(**kwargs) + raise TypeError(f'{type(container).__name__} does not support __replace__') + + +# Wrapper function to use either native copy.replace or our mock +def compatible_replace(container, **kwargs): + """Use either native copy.replace or mock based on Python version.""" + if sys.version_info >= (3, 13): + return copy.replace(container, **kwargs) + return _replace_mock(container, **kwargs) + + +# Remove the skipif decorator to run these tests in all Python versions +class TestCopyReplace: + """Tests for copy.replace functionality.""" + + def test_success_copy_replace(self): + """Tests copy.replace() on Success container.""" + original = Success(42) + replaced = compatible_replace(original, _inner_value=100) + + assert replaced == Success(100) + assert replaced is not original + assert replaced._inner_value == 100 # noqa: SLF001 + + # No changes should return the same object (or equivalent) + copied = compatible_replace(original) + assert copied == original + # Unchanged objects return self per immutability principle + assert copied is original + + def test_failure_copy_replace(self): + """Tests copy.replace() on Failure container.""" + original = Failure('error') + replaced = compatible_replace(original, _inner_value='new error') + + assert replaced == Failure('new error') + assert replaced is not original + assert replaced._inner_value == 'new error' # noqa: SLF001 + + # No changes should return an equivalent object + copied = compatible_replace(original) + assert copied == original + # Unchanged objects return self per immutability principle + assert copied is original + + def test_some_copy_replace(self): + """Tests copy.replace() on Some container.""" + original = Some('value') + replaced = compatible_replace(original, _inner_value='new value') + + assert replaced == Some('new value') + assert replaced is not original + assert replaced._inner_value == 'new value' # noqa: SLF001 + + # No changes should return an equivalent object + copied = compatible_replace(original) + assert copied == original + # Unchanged objects return self per immutability principle + assert copied is original + + def test_nothing_copy_replace(self): + """Tests copy.replace() on Nothing singleton.""" + # Note: Nothing is a singleton, and its constructor ignores the + # passed value. _Nothing will just return Nothing in any case + replaced = compatible_replace(Nothing, _inner_value='something') + + assert replaced is Nothing + assert replaced._inner_value is None # noqa: SLF001 + + def test_io_copy_replace(self): + """Tests copy.replace() on IO container.""" + original = IO('data') + replaced = compatible_replace(original, _inner_value='new data') + + assert replaced == IO('new data') + assert replaced is not original + assert replaced._inner_value == 'new data' # noqa: SLF001 + + def test_type_change_with_replace(self): + """Tests copy.replace() when replacing with a different type.""" + # Test with Success container + int_success = Success(42) + str_success = compatible_replace(int_success, _inner_value='forty-two') + + assert str_success == Success('forty-two') + assert str_success is not int_success + assert isinstance(str_success._inner_value, str) # noqa: SLF001 + + # Extract container tests to separate methods + self._test_failure_type_change() + self._test_io_type_change() + + def test_invalid_arguments_in_replace(self): + """Tests that invalid arguments to copy.replace() raise TypeError.""" + original = Success(42) + + with pytest.raises(TypeError) as excinfo: + # The only valid argument for BaseContainer is _inner_value + compatible_replace(original, invalid_arg=True) + + # This code will not execute, but is moved here to satisfy linter + # The assertions will be checked after the exception is raised + + # Assertions for checking the exception details + error_message = str(excinfo.value) # noqa: WPS441 + assert 'received unexpected arguments' in error_message + assert 'invalid_arg' in error_message + + # Test with multiple invalid arguments + with pytest.raises(TypeError): + compatible_replace(original, invalid_arg1=True, invalid_arg2='test') + + # Valid argument should work normally + assert compatible_replace(original, _inner_value=100) == Success(100) + + def _test_failure_type_change(self): + """Tests type change with copy.replace() on Failure container.""" + str_failure = Failure('error') + int_failure = compatible_replace(str_failure, _inner_value=404) + + assert int_failure == Failure(404) + assert int_failure is not str_failure + assert isinstance(int_failure._inner_value, int) # noqa: SLF001 + + def _test_io_type_change(self): + """Tests type change with copy.replace() on IO container.""" + list_io = IO([1, 2, 3]) + dict_io = compatible_replace(list_io, _inner_value={'a': 1, 'b': 2}) + + assert dict_io == IO({'a': 1, 'b': 2}) + assert dict_io is not list_io + assert isinstance(dict_io._inner_value, dict) # noqa: SLF001 + + +# This class provides tests that run in all Python versions to ensure code +# coverage +class TestBaseContainer: + """Tests BaseContainer.__replace__ directly for all Python versions.""" + + def test_replace_method_basic(self): + """Tests __replace__ method directly.""" + container = Success(42) + replaced = container.__replace__(_inner_value=100) + + assert replaced == Success(100) + assert replaced is not container + + # No changes returns same instance + same_instance = compatible_replace(container) + assert same_instance is container + + def test_replace_method_invalid_args(self): + """Tests __replace__ method with invalid arguments.""" + container = Success(42) + + with pytest.raises(TypeError) as excinfo: + container.__replace__(invalid_arg=True) + + error_message = str(excinfo.value) + assert 'received unexpected arguments' in error_message + assert 'invalid_arg' in error_message + + +# Define custom box type outside of class to avoid nested class +TypeVar_Element = TypeVar('TypeVar_Element') + + +# Create CustomBox as a top-level class +class CustomBox(BaseContainer, Generic[TypeVar_Element]): + """A simple box container for testing copy.replace().""" + + def __init__(self, inner_value: TypeVar_Element) -> None: + """Initialize with a value.""" + super().__init__(inner_value) + + def __eq__(self, other: object) -> bool: + """Check equality based on type and inner value.""" + if not isinstance(other, CustomBox): + return False + return self._inner_value == other._inner_value + + def __hash__(self) -> int: + """Hash based on the inner value.""" + return hash(self._inner_value) + + def __repr__(self) -> str: + """String representation.""" + return f'CustomBox({self._inner_value!r})' + + +# Remove the skipif decorator to run these tests in all Python versions +class TestCustomContainer: + """Tests for copy.replace with custom containers.""" + + def test_custom_container(self): + """Tests copy.replace() works with custom user-defined containers.""" + # Test basic replacement works + original = CustomBox('hello') + replaced = compatible_replace(original, _inner_value='world') + + assert replaced == CustomBox('world') + assert replaced is not original + assert replaced._inner_value == 'world' # noqa: SLF001 + assert isinstance(replaced, CustomBox) # Preserves exact type + + # Test with no changes + copied = compatible_replace(original) + assert copied == original + # Unchanged objects return self per immutability principle + assert copied is original + + # Test with invalid arguments + with pytest.raises(TypeError): + compatible_replace(original, invalid_arg=True)