Repository: ManiacalLabs/BiblioPixel
Pull request #627 (2018-02-19 15:10:13)

Allow construction of animations to fail
This is part of fixing #626 but in testing, I realized that it does not handle errors that happen early in constructing a project:

* if you specify a typename that doesn't exist
* if you forget to specify a typename at all or if you misspelled the keyword `typename`




Patch:
diff --git a/bibliopixel/animation/remote/control.py b/bibliopixel/animation/remote/control.py
index 0c0b600..4851ccf 100644
--- a/bibliopixel/animation/remote/control.py
+++ b/bibliopixel/animation/remote/control.py
@@ -85,7 +85,7 @@ class RemoteControl(collection.Collection):
             name = cfg['name']
             if name in self.name_map:
                 raise ValueError('Cannot have multiple animations with the same name: ' + name)
-            if anim:
+            if getattr(anim, 'empty', False):
                 anim.on_completion = self.on_completion
                 self.name_map[name] = i
             else:
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index e623da8..a65691b 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -1,5 +1,7 @@
+import traceback
 from . import attributes, construct, cleanup, defaults, load, recurse
-from .. util import exception, json
+from .. util import exception, json, log
+from .. animation import BaseAnimation
 
 
 class Project:
@@ -16,27 +18,40 @@ class Project:
         return datatype(**kwds)
 
     def __init__(self, *, drivers, layout, maker, path, animation, **kwds):
-        attributes.check(kwds, 'project')
-        self.path = path
-        layout = layout or cleanup.cleanup_layout(animation)
-
-        self.maker = self.construct_child(**maker)
-
         def post(desc):
             return self.construct_child(**desc)
 
-        def create(root, name):
+        def create(root, name, recover=None):
             with exception.add('Unable to create ' + name):
                 return recurse.recurse(
                     root,
                     pre=None,
                     post=post,
-                    python_path='bibliopixel.' + name)
+                    python_path='bibliopixel.' + name,
+                    recover=recover)
 
+        attributes.check(kwds, 'project')
+        self.path = path
+        layout = layout or cleanup.cleanup_layout(animation)
+
+        self.maker = self.construct_child(**maker)
         self.drivers = [create(d, 'drivers') for d in drivers]
         with exception.add('Unable to create layout'):
-            self.layout = self.construct_child(**layout)
-        self.animation = create(animation, 'animation')
+            layout = self.layout = self.construct_child(**layout)
+
+        class Empty(BaseAnimation):
+            def __init__(self, desc, exception):
+                super().__init__(layout)
+                self.set_runner({})
+                log.error('Unable to create animation for %s', desc)
+                debug = log.get_log_level() <= log.DEBUG
+                msg = traceback.format_exc() if debug else str(exception)
+                log.error('\n%s', msg)
+                self.desc = desc
+                self.exception = exception
+                self.empty = True
+
+        self.animation = create(animation, 'animation', Empty)
 
 
 def project(*descs, root_file=None):
diff --git a/bibliopixel/project/recurse.py b/bibliopixel/project/recurse.py
index a9caa4d..cbe51fe 100644
--- a/bibliopixel/project/recurse.py
+++ b/bibliopixel/project/recurse.py
@@ -17,7 +17,8 @@ children.
 """
 
 
-def recurse(desc, pre='pre_recursion', post=None, python_path=None):
+def recurse(desc, pre='pre_recursion', post=None, python_path=None,
+            recover=None):
     """
     Depth first recursion through a dictionary containing type constructors
 
@@ -42,23 +43,28 @@ def recurse(desc, pre='pre_recursion', post=None, python_path=None):
             f = getattr(datatype, f, None)
         return f and f(desc)
 
-    # Automatically load strings that look like JSON or Yaml filenames.
-    desc = load.load_if_filename(desc) or desc
-    desc = construct.to_type_constructor(desc, python_path)
-    datatype = desc.get('datatype')
+    try:
+        # Automatically load strings that look like JSON or Yaml filenames.
+        desc = load.load_if_filename(desc) or desc
+        desc = construct.to_type_constructor(desc, python_path)
+        datatype = desc.get('datatype')
 
-    desc = call(pre, desc) or desc
+        desc = call(pre, desc) or desc
 
-    for child_name in getattr(datatype, 'CHILDREN', []):
-        child = desc.get(child_name)
-        if child:
-            new_path = python_path or ('bibliopixel.' + child_name)
-            if child_name.endswith('s'):
-                if isinstance(child, (dict, str)):
-                    child = [child]
-                for i, c in enumerate(child):
-                    child[i] = recurse(c, pre, post, new_path)
-            else:
-                desc[child_name] = recurse(child, pre, post, new_path)
+        for child_name in getattr(datatype, 'CHILDREN', []):
+            child = desc.get(child_name)
+            if child:
+                new_path = python_path or ('bibliopixel.' + child_name)
+                if child_name.endswith('s'):
+                    if isinstance(child, (dict, str)):
+                        child = [child]
+                    for i, c in enumerate(child):
+                        child[i] = recurse(c, pre, post, new_path)
+                else:
+                    desc[child_name] = recurse(child, pre, post, new_path)
 
-    return call(post, desc) or desc
+        return call(post, desc) or desc
+    except Exception as e:
+        if recover:
+            return recover(desc, e)
+        raise
diff --git a/bibliopixel/util/log.py b/bibliopixel/util/log.py
index 4de01f0..87ad453 100644
--- a/bibliopixel/util/log.py
+++ b/bibliopixel/util/log.py
@@ -103,6 +103,10 @@ def set_log_level(level):
     logger.setLevel(level)
 
 
+def get_log_level():
+    return logger.getEffectiveLevel()
+
+
 # Add a new logging level FRAME for messages which appear on every frame.
 _addLoggingLevel('FRAME', FRAME)
 logger = _new_custom_logger()
diff --git a/projects/99-remote-matrix.json b/projects/99-remote-matrix.json
index 83c1f9b..0084a81 100644
--- a/projects/99-remote-matrix.json
+++ b/projects/99-remote-matrix.json
@@ -16,7 +16,7 @@
         "animations": [
             {
                 "animation": {
-                    "typename": "BiblioPixelAnimations.matrix.ImageAnim.ImageAnim",
+                    "typename": "MASSIVE FAILURE",
                     "name": "ImageAnim"
                 },
                 "run": {


Test patch:
diff --git a/test/bibliopixel/project/bad_project_test.py b/test/bibliopixel/project/bad_project_test.py
index 59390bb..5c6fbe4 100644
--- a/test/bibliopixel/project/bad_project_test.py
+++ b/test/bibliopixel/project/bad_project_test.py
@@ -99,20 +99,16 @@ class BadProjectTest(unittest.TestCase):
              'Unknown attribute for layout Matrix: "bad_attribute"'))
 
     def test_bad_animation_attribute(self):
-        with self.assertRaises(ValueError) as e:
-            make(BAD_ANIMATION_ATTRIBUTE)
+        animation = make(BAD_ANIMATION_ATTRIBUTE, run_start=False)
         self.assertEquals(
-            e.exception.args,
-            ('Unable to create animation',
-             'Unknown attribute for animation OffAnim: "bad_attribute"'))
+            animation.exception.args,
+            ('Unknown attribute for animation OffAnim: "bad_attribute"',))
 
     def test_bad_run_attribute(self):
-        with self.assertRaises(ValueError) as e:
-            make(BAD_RUN_ATTRIBUTE)
+        animation = make(BAD_RUN_ATTRIBUTE, run_start=False)
         self.assertEquals(
-            e.exception.args,
-            ('Unable to create animation',
-             'Unknown attribute for run: "bad_attribute"'))
+            animation.exception.args,
+            ('Unknown attribute for run: "bad_attribute"',))
 
     def test_missing_layout(self):
         with self.assertRaises(ValueError) as e: