ruk·si

🐍 Python
Exceptions

Updated at 2019-06-09 23:09

Exceptions are the primary error handling method in Python.

raise Exception('error message')
# ... stacktrace ...
# Exception: error message
phonebook = {
    'Alice': 123,
    'Bob': 456,
}

number = None
try:
    number = int(phonebook['David'])
except (KeyError, ValueError):
    pass # actually you should maybe log a warning

assert number is None

Confusingly, exceptions are also used for signaling. It's easier to ask for forgiveness than permission so exceptions are also used as flow control. In many other languages, exceptions are solely for problems that need to handled, but this not the case in Python.

for-loop iteration uses next() to get the next value, if no value
is available, it raises StopIteration exception; it's a signal, not an error

try/else will run if no exceptions are raised.

def dangerous_call(crash: bool) -> None:
    if crash:
        raise ValueError('value is unacceptable')

try:
    dangerous_call(crash=True)
except ValueError as exc:
    assert True
else:
    assert False

try:
    dangerous_call(crash=False)
except ValueError as exc:
    assert False
else:
    assert True

If you call raise inside except, it will re-raise the caught exception.

def dangerous_call() -> None:
    raise ValueError('value is unacceptable')

try:
    dangerous_call()
except ValueError:
    raise
    # => ValueError: value is unacceptable

Make your exception classes self-documenting. Custom exception classes make it easy to understand what's going on when things go wrong. Extra points if you also tell how to fix the issue.

class NameTooShortError(Exception):
    pass

def validate(name: str) -> None:
    count = 5
    if len(name) < count:
        raise NameTooShortError(
            f'name "{name}" is too short, use more than {count} letters'
        )

validate('Alexander')
validate('John')
# => NameTooShortError: name "John" is too short, use more than 5 letters

Define a base exception class for your library. Allows catching only errors related to the library.

class BaseError(Exception):
    pass

class NameTooShortError(BaseError):
    pass

class NameTooLongError(BaseError):
    pass

def validate(name: str) -> None:
    if len(name) < 5:
        raise NameTooShortError(name)
    if len(name) > 10:
        raise NameTooLongError(name)

try:
    validate('Esmeralda')
except BaseError as err:
    print("problem from your library", err)
except Exception as err:
    print("lower-level problem", err)

Most exception messages are in lowercase in the standard library. Consider following this convention but no one will blink an eye of you won't. The important thing is to be consistent with your own errors.

names = []

names.pop()
# => raise IndexError('pop from empty list')
# IndexError: pop from empty list

names[10]
# => raise IndexError('list index out of range')
# IndexError: list index out of range

You may want to add more information to your custom exceptions. Consider making additional information optional, otherwise you need to specify __reduce__ method into your exception class to make it pickleable.

from typing import List, Optional

OListStr = Optional[List[str]]

class ValidationError(Exception):

    def __init__(self, message: str, reasons: OListStr = None) -> None:
        super().__init__(message)
        self.reasons = reasons if reasons else []

def validate(name: str) -> None:
    errors = []
    if len(name) < 5:
        errors.append('is too short')
    if any(char.isdigit() for char in name):
        errors.append('contains a number')
    if errors:
        raise ValidationError(f'name "{name}" is invalid,', errors)

for n in ['John', '4l']:
    try:
        validate(n)
    except ValidationError as err:
        print(err, err.reasons)
        # => name "John" is invalid, ['is too short']
        # => name "4l" is invalid, ['is too short', 'contains a number']
        # you can show these to users as you wish (if you wish)

Minimize code in try-except blocks. Gives more room to raise unexpected exception.

try:
    doSomethingThatCanRaiseError()
except SomeError:
    FixIt()
except (UsError, ThemError):    # catching multiple exceptions
    FixThem()
else:                           # no errors
    doOtherStuff()
finally:                        # error or not, will be ran last
    finalizeStuff()

Never catch all exceptions silently. Don't use blank except: or catching Exception or StandardError, except you are logging or printing the error on the outermost block.

try:
    doSomethingThatCanRaiseError()
except:
    # the above is bad
    pass

Exceptions have 3 interesting dunder properties: __context__, __cause__ and __traceback__

  • __context__ defines that while handling the "context" exception, current exception was raised.
  • __cause__ defines that due to the "cause" exception, current exception was raised.
  • __traceback__ records the function call stack for debugging.

In more technical terms:

  • __context__ is set if you raise inside an except handler, can be None.
  • __cause__ is only set if you use raise ... from exception, can be None.
  • If __cause__ is set, so is __supress_context__ and __context__ is ignored.
  • raise ... from None can be used to hide any context or cause.
try:
    raise ValueError()
except ValueError:
    raise IndexError()
# ... ValueError
# During handling of the above exception, another exception occurred:
# ... IndexError
try:
    raise ValueError()
except ValueError as ve:
    raise IndexError() from ve
# ... ValueError
# The above exception was the direct cause of the following exception:
# ... IndexError

Sources