Repository: ManiacalLabs/BiblioPixel
Pull request #259 (2017-06-29 12:58:42)

Bug fixes and tiny features.
Sorry for the grab-bag nature of this.

I needed to simplify the project stuff, there was just needless crap in there.


Patch:
diff --git a/bibliopixel/drivers/SimPixel/driver.py b/bibliopixel/drivers/SimPixel/driver.py
index 04ba5b5..4a7c788 100644
--- a/bibliopixel/drivers/SimPixel/driver.py
+++ b/bibliopixel/drivers/SimPixel/driver.py
@@ -12,7 +12,7 @@ Perhaps BiblioPixel is already running on your machine?
 
 class SimPixel(DriverBase):
 
-    def __init__(self, num, port=1337, pixel_positions=None, **kwds):
+    def __init__(self, num=1024, port=1337, pixel_positions=None, **kwds):
         """
         Args:
             num:  number of LEDs being visualizer.
diff --git a/bibliopixel/layout/matrix.py b/bibliopixel/layout/matrix.py
index ac04769..7e373ff 100644
--- a/bibliopixel/layout/matrix.py
+++ b/bibliopixel/layout/matrix.py
@@ -23,15 +23,8 @@ class Matrix(Layout):
         super().__init__(drivers, threadedUpdate, brightness,
                          maker=kwargs.get('maker', data_maker.MAKER))
 
-        if width == 0 and height == 0:
-            if len(self.drivers) != 1:
-                raise TypeError(
-                    "Must provide width and height if using multiple drivers!")
-            width = self.drivers[0].width
-            height = self.drivers[0].height
-
-        self.width = width
-        self.height = height
+        self.width = width or getattr(self.drivers[0], 'width') or 32
+        self.height = height or getattr(self.drivers[0], 'height') or 32
         self.pixelSize = pixelSize
         pw, ph = self.pixelSize
 
@@ -44,9 +37,9 @@ class Matrix(Layout):
                 raise TypeError(
                     "No width or height passed but the number of LEDs is not a perfect square")
 
-        if self.width * self.height != self.numLEDs:
-            raise ValueError('width * height MUST equal total pixel count!'
-                             ' %s * %s != %s'
+        if self.width * self.height > self.numLEDs:
+            raise ValueError('width * height cannot exceed total pixel count!'
+                             ' %s * %s > %s'
                              % (self.width, self.height, self.numLEDs))
 
         if coordMap:
diff --git a/bibliopixel/main/arguments.py b/bibliopixel/main/arguments.py
new file mode 100644
index 0000000..3330d97
--- /dev/null
+++ b/bibliopixel/main/arguments.py
@@ -0,0 +1,42 @@
+from .. project import project
+
+"""Common command line arguments for run and demo."""
+
+
+def add_to_parser(parser):
+    parser.add_argument(
+        '-d', '--driver', default='simpixel',
+        help='Default driver type if no driver is specified')
+
+    parser.add_argument(
+        '-l', '--layout', default='matrix',
+        help='Default layout class if no layout is specified')
+
+    parser.add_argument(
+        '-t', '--ledtype', default=None,
+        help='Default LED type if no LED type is specified')
+
+    parser.add_argument(
+        '-a', '--animation', default=None,
+        help='Default animation type if no animation is specified')
+
+    parser.add_argument(
+        '-s', action='store_true', help='Run SimPixel at the default URL')
+
+    parser.add_argument('--simpixel', help='Run SimPixel at a specific URL')
+
+
+def get_dict(args):
+    result = {}
+    for name in 'driver', 'layout', 'animation':
+        value = args and getattr(args, name)
+        result[name] = {'typename': value} if value else {}
+
+    if args and args.ledtype:
+        result['driver']['ledtype'] = args.ledtype
+
+    return result
+
+
+def make_animation(args, desc):
+    return project.project_to_animation(desc, get_dict(args))
diff --git a/bibliopixel/main/demo.py b/bibliopixel/main/demo.py
index 56e62fe..83c66e8 100644
--- a/bibliopixel/main/demo.py
+++ b/bibliopixel/main/demo.py
@@ -1,7 +1,6 @@
-import random, webbrowser
+import random
 
-from . demo_table import DEMO_TABLE
-from . import run as _run
+from . import arguments, demo_table, simpixel
 from .. project import project
 
 HELP = """
@@ -11,8 +10,7 @@ Run a demo.  For the list of possible demos, type
 
 """
 
-DEFAULT_SIMPIXEL_URL = 'http://simpixel.io'
-DEMO_OPTS = ', '.join(sorted(DEMO_TABLE.keys()))
+DEMO_OPTS = ', '.join(sorted(demo_table.DEMO_TABLE.keys()))
 
 
 def make_runnable(demo, args):
@@ -39,8 +37,7 @@ def make_runnable(demo, args):
         if 'z' in layout:
             layout['z'] = layout['z'] or args.depth
 
-    defaults = _run.get_project_default_arguments(args)
-    return project.project_to_animation(project=demo, defaults=defaults).start
+    return arguments.make_animation(args, demo).start
 
 
 def usage():
@@ -54,18 +51,16 @@ def run(args, settings):
 
     if not args.name:
         usage()
-        args.name = random.choice(list(DEMO_TABLE))
+        args.name = random.choice(list(demo_table.DEMO_TABLE))
         print('Randomly selected', args.name)
 
     try:
-        demo = DEMO_TABLE[args.name]
+        demo = demo_table.DEMO_TABLE[args.name]
     except KeyError:
         raise KeyError('Unknown demo %s' % args.name)
 
     runnable = make_runnable(demo, args)
-    if not args.simpixel.startswith('no'):
-        webbrowser.open(args.simpixel, new=0, autoraise=True)
-
+    simpixel.open_simpixel(args.simpixel)
     runnable()
 
 
@@ -73,6 +68,7 @@ def set_parser(parser):
     parser.set_defaults(run=run)
     parser.description = 'Run demo in SimPixel'
 
+    arguments.add_to_parser(parser)
     parser.add_argument(
         'name', nargs='?', default='',
         help='Name of demo to run. Options are: {}'.format(DEMO_OPTS))
@@ -88,9 +84,3 @@ def set_parser(parser):
     parser.add_argument(
         '--depth', default=16, type=int,
         help='Z dimension of display. Only used for Cube demos.')
-
-    parser.add_argument(
-        '--simpixel', default=DEFAULT_SIMPIXEL_URL,
-        help='URL for SimPixel program.')
-
-    _run.add_project_default_arguments(parser)
diff --git a/bibliopixel/main/main.py b/bibliopixel/main/main.py
index 9165dd2..ca01795 100644
--- a/bibliopixel/main/main.py
+++ b/bibliopixel/main/main.py
@@ -26,6 +26,12 @@ LOGLEVEL_HELP = """\
 Set what level of events to log. Higher log levels print less."""
 
 
+VERBOSE_HELP = """\
+If this is set, then errors are reported with a full stack trace.
+If not set, just the exception message is printed.
+"""
+
+
 def no_command(*_):
     print('ERROR: No command entered')
     print('Valid:', ', '.join(COMMANDS))
@@ -35,13 +41,16 @@ def no_command(*_):
 def main():
     parser = argparse.ArgumentParser()
     subparsers = parser.add_subparsers()
+
     for name, module in sorted(MODULES.items()):
         subparser = subparsers.add_parser(name, help=module.HELP)
         module.set_parser(subparser)
-    parser.add_argument('--loglevel', choices=LOG_LEVELS, default='info',
-                        help=LOGLEVEL_HELP)
-    parser.add_argument('--path', default=None,
-                        help=PATH_HELP)
+
+    parser.add_argument(
+        '--loglevel', choices=LOG_LEVELS, default='info', help=LOGLEVEL_HELP)
+    parser.add_argument('--path', default=None, help=PATH_HELP)
+    parser.add_argument('--verbose', '-v', action='store_true')
+
     if ENABLE_PRESETS:
         parser.add_argument('--presets', help='Filename for presets',
                             default=PRESET_LIBRARY_DEFAULT)
@@ -49,10 +58,19 @@ def main():
     args = ['--help' if i == 'help' else i for i in sys.argv[1:]]
     args = parser.parse_args(args)
 
-    log.set_log_level(args.loglevel)
-    presets = ENABLE_PRESETS and PresetLibrary(
-        os.path.expanduser(args.presets), True)
+    try:
+        log.set_log_level(args.loglevel)
+        presets = ENABLE_PRESETS and PresetLibrary(
+            os.path.expanduser(args.presets), True)
+
+        run = getattr(args, 'run', no_command)
+        gitty.sys_path.extend(args.path)
+        result = run(args, presets) or 0
+    except Exception as e:
+        if args.verbose:
+            raise
+        print('ERROR:', e.args[0], file=sys.stderr)
+        print(*e.args[1:], sep='\n', file=sys.stderr)
+        result = getattr(e, 'errorcode', -1)
 
-    run = getattr(args, 'run', no_command)
-    gitty.sys_path.extend(args.path)
-    sys.exit(run(args, presets) or 0)
+    sys.exit(result)
diff --git a/bibliopixel/main/run.py b/bibliopixel/main/run.py
index b1cf0b5..255bb56 100644
--- a/bibliopixel/main/run.py
+++ b/bibliopixel/main/run.py
@@ -1,5 +1,5 @@
 import json
-from .. project import project
+from . import simpixel, arguments
 from .. import log
 from .. util import files
 
@@ -8,54 +8,37 @@ Run a project description file.
 """
 
 
-def add_project_default_arguments(parser):
-    parser.add_argument(
-        '-d', '--driver', default=None,
-        help='Default driver type if no driver is specified')
-
-    parser.add_argument(
-        '-l', '--layout', default=None,
-        help='Default LAYOUT class if no LAYOUT is specified')
-
-    parser.add_argument(
-        '-t', '--ledtype', default=None,
-        help='Default LED type if no LED type is specified')
-
-    parser.add_argument(
-        '-a', '--animation', default=None,
-        help='Default animation type if no animation is specified')
+def make_desc(name, is_json):
+    if not name:
+        return {}
 
-
-def get_project_default_arguments(args):
-    return {
-        'driver': args.driver,
-        'layout': args.layout,
-        'animation': args.animation,
-        'ledtype': args.ledtype,
-    }
-
-
-def project_to_animation(name, is_json, defaults):
-    log.info('Processing project file...')
     data = name if is_json else files.opener(name).read()
     try:
-        desc = json.loads(data)
+        return json.loads(data)
     except ValueError as e:
         e.args += tuple(['in %s' % name])
         raise
-    return project.project_to_animation(desc, defaults)
+
+
+def make_animation(name, is_json, args):
+    desc = make_desc(name, is_json)
+    return arguments.make_animation(args, desc)
 
 
 def run(args, settings):
-    if args.name:
-        defaults = get_project_default_arguments(args)
-        project_to_animation(args.name, args.json, defaults).start()
+    if args.simpixel:
+        simpixel.open_simpixel(args.simpixel)
+    elif args.s:
+        simpixel.open_simpixel()
+    make_animation(args.name, args.json, args).start()
 
 
 def set_parser(parser):
     parser.set_defaults(run=run)
     parser.description = 'Run specified BiblioPixel project from file or URL.'
 
+    arguments.add_to_parser(parser)
+
     parser.add_argument(
         'name', nargs='?',
         help='Path project file - can be a URL or file system location',
@@ -64,5 +47,3 @@ def set_parser(parser):
     parser.add_argument(
         '-j', '--json', action='store_true',
         help='Enter JSON directly as a command line argument.')
-
-    add_project_default_arguments(parser)
diff --git a/bibliopixel/main/set.py b/bibliopixel/main/set.py
index b220507..55bf23c 100644
--- a/bibliopixel/main/set.py
+++ b/bibliopixel/main/set.py
@@ -1,10 +1,11 @@
 import json
-from ..project.defaults import SECTIONS
 
 HELP = """
 Set bibliopixel values
 """
 
+SECTIONS = 'transport', 'driver', 'layout', 'animation', 'run', 'path'
+
 
 def run(args, settings):
     parts = args.name.split('.')
diff --git a/bibliopixel/main/show.py b/bibliopixel/main/show.py
index b25b063..3a741c1 100644
--- a/bibliopixel/main/show.py
+++ b/bibliopixel/main/show.py
@@ -1,5 +1,3 @@
-from ..project.defaults import SECTIONS
-
 HELP = """
 Show bibliopixel settings.
 """
diff --git a/bibliopixel/main/simpixel.py b/bibliopixel/main/simpixel.py
new file mode 100644
index 0000000..2391568
--- /dev/null
+++ b/bibliopixel/main/simpixel.py
@@ -0,0 +1,10 @@
+import webbrowser
+
+DEFAULT_URL = 'http://simpixel.io'
+
+
+def open_simpixel(url=None):
+    url = url or DEFAULT_URL
+
+    if not url.startswith('no'):
+        webbrowser.open(url, new=0, autoraise=True)
diff --git a/bibliopixel/project/aliases.py b/bibliopixel/project/aliases.py
index 9efed8a..2a89f75 100644
--- a/bibliopixel/project/aliases.py
+++ b/bibliopixel/project/aliases.py
@@ -33,26 +33,27 @@ ALIASES = {
         'bibliopixel.animation.tests.MatrixCalibrationTest',
         'matrix_test': 'bibliopixel.animation.tests.MatrixChannelTest',
         'receiver': 'bibliopixel.animation.receiver.BaseReceiver',
+
+
         'sequence': 'bibliopixel.animation.Sequence',
         'strip_test': 'bibliopixel.animation.tests.StripChannelTest',
     },
 }
 
 
-def fill_typename(desc, key):
-    if isinstance(desc, str):
-        return fill_typename({'typename': desc}, key)
+def resolve(*dicts):
+    """Resolve aliases and merge.  Evaluation proceeds from left to right."""
+    result = {}
 
-    typename = desc.get('typename')
-    if typename:
-        desc['typename'] = ALIASES[key].get(typename, typename)
+    for d in dicts:
+        for key, value in d.items():
+            if isinstance(value, str):
+                value = {'typename': value}
 
-    return desc
+            typename = value.get('typename')
+            if typename and key in ALIASES:
+                value['typename'] = ALIASES[key].get(typename, typename)
 
+            result.setdefault(key, {}).update(**value)
 
-def resolve_aliases(project):
-    result = copy.deepcopy(project)
-    for key in ALIASES:
-        if key in result:
-            result[key] = fill_typename(result[key], key)
     return result
diff --git a/bibliopixel/project/defaults.py b/bibliopixel/project/defaults.py
deleted file mode 100644
index c2c0b6d..0000000
--- a/bibliopixel/project/defaults.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import copy
-
-from . aliases import resolve_aliases
-
-DEFAULTS = {
-    'driver': {
-        'typename': 'bibliopixel.drivers.SimPixel.SimPixel',
-        'num': 1024
-    },
-
-    'layout': {
-        'typename': 'bibliopixel.layout.matrix.Matrix',
-        'width': 32,
-        'height': 32,
-    },
-
-    'animation': {
-        'typename': 'BiblioPixelAnimations.matrix.bloom.Bloom'
-    },
-
-    'maker': {},
-    'run': {},
-}
-
-SECTIONS = tuple(sorted(DEFAULTS.keys()))
-
-
-def merge(default, *others):
-    result = copy.deepcopy(default)
-    for o in others:
-        for k, v in o.items():
-            result[k].update(v)
-
-    return result
-
-
-def apply_defaults(desc):
-    return merge(DEFAULTS, resolve_aliases(desc))
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index 1c781d0..1623d7f 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -1,12 +1,11 @@
-import gitty, json, os, sys
-from . import defaults, importer
+import gitty, sys
+from . import aliases, importer
 from . types.defaults import FIELD_TYPES
 from .. animation import runner
 from .. import data_maker
 from .. layout.geometry import gen_matrix
 from .. layout.multimap import MultiMapBuilder
 from .. util import files
-from .. drivers.serial import codes
 
 
 def _make_object(*args, field_types=FIELD_TYPES, **kwds):
@@ -38,44 +37,21 @@ def _make_layout(driver, layout, maker=None):
 
 
 def make_animation(layout, animation, run=None):
-    animation = _make_object(layout, **animation)
+    try:
+        animation = _make_object(layout, **animation)
+    except ImportError:
+        raise ValueError('Could not load animation class "%s"' %
+                         animation.get('typename'))
     animation.set_runner(runner.Runner(**(run or {})))
     return animation
 
 
-def _make_project(path=None, **project):
+def _make_project(path=None, animation=None, run=None, **kwds):
     gitty.sys_path.extend(path)
-
-    kwds = defaults.apply_defaults(project)
-    animation = kwds.pop('animation', {})
-    run = kwds.pop('run', {})
     layout = _make_layout(**kwds)
-    return make_animation(layout, animation, run)
-
-
-def _apply_defaults(project, defaults):
-    # TODO: consolidate with bp.project.defaults.
-    for k, v in defaults.items():
-        if k == 'ledtype' or not v:
-            continue
-
-        if k not in project:
-            project[k] = v
-
-        else:
-            existing = project[k]
-            if 'typename' not in existing:
-                try:
-                    existing['typename'] = v
-                except:
-                    pass
-
-    ledtype = defaults.get('ledtype')
-    if ledtype:
-        if 'ledtype' not in project['driver']:
-            project['driver']['ledtype'] = ledtype
+    return make_animation(layout, animation or {}, run or {})
 
 
-def project_to_animation(project, defaults=None):
-    _apply_defaults(project, defaults or {})
+def project_to_animation(desc, default):
+    project = aliases.resolve(default or {}, desc)
     return _make_project(**project)


Test patch:
diff --git a/test/aliases_test.py b/test/aliases_test.py
index e357b2e..6d49593 100644
--- a/test/aliases_test.py
+++ b/test/aliases_test.py
@@ -1,17 +1,17 @@
 import unittest
-from bibliopixel.project.aliases import resolve_aliases
+from bibliopixel.project import aliases
 
 
 class AliasTest(unittest.TestCase):
     def test_empty(self):
-        self.assertEqual(resolve_aliases({}), {})
+        self.assertEqual(aliases.resolve({}), {})
 
     def test_simple(self):
-        self.assertEqual(resolve_aliases({'layout': {}}), {'layout': {}})
+        self.assertEqual(aliases.resolve({'layout': {}}), {'layout': {}})
         d = {'layout': {'typename': 'bibliopixel.layout.circle.Circle'}}
-        self.assertEqual(resolve_aliases(d), d)
+        self.assertEqual(aliases.resolve(d), d)
 
     def test_resolve(self):
         d = {'layout': {'typename': 'bibliopixel.layout.circle.Circle'}}
-        self.assertEqual(resolve_aliases({'layout': 'circle'}), d)
-        self.assertEqual(resolve_aliases({'layout': {'typename': 'circle'}}), d)
+        self.assertEqual(aliases.resolve({'layout': 'circle'}), d)
+        self.assertEqual(aliases.resolve({'layout': {'typename': 'circle'}}), d)
diff --git a/test/project_test.py b/test/project_test.py
index 26064cc..ed5c471 100644
--- a/test/project_test.py
+++ b/test/project_test.py
@@ -111,7 +111,7 @@ PROJECT_SIM = """
 
 
 def start(name, is_json=True):
-    animation = run.project_to_animation(name, is_json, None)
+    animation = run.make_animation(name, is_json, None)
     if not SKIP_LONG_TESTS:
         animation.start()
     return animation