From bd611927f873ab043e0f61158a8233fd2ba6a058 Mon Sep 17 00:00:00 2001 From: wouter bolsterlee Date: Thu, 23 Dec 2021 17:50:36 +0100 Subject: [PATCH] Add decorator to turn regular functions in Result returning ones 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 https://github.com/python/mypy/issues/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 #33. --- CHANGELOG.md | 2 ++ README.rst | 47 +++++++++++++++++++++++----- setup.cfg | 2 ++ src/result/__init__.py | 3 +- src/result/result.py | 64 ++++++++++++++++++++++++++++++++++++- tests/test_result.py | 71 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a6e82..8e95488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Possible log types: ## [Unreleased] +- `[added]` `as_result` decorator to turn regular functions into + `Result` returning ones (#71) - `[removed]` Drop support for Python 3.6 (#49) - `[added]` Implement `unwrap_or_else` (#74) diff --git a/README.rst b/README.rst index dd8b769..8b8970b 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,4 @@ +====== Result ====== @@ -90,7 +91,7 @@ be OK or not, without resorting to custom exceptions. API ---- +=== Creating an instance: @@ -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 @@ -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?** @@ -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 `_ can help. - - License -------- +======= MIT License 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..faf03ba 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