Skip to content

rustedpy/result

Repository files navigation

Result

GitHub Workflow Status (branch) Coverage

A simple Result type for Python 3inspired by Rust,fully type annotated.

Installation

Latest release:

$ pip install result

Latest GitHubmainbranch version:

$ pip install git+https://github /rustedpy/result

Summary

The idea is that a result value can be eitherOk(value)or Err(error),with a way to differentiate between the two.Okand Errare both classes encapsulating an arbitrary value.Result[T, E] is a generic type alias fortyping.Union[Ok[T], Err[E]].It will change code like this:

defget_user_by_email(email:str)->Tuple[Optional[User],Optional[str]]:
"""
Return the user instance or an error message.
"""
ifnotuser_exists(email):
returnNone,'User does not exist'
ifnotuser_active(email):
returnNone,'User is inactive'
user=get_user(email)
returnuser,None

user,reason=get_user_by_email('ueli@example ')
ifuserisNone:
raiseRuntimeError('Could not fetch user: %s'%reason)
else:
do_something(user)

To something like this:

fromresultimportOk,Err,Result,is_ok,is_err

defget_user_by_email(email:str)->Result[User,str]:
"""
Return the user instance or an error message.
"""
ifnotuser_exists(email):
returnErr('User does not exist')
ifnotuser_active(email):
returnErr('User is inactive')
user=get_user(email)
returnOk(user)

user_result=get_user_by_email(email)
ifis_ok(user_result):
# type(user_result.ok_value) == User
do_something(user_result.ok_value)
else:
# type(user_result.err_value) == str
raiseRuntimeError('Could not fetch user: %s'%user_result.err_value)

Note that.ok_valueexists only on an instance ofOkand .err_valueexists only on an instance ofErr.

And if you're using Python version3.10or later, you can use the elegantmatchstatement as well:

fromresultimportResult,Ok,Err

defdivide(a:int,b:int)->Result[int,str]:
ifb==0:
returnErr("Cannot divide by zero")
returnOk(a//b)

values=[(10,0), (10,5)]
fora,binvalues:
matchdivide(a,b):
caseOk(value):
print(f "{a}//{b}=={value}")
caseErr(e):
print(e)

Not all methods (https://doc.rust-lang.org/std/result/enum.Result.html) have been implemented, only the ones that make sense in the Python context. All of this in a package allowing easier handling of values that can be OK or not, without resorting to custom exceptions.

API

Auto generated API docs are also available at ./docs/README.md.

Creating an instance:

>>>fromresultimportOk,Err
>>>res1=Ok('yay')
>>>res2=Err('nay')

Checking whether a result isOkorErr:

ifis_err(result):
raiseRuntimeError(result.err_value)
do_something(result.ok_value)

or

ifis_ok(result):
do_something(result.ok_value)
else:
raiseRuntimeError(result.err_value)

Alternatively,isinstancecan be used (interchangeably to type guard functions is_okandis_err). However, relying onisinstancemay result in code that is slightly less readable and less concise:

ifisinstance(result,Err):
raiseRuntimeError(result.err_value)
do_something(result.ok_value)

You can also check if an object isOkorErrby using theOkErr type. Please note that this type is designed purely for convenience, and should not be used for anything else. Using(Ok, Err)also works fine:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>isinstance(res1,OkErr)
True
>>>isinstance(res2,OkErr)
True
>>>isinstance(1,OkErr)
False
>>>isinstance(res1,(Ok,Err))
True

Convert aResultto the value orNone:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.ok()
'yay'
>>>res2.ok()
None

Convert aResultto the error orNone:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.err()
None
>>>res2.err()
'nay'

Access the value directly, without any other checks:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.ok_value
'yay'
>>>res2.err_value
'nay'

Note that this is a property, you cannot assign to it. Results are immutable.

When the value inside is irrelevant, we suggest usingNoneor a bool,but you're free to use any value you think works best. An instance of aResult(OkorErr) must always contain something. If you're looking for a type that might contain a value you may be interested in amaybe.

Theunwrapmethod returns the value ifOkandunwrap_errmethod returns the error value ifErr,otherwise it raises anUnwrapError:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.unwrap()
'yay'
>>>res2.unwrap()
Traceback(mostrecentcalllast):
File"<stdin>",line1,in<module>
File"C:\project\result\result.py ",line107,inunwrap
returnself.expect("Called `Result.unwrap()` on an `Err` value")
File"C:\project\result\result.py ",line101,inexpect
raiseUnwrapError(message)
result.result.UnwrapError:Called`Result.unwrap()`onan`Err`value
>>>res1.unwrap_err()
Traceback(mostrecentcalllast):
...
>>>res2.unwrap_err()
'nay'

A custom error message can be displayed instead by usingexpectand expect_err:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.expect('not ok')
'yay'
>>>res2.expect('not ok')
Traceback(mostrecentcalllast):
File"<stdin>",line1,in<module>
File"C:\project\result\result.py ",line101,inexpect
raiseUnwrapError(message)
result.result.UnwrapError:notok
>>>res1.expect_err('not err')
Traceback(mostrecentcalllast):
...
>>>res2.expect_err('not err')
'nay'

A default value can be returned instead by usingunwrap_oror unwrap_or_else:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.unwrap_or('default')
'yay'
>>>res2.unwrap_or('default')
'default'
>>>res1.unwrap_or_else(str.upper)
'yay'
>>>res2.unwrap_or_else(str.upper)
'NAY'

Theunwrapmethod will raised anUnwrapError.A custom exception can be raised by using theunwrap_or_raisemethod instead:

>>>res1=Ok('yay')
>>>res2=Err('nay')
>>>res1.unwrap_or_raise(ValueError)
'yay'
>>>res2.unwrap_or_raise(ValueError)
ValueError:nay

Values and errors can be mapped usingmap,map_or,map_or_elseand map_err:

>>>Ok(1).map(lambdax:x+1)
Ok(2)
>>>Err('nay').map(lambdax:x+1)
Err('nay')
>>>Ok(1).map_or(-1,lambdax:x+1)
2
>>>Err(1).map_or(-1,lambdax:x+1)
-1
>>>Ok(1).map_or_else(lambda:3,lambdax:x+1)
2
>>>Err('nay').map_or_else(lambda:3,lambdax:x+1)
3
>>>Ok(1).map_err(lambdax:x+1)
Ok(1)
>>>Err(1).map_err(lambdax:x+1)
Err(2)

To save memory, both theOkandErrclasses are ‘slotted’, i.e. they define__slots__.This means assigning arbitrary attributes to instances will raiseAttributeError.

as_resultDecorator

Theas_result()decorator can be used to quickly turn ‘normal’ functions intoResultreturning ones by specifying one or more exception types:

@as_result(ValueError,IndexError)
deff(value:int)->int:
ifvalue==0:
raiseValueError# becomes Err
elifvalue==1:
raiseIndexError# becomes Err
elifvalue==2:
raiseKeyError# raises Exception
else:
returnvalue# becomes Ok

res=f(0)# Err[ValueError()]
res=f(1)# Err[IndexError()]
res=f(2)# raises KeyError
res=f(3)# Ok[3]

Exception(or evenBaseException) can be specified to create a ‘catch all’Resultreturn type. This is effectively the same astry followed byexcept Exception,which is not considered good practice in most scenarios, and hence this requires explicit opt-in.

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

importthird_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(...)
ifis_ok(res):
print(res.ok_value)

Do notation

Do notation is syntactic sugar for a sequence ofand_then()calls. Much like the equivalent in Rust or Haskell, but with different syntax. Instead ofx <- Ok(1)we writefor x in Ok(1).Since the syntax is generator-based, the final result must be the first line, not the last.

final_result:Result[int,str]=do(
Ok(x+y)
forxinOk(1)
foryinOk(2)
)

Note that if you exclude the type annotation, final_result: Result[float, int] =...,your type checker may be unable to infer the return type. To avoid an errors or warnings from your type checker, you should add a type hint when using thedo function.

This is similar to Rust'sm! macro:

usedo_notation::m;
letr =m!{
x <-Some(1);
y <-Some(2);
Some(x + y)
};

Note that if your do statement has multiplefor`s, you can access an identifier bound in a previous `for.Example:

my_result:Result[int,str]=do(
f(x,y,z)
forxinget_x()
foryincalculate_y_from_x(x)
forzincalculate_z_from_x_y(x,y)
)

You can usedo()with awaited values as follows:

asyncdefprocess_data(data)->Result[int,str]:
res1=awaitget_result_1(data)
res2=awaitget_result_2(data)
returndo(
Ok(x+y)
forxinres1
foryinres2
)

However, if you want to await something inside the expression, use do_async():

asyncdefprocess_data(data)->Result[int,str]:
returndo_async(
Ok(x+y)
forxinawaitget_result_1(data)
foryinawaitget_result_2(data)
)

Troubleshootingdo()calls:

TypeError("Got async_generator but expected generator")

Sometimes regulardo()can handle async values, but this error means you have hit a case where it does not. You should usedo_async()here instead.

Contributing

These steps should work on any Unix-based system (Linux, macOS, etc) with Python andmakeinstalled. On Windows, you will need to refer to the Python documentation (linked below) and reference theMakefilefor commands to run from the non-unix shell you're using on Windows.

  1. Setup and activate a virtual environment. SeePython docsfor more information about virtual environments and setup.
  2. Runmake installto install dependencies
  3. Switch to a new git branch and make your changes
  4. Test your changes:
  • make test
  • make lint
  • You can also start a Python REPL and importresult
  1. Update documentation
  • Edit any relevant docstrings, markdown files
  • Runmake docs
  1. Add an entry to thechangelog
  2. Git commit all your changes and create a new PR.

FAQ

  • Why should I use theis_ok(is_err) type guard function over theis_ok(is_err) method?

As you can see in the following example, MyPy can only narrow the type correctly while using the type guardfunctions:

result:Result[int,str]

ifis_ok(result):
reveal_type(result)# "result.result.Ok[builtins.int]"
else:
reveal_type(result)# "result.result.Err[builtins.str]"

ifresult.is_ok():
reveal_type(result)# "Union[result.result.Ok[builtins.int], result.result.Err[builtins.str]]"
else:
reveal_type(result)# "Union[result.result.Ok[builtins.int], result.result.Err[builtins.str]]"
  • Why do I get the "Cannot infer type argument" error with MyPy?

There isa bug in MyPy which can be triggered in some scenarios. Usingif isinstance(res, Ok) instead ofif res.is_ok()will help in some cases. Otherwise using one of these workarounds can help.

Related Projects

License

MIT License