Skip to content

Commit

Permalink
Add decorator to turn regular functions in Result returning ones
Browse files Browse the repository at this point in the history
Add a as_result() helper to make a decorator to turn a function into one
that returns a Result: Regular return values are turned into
Ok(return_value). Raised exceptions of the specified exception type(s)
are turned into Err(exc).

The decorator is signature-preserving, except for wrapping the return
type into a Result, of course.

For type annotations, this depends on typing.ParamSpec which requires
Python 3.10+ (or use typing_extensions); see
PEP612 (https://www.python.org/dev/peps/pep-0612/).

This is currently not fully supported by Mypy; see
python/mypy#8645

Calling decorated functions works without errors from Mypy, but will
not be type-safe, i.e. it will behave as if it is calling a function
like f(*args: Any, **kwargs: Any)

Fixes rustedpy#33.
  • Loading branch information
wbolster committed Dec 23, 2021
1 parent 63c528c commit fd95812
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 10 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,8 @@ Possible log types:

## [Unreleased]

- `[added]` `as_result` decorator to turn regular functions into
`Result` returning ones (#33, 71)
- `[removed]` Drop support for Python 3.6 (#49)
- `[added]` Implement `unwrap_or_else` (#74)

Expand Down
47 changes: 40 additions & 7 deletions README.rst
@@ -1,3 +1,4 @@
======
Result
======

Expand Down Expand Up @@ -90,7 +91,7 @@ be OK or not, without resorting to custom exceptions.


API
---
===

Creating an instance:

Expand Down Expand Up @@ -199,7 +200,6 @@ returns the error value if ``Err``, otherwise it raises an ``UnwrapError``:
>>>res2.unwrap_err()
'nay'


A custom error message can be displayed instead by using ``expect`` and ``expect_err``:

.. sourcecode:: python
Expand Down Expand Up @@ -261,10 +261,45 @@ To save memory, both the ``Ok`` and ``Err`` classes are ‘slotted’,
i.e. they define ``__slots__``. This means assigning arbitrary
attributes to instances will raise ``AttributeError``.

The ``as_result()`` decorator can be used to quickly turn ‘normal’
functions into ``Result`` returning ones by specifying one or more
exception types:

FAQ
-------
.. sourcecode:: python

@as_result(ValueError, IndexError)
def f(value: int) -> int:
if value < 0:
raise ValueError
else:
return value

res = f(12) # Ok[12]
res = f(-1) # Err[ValueError(-1)]

``Exception`` (or even ``BaseException``) can be specified to create a
‘catch all’ ``Result`` return type. This is effectively the same as
``try`` followed by ``except Exception``, which is not considered good
practice in most scenarios, and hence this requires explicit opt-in.

Since ``as_result`` is a regular decorator, it can be used to wrap
existing functions (also from other libraries), albeit with a slightly
unconventional syntax (without the usual ``@``):

.. sourcecode:: python

import third_party

x = third_party.do_something(...) # could raise; who knows?

safe_do_something = as_result(Exception)(third_party.do_something)

res = safe_do_something(...) # Ok(...) or Err(...)
if isinstance(res, Ok):
print(res.value)

FAQ
===

- **Why do I get the "Cannot infer type argument" error with MyPy?**

Expand All @@ -274,9 +309,7 @@ Using ``if isinstance(res, Ok)`` instead of ``if res.is_ok()`` will help in some
Otherwise using `one of these workarounds
<https://github.com/python/mypy/issues/3889#issuecomment-325997911>`_ can help.



License
-------
=======

MIT License
2 changes: 2 additions & 0 deletions setup.cfg
Expand Up @@ -22,6 +22,8 @@ classifiers =

[options]
include_package_data = True
install_requires =
typing_extensions;python_version<'3.10'
package_dir =
=src
packages = find:
Expand Down
3 changes: 2 additions & 1 deletion src/result/__init__.py
@@ -1,10 +1,11 @@
from .result import Err, Ok, OkErr, Result, UnwrapError
from .result import Err, Ok, OkErr, Result, UnwrapError, as_result

__all__ = [
"Err",
"Ok",
"OkErr",
"Result",
"UnwrapError",
"as_result",
]
__version__ = "0.7.0"
64 changes: 63 additions & 1 deletion src/result/result.py
@@ -1,11 +1,33 @@
from __future__ import annotations

from typing import Any, Callable, Generic, NoReturn, TypeVar, Union, cast, overload
import functools
import inspect
import sys
from typing import (
Any,
Callable,
Generic,
NoReturn,
Type,
TypeVar,
Union,
cast,
overload,
)

if sys.version_info[:2] >= (3, 10):
from typing import ParamSpec
else:
from typing_extensions import ParamSpec


T = TypeVar("T", covariant=True) # Success type
E = TypeVar("E", covariant=True) # Error type
U = TypeVar("U")
F = TypeVar("F")
P = ParamSpec("P")
R = TypeVar("R")
TBE = TypeVar("TBE", bound=BaseException)


class Ok(Generic[T]):
Expand Down Expand Up @@ -287,3 +309,43 @@ def result(self) -> Result[Any, Any]:
Returns the original result.
"""
return self._result


def as_result(
*exceptions: Type[TBE],
) -> Callable[[Callable[P, R]], Callable[P, Result[R, TBE]]]:
"""
Make a decorator to turn a function into one that returns a ``Result``.
Regular return values are turned into ``Ok(return_value)``. Raised
exceptions of the specified exception type(s) are turned into ``Err(exc)``.
"""
# Note: type annotations for signature-preserving decorators via ParamSpec
# are currently not fully supported by Mypy 0.930; see
# https://github.com/python/mypy/issues/8645
#
# The ‘type: ignore’ comments below are for our own linting purposes.
# Calling code works without errors from Mypy, but will also not be
# type-safe, i.e. it will behave as if it is calling a function like
# f(*args: Any, **kwargs: Any)
if not exceptions or not all(
inspect.isclass(exception) and issubclass(exception, BaseException)
for exception in exceptions
):
raise TypeError("as_result() requires one or more exception types")

def decorator(f: Callable[P, R]) -> Callable[P, Result[R, TBE]]:
"""
Decorator to turn a function into one that returns a ``Result``.
"""

@functools.wraps(f)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, TBE]:
try:
return Ok(f(*args, **kwargs))
except exceptions as exc:
return Err(exc)

return wrapper

return decorator
71 changes: 70 additions & 1 deletion tests/test_result.py
Expand Up @@ -2,7 +2,7 @@

import pytest

from result import Err, Ok, OkErr, Result, UnwrapError
from result import Err, Ok, OkErr, Result, UnwrapError, as_result


def test_ok_factories() -> None:
Expand Down Expand Up @@ -197,3 +197,72 @@ def test_slots() -> None:
o.some_arbitrary_attribute = 1 # type: ignore[attr-defined]
with pytest.raises(AttributeError):
n.some_arbitrary_attribute = 1 # type: ignore[attr-defined]


def test_as_result() -> None:
"""
``as_result()`` turns functions into ones that return a ``Result``.
"""

@as_result(ValueError)
def good(value: int) -> int:
return value

@as_result(IndexError, ValueError)
def bad(value: int) -> int:
raise ValueError

good_result = good(123)
bad_result = bad(123)

assert isinstance(good_result, Ok)
assert good_result.unwrap() == 123
assert isinstance(bad_result, Err)
assert isinstance(bad_result.unwrap_err(), ValueError)


def test_as_result_other_exception() -> None:
"""
``as_result()`` only catches the specified exceptions.
"""

@as_result(ValueError)
def f() -> int:
raise IndexError

with pytest.raises(IndexError):
f()


def test_as_result_invalid_usage() -> None:
"""
Invalid use of ``as_result()`` raises reasonable errors.
"""
message = "requires one or more exception types"

with pytest.raises(TypeError, match=message):

@as_result() # No exception types specified
def f() -> int:
return 1

with pytest.raises(TypeError, match=message):

@as_result("not an exception type") # type: ignore[arg-type]
def g() -> int:
return 1


def test_as_result_type_checking() -> None:
"""
The ``as_result()`` is a signature-preserving decorator.
"""

@as_result(ValueError)
def f(a: int) -> int:
return a

expected = {"a": "int", "return": "int"}
assert f.__annotations__ == expected
res = f(123)
assert res.ok() == 123

0 comments on commit fd95812

Please sign in to comment.