Repository: podhmo/dictknife

Issue:
jsonknife accessor, need WrappedError
hmm.

```
FileNotFoundError("[Errno 2] No such file or directory: 'cwd/example_linter/right.yaml' (where=cwd/example_linter/right.yaml) (where=cwd/example_linter/ng-pair.yaml)")
```


Discussion:

Patch:
diff --git a/dictknife/jsonknife/_wrapped_exception.py b/dictknife/jsonknife/_wrapped_exception.py
new file mode 100644
index 0000000..560c087
--- /dev/null
+++ b/dictknife/jsonknife/_wrapped_exception.py
@@ -0,0 +1,40 @@
+from dictknife.langhelpers import reify
+
+
+class WrappedExceptionFactory:
+    def __init__(self, *, prefix="Wrapped", mixin_class=None):
+        self.prefix = prefix
+        self.mixin_class = mixin_class or WrappedMixin
+        self.classes = {}
+
+    def __call__(self, e: Exception, *, where: str) -> Exception:
+        if isinstance(e, self.mixin_class):
+            e.stack.append(where)
+            return e
+
+        cls = self.classes.get(e.__class__)
+        if cls is None:
+            cls = type(
+                f"{self.prefix}{e.__class__.__name__}", (WrappedMixin, e.__class__), {}
+            )
+            self.classes[e.__class__] = cls
+
+        # sync
+        exc = cls.__new__(cls).with_traceback(e.__traceback__)
+        exc.__dict__.update(e.__dict__)
+        if hasattr(e, "args"):
+            exc.args = e.args
+        return self(exc, where=where)
+
+
+class WrappedMixin:
+    @reify
+    def stack(self):
+        # This is just a list, but appended from inner to outer, on except-clause, usually.
+        return []
+
+    def __str__(self):
+        return f"{super().__str__()} (where={tuple(reversed(self.stack))})"
+
+
+wrap_exception = WrappedExceptionFactory(prefix="Ex")
diff --git a/dictknife/jsonknife/accessor.py b/dictknife/jsonknife/accessor.py
index 92d09a3..2b602a4 100644
--- a/dictknife/jsonknife/accessor.py
+++ b/dictknife/jsonknife/accessor.py
@@ -4,6 +4,7 @@ from dictknife import Accessor
 from dictknife import operators
 from dictknife.langhelpers import as_jsonpointer, as_path_node
 
+from ._wrapped_exception import wrap_exception
 
 logger = logging.getLogger(__name__)
 
@@ -132,9 +133,10 @@ def maybe_remove_by_json_pointer(doc, query, *, accessor=Accessor()):
 
 
 class StackedAccessor:
-    def __init__(self, resolver, accessor=Accessor()):
+    def __init__(self, resolver, *, accessor=Accessor(), wrap_exception=wrap_exception):
         self.stack = [resolver]
         self.accessor = accessor
+        self.wrap_exception = wrap_exception
 
     @property
     def resolver(self):
@@ -149,10 +151,7 @@ class StackedAccessor:
             where = None
             if len(self.stack) > 1:
                 where = self.stack[-2].name
-            exc = e.__class__("{} (where={})".format(e, where)).with_traceback(
-                e.__traceback__
-            )
-            exc.__dict__.update(e.__dict__)  # sync
+            exc = self.wrap_exception(e, where=where)
             raise exc from None
 
     def _access(self, subresolver, pointer):
diff --git a/dictknife/jsonknife/resolver.py b/dictknife/jsonknife/resolver.py
index e134543..85d6309 100644
--- a/dictknife/jsonknife/resolver.py
+++ b/dictknife/jsonknife/resolver.py
@@ -9,7 +9,7 @@ from dictknife.langhelpers import reify, pairrsplit
 
 from .accessor import AccessingMixin
 from .relpath import normpath
-
+from ._wrapped_exception import wrap_exception
 
 logger = logging.getLogger("jsonknife.resolver")
 
@@ -41,7 +41,8 @@ class ExternalFileResolver(AccessingMixin):
         doc=None,
         rawfilename=None,
         onload=None,
-        format=None
+        format=None,
+        wrap_exception=wrap_exception
     ):
         self.rawfilename = rawfilename or filename
         self.filename = os.path.normpath(os.path.abspath(str(filename)))
@@ -50,6 +51,7 @@ class ExternalFileResolver(AccessingMixin):
         self.history = history or [ROOT]
         self.onload = onload
         self.format = format
+        self.wrap_exception = wrap_exception
         if doc is not None:
             self.doc = doc
             if self.onload is not None:
@@ -77,13 +79,7 @@ class ExternalFileResolver(AccessingMixin):
                 self.onload(self.doc, self)
             return self.doc
         except Exception as e:
-            try:
-                exc = e.__class__("{} (where={})".format(e, self.name)).with_traceback(
-                    e.__traceback__
-                )
-                exc.__dict__.update(e.__dict__)  # sync
-            except Exception:
-                raise e from None
+            exc = self.wrap_exception(e, where=self.name)
             raise exc from None
 
     def new(self, filename, doc=None, rawfilename=None, format=None):
@@ -99,6 +95,7 @@ class ExternalFileResolver(AccessingMixin):
             rawfilename=rawfilename,
             onload=self.onload,
             format=format,
+            wrap_exception=self.wrap_exception,
         )
 
     def resolve_pathset(self, query):  # todo: refactoring


Test patch:
diff --git a/dictknife/tests/jsonknife/test_wrapped_exception.py b/dictknife/tests/jsonknife/test_wrapped_exception.py
new file mode 100644
index 0000000..a650581
--- /dev/null
+++ b/dictknife/tests/jsonknife/test_wrapped_exception.py
@@ -0,0 +1,55 @@
+import unittest
+
+
+class Tests(unittest.TestCase):
+    def _makeOne(self, *args, **kwargs):
+        from dictknife.jsonknife._wrapped_exception import WrappedExceptionFactory
+
+        return WrappedExceptionFactory(*args, **kwargs)
+
+    def test_it(self):
+        prefix = "Wrapped"
+        create_class = self._makeOne(prefix=prefix)
+        try:
+            raise KeyError("hmm")
+        except KeyError as e:
+            e2 = create_class(e, where="<toplevel>")
+            self.assertIsInstance(e2, KeyError)
+            self.assertEqual(e2.args, e.args)
+
+            self.assertEqual(e2.__class__.__name__, f"{prefix}KeyError")
+            self.assertListEqual(e2.stack, ["<toplevel>"])
+        else:
+            self.fail("must be raised")
+
+    def test_nested(self):
+        create_class = self._makeOne()
+
+        def f():
+            try:
+                return g()
+            except Exception as e:
+                raise create_class(e, where="f") from None
+
+        def g():
+            try:
+                return h()
+            except Exception as e:
+                raise create_class(e, where="g") from None
+
+        def h():
+            raise MyException("hmm", context="oyoyo")
+
+        class MyException(Exception):
+            def __init__(self, val, *, context):
+                super().__init__(val)
+                self.context = context
+
+        try:
+            f()
+        except Exception as e:
+            self.assertIsInstance(e, MyException)
+            self.assertListEqual(e.stack, ["g", "f"])
+            self.assertEqual(getattr(e, "context", None), "oyoyo")
+        else:
+            self.fail("must be raised")