Repository: RedHatInsights/insights-core

Issue:
parsr parsers should return meaningful exceptions when mapped functions fail
Parsr uses exceptions to control backtracking and doesn't differentiate between control flow exceptions and exceptions from mapped or lifted functions. This means if mapped or lifted functions raise exceptions, they're eaten as parser errors instead of raised as problems to fix.


Discussion:

Resolving patch:
diff --git a/insights/parsr/__init__.py b/insights/parsr/__init__.py
--- a/insights/parsr/__init__.py
+++ b/insights/parsr/__init__.py
@@ -47,7 +47,9 @@ def op(args):
 from __future__ import print_function
 import functools
 import logging
+import os
 import string
+import traceback
 from bisect import bisect_left
 from six import StringIO, with_metaclass
 
@@ -116,6 +118,9 @@ def _debug_hook(func):
     """
     @functools.wraps(func)
     def inner(self, pos, data, ctx):
+        if ctx.function_error is not None:
+            # no point in continuing...
+            raise Exception()
         ctx.parser_stack.append(self)
         if self._debug:
             line = ctx.line(pos) + 1
@@ -136,6 +141,15 @@ def inner(self, pos, data, ctx):
     return inner
 
 
+class Backtrack(Exception):
+    """
+    Mapped or Lifted functions should Backtrack if they want to fail without
+    causing parsing to fail.
+    """
+    def __init__(self, msg):
+        super(Backtrack, self).__init__(msg)
+
+
 class Context(object):
     """
     An instance of Context is threaded through the process call to every
@@ -151,6 +165,7 @@ def __init__(self, lines, src=None):
         self.lines = [i for i, x in enumerate(lines) if x == "\n"]
         self.parser_stack = []
         self.errors = []
+        self.function_error = None
 
     def set(self, pos, msg):
         """
@@ -319,6 +334,13 @@ def __call__(self, data, src=None, Ctx=Context):
         except Exception:
             pass
 
+        if ctx.function_error is not None:
+            pos, msg = ctx.function_error
+            lineno = ctx.line(pos) + 1
+            colno = ctx.col(pos) + 1
+            msg = "At line {0} column {1}: {2}".format(lineno, colno, msg)
+            raise Exception(msg)
+
         err = StringIO()
 
         lineno = ctx.line(ctx.pos) + 1
@@ -917,7 +939,16 @@ def __init__(self, child, func):
 
     def process(self, pos, data, ctx):
         pos, res = self.children[0].process(pos, data, ctx)
-        return pos, self.func(res)
+        try:
+            return pos, self.func(res)
+        except Backtrack as bt:
+            ctx.set(pos, bt.msg)
+            raise
+        except:
+            tb = traceback.format_exc()
+            msg = (self.name or "Map") + " raised{l}{tb}".format(l=os.linesep, tb=tb)
+            ctx.function_error = (pos, msg)
+            raise
 
     def __repr__(self):
         if not self.name:
@@ -969,8 +1000,14 @@ def process(self, pos, data, ctx):
             results.append(res)
         try:
             return pos, self.func(*results)
-        except Exception as e:
-            ctx.set(pos, str(e))
+        except Backtrack as bt:
+            ctx.set(pos, bt.msg)
+            raise
+        except:
+            tb = traceback.format_exc()
+            msg = (self.name or "Lift") + " raised{l}{tb}".format(l=os.linesep, tb=tb)
+            ctx.set(pos, msg)
+            ctx.function_error = (pos, msg)
             raise
 
 


Test patch:
diff --git a/insights/parsr/tests/test_function_error.py b/insights/parsr/tests/test_function_error.py
new file mode 100644
--- /dev/null
+++ b/insights/parsr/tests/test_function_error.py
@@ -0,0 +1,13 @@
+from insights.parsr import Char
+
+
+def boom(_):
+    raise Exception("Boom")
+
+
+def test_error():
+    p = Char("a").map(boom)
+    try:
+        p("a")
+    except Exception as ex:
+        assert "boom" in str(ex)