From 76a57eda71280e728e9ccc9a0b3c81159f244e3b Mon Sep 17 00:00:00 2001 From: jorenham Date: Tue, 27 Oct 2026 17:34:45 +0100 Subject: [PATCH] Fix variance of `Law{1,2,3}`, `Unwrappable`, `Contextable`, and `Trampoline` --- CHANGELOG.md | 2 + returns/_internal/pipeline/pipe.pyi | 8 ++- returns/interfaces/specific/reader.py | 10 ++-- returns/interfaces/unwrappable.py | 10 ++-- returns/primitives/laws.py | 46 ++++++++++----- returns/trampolines.py | 9 +-- .../test_reader/test_contextable_variance.yml | 7 +++ .../test_unwrappable_variance.yml | 7 +++ .../test_laws/test_law_variance.yml | 58 +++++++++++++++++++ .../test_trampolines/test_trampoline.yml | 9 +++ 10 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 typesafety/test_interfaces/test_specific/test_reader/test_contextable_variance.yml create mode 100644 typesafety/test_interfaces/test_unwrappable/test_unwrappable_variance.yml create mode 100644 typesafety/test_primitives/test_laws/test_law_variance.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bbded4b..0afece6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ See [0Ver](https://0ver.org/). ### Bugfixes - Fix `{Success, Failure}.swap` returning `Any` +- Fix type parameter variance of `Law{1,2,3}`, `Unwrappable`, `Contextable`, + and `Trampoline` ## 0.30.0 diff --git a/returns/_internal/pipeline/pipe.pyi b/returns/_internal/pipeline/pipe.pyi index 7efe14abd..6bd6bc92b 100644 --- a/returns/_internal/pipeline/pipe.pyi +++ b/returns/_internal/pipeline/pipe.pyi @@ -1,7 +1,9 @@ from typing import Generic, TypeVar _InstanceType = TypeVar('_InstanceType') +_InstanceType_contra = TypeVar('_InstanceType_contra', contravariant=True) _ReturnType = TypeVar('_ReturnType') +_ReturnType_co = TypeVar('_ReturnType_co', covariant=True) _PipelineStepType1 = TypeVar('_PipelineStepType1') _PipelineStepType2 = TypeVar('_PipelineStepType2') @@ -26,8 +28,8 @@ _PipelineStepType20 = TypeVar('_PipelineStepType20') class _Pipe( Generic[ - _InstanceType, - _ReturnType, + _InstanceType_contra, + _ReturnType_co, _PipelineStepType1, _PipelineStepType2, _PipelineStepType3, @@ -76,7 +78,7 @@ class _Pipe( ], /, ) -> None: ... - def __call__(self, instance: _InstanceType) -> _ReturnType: ... + def __call__(self, instance: _InstanceType_contra) -> _ReturnType_co: ... def pipe( # noqa: WPS451 function1: _PipelineStepType1, diff --git a/returns/interfaces/specific/reader.py b/returns/interfaces/specific/reader.py index 65e2b297a..a836d1fff 100644 --- a/returns/interfaces/specific/reader.py +++ b/returns/interfaces/specific/reader.py @@ -49,13 +49,15 @@ _UpdatedType = TypeVar('_UpdatedType') _ValueType = TypeVar('_ValueType') +_ValueType_co = TypeVar('_ValueType_co', covariant=True) _EnvType = TypeVar('_EnvType') +_EnvType_contra = TypeVar('_EnvType_contra', contravariant=True) _ReaderLike2Type = TypeVar('_ReaderLike2Type', bound='ReaderLike2') _ReaderLike3Type = TypeVar('_ReaderLike3Type', bound='ReaderLike3') -class Contextable(Generic[_ValueType, _EnvType]): +class Contextable(Generic[_ValueType_co, _EnvType_contra]): """ Special type we use as a base one for all callble ``Reader`` instances. @@ -75,7 +77,7 @@ class Contextable(Generic[_ValueType, _EnvType]): __slots__ = () @abstractmethod - def __call__(self, deps: _EnvType) -> _ValueType: + def __call__(self, deps: _EnvType_contra) -> _ValueType_co: """Receives one parameter, returns a value. As simple as that.""" @@ -128,7 +130,7 @@ def from_context( class CallableReader2( ReaderLike2[_FirstType, _SecondType], - Contextable[_ValueType, _EnvType], + Contextable[_ValueType_co, _EnvType_contra], ): """ Intermediate interface for ``ReaderLike2`` + ``__call__`` method. @@ -193,7 +195,7 @@ def from_context( class CallableReader3( ReaderLike3[_FirstType, _SecondType, _ThirdType], - Contextable[_ValueType, _EnvType], + Contextable[_ValueType_co, _EnvType_contra], ): """ Intermediate interface for ``ReaderLike3`` + ``__call__`` method. diff --git a/returns/interfaces/unwrappable.py b/returns/interfaces/unwrappable.py index 859a2ee17..0aeea5b71 100644 --- a/returns/interfaces/unwrappable.py +++ b/returns/interfaces/unwrappable.py @@ -1,13 +1,13 @@ from abc import abstractmethod from typing import Generic, TypeVar -_FirstType = TypeVar('_FirstType') -_SecondType = TypeVar('_SecondType') +_FirstType_co = TypeVar('_FirstType_co', covariant=True) +_SecondType_co = TypeVar('_SecondType_co', covariant=True) _UnwrappableType = TypeVar('_UnwrappableType', bound='Unwrappable') -class Unwrappable(Generic[_FirstType, _SecondType]): +class Unwrappable(Generic[_FirstType_co, _SecondType_co]): """ Represents containers that can unwrap and return its wrapped value. @@ -21,7 +21,7 @@ class Unwrappable(Generic[_FirstType, _SecondType]): __slots__ = () @abstractmethod - def unwrap(self: _UnwrappableType) -> _FirstType: + def unwrap(self: _UnwrappableType) -> _FirstType_co: """ Custom magic method to unwrap inner value from container. @@ -37,7 +37,7 @@ def unwrap(self: _UnwrappableType) -> _FirstType: """ @abstractmethod - def failure(self: _UnwrappableType) -> _SecondType: + def failure(self: _UnwrappableType) -> _SecondType_co: """ Custom magic method to unwrap inner value from the failed container. diff --git a/returns/primitives/laws.py b/returns/primitives/laws.py index 4df4b8a5c..f98414fa0 100644 --- a/returns/primitives/laws.py +++ b/returns/primitives/laws.py @@ -4,10 +4,10 @@ from returns.primitives.types import Immutable _Caps = TypeVar('_Caps') -_ReturnType = TypeVar('_ReturnType') -_TypeArgType1 = TypeVar('_TypeArgType1') -_TypeArgType2 = TypeVar('_TypeArgType2') -_TypeArgType3 = TypeVar('_TypeArgType3') +_ReturnType_co = TypeVar('_ReturnType_co', covariant=True) +_TypeArgType1_contra = TypeVar('_TypeArgType1_contra', contravariant=True) +_TypeArgType2_contra = TypeVar('_TypeArgType2_contra', contravariant=True) +_TypeArgType3_contra = TypeVar('_TypeArgType3_contra', contravariant=True) law_definition = staticmethod """Special alias to define laws as functions even inside a class.""" @@ -42,17 +42,17 @@ def name(self) -> str: @final class Law1( Law, - Generic[_TypeArgType1, _ReturnType], + Generic[_TypeArgType1_contra, _ReturnType_co], ): """Law definition for functions with a single argument.""" __slots__ = () - definition: Callable[['Law1', _TypeArgType1], _ReturnType] + definition: Callable[['Law1', _TypeArgType1_contra], _ReturnType_co] def __init__( self, - function: Callable[[_TypeArgType1], _ReturnType], + function: Callable[[_TypeArgType1_contra], _ReturnType_co], ) -> None: """Saves function of one argument to the inner state.""" super().__init__(function) @@ -61,17 +61,23 @@ def __init__( @final class Law2( Law, - Generic[_TypeArgType1, _TypeArgType2, _ReturnType], + Generic[_TypeArgType1_contra, _TypeArgType2_contra, _ReturnType_co], ): """Law definition for functions with two arguments.""" __slots__ = () - definition: Callable[['Law2', _TypeArgType1, _TypeArgType2], _ReturnType] + definition: Callable[ + ['Law2', _TypeArgType1_contra, _TypeArgType2_contra], + _ReturnType_co, + ] def __init__( self, - function: Callable[[_TypeArgType1, _TypeArgType2], _ReturnType], + function: Callable[ + [_TypeArgType1_contra, _TypeArgType2_contra], + _ReturnType_co, + ], ) -> None: """Saves function of two arguments to the inner state.""" super().__init__(function) @@ -80,22 +86,32 @@ def __init__( @final class Law3( Law, - Generic[_TypeArgType1, _TypeArgType2, _TypeArgType3, _ReturnType], + Generic[ + _TypeArgType1_contra, + _TypeArgType2_contra, + _TypeArgType3_contra, + _ReturnType_co, + ], ): """Law definition for functions with three argument.""" __slots__ = () definition: Callable[ - ['Law3', _TypeArgType1, _TypeArgType2, _TypeArgType3], - _ReturnType, + [ + 'Law3', + _TypeArgType1_contra, + _TypeArgType2_contra, + _TypeArgType3_contra, + ], + _ReturnType_co, ] def __init__( self, function: Callable[ - [_TypeArgType1, _TypeArgType2, _TypeArgType3], - _ReturnType, + [_TypeArgType1_contra, _TypeArgType2_contra, _TypeArgType3_contra], + _ReturnType_co, ], ) -> None: """Saves function of three arguments to the inner state.""" diff --git a/returns/trampolines.py b/returns/trampolines.py index e836d8328..d66eadb24 100644 --- a/returns/trampolines.py +++ b/returns/trampolines.py @@ -5,11 +5,12 @@ from typing_extensions import ParamSpec _ReturnType = TypeVar('_ReturnType') +_ReturnType_co = TypeVar('_ReturnType_co', covariant=True) _FuncParams = ParamSpec('_FuncParams') @final -class Trampoline(Generic[_ReturnType]): +class Trampoline(Generic[_ReturnType_co]): """ Represents a wrapped function call. @@ -18,13 +19,13 @@ class Trampoline(Generic[_ReturnType]): __slots__ = ('args', 'func', 'kwargs') - func: Callable[..., _ReturnType] + func: Callable[..., _ReturnType_co] args: tuple[Any, ...] kwargs: dict[str, Any] def __init__( # noqa: WPS451 self, - func: Callable[_FuncParams, _ReturnType], + func: Callable[_FuncParams, _ReturnType_co], /, # We use pos-only here to be able to store `kwargs` correctly. *args: _FuncParams.args, **kwargs: _FuncParams.kwargs, @@ -34,7 +35,7 @@ def __init__( # noqa: WPS451 self.args = args self.kwargs = kwargs - def __call__(self) -> _ReturnType: + def __call__(self) -> _ReturnType_co: """Call wrapped function with given arguments.""" return self.func(*self.args, **self.kwargs) diff --git a/typesafety/test_interfaces/test_specific/test_reader/test_contextable_variance.yml b/typesafety/test_interfaces/test_specific/test_reader/test_contextable_variance.yml new file mode 100644 index 000000000..d0076c367 --- /dev/null +++ b/typesafety/test_interfaces/test_specific/test_reader/test_contextable_variance.yml @@ -0,0 +1,7 @@ +- case: contextable_correct_cast + disable_cache: false + main: | + from returns.interfaces.specific.reader import Contextable + + first: Contextable[bool, object] + second: Contextable[int, str] = first diff --git a/typesafety/test_interfaces/test_unwrappable/test_unwrappable_variance.yml b/typesafety/test_interfaces/test_unwrappable/test_unwrappable_variance.yml new file mode 100644 index 000000000..14ac717f8 --- /dev/null +++ b/typesafety/test_interfaces/test_unwrappable/test_unwrappable_variance.yml @@ -0,0 +1,7 @@ +- case: unwrappable_correct_cast + disable_cache: false + main: | + from returns.interfaces.unwrappable import Unwrappable + + first: Unwrappable[bool, ValueError] + second: Unwrappable[int, Exception] = first diff --git a/typesafety/test_primitives/test_laws/test_law_variance.yml b/typesafety/test_primitives/test_laws/test_law_variance.yml new file mode 100644 index 000000000..a7ba9775d --- /dev/null +++ b/typesafety/test_primitives/test_laws/test_law_variance.yml @@ -0,0 +1,58 @@ +- case: law1_correct_cast + disable_cache: false + main: | + from returns.primitives.laws import Law1 + + first: Law1[int, bool] + second: Law1[bool, int] = first + + +- case: law1_wrong_cast + disable_cache: false + main: | + from returns.primitives.laws import Law1 + + first: Law1[bool, int] + second: Law1[int, bool] = first + out: | + main:4: error: Incompatible types in assignment (expression has type "Law1[bool, int]", variable has type "Law1[int, bool]") [assignment] + + +- case: law2_correct_cast + disable_cache: false + main: | + from returns.primitives.laws import Law2 + + first: Law2[int, int, bool] + second: Law2[bool, bool, int] = first + + +- case: law2_wrong_cast + disable_cache: false + main: | + from returns.primitives.laws import Law2 + + first: Law2[bool, bool, int] + second: Law2[int, int, bool] = first + out: | + main:4: error: Incompatible types in assignment (expression has type "Law2[bool, bool, int]", variable has type "Law2[int, int, bool]") [assignment] + + +- case: law3_correct_cast + disable_cache: false + main: | + from returns.primitives.laws import Law3 + + first: Law3[int, int, int, bool] + second: Law3[bool, bool, bool, int] = first + + +- case: law3_wrong_cast + disable_cache: false + main: | + from returns.primitives.laws import Law3 + + first: Law3[bool, bool, bool, int] + second: Law3[int, int, int, bool] = first + out: | + main:4: error: Incompatible types in assignment (expression has type "Law3[bool, bool, bool, int]", variable has type "Law3[int, int, int, bool]") [assignment] diff --git a/typesafety/test_trampolines/test_trampoline.yml b/typesafety/test_trampolines/test_trampoline.yml index d44dcda90..88227015e 100644 --- a/typesafety/test_trampolines/test_trampoline.yml +++ b/typesafety/test_trampolines/test_trampoline.yml @@ -56,3 +56,12 @@ reveal_type(t.func) # N: Revealed type is "def (*Any, **Any) -> int" reveal_type(t.args) # N: Revealed type is "tuple[Any, ...]" reveal_type(t.kwargs) # N: Revealed type is "dict[str, Any]" + + +- case: trampoline_correct_cast + disable_cache: false + main: | + from returns.trampolines import Trampoline + + first: Trampoline[bool] + second: Trampoline[int] = first