Skip to content

Add __replace__ magic method to BaseContainer for copy.replace() support #2093

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
120 changes: 120 additions & 0 deletions docs/pages/container.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
73 changes: 69 additions & 4 deletions returns/primitives/container.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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],
Expand Down
Loading