Repository: ManiacalLabs/BiblioPixel
Pull request #618 (2018-02-13 21:05:31)

Almost the last code extracted from the control branch
These are all nice features on their own.

1. Now an `Address` can include function calls as well as indices and attributes!  Really useful for controlling existing applications.

2. Now anytime you need to specify any subobject - animation, layout, driver, or the yet-to-come controls - you can put a JSON filename in, and it'll load from that file.

3. Tweaked `Ops` to be [falsy](https://stackoverflow.com/questions/39983695/what-is-truthy-and-falsy-in-python-how-is-it-different-from-true-and-false) if it contains no operations.

---

The control branch itself is looking very good - I actually have tests passing with all the routing and everything working in it.  I think I'm out of code to write!  Now I need to write some tests - and a demo.




Patch:
diff --git a/bibliopixel/animation/collection.py b/bibliopixel/animation/collection.py
index 60dcc7c..79ca86b 100644
--- a/bibliopixel/animation/collection.py
+++ b/bibliopixel/animation/collection.py
@@ -1,5 +1,5 @@
 import os, traceback
-from .. project import aliases, construct, project
+from .. project import aliases, construct, load, project
 from . import animation
 from .. util import json, log
 
@@ -10,15 +10,9 @@ class Collection(animation.BaseAnimation):
     @staticmethod
     def pre_recursion(desc):
         def cleanup_animation(a):
-            if isinstance(a, str) and (
-                    a.endswith('.yml') or a.endswith('.json')):
-                if not os.path.isabs(a):
-                    a = os.path.join(os.path.dirname(project.ROOT_FILE), a)
-                a = json.load(a)
-                if 'animation' in a:
-                    # Dump other cruft.  This is useful if you want to load just
-                    # the animation from a complete project.
-                    a = {'animation': a['animation'], 'run': a.get('run', {})}
+            ld = load.load_if_filename(a)
+            if ld:
+                a = {k: v for k, v in ld.items() if k in ('animation', 'run')}
 
             if callable(a) or isinstance(a, str) or 'animation' not in a:
                 animation = a
diff --git a/bibliopixel/controllers/address.py b/bibliopixel/controllers/address.py
index aebf7de..ddfe3a8 100644
--- a/bibliopixel/controllers/address.py
+++ b/bibliopixel/controllers/address.py
@@ -4,86 +4,69 @@ using attributes and indexing.
 
 An address description is a string looking like:
 
+::
+
     .foo.bar[32][5][baz].bang
 
 which would mean
 
-"given an object x, the value x.foo.bar[32][5]['baz'].bang".
+"given an object target, the value ``target.foo.bar[32][5]['baz'].bang``".
+
+Addresses are divided into "segments".
+
+A segment contained in brackets ``[]`` is an index (for a list) or a key (for
+a dictionary) - otherwise, it's an attribute.
 
-Addresses are divided into "segments".  Segments in brackets are indexes or keys;
-otherwise they are attributes.  In the example above, the segments are
-foo, bar, [32], [5], [baz] and bang; foo and bar are attributes.
-baz is a string index, and 32 and 5 are numeric indexes.
+In the example above, the segments are ``foo``, ``bar``, ``[32]``, ``[5]``,
+``[baz]`` and ``bang``; ``foo`` and ``bar`` are attributes.
+``baz`` is a string index, and ``32`` and ``5`` are numeric indexes.
 
-You can use addresses to either get or set values in an object. Getting or
-setting throw a ValueError if the address can't be get or set, so calling code
-that needs to be robust even in the face of bad user data will have to catch and
-report or handle the error.
+You can use an Address to either get or set values in a target object.
 
-Numerical indexes are automatically distinguished from string indexes.  This is
-convenient for the user but prevents them from having dictionaries like
-{1: 'number one', '1': 'string one'} - they probably didn't want to do that
-anyway.
+Any key that's entirely numeric is taken to be an integer index.  This is
+convenient but prevents the creation of dictionaries like ``{1: 'x', '1': 'y'}``
+which probably didn't want to do that anyway.
 """
 
+from .. util import log
+
 
 class Address:
     class Segment:
         def __init__(self, name):
             self.name = name
 
-        def set(self, x, *value):
-            return self._set(x, (value[0] if len(value) == 1 else value))
+        def set(self, target, *value):
+            self._set(target, (value[0] if len(value) == 1 else value))
 
     class Attribute(Segment):
-        def get(self, x):
-            return getattr(x, self.name)
+        def get(self, target):
+            return getattr(target, self.name)
 
-        def _set(self, x, value):
-            return setattr(x, self.name, value)
+        def _set(self, target, value):
+            setattr(target, self.name, value)
 
     class Index(Segment):
-        def get(self, x):
-            return x[self.name]
+        def get(self, target):
+            return target[self.name]
 
-        def _set(self, x, value):
-            x[self.name] = value
+        def _set(self, target, value):
+            target[self.name] = value
 
-    def __init__(self, s):
-        def generate(s):
-            # Split on dots, then use [ and ] to split out indices
-            s = s.strip()
-            while s.startswith('.'):
-                s = s[1:]
-            while s.endswith('.'):
-                s = s[:-1]
-            for i, part in enumerate(s.split('.')):
-                head, *rest = part.split('[')
-
-                # Suppose our address was 'xxx[yyy][zzz]'
-                # Now we have first='xxx' and rest = 'yyy]', 'zzz]'
-                if head:
-                    yield Address.Attribute(head)
-                elif i:
-                    # An index [] is only allowed to start the *first* segment -
-                    # for example, an address like a.[2] is forbidden.
-                    raise ValueError
-
-                for r in rest:
-                    # A segment must have exactly one ']', exactly at the end.
-                    index, between = r.split(']')
-                    if between:
-                        raise ValueError
-
-                    try:
-                        index = int(index)
-                    except ValueError:
-                        pass
-
-                    yield Address.Index(index)
+    class Call(Segment):
+        def __init__(self):
+            pass
 
+        def get(self, target):
+            return target()
+
+        def set(self, target, *value):
+            print('!!!', target)
+            target(*value)
+
+    def __init__(self, s):
         try:
-            self.address = list(generate(s))
+            self.address = list(_generate(s))
         except:
             raise ValueError('%s is not a legal address' % s)
 
@@ -91,15 +74,79 @@ class Address:
             raise ValueError('Empty Addresses are not allowed')
 
     @staticmethod
-    def _get(x, address):
+    def _get(target, address):
         for a in address:
-            x = a.get(x)
-        return x
+            target = a.get(target)
+        return target
 
-    def get(self, x):
-        return self._get(x, self.address)
+    def get(self, target):
+        return self._get(target, self.address)
 
-    def set(self, x, *value):
+    def set(self, target, *value):
         *first, last = self.address
-        parent = self._get(x, first)
+        parent = self._get(target, first)
         last.set(parent, *value)
+
+
+def _generate(s):
+    def extract_calls(p):
+        # Extract () pairs from start and finish of a string
+        before, after = [], []
+        while p.startswith('()'):
+            before.append(Address.Call())
+            p = p[2:]
+
+        while p.endswith('()'):
+            after.append(Address.Call())
+            p = p[:-2]
+
+        return before, p, after
+
+    # Split on dots, then use [ and ] to split out indices
+    s = s.strip()
+    if s.startswith('.'):
+        s = s[1:]
+    if s.endswith('.'):
+        raise ValueError
+
+    for i, part in enumerate(s.split('.')):
+        if not part:
+            raise ValueError
+
+        head, *rest = part.split('[')
+
+        # If we had e.g. 'xxx()()[yyy]()[zzz]()()'
+        # Now we have first='xxx()' and rest = 'yyy]()', 'zzz]()()'
+        # They might have written: ()xxx by mistake
+        before, head, after = extract_calls(head)
+
+        if before:
+            # A call () is only allowed to start the first segment -
+            # for example, an address like a.() is forbidden.
+            if i or head:
+                raise ValueError
+            yield from before
+
+        elif head:
+            yield Address.Attribute(head)
+            yield from after
+
+        elif i:
+            # An index [] is only allowed to start the first segment -
+            # for example, an address like a.[2] is forbidden.
+            raise ValueError
+
+        for r in rest:
+            before, r, after = extract_calls(r)
+            if before:
+                # A segment cannot contain a () - they must all be at top level.
+                raise ValueError
+
+            # A segment must have exactly one ']', exactly at the end.
+            index, between = r.split(']', 1)
+            if between:
+                raise ValueError
+
+            index = int(index) if index.isdigit() else index
+            yield Address.Index(index)
+            yield from after
diff --git a/bibliopixel/controllers/ops.py b/bibliopixel/controllers/ops.py
index 312f5c3..5f93382 100644
--- a/bibliopixel/controllers/ops.py
+++ b/bibliopixel/controllers/ops.py
@@ -51,6 +51,9 @@ class Ops:
             x = op(x)
         return x
 
+    def __bool__(self):
+        return bool(self.ops)
+
 
 def _make_ops(ops):
     op, values = None, []
diff --git a/bibliopixel/project/construct.py b/bibliopixel/project/construct.py
index 2ce0b04..64fa883 100644
--- a/bibliopixel/project/construct.py
+++ b/bibliopixel/project/construct.py
@@ -43,3 +43,8 @@ def to_type_constructor(value, python_path=None):
         value['datatype'] = importer.import_symbol(r, python_path=python_path)
 
     return value
+
+
+def construct_type(desc, python_path=None):
+    tc = to_type_constructor(desc, python_path)
+    return construct(**tc)
diff --git a/bibliopixel/project/load.py b/bibliopixel/project/load.py
index 60b9756..4231893 100644
--- a/bibliopixel/project/load.py
+++ b/bibliopixel/project/load.py
@@ -4,6 +4,7 @@ from .. util import json
 
 guess_name = loady.importer.guess_name
 CACHE = os.path.expanduser('~/.bibliopixel/code_cache')
+ROOT_FILE = None
 
 
 def data(name, use_json=True):
@@ -27,3 +28,11 @@ def module(name, python_path=None):
 def extender(path):
     parts = [os.getcwd()] + path.split(':')
     return parts and loady.sys_path.extender(':'.join(parts))
+
+
+def load_if_filename(s):
+    if isinstance(s, str) and (s.endswith('.yml') or s.endswith('.json')):
+        if not os.path.isabs(s):
+            s = os.path.join(os.path.dirname(ROOT_FILE), s)
+
+        return json.load(s)
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index 4ed3514..e623da8 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -1,8 +1,6 @@
 from . import attributes, construct, cleanup, defaults, load, recurse
 from .. util import exception, json
 
-ROOT_FILE = None
-
 
 class Project:
     CHILDREN = 'maker', 'drivers', 'layout', 'animation'
@@ -49,9 +47,7 @@ def project(*descs, root_file=None):
 
     desc = defaults.merge(*descs)
 
-    global ROOT_FILE
-    ROOT_FILE = root_file
-
+    load.ROOT_FILE = root_file
     with load.extender(desc.get('path', '')):
         desc = recurse.recurse(desc)
 
diff --git a/bibliopixel/project/recurse.py b/bibliopixel/project/recurse.py
index c6b7711..a9caa4d 100644
--- a/bibliopixel/project/recurse.py
+++ b/bibliopixel/project/recurse.py
@@ -1,4 +1,4 @@
-from . import construct
+from . import construct, load
 
 """
 In order to validate project descriptions, we need to recurse through project
@@ -42,6 +42,8 @@ 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')
 
@@ -52,6 +54,8 @@ def recurse(desc, pre='pre_recursion', post=None, python_path=None):
         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:


Test patch:
diff --git a/test/bibliopixel/controllers/address_test.py b/test/bibliopixel/controllers/address_test.py
index e430e19..006b870 100644
--- a/test/bibliopixel/controllers/address_test.py
+++ b/test/bibliopixel/controllers/address_test.py
@@ -5,10 +5,8 @@ from bibliopixel.controllers.address import Address
 class AddressTest(unittest.TestCase):
 
     def test_empty_error(self):
-        with self.assertRaises(ValueError) as e:
+        with self.assertRaises(ValueError):
             Address('')
-        print(dir(e.exception))
-        self.assertIn('Empty Address', e.exception.args[0])
 
     def test_attrib(self):
         address = Address('attr')
@@ -56,6 +54,33 @@ class AddressTest(unittest.TestCase):
         address.set(self, 'bang')
         self.assertEqual(self.attr3, 'bang')
 
+    def call(self, x):
+        self.call_result = 23
+
+    def test_trivial_call(self):
+        address = Address('()')
+        self.assertEqual(len(address.address), 1)
+        result = []
+
+        address.set(result.append, 'value')
+        self.assertEqual(result, ['value'])
+
+    def test_call(self):
+        address = Address('.call()')
+        self.assertEqual(len(address.address), 2)
+        address.set(self, 23)
+        self.assertEqual(self.call_result, 23)
+
+    def call2(self):
+        return None, lambda: self
+
+    def test_call_complex(self):
+        self.results = []
+        address = Address('.call2()[1]().call()')
+        self.assertEqual(len(address.address), 6)
+        address.set(self, 23)
+        self.assertEqual(self.call_result, 23)
+
     def test_compound_error(self):
         address = Address('attr1[0][test][1][heck].attr2.attr3')