Error handling in configpile

Error handling in configpile

The goal behind configpile error reporting is to provide helpful error messages to the user. In particular:

  • configpile does not rely on Python exceptions, rather implements its own error class

  • The error class is designed to be used in a result type that follows existing Python usage patterns. (To be pedantic, it is not monadic.)

  • configpile accumulate errors instead of stopping at the first error

  • Instead of relying on stack traces to convey contextual information, configpile errors store context information that is manually added when results are processed.

Errors

The base error type is Err, which contains either a single error or a sequence of errors.

A single error is constructed through the make() static method.

Errors can be pretty-printed. If the Rich library is available, some light formatting will be applied.

from configpile import Err
e1 = Err.make("First error", context_info = 1, other_info = "bla")
e1.pretty_print()
In other_info: bla                                                                           

In context_info: 1                                                                           

First error                                                                                  

Errors can be collected in a single Err instance, and pretty-printing will collect errors occurring in the same context.

e1 = Err.make("First error", context_info = 1, other_info = "blub")
e2 = Err.make("Second error", context_info = 1)
e12 = Err.collect1(e1, e2)
e12.pretty_print()
In context_info: 1                                                                        
    0 In other_info: blub                                                                    
      First error                                                                            
    1 Second error                                                                           

A sequence of single errors can always be recovered:

e12.errors()
[Err1(msg='First error', contexts=[('context_info', 1), ('other_info', 'blub')]),
 Err1(msg='Second error', contexts=[('context_info', 1)])]

Results

The error type is designed to be used in functions that either return a valid value, or an error. Such functions return a result, or a configpile.userr.Res type.

Note that the configpile.userr.Res type is parameterized by the valid value type: in the example below, it is int.

An example of such a function would be:

from configpile.userr import Res

def parse_int(s: str) -> Res[int]:
    try:
        return int(s)
    except ValueError as e:
        return Err.make(str(e))

and would give the following results:

parse_int("invalid")
Err1(msg="invalid literal for int() with base 10: 'invalid'", contexts=[])
parse_int(1234)
1234

Results can be processed further. For example, the function that squares the value contained in a result, while leaving any error untouched, can be written:

def square_result(res: Res[int]) -> Res[int]:
    if isinstance(res, Err):
        return res
    return res*res

… or, using the map() helper:

from configpile import userr

def square_result1(res: Res[int]) -> Res[int]:
    return userr.map(lambda x: x*x, res)

and we have, unsurprisingly:

square_result(parse_int("invalid"))
Err1(msg="invalid literal for int() with base 10: 'invalid'", contexts=[])
square_result1(parse_int(4))
16

The flat_map() function is useful to chain processing where each step can fail.

import math

def square_root(x: int) -> Res[float]:
    if x < 0:
        return Err.make(f"Cannot take square root of negative number {x}")
    else:
        return math.sqrt(float(x))
userr.flat_map(square_root, parse_int("valid"))
Err1(msg="invalid literal for int() with base 10: 'valid'", contexts=[])
userr.flat_map(square_root, parse_int("2"))
1.4142135623730951
userr.flat_map(square_root, parse_int("-2"))
Err1(msg='Cannot take square root of negative number -2', contexts=[])

Combining results and errors

Finally, the userr module offers ways to combine results.

For example, if one parses several integers, one can collect the results in a tuple using the configpile.userr.collect() function.

userr.collect(parse_int(2), parse_int(3))
(2, 3)
userr.collect(parse_int(3), parse_int("invalid"))
Err1(msg="invalid literal for int() with base 10: 'invalid'", contexts=[])
userr.collect(parse_int("invalid"), parse_int("invalid"))
ManyErr(errs=[Err1(msg="invalid literal for int() with base 10: 'invalid'", contexts=[]), Err1(msg="invalid literal for int() with base 10: 'invalid'", contexts=[])])

See also configpile.userr.collect_seq() when dealing with sequences.

Errors can be collected and combined too. The configpile.userr.Err.collect1() method expect at least one argument and returns an Err, while configpile.userr.Err.collect() can deal with no argument being passed, or with optional arguments.

In particular, optional errors, of type Optional[Err], are great for validation: a None value indicates no error, while an error indicates that one or several problems are present.

from typing import Optional, Sequence
a = -2
b = 1
check_a: Optional[Err] = Err.check(a > 0, "a must be positive")
check_b: Optional[Err] = Err.check(b > 0, "b must be positive")
Err.collect(check_a, check_b)
Err1(msg='a must be positive', contexts=[])