¿Extiende dinámicamente la excepción con la excepción personalizada en Python?

Oct 05 2020

Digamos que tengo una excepción especial que hace algunas cosas interesantes y maravillosas: resolver el hambre mundial, la buena voluntad hacia los hombres, la tala, etc.

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

Ahora digamos que se puede generar una excepción, pero no sabemos qué tipo de excepción encontraremos:

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

Cuando se genera ese tipo de excepción desconocido, queremos capturarlo, envolverlo en nuestra SpecialExceptionclase y generarlo para que pueda ser detectado por el tipo original de excepción lanzada o mediante la captura 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')

¿Alguna recomendación sobre cómo resolver esto (razonablemente)?

En resumen, necesitamos:

  • Capacidad para detectar la excepción planteada originalmente y capacidad para detectar SpecialException
  • Conserve el rastreo para facilitar la depuración
  • Contiene el mensaje de error de la excepción original

Lo que he intentado:

Intenté usar raise SpecialException from e. Si bien podemos ver el mensaje de error y el rastreo de la excepción originalmente generada, ya no podemos detectarlo por el tipo de excepción lanzada originalmente ... Por ejemplo: podemos detectar SpecialException, pero no podemos detectar 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')

Sin embargo, lo más cerca que hemos estado cumple técnicamente con nuestras necesidades:

  1. Si bien la excepción generada se puede detectar como SpecialExceptiono ValueError, en realidad se genera como otra clase de uso único:DynamicSpecialException
  2. Es realmente asqueroso y aparentemente muy poco pitónico.
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!')

Lo que realmente esperaba encontrar era algo similar raise e.extend(SpecialException)o raise SpecialException.from(e), en lugar de, esta madriguera de conejo por la que aparentemente me he abierto camino hoy. :)

Respuestas

1 JLPeyret Oct 07 2020 at 21:19

Aquí hay un poco de puñalada. Parece hacer la mayor parte de lo que desea, excepto que agrega el seguimiento de pila del manejo de fábrica especial.

Lo que aprendí es que no puedes intercambiar la clase de excepción e.__class__ = <dynamic class>, tienes que crear una nueva y subirla.

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()

salida:

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