Joshua Burns
Joshua Burns

Reputation: 8572

Dynamically extend exception with custom exception in Python?

Let's say I have a special exception that does some neat and wonderful things - Solving world hunger, good will toward men, logging, etc:

class SpecialException(Exception):
    # Does something really neat here.
    pass

Now let's say that an exception may be raised, but we don't know what type of exception we'll encounter:

def crashAndBurn():
    try:
        import random
        choice = random.choice([1,2])
        if choice == 1:
            int('asdf') # Can't cast string to int: ValueError.
        else:
            x # Variable `x` doesn't exist: NameError.
    except Exception as e:
        # Code to wrap `e` into `SpecialException` class
        raise e

When that unknown type of exception is raised, we want to catch it, wrap it in our SpecialException class, and raise it so it can be caught either by the original type of exception thrown, or by catching SpecialException:

try:
    crashAndBurn()
except ValueError as e:
    print('This time it was a ValueError, and we want to handle this differently for some reason')
except SpecialException as e:
    print('Handle this more generically')

Any recommendations on how to (reasonably) solve this?

In summary, we need:

What I've Tried:

Tried using raise SpecialException from e. While we are able to view the error message and traceback from the originally raised exception, we are no longer able to catch it by the type of exception originally thrown... Eg: We can catch SpecialException, but can't catch ValueError:

def crashAndBurn():
    try:
        int('asdf') # ValueError
    except Exception as e:
        raise SpecialException('Special Exception Encountered').with_traceback(e.__traceback__) from e

try:
    crashAndBurn()
except ValueError as e:
    print('This will never be encountered')
except SpecialException as e:
    print('This will be encountered, when we wanted it to be handled above')

The closest we've gotten technically fulfills our needs, however:

  1. While the exception raised can be caught as either SpecialException or ValueError, it's actually raised as another, one-time use class: DynamicSpecialException
  2. It's really gross and seemingly very un-pythonic
def crashAndBurn():
    try:
        int('asdf') # ValueError
    except Exception as e:
        class DynamicSpecialException(SpecialException, e.__class__):
            pass # I feel dirty
        raise DynamicSpecialException('Special Exception Encountered').with_traceback(e.__traceback__)

try:
    crashAndBurn()
except ValueError as e:
    print('Caught as a ValueError!')

try:
    crashAndBurn()
except SpecialException as e:
    print('Caught as SpecialException!')

What I was really expecting to find was something similar to raise e.extend(SpecialException) or raise SpecialException.from(e) - rather than this rabbit hole I've seemingly wiggled my way down today! :)

Upvotes: 4

Views: 1672

Answers (1)

JL Peyret
JL Peyret

Reputation: 12174

Here's a bit of a stab at it. It seems to do most of what you want, except that it appends the stack trace of the specialfactory handling.

The thing I learned is that you can't swap the exception class, e.__class__ = <dynamic class>, you have to create a new one and raise it.

import pdb
from traceback import print_exc as xp
import sys

def cpdb():
    """ put `pdb` on commmand line to halt execution in debugger """
    return "pdb" in sys.argv



class SpecialException(Exception):

    def solve_world_hunger(self):
        print(f"eat more 🦄")

def specialfactory(e):
    """ creates a class dynamically and keeps the original as a base"""
    cls_ = type("SpecialException", (SpecialException, e.__class__),{})
    e2 = cls_(str(e))
    e2.ori = e
    e2.__dict__.update(**e.__dict__)

    # 👇 you can try different flavors to see what changes:
    #  basically, I haven't found a way to suppress `The above exception was the direct cause of the following exception:`
    #  see also https://stackoverflow.com/questions/33809864/disable-exception-chaining-in-python-3

    # return e2
    # raise e2.        raise specialfactory(e).with_traceback(e.__traceback__) from e
    # raise e2 from e
    raise e2.with_traceback(e.__traceback__) from e


def crashAndBurn(no_special=False, custom_message=None):
    try:

        if custom_message:
            exc = ValueError(custom_message)
            exc.foo = "bar"
            raise exc

        int('asdf') # ValueError
    except Exception as e:
        if no_special:
            #just to investigate what things look like for a plain ValueError
            raise
        # raise specialfactory(e).with_traceback(e.__traceback__) from e
        raise specialfactory(e) from e



#################################################################
# check what a regular unchanged ValueError looks like
#################################################################

try:
    print("\n\n\n🔬regular ValueError, unchanged")
    crashAndBurn(no_special=1)
except ValueError as e:
    if cpdb(): pdb.set_trace()
    print(f'  plain ValueError: {e}')
    xp()
except SpecialException as e:
    if cpdb(): pdb.set_trace()
    print(f'  Caught as a SpecialException!: {e}')
    xp()

#################################################################
# catch a Special as a ValueError
#################################################################


try:
    print("\n\n\n🔬ValueError ")
    crashAndBurn()
except ValueError as e:
    if cpdb(): pdb.set_trace()
    print(f'  Caught as a ValueError! {e}')
    xp()
except SpecialException as e:
    if cpdb(): pdb.set_trace()
    print(f' Caught as a SpecialException! {e}')
    xp()

#################################################################
# catch a Special 
#################################################################



try:
    print("\n\n\n🔬SpecialException handling")
    crashAndBurn()
except SpecialException as e:
    if cpdb(): pdb.set_trace()
    print(f'  Caught as a SpecialException! {e} {e.solve_world_hunger()}')
    xp()
except ValueError as e:
    if cpdb(): pdb.set_trace()
    print(f'  Caught as a ValueError! {e}')
    xp()

#################################################################
# custom variables are still available
#################################################################

try:
    print("\n\n\n🔬ValueError with custom_message/content ")
    crashAndBurn(custom_message="my custom_message")
except SpecialException as e:
    if cpdb(): pdb.set_trace()
    print(f'  Caught as a SpecialException! {e} {e.foo=} {e.solve_world_hunger()}')
    xp()
except ValueError as e:
    if cpdb(): pdb.set_trace()
    print(f'  Caught as a ValueError! {e}')
    xp()

output:

Traceback (most recent call last):
  File "test_183.py", line 57, in <module>
    crashAndBurn(no_special=1)
  File "test_183.py", line 41, in crashAndBurn
    int('asdf') # ValueError
ValueError: invalid literal for int() with base 10: 'asdf'
Traceback (most recent call last):
  File "test_183.py", line 41, in crashAndBurn
    int('asdf') # ValueError
ValueError: invalid literal for int() with base 10: 'asdf'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test_183.py", line 74, in <module>
    crashAndBurn()
  File "test_183.py", line 47, in crashAndBurn
    raise specialfactory(e) from e
  File "test_183.py", line 30, in specialfactory
    raise e2.with_traceback(e.__traceback__) from e
  File "test_183.py", line 41, in crashAndBurn
    int('asdf') # ValueError
SpecialException: invalid literal for int() with base 10: 'asdf'
Traceback (most recent call last):
  File "test_183.py", line 41, in crashAndBurn
    int('asdf') # ValueError
ValueError: invalid literal for int() with base 10: 'asdf'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test_183.py", line 92, in <module>
    crashAndBurn()
  File "test_183.py", line 47, in crashAndBurn
    raise specialfactory(e) from e
  File "test_183.py", line 30, in specialfactory
    raise e2.with_traceback(e.__traceback__) from e
  File "test_183.py", line 41, in crashAndBurn
    int('asdf') # ValueError
SpecialException: invalid literal for int() with base 10: 'asdf'
Traceback (most recent call last):
  File "test_183.py", line 39, in crashAndBurn
    raise exc
ValueError: my custom_message

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test_183.py", line 108, in <module>
    crashAndBurn(custom_message="my custom_message")
  File "test_183.py", line 47, in crashAndBurn
    raise specialfactory(e) from e
  File "test_183.py", line 30, in specialfactory
    raise e2.with_traceback(e.__traceback__) from e
  File "test_183.py", line 39, in crashAndBurn
    raise exc
SpecialException: my custom_message



🔬regular ValueError, unchanged
  plain ValueError: invalid literal for int() with base 10: 'asdf'



🔬ValueError 
  Caught as a ValueError! invalid literal for int() with base 10: 'asdf'



🔬SpecialException handling
eat more 🦄
  Caught as a SpecialException! invalid literal for int() with base 10: 'asdf' None



🔬ValueError with custom_message/content 
eat more 🦄
  Caught as a SpecialException! my custom_message e.foo='bar' None

Upvotes: 1

Related Questions