/explicitor

Rust-like explicit errors for typed Python

Primary LanguagePythonMIT LicenseMIT

Explicitor

Rust-like explicit errors for typed Python.

Why this exists

Exceptions suck in many statically typed programming languages. From a function signature it is usually impossible to know whether the function can throw an error. The Rust programming language solves this problem quite elegantly with the Result enum, which allows you to use the type checker to reason about correct error handling. An additional benefit is that error handling is much more elegant than try ... except clauses.

The goal of this package is to emulate in Python the Rust way of dealing with errors. The implementation is heavily inspired by the contents of this blogpost by Justin Ellis. The philosophy is that instead of raising exceptions, exceptions are returned wrapped in an Err class, which is a variant of the Result type. Hence, a function that can get into an error state returns a Result which can be clearly indicated in a function signature. This can be valuable when one uses static type analysis tools like mypy.

Installation

pip install explicitor

How to use it

There are 3 types defined: Ok, Err which are the two variants of Result.

Instead of defining a function that can raise an exception, for example:

def function(x: int) -> int:
    if x < 10:
        raise ValueError("too small")
    return x + 1

you would define your function as

from explicitor import Ok, Err, Result


def function(x: int) -> Result[int, ValueError]:
    if x < 10:
        return Err(ValueError("too small"))
    return Ok(x + 1)

To deal with the error, there's a number of methods implemented on Err and Ok:

result_1 = function(20)  # should be an Ok
result_2 = function(5)  # should be an Err


# unwrap: returns the wrapped value of an Ok or raises the error contained by an Err
assert result_1.unwrap() == 21
try:
    result_2.unwrap()
except ValueError as e:
    assert str(e) == "too small"


# expect: same as unwrap, but raises a custom exception with custom message
assert result_1.expect("bla") == 21
try:
    result_2.expect("bla")
except Exception as e:
    assert str(e) == "bla"


# is_ok and is_err: checks what variant it is and returns a boolean
assert result_1.is_ok()
assert result_2.is_err()


# unwrap_or: if Ok, unwrap, if Err, return the value supplied
assert result_1.unwrap_or(2) == 21
assert result_2.unwrap_or(2) == 2


# unwrap_or_else: if Ok, unwrap, if Err, apply a function to the wrapped error and return it
assert result_1.unwrap_or_else(lambda x: str(x)) == 21
assert result_2.unwrap_or_else(lambda x: str(x)) == "too small"


# unwrap_err: the opposite of unwrap, raise an exception if Ok, return the wrapped Exception if Err
try:
    result_1.unwrap_err()
except Exception as e:
    assert str(e) == "Unexpected Ok(21)"
assert str(result_2.unwrap_err()) == "too small"


# expect_err: the opposite of expect, raise an exception with custom message if Ok, return the wrapped Exception if Err
try:
    result_1.expect_err("custom message")
except Exception as e:
    assert str(e) == "custom message"
assert str(result_2.expect_err("custom message")) == "too small"


# map: apply a function to the wrapped value in Ok, do nothing if Err
assert result_1.map(lambda x: x + 1) == Ok(22)
assert result_2.map(lambda x: x + 1) == Err(ValueError("too small"))

Finally, in Python 3.10+, it's possible to use Rust-like match statements for dealing with the errors:

match function(20):
    case Ok(v): print("The value is", v)
    case Err(e): print("The error is", str(e))

Suppose a function does raise exceptions, it is possible to use the unraise decorator to catch these exceptions and return them wrapped in an Err instead. If no exception is raised, the decorator wraps the output of the function in an Ok.

from typing import Callable
from explicitor import unraise


def function(x: int) -> int:
    if x < 10:
        raise ValueError("too small")
    return x + 1


safe_func: Callable[[int], Result[int, ValueError]] = unraise(function)

What does not work

In Rust, there is the handy ? operator, which allows you to quickly return an Err from a function or continue if the result is an Ok. In Python, the equivalent might be:

result: Result = function()

if result.is_err():
    return result

...

There are also a lot of additional methods available on Rust's Ok and Err which are thus far not implemented, particularly their translations to Option.

Personal experimentation has shown that mypy may not always deal with these types or the match expression as expected, certainly I would not consider them as safe as the Rust equivalents. If you know how to improve the implementation so that it works better with mypy, and/or makes the behavior more Rust-like, feel free to make a PR.

Contributing

  1. Fork and clone the repo
  2. Create a virtual environment
  3. poetry install