Estender a exceção dinamicamente com exceção personalizada em Python?

Oct 05 2020

Digamos que eu tenha uma exceção especial que faz algumas coisas legais e maravilhosas - Resolvendo a fome mundial, boa vontade para com os homens, extração de madeira, etc:

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

Agora, digamos que uma exceção possa ser levantada, mas não sabemos que tipo de exceção 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

Quando esse tipo desconhecido de exceção é gerado, queremos capturá-lo, envolvê-lo em nossa SpecialExceptionclasse e aumentá-lo para que possa ser capturado pelo tipo original de exceção lançada ou capturando 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')

Alguma recomendação sobre como (razoavelmente) resolver isso?

Em resumo, precisamos:

  • Capacidade de capturar a exceção originalmente levantada e capacidade de capturar SpecialException
  • Reter traceback para fácil depuração
  • Contém a mensagem de erro da exceção original

O que eu tentei:

Tentei usar raise SpecialException from e. Embora possamos visualizar a mensagem de erro e rastrear a exceção gerada originalmente, não somos mais capazes de capturá-la pelo tipo de exceção originalmente lançada ... Por exemplo: Podemos capturar SpecialException, mas não podemos capturar 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')

O mais próximo que conseguimos tecnicamente atende às nossas necessidades, no entanto:

  1. Embora a exceção gerada possa ser capturada como SpecialExceptionou ValueError, na verdade é gerada como outra classe de uso único:DynamicSpecialException
  2. É realmente nojento e aparentemente muito pouco python
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!')

O que eu realmente esperava encontrar era algo semelhante raise e.extend(SpecialException)ou raise SpecialException.from(e)- ao invés desta toca de coelho que eu aparentemente abri meu caminho hoje! :)

Respostas

1 JLPeyret Oct 07 2020 at 21:19

Aqui está um pouco de tentativa. Parece fazer a maior parte do que você deseja, exceto que acrescenta o rastreamento de pilha do tratamento de fábrica especial.

O que aprendi é que você não pode trocar a classe de exceção e.__class__ = <dynamic class>, você tem que criar uma nova e aumentá-la.

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

resultado:

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