Mikhail Gerasimov
Mikhail Gerasimov

Reputation: 39546

Why doesn't contextmanager reraise exception?

I want to replace class's __enter__/__exit__ functions with single function decorated as contextlib.contextmanager, here's code:

class Test:
    def __enter__(self):
        self._cm_obj = self._cm()
        self._cm_obj.__enter__()

    def __exit__(self, exc_type, exc_val, exc_tb):
        try:
            # Here we first time caught exception,
            # Pass it to _cm:
            self._cm_obj.__exit__(exc_type, exc_val, exc_tb)
        except:
            # Here we should catch exception reraised by _cm,
            # but it doesn't happen.
            raise
        else:
            return True

    @contextmanager
    def _cm(self):
        print('enter')
        try:
            yield
        except:
            # Here we got exception from __exit__
            # Reraise it to tell __exit__ it should be raised.
            raise
        finally:
            print('exit')


with Test():
    raise Exception(123)

When we got exception inside Test's __exit__, we pass it to _cm's __exit__, it works fine, I see exception inside _cm. But then, when I decide to reraise inside _cm, it doesn't happen: after Test's __exit__ I don't see exception (and code works wrong, without exception).

Why doesn't exeprion reraised inside __exit__?

If it's normal behavior, could you advice me solution to properly replace __enter__/__exit__ with contextmanager function?

Edit:

cm_obj.__exit__ returns None on exception inside _cm and True if exception was suppressed (or not raised at all).

At other side, inside Test.__exit__ we can return None to propagate current exception or True to suppress it.

Looks like just returning cm_obj.__exit__'s value inside Test.__exit__do job, this code working as I want:

class Test:
    def __enter__(self):
        self._cm_obj = self._cm()
        self._cm_obj.__enter__()

    def __exit__(self, exc_type, exc_val, exc_tb):
        return self._cm_obj.__exit__(exc_type, exc_val, exc_tb)

    @contextmanager
    def _cm(self):
        print('---- enter')
        try:
            yield
        except:
            raise  # comment to suppess exception
            pass
        finally:
            print('---- exit')


with Test():
    raise Exception(123)

Upvotes: 4

Views: 1773

Answers (2)

Martijn Pieters
Martijn Pieters

Reputation: 1121644

The exception is not raised in __exit__, so there is nothing to reraise.

The exception is passed in to the method as arguments, because you can't raise an exception in another method (other than a generator). You don't have to raise the exception either, you need to simply return not return a true value for the exception to be propagated. Returning None should suffice.

From the With Statement Context Managers documentation:

Exit the runtime context related to this object. The parameters describe the exception that caused the context to be exited. If the context was exited without an exception, all three arguments will be None.

If an exception is supplied, and the method wishes to suppress the exception (i.e., prevent it from being propagated), it should return a true value. Otherwise, the exception will be processed normally upon exit from this method.

Note that __exit__() methods should not reraise the passed-in exception; this is the caller’s responsibility.

Bold emphasis mine.

You instead suppress the result of the self._cm_obj.__exit__() call, and return True to ensure the exception isn't raised again:

def __exit__(self, exc_type, exc_val, exc_tb):
    self._cm_obj.__exit__(exc_type, exc_val, exc_tb)
    return True

If you didn't return True here you'd see the exception re-raised by the with statement.

You can't re-raise exceptions that don't reach the context manager however. If you catch and handle an exception in the code block (inside _cm for example), then the context manager will never be told about this. Suppressed exceptions stay suppressed.

Note that @contextmanager can't change these rules; although it transfers the exception into the generator with generator.throw(), it also has to catch that same exception if your generator doesn't handle it. It'll instead return a false value at that moment, because that is what a contextmanager.__exit__() method should do.

Upvotes: 3

user2357112
user2357112

Reputation: 280426

When a context manager's __exit__ method needs to indicate that an exception should be propagated, it does not do so by propagating the exception out of __exit__. It does so by returning a falsy value.

@contextmanager provides a different API to you, but it still needs to convert that to the normal context manager API to show to Python. When the exception passed to a @contextmanager context manager's __exit__ is propagated out of the wrapped function uncaught, @contextmanager catches the exception and returns a falsy value from __exit__. That means that this call:

self._cm_obj.__exit__(exc_type, exc_val, exc_tb)

does not raise an exception, because it's caught by @contextmanager.

You can see the code where @contextmanager catches the exception in Lib/contextlib.py

            try:
                self.gen.throw(type, value, traceback)
                raise RuntimeError("generator didn't stop after throw()")
            except StopIteration, exc:
                # Suppress the exception *unless* it's the same exception that
                # was passed to throw().  This prevents a StopIteration
                # raised inside the "with" statement from being suppressed
                return exc is not value
            except:
                # only re-raise if it's *not* the exception that was
                # passed to throw(), because __exit__() must not raise
                # an exception unless __exit__() itself failed.  But throw()
                # has to raise the exception to signal propagation, so this
                # fixes the impedance mismatch between the throw() protocol
                # and the __exit__() protocol.
                #
                if sys.exc_info()[1] is not value:
                    raise

Upvotes: 2

Related Questions