Skip to content

Make @safe((TypeError, ValueError)) variant #1199

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

Merged
merged 6 commits into from
Dec 31, 2021
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ See [0Ver](https://0ver.org/).
- Enables Pattern Matching support for `IOResult` container
- Improves `hypothesis` plugin, now we detect
when type cannot be constructed and give a clear error message
- Adds the option to pass what exceptions `@safe` will handle


## 0.16.0
Expand Down
16 changes: 16 additions & 0 deletions docs/pages/result.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ use :func:`future_safe <returns.future.future_safe>` instead.
>>> str(divide(0))
'<Failure: division by zero>'

If you want to `safe` handle only a set of exceptions:

.. code:: python

>>> @safe(exceptions=(ZeroDivisionError,)) # Other exceptions will be raised
... def divide(number: int) -> float:
... if number > 10:
... raise ValueError('Too big')
... return number / number

>>> assert divide(5) == Success(1.0)
>>> assert divide(0).failure()
>>> divide(15)
Traceback (most recent call last):
...
ValueError: Too big

FAQ
---
Expand Down
70 changes: 60 additions & 10 deletions returns/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
List,
NoReturn,
Optional,
Tuple,
Type,
TypeVar,
Union,
overload,
)

from typing_extensions import ParamSpec, final
Expand Down Expand Up @@ -442,9 +444,33 @@ def failure(self) -> NoReturn:

# Decorators:

@overload
def safe(
function: Callable[_FuncParams, _ValueType],
) -> Callable[_FuncParams, ResultE[_ValueType]]:
"""Decorator to convert exception-throwing for any kind of Exception."""


@overload
def safe(
exceptions: Tuple[Type[Exception], ...],
) -> Callable[
[Callable[_FuncParams, _ValueType]],
Callable[_FuncParams, ResultE[_ValueType]],
]:
"""Decorator to convert exception-throwing just for a set of Exceptions."""


def safe( # type: ignore # noqa: WPS234, C901
function: Optional[Callable[_FuncParams, _ValueType]] = None,
exceptions: Optional[Tuple[Type[Exception], ...]] = None,
) -> Union[
Callable[_FuncParams, ResultE[_ValueType]],
Callable[
[Callable[_FuncParams, _ValueType]],
Callable[_FuncParams, ResultE[_ValueType]],
],
]:
"""
Decorator to convert exception-throwing function to ``Result`` container.

Expand All @@ -466,16 +492,40 @@ def safe(
>>> assert might_raise(1) == Success(1.0)
>>> assert isinstance(might_raise(0), Result.failure_type)

You can also use it with explicit exception types as the first argument:

.. code:: python

>>> from returns.result import Result, Success, safe

>>> @safe(exceptions=(ZeroDivisionError,))
... def might_raise(arg: int) -> float:
... return 1 / arg

>>> assert might_raise(1) == Success(1.0)
>>> assert isinstance(might_raise(0), Result.failure_type)

In this case, only exceptions that are explicitly
listed are going to be caught.

Similar to :func:`returns.io.impure_safe`
and :func:`returns.future.future_safe` decorators.
"""
@wraps(function)
def decorator(
*args: _FuncParams.args,
**kwargs: _FuncParams.kwargs,
) -> ResultE[_ValueType]:
try:
return Success(function(*args, **kwargs))
except Exception as exc:
return Failure(exc)
return decorator
def factory(
inner_function: Callable[_FuncParams, _ValueType],
inner_exceptions: Tuple[Type[Exception], ...],
) -> Callable[_FuncParams, ResultE[_ValueType]]:
@wraps(inner_function)
def decorator(*args: _FuncParams.args, **kwargs: _FuncParams.kwargs):
try:
return Success(inner_function(*args, **kwargs))
except inner_exceptions as exc:
return Failure(exc)
return decorator

if callable(function):
return factory(function, (Exception,))
if isinstance(function, tuple):
exceptions = function # type: ignore
function = None
return lambda function: factory(function, exceptions) # type: ignore
30 changes: 30 additions & 0 deletions tests/test_result/test_result_functions/test_safe.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from typing import Union

import pytest

from returns.result import Success, safe

Expand All @@ -7,6 +10,18 @@ def _function(number: int) -> float:
return number / number


@safe(exceptions=(ZeroDivisionError,))
def _function_two(number: Union[int, str]) -> float:
assert isinstance(number, int)
return number / number


@safe((ZeroDivisionError,)) # no name
def _function_three(number: Union[int, str]) -> float:
assert isinstance(number, int)
return number / number


def test_safe_success():
"""Ensures that safe decorator works correctly for Success case."""
assert _function(1) == Success(1.0)
Expand All @@ -16,3 +31,18 @@ def test_safe_failure():
"""Ensures that safe decorator works correctly for Failure case."""
failed = _function(0)
assert isinstance(failed.failure(), ZeroDivisionError)


def test_safe_failure_with_expected_error():
"""Ensures that safe decorator works correctly for Failure case."""
failed = _function_two(0)
assert isinstance(failed.failure(), ZeroDivisionError)

failed2 = _function_three(0)
assert isinstance(failed2.failure(), ZeroDivisionError)


def test_safe_failure_with_non_expected_error():
"""Ensures that safe decorator works correctly for Failure case."""
with pytest.raises(AssertionError):
_function_two('0')
118 changes: 118 additions & 0 deletions typesafety/test_result/test_safe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@
reveal_type(test) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_passing_exceptions_no_params
disable_cache: false
main: |
from returns.result import safe

@safe((ValueError,))
def test() -> int:
return 1

reveal_type(test) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.Exception]"

@safe(exceptions=(ValueError,))
def test2() -> int:
return 1

reveal_type(test2) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_composition_no_params
disable_cache: false
main: |
Expand All @@ -21,6 +39,17 @@
reveal_type(safe(test)) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_composition_passing_exceptions_no_params
disable_cache: false
main: |
from returns.result import safe

def test() -> int:
return 1

reveal_type(safe((EOFError,))(test)) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_with_args
disable_cache: false
main: |
Expand All @@ -34,6 +63,19 @@
reveal_type(test) # N: Revealed type is "def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_passing_exceptions_with_args
disable_cache: false
main: |
from typing import Optional
from returns.result import safe

@safe((ValueError, EOFError))
def test(first: int, second: Optional[str] = None, *, kw: bool = True) -> int:
return 1

reveal_type(test) # N: Revealed type is "def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_composition_with_args
disable_cache: false
main: |
Expand All @@ -46,6 +88,18 @@
reveal_type(safe(test)) # N: Revealed type is "def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_composition_passing_exceptions_with_args
disable_cache: false
main: |
from typing import Optional
from returns.result import safe

def test(first: int, second: Optional[str] = None, *, kw: bool = True) -> int:
return 1

reveal_type(safe((ValueError,))(test)) # N: Revealed type is "def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_regression333
disable_cache: false
main: |
Expand All @@ -59,6 +113,19 @@
reveal_type(send) # N: Revealed type is "def (text: builtins.str) -> returns.result.Result[Any, builtins.Exception]"


- case: safe_passing_exceptions_regression333
disable_cache: false
main: |
from returns.result import safe
from typing import Any

@safe((Exception,))
def send(text: str) -> Any:
return "test"

reveal_type(send) # N: Revealed type is "def (text: builtins.str) -> returns.result.Result[Any, builtins.Exception]"


- case: safe_regression641
disable_cache: false
main: |
Expand All @@ -72,6 +139,19 @@
reveal_type(safe(tap(Response.raise_for_status))) # N: Revealed type is "def (main.Response*) -> returns.result.Result[main.Response, builtins.Exception]"


- case: safe_passing_exceptions_regression641
disable_cache: false
main: |
from returns.result import safe
from returns.functions import tap

class Response(object):
def raise_for_status(self) -> None:
...

reveal_type(safe((EOFError,))(tap(Response.raise_for_status))) # N: Revealed type is "def (main.Response*) -> returns.result.Result[main.Response, builtins.Exception]"


- case: safe_decorator_with_args_kwargs
disable_cache: false
main: |
Expand All @@ -84,6 +164,18 @@
reveal_type(test) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_passing_exceptions_with_args_kwargs
disable_cache: false
main: |
from returns.result import safe

@safe((EOFError,))
def test(*args, **kwargs) -> int:
return 1

reveal_type(test) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_with_args_kwargs
disable_cache: false
main: |
Expand All @@ -96,6 +188,18 @@
reveal_type(test) # N: Revealed type is "def (*args: builtins.int, **kwargs: builtins.str) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_passing_exceptions_with_args_kwargs
disable_cache: false
main: |
from returns.result import safe

@safe((Exception,))
def test(*args: int, **kwargs: str) -> int:
return 1

reveal_type(test) # N: Revealed type is "def (*args: builtins.int, **kwargs: builtins.str) -> returns.result.Result[builtins.int, builtins.Exception]"


- case: safe_decorator_composition
disable_cache: false
main: |
Expand All @@ -108,3 +212,17 @@
return 1

reveal_type(test) # N: Revealed type is "def (*args: builtins.int, **kwargs: builtins.str) -> returns.io.IO[returns.result.Result*[builtins.int*, builtins.Exception]]"


- case: safe_decorator_passing_exceptions_composition
disable_cache: false
main: |
from returns.io import impure
from returns.result import safe

@impure
@safe((ValueError,))
def test(*args: int, **kwargs: str) -> int:
return 1

reveal_type(test) # N: Revealed type is "def (*args: builtins.int, **kwargs: builtins.str) -> returns.io.IO[returns.result.Result*[builtins.int*, builtins.Exception]]"