Monday, January 14, 2019

Scala like error handling with Python

Abstract

On this article, I’ll try to do Scala-like error and output handling with Python.
This is my personal memo.




Code

Python’s function doesn’t need type information of the arguments to write. So, it is not necessary to make the class for output value and error in neat way. But as an assumption that I use type information on some functions, I’ll write Either class, which is extended by Right and Left.
Right will wrap the output of functions when it doesn’t raise any error. Left will wrap the error.

from typing import Any


class Either:
    pass


class Right(Either):
    def __init__(self, content: Any):
        self.content = content


class Left(Either):
    def __init__(self, error):
        self.error = error

To use these classes, I can define the function which automatically wraps output of the function. This higher order function will unwrap the input if the input is wrapped, execute the function and wrap the output by Right or Left. It assumes that the function which is the first argument will take just one argument.

from typing import Any, Union
from .base import Either, Right, Left


def Try(fn, obj: Union[Any, Either]) -> Either:
    if type(obj) == Right:
        try:
            return Right(fn(obj.content))
        except Exception as e:
            return Left(e)
    elif type(obj) == Left:
        return obj
    else:
        try:
            return Right(fn(obj))
        except Exception as e:
            return Left(e)


I’ll define some functions and check the behavior.

def fun_1(obj):
    return obj * 2

def fun_2(obj):
    try:
        return 10 / obj
    except ZeroDivisionError as e:
        raise e
        
def fun_3(obj):
    return obj + 1


When the function succeeds to execute, the output is wrapped by Right.

fun_1_output = Try(fun_1, 10)
print(type(fun_1_output))
<class 'toolbox.base.Right'>

The value of the function’s output is stored on the content field.

print(fun_1_output.content)
20

Because Try() unwraps the input if it is wrapped, I can use the output of Try() as the next Try()'s input.

fun_1_2_output = Try(fun_2, Try(fun_1, 10))

I can check the value of the function with the same manner before.

print(fun_1_2_output.content)
0.5

When the function raises the exception, the output of the function is wrapped by Left.

fun_2_output = Try(fun_2, 0)

The type of the fun_2_outptu is Left.

print(type(fun_2_output))
<class 'toolbox.base.Left'>

Left contains error information.

fun_2_output.error
ZeroDivisionError('division by zero')

Of course I can raise this error.

raise fun_2_output.error
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-10-91cc3f6e850c> in <module>
----> 1 raise fun_2_output.error

~/Project/personal/toolbox/toolbox/manager.py in Try(fn, obj)
     13     else:
     14         try:
---> 15             return Right(fn(obj))
     16         except Exception as e:
     17             return Left(e)

<ipython-input-2-043eb3acd818> in fun_2(obj)
      6         return 10 / obj
      7     except ZeroDivisionError as e:
----> 8         raise e
      9 
     10 def fun_3(obj):

<ipython-input-2-043eb3acd818> in fun_2(obj)
      4 def fun_2(obj):
      5     try:
----> 6         return 10 / obj
      7     except ZeroDivisionError as e:
      8         raise e

ZeroDivisionError: division by zero

When the input type is Left, Try() just passes the input value to output value.
On the case below, fun_2 raises exception. The Left instance contains that error information and on the Try(fun_3, ..), that is just past to the output.

fun_2_3_output = Try(fun_3, Try(fun_2, 0))

If I raise the fun_2_3_output's error, the error which happened on the Try(fun_2, 0) is raised.

raise fun_2_3_output.error
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-12-cf4375ab93a6> in <module>
----> 1 raise fun_2_3_output.error

~/Project/personal/toolbox/toolbox/manager.py in Try(fn, obj)
     13     else:
     14         try:
---> 15             return Right(fn(obj))
     16         except Exception as e:
     17             return Left(e)

<ipython-input-2-043eb3acd818> in fun_2(obj)
      6         return 10 / obj
      7     except ZeroDivisionError as e:
----> 8         raise e
      9 
     10 def fun_3(obj):

<ipython-input-2-043eb3acd818> in fun_2(obj)
      4 def fun_2(obj):
      5     try:
----> 6         return 10 / obj
      7     except ZeroDivisionError as e:
      8         raise e

ZeroDivisionError: division by zero

With functional programming language like funcy, it works on the same manner.
On the code below, the three function is sequentially composed to the one. If I use Try() to this with the input value 0, fun_2 raises exception and that is stored and past to the final output.

import funcy

composed_functions = funcy.rcompose(fun_1, fun_2, fun_3)

raise Try(composed_functions, 0).error
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-13-e72ffcadb978> in <module>
      3 composed_functions = funcy.rcompose(fun_1, fun_2, fun_3)
      4 
----> 5 raise Try(composed_functions, 0).error

~/Project/personal/toolbox/toolbox/manager.py in Try(fn, obj)
     13     else:
     14         try:
---> 15             return Right(fn(obj))
     16         except Exception as e:
     17             return Left(e)

~/.pyenv/versions/3.6.7/envs/toolbox/lib/python3.6/site-packages/funcy/funcs.py in <lambda>(*a, **kw)
    104     """Composes passed functions."""
    105     if fs:
--> 106         pair = lambda f, g: lambda *a, **kw: f(g(*a, **kw))
    107         return reduce(pair, map(make_func, fs))
    108     else:

~/.pyenv/versions/3.6.7/envs/toolbox/lib/python3.6/site-packages/funcy/funcs.py in <lambda>(*a, **kw)
    104     """Composes passed functions."""
    105     if fs:
--> 106         pair = lambda f, g: lambda *a, **kw: f(g(*a, **kw))
    107         return reduce(pair, map(make_func, fs))
    108     else:

<ipython-input-2-043eb3acd818> in fun_2(obj)
      6         return 10 / obj
      7     except ZeroDivisionError as e:
----> 8         raise e
      9 
     10 def fun_3(obj):

<ipython-input-2-043eb3acd818> in fun_2(obj)
      4 def fun_2(obj):
      5     try:
----> 6         return 10 / obj
      7     except ZeroDivisionError as e:
      8         raise e

ZeroDivisionError: division by zero