Repository: ManiacalLabs/BiblioPixel
Pull request #1103 (2018-12-09 17:03:26)

Improve Project Builder

rec: This really cleans up the Project Builder and fixes a bunch of edgecases that I found experimenting with it.

Next - more documentation.
rec: Oops.

Patch:
diff --git a/bibliopixel/builder/__init__.py b/bibliopixel/builder/__init__.py
new file mode 100644
index 0000000..194b23c
--- /dev/null
+++ b/bibliopixel/builder/__init__.py
@@ -0,0 +1,1 @@
+from . builder import Builder
diff --git a/bibliopixel/builder/builder.py b/bibliopixel/builder/builder.py
new file mode 100644
index 0000000..493daf8
--- /dev/null
+++ b/bibliopixel/builder/builder.py
@@ -0,0 +1,103 @@
+from . pixels import Pixels
+from . runner import Runner
+from . saved_description import SavedDescription
+from .. import colors
+from .. commands import animations
+from .. drivers.SimPixel import driver as simpixel_driver
+from .. project import project
+
+
+class Builder(SavedDescription):
+    """
+    A Project Builder to allow people to experiment with projects
+    from the command line or in their own main program.
+    """
+    COLORS = colors.COLORS
+
+    def __init__(self, project_file='', desc=None, threaded=None, **kwds):
+
+        self.project = None
+        self.pixel = Pixels(self)
+        self._runner = Runner(self)
+
+        super().__init__(project_file, desc, **kwds)
+
+        if threaded is not None:
+            self.threaded = threaded
+
+    def start(self, threaded=None):
+        """Creates and starts the project."""
+        if threaded is not None:
+            self.threaded = threaded
+        self._runner.start(self.threaded)
+
+    def stop(self=None):
+        """Stop the builder if it's running."""
+        if not self:
+            self = getattr(Runner.instance(), 'builder', None)
+            if not self:
+                return
+
+        self._runner.stop()
+        if self.project:
+            self.project.stop()
+
+    def clear(self):
+        """Stop the project if it's running and clear the project description"""
+        self.stop()
+        super().clear()
+
+    @property
+    def is_running(self):
+        return self._runner.is_running
+
+    @property
+    def threaded(self):
+        return self.desc.run.get('threaded', False)
+
+    @threaded.setter
+    def threaded(self, value):
+        self.desc.run['threaded'] = bool(value)
+
+    @staticmethod
+    def simpixel(new=0, autoraise=True):
+        """Open an instance of simpixel in the browser"""
+        simpixel_driver.open_browser(new=new, autoraise=autoraise)
+
+    @staticmethod
+    def animations():
+        """List all the existing animations"""
+        animations.run(None)
+
+    def __repr__(self):
+        r = super().__repr__()
+        return 'Running ' + r if self.is_running else r
+
+    def __iadd__(self, other):
+        self.desc.update(other)
+        return self
+
+    def __add__(self, other):
+        b = Builder()
+        b += self
+        b += other
+        return b
+
+    def __radd__(self, other):
+        b = Builder()
+        b += other
+        b += self
+        return b
+
+    def __copy__(self):
+        return self + {}
+
+    def __deepcopy__(self, _):
+        return self.__copy__()
+
+    def _run(self):
+        """Called from the Runner class"""
+        run = {'run': {'threaded': False}}
+        self.project = project.project(
+            self.desc, run, root_file=self.project_file)
+        self.project.start()
diff --git a/bibliopixel/builder/description.py b/bibliopixel/builder/description.py
new file mode 100644
index 0000000..0452663
--- /dev/null
+++ b/bibliopixel/builder/description.py
@@ -0,0 +1,81 @@
+import os
+from . import sections
+from .. project import aliases, importer, merge
+from .. util import class_name, data_file, log
+
+
+class Description:
+    """
+    Set and get project description, either as attributes or as indexes.
+
+    Examples are :
+
+    desc.run['threaded'] = True
+    desc.animation = '.tests.PixelTester'
+    """
+
+    def __init__(self):
+        self.clear()
+
+    def clear(self):
+        """Clear description to default values"""
+        self._desc = {}
+        for key, value in merge.DEFAULT_PROJECT.items():
+            if key not in self._HIDDEN:
+                self._desc[key] = type(value)()
+
+    def items(self):
+        """Return an iterable of (key, section value) pairs"""
+        return self._desc.items()
+
+    def update(self, desc=None, **kwds):
+        """This method updates the description much like dict.update(), *except*:
+
+        1. for description which have dictionary values, it uses update
+           to alter the existing value and does not replace them.
+
+        2. `None` is a special value that means "clear section to default" or
+           "delete field".
+        """
+        sections.update(self._desc, desc, **kwds)
+
+    def as_dict(self):
+        """Returns a dictionary of non-empty description"""
+        return {k: v for k, v in self.items() if v}
+
+    @property
+    def desc(self):
+        return self._desc
+
+    def __str__(self):
+        return data_file.dumps(self.as_dict()).strip()
+
+    _HIDDEN = 'maker', 'typename'
+    _ATTRIBUTES = ()
+
+    def __getitem__(self, key):
+        return self._desc[key]
+
+    def __setitem__(self, key, value):
+        sections.set_one(self._desc, key, value)
+
+    def __getattr__(self, key):
+        try:
+            return self[key]
+        except KeyError:
+            raise AttributeError("'Description' has no attribute '%s'" % key)
+
+    def __setattr__(self, key, value):
+        if key.startswith('_') or key in self._ATTRIBUTES:
+            super().__setattr__(key, value)
+        else:
+            self[key] = value
+
+    def __dir__(self):
+        return sorted(super().__dir__() + list(self._desc))
+
+    def __contains__(self, key):
+        return key in self._desc
+
+    def __iter__(self):
+        return iter(self._desc)
diff --git a/bibliopixel/builder/pixels.py b/bibliopixel/builder/pixels.py
new file mode 100644
index 0000000..9679dcb
--- /dev/null
+++ b/bibliopixel/builder/pixels.py
@@ -0,0 +1,62 @@
+from .. colors import make
+from .. util import log
+
+
+class Pixels:
+    """
+    Wrap pixels attached to a layout for easy access and
+    better error reporting.
+    """
+    def __init__(self, builder):
+        self.builder = builder
+
+    def __getitem__(self, index):
+        """
+        Returns the r, g, b pixel at a location in the layout.  May only be
+        called if self.is_running is true.
+        """
+        index = self._check_index(index)
+        return self.layout.get(*index)
+
+    def __setitem__(self, index, color):
+        """
+        Sets the r, g, b pixel at a location in the layout.  May only be called
+        if self.is_running is true.
+        """
+        index = self._check_index(index)
+        try:
+            color = make.color(color)
+        except:
+            log.error('Do not understand color %s', color)
+            raise
+        index.append(color)
+        return self.layout.set(*index)
+
+    @property
+    def layout(self):
+        if not self.builder.project:
+            raise ValueError('Cannot get layout before Builder has started')
+        return self.builder.project.layout
+
+    @property
+    def shape(self):
+        return self.layout.shape
+
+    def _check_index(self, index):
+        if isinstance(index, int):
+            index = [index]
+        else:
+            index = list(index)
+
+        l1, l2 = len(self.shape), len(index)
+        if l1 != l2:
+            msg = 'Expected %d coordinates but got %d: %s'
+            raise ValueError(msg % (l1, l2, index))
+
+        for i, s in enumerate(self.shape):
+            if index[i] < 0:
+                index[i] += s
+            if not (0 <= index[i] < s):
+                raise IndexError('Index %d was out of range' % i)
+
+        return index
diff --git a/bibliopixel/builder/runner.py b/bibliopixel/builder/runner.py
new file mode 100644
index 0000000..37937b1
--- /dev/null
+++ b/bibliopixel/builder/runner.py
@@ -0,0 +1,62 @@
+import traceback, weakref
+from .. util import log
+from .. util.threads import runnable
+
+
+class Runner:
+    """
+    Class that makes sure that a single builder is running.
+    """
+    def __init__(self, builder):
+        self.thread = None
+        self.is_running = False
+        self.builder = builder
+
+    def start(self, threaded):
+        """Creates and starts the project."""
+        self.stop()
+        self.__class__._INSTANCE = weakref.ref(self)
+        self.is_running = True
+
+        if threaded:
+            self.thread = runnable.LoopThread()
+            self.thread.run_once = self._target
+            self.thread.start()
+        else:
+            self._target()
+
+    def stop(self):
+        """
+        Stop the Runner if it's running.
+        Called as a classmethod, stop the running instance if any.
+        """
+        if self.is_running:
+            log.info('Stopping')
+            self.is_running = False
+            self.__class__._INSTANCE = None
+
+            try:
+                self.thread and self.thread.stop()
+            except:
+                log.error('Error stopping thread')
+                traceback.print_exc()
+            self.thread = None
+            return True
+
+    @classmethod
+    def instance(cls):
+        """Return the unique instance of Runner, if any, or None"""
+        return cls._INSTANCE and cls._INSTANCE()
+
+    _INSTANCE = None
+
+    def _target(self):
+        try:
+            log.info('Running Builder')
+            self.builder._run()
+        except:
+            if self.thread:
+                log.error(traceback.format_exc())
+            raise
+        finally:
+            self.builder.stop()
diff --git a/bibliopixel/builder/saved_description.py b/bibliopixel/builder/saved_description.py
new file mode 100644
index 0000000..4dfb1db
--- /dev/null
+++ b/bibliopixel/builder/saved_description.py
@@ -0,0 +1,58 @@
+from . description import Description
+from .. util import data_file
+
+
+class SavedDescription:
+    """
+    Project descriptions that are saved in a file.
+    """
+    def __init__(self, project_file='', desc=None, **kwds):
+        self.desc = Description()
+
+        self.project_file = project_file
+        if project_file:
+            self.load(project_file)
+        else:
+            self._project_file = project_file
+        self.desc.update(desc, **kwds)
+
+    def load(self, project_file=''):
+        """Load/reload the description from a YML file. Prompt if no file given."""
+        self._request_project_file(project_file)
+        self.clear()
+        self.desc.update(data_file.load(self._project_file))
+
+    def save(self, project_file=''):
+        """Save the description as a YML file. Prompt if no file given."""
+        self._request_project_file(project_file)
+        data_file.dump(self.desc.as_dict(), self.project_file)
+
+    def clear(self):
+        """Clear description to default values"""
+        self.desc.clear()
+
+    def __str__(self):
+        return str(self.desc)
+
+    def __repr__(self):
+        return super().__repr__() + ' from file ' + self.project_file
+
+    @property
+    def project_file(self):
+        return self._project_file
+
+    @project_file.setter
+    def project_file(self, project_file):
+        if project_file and not project_file.endswith('.yml'):
+            project_file += '.yml'
+        self._project_file = project_file
+
+    _ATTRIBUTES = 'project_file',
+
+    def _request_project_file(self, project_file, action='store'):
+        project_file = project_file or self.project_file
+        if not project_file:
+            project_file = input('Enter project_file to %s: ' % action).strip()
+            if not project_file:
+                raise ValueError('%s aborted', action.capitalize())
+        self.project_file = project_file
diff --git a/bibliopixel/builder/sections.py b/bibliopixel/builder/sections.py
new file mode 100644
index 0000000..1e79b38
--- /dev/null
+++ b/bibliopixel/builder/sections.py
@@ -0,0 +1,79 @@
+import os
+from .. project import aliases, importer
+from .. util import class_name, data_file, log
+
+CLASS_SECTIONS = 'layout', 'animation', 'driver'
+
+
+def set_one(desc, name, value):
+    """Set one section in a Project description"""
+    old_value = desc.get(name)
+    if old_value is None:
+        raise KeyError('No section "%s"' % name)
+
+    if value is None:
+        value = type(old_value)()
+
+    elif name in CLASS_SECTIONS:
+        if isinstance(value, str):
+            value = {'typename': aliases.resolve(value)}
+        elif isinstance(value, type):
+            value = {'typename': class_name.class_name(value)}
+        elif not isinstance(value, dict):
+            raise TypeError('Expected dict, str or type, got "%s"' % value)
+
+        typename = value.get('typename')
+        if typename:
+            s = 's' if name == 'driver' else ''
+            path = 'bibliopixel.' + name + s
+            importer.import_symbol(typename, path)
+
+    elif name == 'shape':
+        if not isinstance(value, (list, int, tuple, str)):
+            raise TypeError('Expected shape, got "%s"' % value)
+
+    elif type(old_value) is not type(value):
+        raise TypeError('Expected %s but got "%s" of type %s' %
+                        (type(old_value), value, type(value)))
+
+    desc[name] = value
+
+
+def update(desc, other=None, **kwds):
+    """Update sections in a Project description"""
+    other = other and _as_dict(other) or {}
+    for i in other, kwds:
+        for k, v in i.items():
+            if isinstance(v, dict):
+                # Only for dicts, merge instead of overwriting
+                old_v = desc[k]
+                for k2, v2 in v.items():
+                    if v2 is None:
+                        old_v.pop(k2, None)
+                    else:
+                        old_v[k2] = v2
+            else:
+                set_one(desc, k, v)
+
+
+def _as_dict(data):
+    if isinstance(data, dict):
+        return data
+
+    as_dict = getattr(data, 'as_dict', None)
+    if callable(as_dict):
+        return as_dict()
+
+    if isinstance(as_dict, dict):
+        return as_dict
+
+    desc = getattr(data, 'desc', None)
+    if desc is not None:
+        return _as_dict(desc)
+
+    if isinstance(data, str):
+        data = data_file.load_if(data)
+        if isinstance(data, dict):
+            return data
+
+    raise TypeError('Expected dict but got value "%s"' % data)
diff --git a/bibliopixel/project/builder.py b/bibliopixel/project/builder.py
deleted file mode 100644
index 6916036..0000000
--- a/bibliopixel/project/builder.py
+++ /dev/null
@@ -1,250 +0,0 @@
-import os, traceback, weakref
-from . import aliases, importer, merge, project
-from .. commands import animations
-from .. drivers.SimPixel import driver as simpixel_driver
-from .. util import class_name, data_file, log
-from .. util.threads import runnable
-from .. import colors
-
-_HIDDEN = 'maker', 'typename'
-_CLASS_SECTIONS = 'layout', 'animation', 'driver'
-
-
-class Builder:
-    """
-    A Project Builder to allow people to experiment with projects
-    from the command line or in their own main program.
-    """
-    COLORS = colors.COLORS
-
-    def __init__(self, filename='', **kwds):
-        self.description = {}
-        self.project = None
-        self.thread = None
-        self.filename = filename
-        if os.path.exists(filename):
-            self.load(filename)
-        else:
-            self.filename = filename
-            self._clear_description()
-            if filename:
-                log.info('New filename %s', filename)
-
-        for k, v in kwds.items():
-            setattr(self, k, v)
-
-    def save(self, filename=''):
-        """Save the project as a YML file.  Remembers the previous filename.
-        Prompt if no file given."""
-        self._load_save(filename, 'save')
-
-    def load(self, filename=''):
-        """Load or reload the project from a YML file.  Remembers the previous
-        filename. Prompt if no file given.
-        """
-        self._load_save(filename, 'load')
-
-    def start(self, threaded=None):
-        """Creates and starts the project."""
-        # Sadly, we can only run a single project at once.
-        self.stop()
-        if not Builder.running_builder():
-            Builder._RUNNING_BUILDER = weakref.ref(self)
-        elif Builder.INSTANCE() is not self:
-            raise ValueError('Only one instance of Builder can run at once')
-
-        if threaded is not None:
-            self.threaded = threaded
-        if self.threaded:
-            self.thread = runnable.LoopThread()
-            self.thread.run_once = self._run_once
-            self.thread.start()
-        else:
-            self._run_once()
-        return self
-
-    def stop(self=None):
-        """Stop the project if it's running"""
-        self = self or Builder.running_builder
-        if not self:
-            return
-
-        if self.project:
-            try:
-                self.project.stop()
-            except:
-                log.error('Error stopping project')
-                traceback.print_exc()
-            self.project = None
-        if self.thread:
-            try:
-                self.thread.stop()
-            except:
-                log.error('Error stopping thread')
-                traceback.print_exc()
-            self.thread = None
-        if Builder.running_builder() is self:
-            Builder._RUNNING_BUILDER = None
-        return self
-
-    def copy(self):
-        """
-        Returns a copy of this Builder.  The project, the thread, and the
-        filename are not copied, only the description. """
-        return Builder(**self.description)
-
-    def clear(self):
-        """Stop the project if it's running and clear the project description"""
-        self.stop()
-        self._clear_description()
-        return self
-
-    @property
-    def is_running(self):
-        """
-        Return True if the Builder is running, false otherwise.
-
-        At most one Builder can be running at any time.
-        """
-        return bool(self.project)
-
-    @property
-    def threaded(self):
-        return bool(self.run.get('threaded'))
-
-    @threaded.setter
-    def threaded(self, value):
-        self.run['threaded'] = bool(value)
-
-    @staticmethod
-    def simpixel(new=0, autoraise=True):
-        """Open an instance of simpixel in the browser"""
-        simpixel_driver.open_browser(new=new, autoraise=autoraise)
-
-    @staticmethod
-    def animations():
-        """List all the existing animations"""
-        animations.run(None)
-
-    @classmethod
-    def running_builder(cls):
-        """Return the unique Builder that is running, if any, or None"""
-        return cls._RUNNING_BUILDER and cls._RUNNING_BUILDER()
-
-    def __getattr__(self, k):
-        if k not in self.description:
-            return AttributeError("'Builder' has no attribute '%s'" % k)
-        return self.description[k]
-
-    def __setattr__(self, k, v):
-        if k in self.ATTRIBUTES:
-            super().__setattr__(k, v)
-
-        elif k not in self.PROPERTIES:
-            raise AttributeError('Cannot set key %s' % k)
-
-        elif k not in _CLASS_SECTIONS or isinstance(v, dict):
-            self.description[k] = v
-
-        else:
-            if isinstance(v, str):
-                path = 'bibliopixel.' + k + ('s' if k == 'driver' else '')
-                # Test to see it really exists
-                rv = aliases.resolve(v)
-                importer.import_symbol(rv, path)
-            else:
-                v = class_name.class_name(v)
-
-            self.description.setdefault(k, {})['typename'] = v
-
-    def __dir__(self):
-        return sorted(super().__dir__() + list(self._PROPERTIES))
-
-    def __repr__(self):
-        parts = []
-        if self.is_running:
-            parts.append('Running')
-        parts.append(super().__repr__())
-        if self.filename:
-            parts.append('from file')
-            parts.append(self.filename)
-        return ' '.join(parts)
-
-    def __str__(self):
-        return data_file.dumps(self._clean_description()).strip()
-
-    def __add__(self, other):
-        b = Builder()
-        b += self
-        b += other
-        return b
-
-    def __radd__(self, other):
-        b = Builder()
-        b += other
-        b += self
-        return b
-
-    def __iadd__(self, other):
-        if isinstance(other, dict):
-            d = other
-        elif isinstance(other, Builder):
-            d = other.description
-        elif isinstance(other, str):
-            d = data_file.load(other)
-        else:
-            raise TypeError('Do not understand type %s' % type(d))
-
-        self.description.update(d)
-        return self
-
-    def __copy__(self):
-        return self.copy()
-
-    def __deepcopy__(self, _):
-        return self.copy()
-
-    ATTRIBUTES = 'description', 'filename', 'project', 'thread', 'threaded'
-    PROPERTIES = tuple(k for k in merge.DEFAULT_PROJECT if k not in _HIDDEN)
-    _RUNNING_BUILDER = None
-
-    def _run_once(self):
-        try:
-            descs = self.description, {'run': {'threaded': False}}
-            log.info('Constructing project')
-            self.project = project.project(*descs, root_file=self.filename)
-            self.project.start()
-        except:
-            log.error(traceback.format_exc())
-            raise
-        finally:
-            log.info('Project finished')
-            self.stop()
-
-    def _load_save(self, filename, action):
-        filename = filename or self.filename
-        if not filename:
-            filename = input('Enter filename to %s: ' % action).strip()
-            if not filename:
-                log.info('%s aborted', action.capitalize())
-                return
-        if not filename.endswith('.yml'):
-            filename += '.yml'
-
-        if action == 'load':
-            self._clear_description()
-            self.description.update(data_file.load(filename))
-        else:
-            data_file.dump(self._clean_description(), filename)
-        self.filename = filename
-
-    def _clear_description(self):
-        self.description = {}
-        for key, value in merge.DEFAULT_PROJECT.items():
-            if key not in _HIDDEN:
-                if isinstance(value, (list, dict)):
-                    value = type(value)()
-                self.description[key] = value
-
-    def _clean_description(self):
-        return {k: v for k, v in self.description.items() if v}
diff --git a/bibliopixel/project/fill.py b/bibliopixel/project/fill.py
index 9b80735..4bbabad 100644
--- a/bibliopixel/project/fill.py
+++ b/bibliopixel/project/fill.py
@@ -129,7 +129,8 @@ def _fill_shape(desc):
         except:
             raise ValueError('Cannot parse shape %s' % shape)
     elif not isinstance(shape, (list, tuple)):
-        raise ValueError('`shape` must be a number or a list, was "%s"' % shape)
+        raise ValueError('`shape` must be a number or a list, was "%s" (%s)' %
+                         (shape, type(shape)))
 
     ldesc = construct.to_type_constructor(desc.get('layout') or {},
                                           python_path='bibliopixel.layout')
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index db31155..06f1a8d 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -106,6 +106,7 @@ class Project:
         for c in self.controls:
             c.stop()
         self.layout.stop()
+        return True
 
     def cleanup(self):
         self.animation.cleanup()
diff --git a/bibliopixel/util/data_file.py b/bibliopixel/util/data_file.py
index ff4982c..1a6ce9a 100644
--- a/bibliopixel/util/data_file.py
+++ b/bibliopixel/util/data_file.py
@@ -97,3 +97,9 @@ def load(file, use_yaml=None):
     except Exception as e:
         e.args = ('There was a error in the data file', filename) + e.args
         raise
+
+
+def load_if(s):
+    """Load either a filename, or a string representation of yml/json."""
+    is_data_file = s.endswith('.json') or s.endswith('.yml')
+    return load(s) if is_data_file else loads(s)
diff --git a/bibliopixel/util/log.py b/bibliopixel/util/log.py
index 1d02cd6..f529c48 100644
--- a/bibliopixel/util/log.py
+++ b/bibliopixel/util/log.py
@@ -6,6 +6,9 @@ LOG_NAMES = {'frame': FRAME, 'debug': DEBUG, 'info': INFO, 'warning': WARNING,
              'error': ERROR}
 
 SORTED_NAMES = tuple(k for k, v in sorted(LOG_NAMES.items()))
+VERBOSE_FMT = '\
+%(asctime)s,%(msecs)d %(levelname)-7s [%(filename)s:%(lineno)d] %(message)s'
+DATE_FMT = '%d-%m-%Y:%H:%M:%S'
 
 
 def add_arguments(parser):
@@ -18,7 +21,8 @@ def add_arguments(parser):
 
 def apply_args(args):
     if args.verbose and args.loglevel != 'frame':
-        set_log_level('debug')
+        logging.basicConfig(
+            format=VERBOSE_FMT, datefmt=DATE_FMT, level=logging.DEBUG)
     else:
         set_log_level(args.loglevel)
 


Test patch:
diff --git a/scripts/release_test/tests/builder.py b/scripts/release_test/tests/builder.py
index def4abf..08759ea 100644
--- a/scripts/release_test/tests/builder.py
+++ b/scripts/release_test/tests/builder.py
@@ -1,6 +1,6 @@
 import os, tempfile, threading, time
 import common
-from bibliopixel.project.builder import Builder
+from bibliopixel.builder import Builder
 from bibliopixel.animation.tests import StripChannelTest
 from bibliopixel.drivers.simpixel import SimPixel
 
@@ -13,8 +13,9 @@ def run():
     with tempfile.TemporaryDirectory() as td:
         save_file = os.path.join(td, SAVE_FILE)
         pb = Builder()
-        pb.shape = 32
-        pb.animation = '.tests.PixelTester'
+        desc = pb.desc
+        desc.shape = 32
+        desc.animation = '.tests.PixelTester'
         pb.simpixel()
 
         def stop():
@@ -33,7 +34,7 @@ def run():
         pb = Builder(save_file)
         start_and_stop()
 
-        pb.animation = StripChannelTest
+        pb.danimation = StripChannelTest
         start_and_stop()
 
         pb.animation = '$bpa.strip.Wave'
diff --git a/test/bibliopixel/builder/__init__.py b/test/bibliopixel/builder/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/bibliopixel/builder/builder_test.py b/test/bibliopixel/builder/builder_test.py
new file mode 100644
index 0000000..c59a89c
--- /dev/null
+++ b/test/bibliopixel/builder/builder_test.py
@@ -0,0 +1,26 @@
+import time, unittest
+from bibliopixel.builder.builder import Builder
+from bibliopixel.util import data_file
+
+
+class BuilderTest(unittest.TestCase):
+    def test_simple(self):
+        b = Builder(driver='dummy')
+
+        self.assertFalse(b.is_running)
+        b.start(True)
+        self.assertTrue(b.is_running)
+        b.stop()
+
+    def test_merging(self):
+        b = Builder(driver='.dummy') + {'animation': '.tests.PixelTester'}
+        b.desc.shape = [12, 16]
+        b.start(True)
+        actual = b.desc.as_dict()
+        b.stop()
+        expected = {
+            'animation': {'typename': '.tests.PixelTester'},
+            'driver': {'typename': '.dummy'},
+            'run': {'threaded': True},
+            'shape': [12, 16]}
+        self.assertEqual(actual, expected)
diff --git a/test/bibliopixel/builder/description_test.py b/test/bibliopixel/builder/description_test.py
new file mode 100644
index 0000000..2bfe538
--- /dev/null
+++ b/test/bibliopixel/builder/description_test.py
@@ -0,0 +1,66 @@
+import time, unittest
+from bibliopixel.builder.description import Description
+from bibliopixel.util import data_file
+
+
+class DescriptionTest(unittest.TestCase):
+    def test_empty(self):
+        d = Description()
+        self.assertEqual(str(d), '{}')
+        self.assertEqual(d.as_dict(), {})
+
+        expected = {
+            'aliases': {},
+            'animation': {},
+            'colors': {},
+            'controls': [],
+            'driver': {},
+            'drivers': [],
+            'layout': {},
+            'numbers': '',
+            'palettes': {},
+            'path': '',
+            'run': {},
+            'shape': ()}
+        self.assertEqual(d.desc, expected)
+        self.assertEqual(d.animation, {})
+        with self.assertRaises(AttributeError):
+            d.wombat
+
+        self.assertIn('colors', d)
+        self.assertNotIn('colours', d)
+        self.assertEqual(sorted(d), sorted(expected))
+        directory = [i for i in dir(d) if not i.startswith('_')]
+        expected = [
+            'aliases',
+            'animation',
+            'as_dict',
+            'clear',
+            'colors',
+            'controls',
+            'desc',
+            'driver',
+            'drivers',
+            'items',
+            'layout',
+            'numbers',
+            'palettes',
+            'path',
+            'run',
+            'shape',
+            'update']
+
+        self.assertEqual(directory, expected)
+
+    def test_setting(self):
+        d = Description()
+        d.animation = '.tests.PixelTester'
+        self.assertEqual(d['animation'], {'typename': '.tests.PixelTester'})
+        with self.assertRaises(ImportError):
+            d.animation = '.nonexistent.Nonexistent'
+
+        d['animation'] = '.tests.StripChannelTest'
+        self.assertEqual(d.animation, {'typename': '.tests.StripChannelTest'})
+
+        d.shape = (10, 11)
+        self.assertEqual(d.shape, (10, 11))