]> arthur.barton.de Git - bup.git/commitdiff
Support exception chaining and tracebacks
authorRob Browning <rlb@defaultvalue.org>
Fri, 7 Apr 2017 00:32:50 +0000 (19:32 -0500)
committerRob Browning <rlb@defaultvalue.org>
Sat, 20 May 2017 19:27:53 +0000 (14:27 -0500)
Python 3 has added support for exception chaining
https://www.python.org/dev/peps/pep-3134/

Which makes it possible to avoid losing information when an exception
is thrown from within an exception handler:

  try:
      ...
      raise lp0_on_fire()
  except Exception as ex:
    # Imagine the disk is also full and close() throws too
    some_output_file.close()

In this situation, you'll never find out the printer's on fire.  With
chaining, the first exception will be attached to the second as its
__context__.  (Note that "finally" blocks suffer from the same issue.)

The PEP also describes adding a __traceback__ attribute to exceptions
so that they're more self-contained.

Python 3 handles all of this automatically, and includes any chained
exceptions in its tracebacks.  For this:

  def inner():
      raise Exception('first')

  def outer():
      try:
          inner()
      except Exception as ex:
          raise chain_ex(Exception('second'), ex)

  wrap_main(outer)

Python 3 produces:

  $ python3 lib/bup/compat.py
  Traceback (most recent call last):
    File "lib/bup/compat.py", line 74, in outer
      inner()
    File "lib/bup/compat.py", line 70, in inner
      raise Exception('first')
  Exception: first

  During handling of the above exception, another exception occurred:

  Traceback (most recent call last):
    File "lib/bup/compat.py", line 78, in <module>
      wrap_main(outer)
    File "lib/bup/compat.py", line 50, in wrap_main
      sys.exit(main())
    File "lib/bup/compat.py", line 76, in outer
      raise chain_ex(Exception('second'), ex)
  Exception: second

Add a compat.py supporting something similar for Python 2, and use it
in main.py.

Signed-off-by: Rob Browning <rlb@defaultvalue.org>
Tested-by: Rob Browning <rlb@defaultvalue.org>
lib/bup/compat.py [new file with mode: 0644]
main.py

diff --git a/lib/bup/compat.py b/lib/bup/compat.py
new file mode 100644 (file)
index 0000000..d0e2c5d
--- /dev/null
@@ -0,0 +1,83 @@
+
+from __future__ import print_function
+from traceback import print_exception
+import sys
+
+py_maj = sys.version_info[0]
+py3 = py_maj >= 3
+
+if py3:
+
+    def add_ex_tb(ex):
+        pass
+
+    def chain_ex(ex, context_ex):
+        return ex
+
+else:  # Python 2
+
+    def add_ex_tb(ex):
+        if not getattr(ex, '__traceback__', None):
+            ex.__traceback__ = sys.exc_info()[2]
+
+    def chain_ex(ex, context_ex):
+        if context_ex:
+            add_ex_tb(context_ex)
+            if not getattr(ex, '__context__', None):
+                ex.__context__ = context_ex
+        return ex
+
+    def dump_traceback(ex):
+        stack = [ex]
+        next_ex = getattr(ex, '__context__', None)
+        while next_ex:
+            stack.append(next_ex)
+            next_ex = getattr(next_ex, '__context__', None)
+        stack = reversed(stack)
+        ex = next(stack)
+        tb = getattr(ex, '__traceback__', None)
+        print_exception(type(ex), ex, tb)
+        for ex in stack:
+            print('\nDuring handling of the above exception, another exception occurred:\n',
+                  file=sys.stderr)
+            tb = getattr(ex, '__traceback__', None)
+            print_exception(type(ex), ex, tb)
+
+def wrap_main(main):
+    """Run main() and raise a SystemExit with the return value if it
+    returns, pass along any SystemExit it raises, convert
+    KeyboardInterrupts into exit(130), and print a Python 3 style
+    contextual backtrace for other exceptions in both Python 2 and
+    3)."""
+    try:
+        sys.exit(main())
+    except KeyboardInterrupt as ex:
+        sys.exit(130)
+    except SystemExit as ex:
+        raise
+    except BaseException as ex:
+        if py3:
+            raise
+        add_ex_tb(ex)
+        dump_traceback(ex)
+        sys.exit(1)
+
+
+# Excepting wrap_main() in the traceback, these should produce the same output:
+#   python2 lib/bup/compat.py
+#   python3 lib/bup/compat.py
+# i.e.:
+#   diff -u <(python2 lib/bup/compat.py 2>&1) <(python3 lib/bup/compat.py 2>&1)
+
+if __name__ == '__main__':
+
+    def inner():
+        raise Exception('first')
+
+    def outer():
+        try:
+            inner()
+        except Exception as ex:
+            raise chain_ex(Exception('second'), ex)
+
+    wrap_main(outer)
diff --git a/main.py b/main.py
index ecc975fed5d274b7550f5b25a8aa9eaf7e18a0cd..33d21dfeecdd201b763bd35f01f6336da437af6a 100755 (executable)
--- a/main.py
+++ b/main.py
@@ -32,6 +32,7 @@ os.environ['BUP_RESOURCE_PATH'] = resourcepath
 
 
 from bup import helpers
 
 
 from bup import helpers
+from bup.compat import wrap_main
 from bup.helpers import atoi, columnate, debug1, log, tty_width
 
 
 from bup.helpers import atoi, columnate, debug1, log, tty_width
 
 
@@ -178,37 +179,40 @@ def handler(signum, frame):
         signal.signal(signal.SIGTSTP, handler)
     ret = 94
 
         signal.signal(signal.SIGTSTP, handler)
     ret = 94
 
-signal.signal(signal.SIGTERM, handler)
-signal.signal(signal.SIGINT, handler)
-signal.signal(signal.SIGTSTP, handler)
-signal.signal(signal.SIGCONT, handler)
+def main():
+    signal.signal(signal.SIGTERM, handler)
+    signal.signal(signal.SIGINT, handler)
+    signal.signal(signal.SIGTSTP, handler)
+    signal.signal(signal.SIGCONT, handler)
 
 
-try:
     try:
     try:
-        c = (do_profile and [sys.executable, '-m', 'cProfile'] or []) + subcmd
-        if not n and not outf and not errf:
-            # shortcut when no bup-newliner stuff is needed
-            os.execvp(c[0], c)
-        else:
-            p = subprocess.Popen(c, stdout=outf, stderr=errf,
-                                 preexec_fn=force_tty)
-        while 1:
-            # if we get a signal while waiting, we have to keep waiting, just
-            # in case our child doesn't die.
-            ret = p.wait()
-            forward_signals = False
-            break
-    except OSError as e:
-        log('%s: %s\n' % (subcmd[0], e))
-        ret = 98
-finally:
-    if p and p.poll() == None:
-        os.kill(p.pid, signal.SIGTERM)
-        p.wait()
-    if n:
-        n.stdin.close()
         try:
         try:
-            n.wait()
-        except:
-            pass
-sys.exit(ret)
+            c = (do_profile and [sys.executable, '-m', 'cProfile'] or []) + subcmd
+            if not n and not outf and not errf:
+                # shortcut when no bup-newliner stuff is needed
+                os.execvp(c[0], c)
+            else:
+                p = subprocess.Popen(c, stdout=outf, stderr=errf,
+                                     preexec_fn=force_tty)
+            while 1:
+                # if we get a signal while waiting, we have to keep waiting, just
+                # in case our child doesn't die.
+                ret = p.wait()
+                forward_signals = False
+                break
+        except OSError as e:
+            log('%s: %s\n' % (subcmd[0], e))
+            ret = 98
+    finally:
+        if p and p.poll() == None:
+            os.kill(p.pid, signal.SIGTERM)
+            p.wait()
+        if n:
+            n.stdin.close()
+            try:
+                n.wait()
+            except:
+                pass
+    sys.exit(ret)
+
+wrap_main(main)