"""Result Return Type / Error-Handling Implementation
This module implements an error-handling model that is heavily influenced by
the Rust programming language's Result type [1] and an article titled
"Python: better typed than you think" [2]. This model is used as the default
error-handling model in some modern programming languages (e.g. Go and Rust),
but has also been used successfully for years by functional programming
languages (e.g. Haskell).
The model revolves around the use of the Result type, which, in turn, is
always either an Ok instance or an Err instance.
[1]: https://doc.rust-lang.org/std/result
[2]: https://beepb00p.xyz/mypy-error-handling.html#coderef-throw_exc
Examples:
Consider a function which has a known error-state. If an error does occur,
we want to propagate the error to the caller somehow. We can rely on
Python's built-in exceptions to handle this propagation, like so:
def do_stuff() -> str:
# do some stuff which sets the 'status' variable
if status == SUCCESS:
return "SUCCESS!"
else:
raise SomeError("<Error Context>")
This method couples the caller to the `do_stuff()` function's
implementation, however, since the caller MUST know that `do_stuff()` might
raise a SomeError exeption before calling `do_stuff()`. Furthermore, if the
caller does NOT handle this exception, there is no warning; SomeError will
continue to propagate up the call-stack until it crashes the program.
This module attempts to offer a safer approach. Using the `Result` return
type, we might define `do_stuff()` like this:
def do_stuff() -> Result[str, SomeError]:
# do some stuff which sets the 'status' variable
if status == SUCCESS:
return Ok("SUCCESS!")
else:
e = SomeError("<Error Context>")
return Err(e)
This approach has the benefit of being type-safe: A function that calls
`do_stuff()` MUST check for errors. This is enforced by type-checking tools
like mypy, but is also made fairly obvious to the caller given the return
type of `do_stuff()`. To demonstrate, let us now consider how a client
might go about calling `do_stuff()`:
def main() -> int:
msg_result = do_stuff()
if isinstance(msg_result, Err):
e = msg_result.err()
logger.error("An error occurred while doing stuff: %r", e)
return 1
msg = msg_result.ok()
logger.info(msg)
return 0
Some might say a downside of this approach is that it requires you to write
a lot of boilerplate error-handling logic. I would argue that this is yet
another benefit of this approach, since dangerous code _should_ look
dangerous. With that said, if we just want to crash the program on error,
we could shorten the above `main()` function like so:
def main() -> int:
msg = do_stuff().unwrap() # raises SomeError if an error occurs
logger.info(msg)
return 0
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import wraps
from typing import (
Any,
Callable,
Dict,
Generic,
NoReturn,
Optional,
Tuple,
TypeVar,
Union,
)
E = TypeVar("E", bound=Exception)
T = TypeVar("T")
class _ResultMixin(ABC, Generic[T, E]):
def __bool__(self) -> NoReturn:
raise ValueError(
f"{self.__class__.__name__} object cannot be evaluated as a"
" boolean. This is probably a bug in your code. Make sure you are"
" either explicitly checking for Err results or using one of the"
f" `Result.unwrap*()` methods: {self!r}"
)
@abstractmethod
def err(self) -> Optional[E]:
"""Returns None if successful or an Exception type otherwise."""
@abstractmethod
def unwrap(self) -> T:
"""Returns real return type or raises an exception if unsuccessful."""
@abstractmethod
def unwrap_or(self, default: T) -> T:
"""Returns real return type if successful or ``default`` otherwise."""
@abstractmethod
def unwrap_or_else(self, op: Callable[[E], T]) -> T:
"""Returns real return type if successful or ``op(e)`` otherwise."""
[docs]@dataclass(frozen=True)
class Ok(_ResultMixin[T, E]):
"""Ok result type.
A value that indicates success and which stores arbitrary data for the
return value.
"""
_value: T
[docs] @staticmethod
def err() -> None: # noqa: D102
return None
[docs] def ok(self) -> T: # noqa: D102
return self._value
[docs] def unwrap(self) -> T: # noqa: D102
return self.ok()
[docs] def unwrap_or(self, default: T) -> T: # noqa: D102
return self.ok()
[docs] def unwrap_or_else(self, op: Callable[[E], T]) -> T: # noqa: D102
return self.ok()
[docs]@dataclass(frozen=True)
class Err(_ResultMixin[T, E]):
"""Err result type.
A value that signifies failure and which stores arbitrary data for the
error.
"""
_error: E
[docs] def err(self) -> E: # noqa: D102
return self._error
[docs] def unwrap(self) -> NoReturn: # noqa: D102
raise self.err()
[docs] def unwrap_or(self, default: T) -> T: # noqa: D102
return default
[docs] def unwrap_or_else(self, op: Callable[[E], T]) -> T: # noqa: D102
return op(self.err())
# The 'Result' return type is used to implement an error-handling model heavily
# influenced by that used by the Rust programming language
# (see https://doc.rust-lang.org/book/ch09-00-error-handling.html).
Result = Union[Ok[T, E], Err[T, E]]
[docs]def return_lazy_result(
func: Callable[..., Result[T, E]]
) -> Callable[..., "LazyResult[T, E]"]:
"""Converts the return type of a function from result to a "lazy" result.
In order to fetch the real return type from lazy_result, you must call
lazy_result.result() or any other valid Result method [e.g.
lazy_result.unwrap()].
This decorator is useful when dealing with functions that return
Result[None, E] (i.e. functions that are used soley for their
side-effects), since it makes it harder to ignore potential errors.
"""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> LazyResult[T, E]:
return LazyResult(func, *args, **kwargs)
return wrapper
[docs]class LazyResult(_ResultMixin[T, E]):
"""See `help(return_lazy_result)`."""
def __init__(
self, func: Callable[..., Result[T, E]], *args: Any, **kwargs: Any
) -> None:
self._func = func
self._args: Tuple[Any, ...] = args
self._kwargs: Dict[str, Any] = kwargs
self._result: Optional[Result[T, E]] = None
[docs] def result(self) -> Result[T, E]:
"""Retrieve the Result object corresponding with this LazyResult.
Calls the function corresponding with this LazyResult (this function
will only be called once, even if this method is called multiple times)
and returns the same Result returned by that function.
"""
if self._result is None:
self._result = self._func(*self._args, **self._kwargs)
return self._result
[docs] def err(self) -> Optional[E]: # noqa: D102
return self.result().err()
[docs] def unwrap(self) -> T: # noqa: D102
return self.result().unwrap()
[docs] def unwrap_or(self, default: T) -> T: # noqa: D102
return self.result().unwrap_or(default)
[docs] def unwrap_or_else(self, op: Callable[[E], T]) -> T: # noqa: D102
return self.result().unwrap_or_else(op)