diff --git a/setup.cfg b/setup.cfg index adf6a1b..f0731fb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,6 +22,8 @@ classifiers = [options] include_package_data = True +install_requires = + typing_extensions;python_version<'3.10' package_dir = =src packages = find: diff --git a/src/result/__init__.py b/src/result/__init__.py index 8545430..c8dd7e5 100644 --- a/src/result/__init__.py +++ b/src/result/__init__.py @@ -1,4 +1,4 @@ -from .result import Err, Ok, OkErr, Result, UnwrapError +from .result import Err, Ok, OkErr, Result, UnwrapError, as_result __all__ = [ "Err", @@ -6,5 +6,6 @@ "OkErr", "Result", "UnwrapError", + "as_result", ] __version__ = "0.7.0" diff --git a/src/result/result.py b/src/result/result.py index da7f280..cb40cd3 100644 --- a/src/result/result.py +++ b/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]): @@ -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 diff --git a/tests/test_result.py b/tests/test_result.py index 8bb1c54..9e20f2d 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -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: @@ -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