diff --git a/CHANGELOG.md b/CHANGELOG.md index da9d9e518..073256bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ We follow Semantic Versions since the `0.1.0` release. ### Features +- Reintroduces the `Maybe` monad, typed! - Adds `mypy` plugin to type decorators - Complete rewrite of `Result` types - Partial API change, now `Success` and `Failure` are not types, but functions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29a6dea7d..044d2e329 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,22 @@ flake8 returns tests docs These steps are mandatory during the CI. +### Fixing pytest coverage issue + +Coverage does not work well with `pytest-mypy-plugin`, +that's why we have two phases of `pytest` run. + +If you accidentally mess things up +and see `INTERNALERROR> coverage.misc.CoverageException` in your log, +do: + +```bash +rm .coverage* +rm -rf .pytest_cache htmlcov +``` + +And it should solve it. +Then use correct test commands. ## Type checks diff --git a/returns/contrib/mypy/decorator_plugin.py b/returns/contrib/mypy/decorator_plugin.py index cc271b2fd..54b001166 100644 --- a/returns/contrib/mypy/decorator_plugin.py +++ b/returns/contrib/mypy/decorator_plugin.py @@ -26,6 +26,7 @@ _TYPED_DECORATORS = { 'returns.result.safe', 'returns.io.impure', + 'returns.maybe.maybe', } diff --git a/returns/maybe.py b/returns/maybe.py new file mode 100644 index 000000000..60535ad85 --- /dev/null +++ b/returns/maybe.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- + +from abc import ABCMeta +from functools import wraps +from inspect import iscoroutinefunction +from typing import TypeVar + +from returns.primitives.container import ( + FixableContainer, + GenericContainerOneSlot, + ValueUnwrapContainer, +) +from returns.primitives.exceptions import UnwrapFailedError + +_ValueType = TypeVar('_ValueType') + + +class Maybe( + GenericContainerOneSlot[_ValueType], + FixableContainer, + ValueUnwrapContainer, + metaclass=ABCMeta, +): + """ + Represents a result of a series of commutation that can return ``None``. + + An alternative to using exceptions or constant ``is None`` checks. + ``Maybe`` is an abstract type and should not be instantiated directly. + Instead use ``Some`` and ``Nothing``. + """ + + @classmethod + def new(cls, inner_value): + """Creates new instance of Maybe container based on a value.""" + if inner_value is None: + return _Nothing(inner_value) + return _Some(inner_value) + + +class _Nothing(Maybe[None]): # noqa: Z214 + """Represents an empty state.""" + + def __init__(self, inner_value=None): + """ + Wraps the given value in the Container. + + 'value' can only be ``None``. + """ + object.__setattr__(self, '_inner_value', inner_value) # noqa: Z462 + + def __str__(self): + """Custom str definition without state inside.""" + return '' + + def map(self, function): # noqa: A003 + """Returns the 'Nothing' instance that was used to call the method.""" + return self + + def bind(self, function): + """Returns the 'Nothing' instance that was used to call the method.""" + return self + + def fix(self, function): + """ + Applies function to the inner value. + + Applies 'function' to the contents of the 'Some' instance + and returns a new 'Some' object containing the result. + 'function' should not accept any arguments + and return a non-container result. + """ + return _Some(function()) + + def rescue(self, function): + """ + Applies 'function' to the result of a previous calculation. + + 'function' should not accept any arguments + and return Maybe a 'Nothing' or 'Some' type object. + """ + return function() + + def value_or(self, default_value): + """Returns the value if we deal with 'Some' or default if 'Nothing'.""" + return default_value + + def unwrap(self): + """Raises an exception, since it does not have a value inside.""" + raise UnwrapFailedError(self) + + def failure(self): + """Unwraps inner error value from failed container.""" + return self._inner_value + + +class _Some(Maybe[_ValueType]): + """ + Represents a calculation which has succeeded and contains the value. + + Quite similar to ``Success`` type. + """ + + def map(self, function): # noqa: A003 + """ + Applies function to the inner value. + + Applies 'function' to the contents of the 'Some' instance + and returns a new 'Some' object containing the result. + 'function' should accept a single "normal" (non-container) argument + and return a non-container result. + """ + return _Some(function(self._inner_value)) + + def bind(self, function): + """ + Applies 'function' to the result of a previous calculation. + + 'function' should accept a single "normal" (non-container) argument + and return 'Nothing' or 'Some' type object. + """ + return function(self._inner_value) + + def fix(self, function): + """Returns the 'Some' instance that was used to call the method.""" + return self + + def rescue(self, function): + """Returns the 'Some' instance that was used to call the method.""" + return self + + def value_or(self, default_value): + """Returns the value if we deal with 'Some' or default if 'Nothing'.""" + return self._inner_value + + def unwrap(self): + """Returns the unwrapped value from the inside of this container.""" + return self._inner_value + + def failure(self): + """Raises an exception, since it does not have an error inside.""" + raise UnwrapFailedError(self) + + +def Some(inner_value): # noqa: N802 + """Public unit function of protected `_Some` type.""" + return _Some(inner_value) + + +#: Public unit value of protected `_Nothing` type. +Nothing = _Nothing() + + +def maybe(function): + """ + Decorator to covert ``None`` returning function to ``Maybe`` container. + + Supports both async and regular functions. + """ + if iscoroutinefunction(function): + async def decorator(*args, **kwargs): + regular_result = await function(*args, **kwargs) + if regular_result is None: + return Nothing + return Some(regular_result) + else: + def decorator(*args, **kwargs): + regular_result = function(*args, **kwargs) + if regular_result is None: + return Nothing + return Some(regular_result) + return wraps(function)(decorator) diff --git a/returns/maybe.pyi b/returns/maybe.pyi new file mode 100644 index 000000000..b58f3492d --- /dev/null +++ b/returns/maybe.pyi @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- + +from abc import ABCMeta +from typing import Any, Callable, Coroutine, Optional, TypeVar, Union, overload + +from typing_extensions import final + +from returns.primitives.container import ( + FixableContainer, + GenericContainerOneSlot, + ValueUnwrapContainer, +) + +_ValueType = TypeVar('_ValueType') +_NewValueType = TypeVar('_NewValueType') +_ErrorType = TypeVar('_ErrorType') + + +class Maybe( + GenericContainerOneSlot[_ValueType], + FixableContainer, + ValueUnwrapContainer, + metaclass=ABCMeta, +): + @classmethod + def new(cls, inner_value: _ValueType) -> 'Maybe[_ValueType]': + ... + + def map( # noqa: A003 + self, + function: Callable[[_ValueType], _NewValueType], + ) -> 'Maybe[_NewValueType]': + ... + + def bind( + self, + function: Callable[[_ValueType], 'Maybe[_NewValueType]'], + ) -> 'Maybe[_NewValueType]': + ... + + def fix( + self, + function: Callable[[], '_NewValueType'], + ) -> 'Maybe[_NewValueType]': + ... + + def rescue( + self, + function: Callable[[], 'Maybe[_NewValueType]'], + ) -> 'Maybe[_NewValueType]': + ... + + def value_or( + self, + default_value: _NewValueType, + ) -> Union[_ValueType, _NewValueType]: + ... + + def unwrap(self) -> _ValueType: + ... + + def failure(self) -> None: + ... + + +@final +class _Nothing(Maybe[Any]): + _inner_value: None + + def __init__(self, inner_value: None = ...) -> None: # noqa: Z459 + ... + + +@final +class _Some(Maybe[_ValueType]): + _inner_value: _ValueType + + def __init__(self, inner_value: _ValueType) -> None: + ... + + +def Some(inner_value: _ValueType) -> Maybe[_ValueType]: # noqa: N802 + ... + + +Nothing: Maybe[Any] + + +@overload # noqa: Z320 +def maybe( # type: ignore + function: Callable[ + ..., + Coroutine[_ValueType, _ErrorType, Optional[_NewValueType]], + ], +) -> Callable[ + ..., + Coroutine[_ValueType, _ErrorType, Maybe[_NewValueType]], +]: + ... + + +@overload +def maybe( + function: Callable[..., Optional[_NewValueType]], +) -> Callable[..., Maybe[_NewValueType]]: + ... diff --git a/returns/result.py b/returns/result.py index 53d3cde90..8b1103ab0 100644 --- a/returns/result.py +++ b/returns/result.py @@ -155,7 +155,7 @@ def safe(function): # noqa: C901 """ Decorator to covert exception throwing function to 'Result' container. - Show be used with care, since it only catches 'Exception' subclasses. + Should be used with care, since it only catches 'Exception' subclasses. It does not catch 'BaseException' subclasses. Supports both async and regular functions. @@ -163,15 +163,15 @@ def safe(function): # noqa: C901 if iscoroutinefunction(function): async def decorator(*args, **kwargs): try: - return _Success(await function(*args, **kwargs)) + return Success(await function(*args, **kwargs)) except Exception as exc: - return _Failure(exc) + return Failure(exc) else: def decorator(*args, **kwargs): try: - return _Success(function(*args, **kwargs)) + return Success(function(*args, **kwargs)) except Exception as exc: - return _Failure(exc) + return Failure(exc) return wraps(function)(decorator) diff --git a/returns/result.pyi b/returns/result.pyi index 12beee8ab..3feab8eaa 100644 --- a/returns/result.pyi +++ b/returns/result.pyi @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from abc import ABCMeta -from typing import Any, Callable, Coroutine, NoReturn, TypeVar, Union, overload +from typing import Any, Callable, Coroutine, TypeVar, Union, overload from typing_extensions import final @@ -22,7 +22,6 @@ _NewErrorType = TypeVar('_NewErrorType') # Just aliases: _FirstType = TypeVar('_FirstType') _SecondType = TypeVar('_SecondType') -_ThirdType = TypeVar('_ThirdType') # Hacks for functions: _ReturnsResultType = TypeVar( @@ -77,10 +76,10 @@ class Result( ) -> Union[_ValueType, _NewValueType]: ... - def unwrap(self) -> Union[_ValueType, NoReturn]: + def unwrap(self) -> _ValueType: ... - def failure(self) -> Union[_ErrorType, NoReturn]: + def failure(self) -> _ErrorType: ... diff --git a/tests/test_maybe/test_maybe_bind.py b/tests/test_maybe/test_maybe_bind.py new file mode 100755 index 000000000..95f02e5dc --- /dev/null +++ b/tests/test_maybe/test_maybe_bind.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +from returns.maybe import Maybe, Nothing, Some + + +def test_bind_some(): + """Ensures that left identity works for Some container.""" + def factory(inner_value: int) -> Maybe[int]: + return Some(inner_value * 2) + + input_value = 5 + bound = Some(input_value).bind(factory) + + assert bound == factory(input_value) + assert str(bound) == '' + + +def test_bind_nothing(): + """Ensures that left identity works for Nothing container.""" + def factory(inner_value) -> Maybe[int]: + return Some(1) + + bound = Nothing.bind(factory) + + assert bound == Nothing + assert str(bound) == '' + + +def test_rescue_some(): + """Ensures that rescue works for Some container.""" + def factory() -> Maybe[int]: + return Some(10) + + bound = Some(5).rescue(factory) + + assert bound == Some(5) + + +def test_rescue_nothing(): + """Ensures that rescue works for Nothing container.""" + def factory() -> Maybe[int]: + return Some(1) + + bound = Nothing.rescue(factory) + + assert bound == Some(1) diff --git a/tests/test_maybe/test_maybe_equality.py b/tests/test_maybe/test_maybe_equality.py new file mode 100755 index 000000000..0b4ad835e --- /dev/null +++ b/tests/test_maybe/test_maybe_equality.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +import pytest + +from returns.maybe import Nothing, Some, _Nothing +from returns.primitives.exceptions import ImmutableStateError + + +def test_equality(): + """Ensures that containers can be compared.""" + assert Nothing is Nothing # noqa: Z312 + assert Nothing == _Nothing() == _Nothing(None) + assert Some(5) == Some(5) + assert Some(None) == Some(None) + + +def test_nonequality(): + """Ensures that containers are not compared to regular values.""" + assert Nothing is not None + assert Nothing != None # noqa: E711 + assert _Nothing(None) is not None + assert _Nothing(None) != None # noqa: E711 + assert Some(5) != 5 + assert Some(3) is not Some(3) + assert Some(None) != Nothing + + +def test_is_compare(): + """Ensures that `is` operator works correctly.""" + some_container = Some(1) + + assert Nothing.bind(lambda state: state) is Nothing + assert some_container.rescue(lambda: Some('fix')) is some_container + assert some_container is not Some(1) + + +def test_immutability_failure(): + """Ensures that Failure container is immutable.""" + with pytest.raises(ImmutableStateError): + Nothing._inner_state = 1 # noqa: Z441 + + with pytest.raises(ImmutableStateError): + Nothing.missing = 2 + + with pytest.raises(ImmutableStateError): + del Nothing._inner_state # type: ignore # noqa: Z420, Z441 + + with pytest.raises(AttributeError): + Nothing.missing # type: ignore # noqa: Z444 + + +def test_immutability_success(): + """Ensures that Success container is immutable.""" + with pytest.raises(ImmutableStateError): + Some(0)._inner_state = 1 # noqa: Z441 + + with pytest.raises(ImmutableStateError): + Some(1).missing = 2 + + with pytest.raises(ImmutableStateError): + del Some(0)._inner_state # type: ignore # noqa: Z420, Z441 + + with pytest.raises(AttributeError): + Some(1).missing # type: ignore # noqa: Z444 diff --git a/tests/test_maybe/test_maybe_failure.py b/tests/test_maybe/test_maybe_failure.py new file mode 100755 index 000000000..64a71683e --- /dev/null +++ b/tests/test_maybe/test_maybe_failure.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +import pytest + +from returns.maybe import Nothing, Some +from returns.primitives.exceptions import UnwrapFailedError + + +def test_unwrap_success(): + """Ensures that unwrap works for Some container.""" + with pytest.raises(UnwrapFailedError): + assert Some(1).failure() # type: ignore + + +def test_unwrap_failure(): + """Ensures that unwrap works for Nothing container.""" + assert Nothing.failure() is None # type: ignore diff --git a/tests/test_maybe/test_maybe_functions/test_maybe_decorator.py b/tests/test_maybe/test_maybe_functions/test_maybe_decorator.py new file mode 100644 index 000000000..00a114a5f --- /dev/null +++ b/tests/test_maybe/test_maybe_functions/test_maybe_decorator.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +from typing import Dict, Optional + +import pytest + +from returns.maybe import Nothing, Some, maybe + + +@maybe +def _function(hashmap: Dict[str, str], key: str) -> Optional[str]: + return hashmap.get(key, None) + + +@maybe +async def _coroutine(hashmap: Dict[str, str], key: str) -> Optional[str]: + return hashmap.get(key, None) + + +def test_maybe_some(): + """Ensures that maybe decorator works correctly for some case.""" + assert _function({'a': 'b'}, 'a') == Some('b') + + +def test_maybe_nothing(): + """Ensures that maybe decorator works correctly for nothing case.""" + assert _function({'a': 'b'}, 'c') == Nothing + + +@pytest.mark.asyncio +async def test_async_maybe_some(): + """Ensures that maybe decorator works correctly for some case.""" + container = await _coroutine({'a': 'b'}, 'a') + assert container == Some('b') + + +@pytest.mark.asyncio +async def test_async_maybe_nothing(): + """Ensures that maybe decorator works correctly for nothing case.""" + container = await _coroutine({'a': 'b'}, 'c') + assert container == Nothing diff --git a/tests/test_maybe/test_maybe_map.py b/tests/test_maybe/test_maybe_map.py new file mode 100755 index 000000000..f8b815b38 --- /dev/null +++ b/tests/test_maybe/test_maybe_map.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +from returns.maybe import Nothing, Some + + +def test_map_some(): + """Ensures that map works for Some container.""" + assert Some(5).map(str) == Some('5') + + +def test_map_nothing(): + """Ensures that map works for Nothing container.""" + assert Nothing.map(str) == Nothing + + +def test_fix_some(): + """Ensures that fix works for Some container.""" + assert Some(5).fix(str) == Some(5) + + +def test_fix_nothing(): + """Ensures that fix works for Nothing container.""" + assert Nothing.fix(lambda: 2) == Some(2) diff --git a/tests/test_maybe/test_maybe_new.py b/tests/test_maybe/test_maybe_new.py new file mode 100755 index 000000000..87a381476 --- /dev/null +++ b/tests/test_maybe/test_maybe_new.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +from returns.maybe import Maybe, Nothing, Some + + +def test_maybe_new_some(): + """Ensures that `new` works for Some container.""" + assert Maybe.new(5) == Some(5) + + +def test_maybe_new_nothing(): + """Ensures that `new` works for Nothing container.""" + assert Maybe.new(None) == Nothing diff --git a/tests/test_maybe/test_maybe_unwrap.py b/tests/test_maybe/test_maybe_unwrap.py new file mode 100755 index 000000000..82f6a0cbb --- /dev/null +++ b/tests/test_maybe/test_maybe_unwrap.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +import pytest + +from returns.maybe import Nothing, Some +from returns.primitives.exceptions import UnwrapFailedError + + +def test_unwrap_success(): + """Ensures that unwrap works for Some container.""" + assert Some(5).unwrap() == 5 + + +def test_unwrap_failure(): + """Ensures that unwrap works for Nothing container.""" + with pytest.raises(UnwrapFailedError): + assert Nothing.unwrap() diff --git a/tests/test_maybe/test_maybe_value_or.py b/tests/test_maybe/test_maybe_value_or.py new file mode 100755 index 000000000..34693ff6c --- /dev/null +++ b/tests/test_maybe/test_maybe_value_or.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +from returns.maybe import Nothing, Some + + +def test_some_value(): + """Ensures that value is fetch correctly from the Some.""" + assert Some(5).value_or(None) == 5 + + +def test_nothing_value(): + """Ensures that value is fetch correctly from the Nothing.""" + assert Nothing.value_or(default_value=1) == 1 diff --git a/tests/test_result/test_result_bind.py b/tests/test_result/test_result_bind.py index 848787cb0..37e73d5ff 100644 --- a/tests/test_result/test_result_bind.py +++ b/tests/test_result/test_result_bind.py @@ -26,7 +26,7 @@ def factory(inner_value: int) -> Result[int, str]: def test_left_identity_success(): - """Ensures that Failure identity works for Success container.""" + """Ensures that left identity works for Success container.""" def factory(inner_value: int) -> Result[int, Any]: return Success(inner_value * 2) @@ -38,7 +38,7 @@ def factory(inner_value: int) -> Result[int, Any]: def test_left_identity_failure(): - """Ensures that Failure identity works for Success container.""" + """Ensures that left identity works for Failure container.""" def factory(inner_value: int) -> Result[Any, TypeError]: return Failure(TypeError()) @@ -61,7 +61,7 @@ def factory(inner_value: int) -> Result[int, Any]: def test_rescue_failure(): - """Ensures that rescue works for Success container.""" + """Ensures that rescue works for Failure container.""" def factory(inner_value: int) -> Result[Any, float]: return Failure(float(inner_value + 1)) diff --git a/tests/test_result/test_helper_functions/test_is_successful.py b/tests/test_result/test_result_functions/test_is_successful.py similarity index 100% rename from tests/test_result/test_helper_functions/test_is_successful.py rename to tests/test_result/test_result_functions/test_is_successful.py diff --git a/tests/test_result/test_helper_functions/test_pipeline.py b/tests/test_result/test_result_functions/test_pipeline.py similarity index 100% rename from tests/test_result/test_helper_functions/test_pipeline.py rename to tests/test_result/test_result_functions/test_pipeline.py diff --git a/tests/test_result/test_helper_functions/test_safe.py b/tests/test_result/test_result_functions/test_safe.py similarity index 100% rename from tests/test_result/test_helper_functions/test_safe.py rename to tests/test_result/test_result_functions/test_safe.py diff --git a/typesafety/test_maybe_types/maybe.test b/typesafety/test_maybe_types/maybe.test new file mode 100644 index 000000000..ed5169fa8 --- /dev/null +++ b/typesafety/test_maybe_types/maybe.test @@ -0,0 +1,81 @@ +[CASE maybe_decorator_no_params] +[disable_cache] +from returns.maybe import maybe + +@maybe +def test() -> int: + return 1 + +reveal_type(test) # E: Revealed type is 'def () -> returns.maybe.Maybe[builtins.int*]' +[/CASE] + + +[CASE maybe_composition_no_params] +[disable_cache] +from returns.maybe import maybe + +def test() -> int: + return 1 + +reveal_type(maybe(test)) # E: Revealed type is 'def () -> returns.maybe.Maybe[builtins.int*]' +[/CASE] + + +[CASE maybe_decorator_with_args] +[disable_cache] +from returns.maybe import maybe + +@maybe +def test(first: int, second: str = None, *, kw: bool = True) -> int: + return 1 + +reveal_type(test) # E: Revealed type is 'def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.maybe.Maybe[builtins.int*]' +[/CASE] + + +[CASE maybe_composition_with_args] +[disable_cache] +from returns.maybe import maybe + +def test(first: int, second: str = None, *, kw: bool = True) -> int: + return 1 + +reveal_type(maybe(test)) # E: Revealed type is 'def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.maybe.Maybe[builtins.int*]' +[/CASE] + + +[CASE maybe_decorator_with_args_kwargs] +[disable_cache] +from returns.maybe import maybe + +@maybe +def test(*args, **kwargs) -> int: + return 1 + +reveal_type(test) # E: Revealed type is 'def (*args: Any, **kwargs: Any) -> returns.maybe.Maybe[builtins.int*]' +[/CASE] + + +[CASE maybe_decorator_with_typed_args_kwargs] +[disable_cache] +from returns.maybe import maybe + +@maybe +def test(*args: int, **kwargs: str) -> int: + return 1 + +reveal_type(test) # E: Revealed type is 'def (*args: builtins.int, **kwargs: builtins.str) -> returns.maybe.Maybe[builtins.int*]' +[/CASE] + + +[CASE maybe_decorator_with_optional] +[disable_cache] +from typing import Optional +from returns.maybe import maybe + +@maybe +def test() -> Optional[int]: + return 1 + +reveal_type(test) # E: Revealed type is 'def () -> returns.maybe.Maybe[builtins.int*]' +[/CASE]