Repository: ManiacalLabs/BiblioPixel
Pull request #1073 (2018-11-26 21:09:03)

New project.Builder
And also many tweaks that I discovered while experimenting with the builder.


Patch:
diff --git a/bibliopixel/colors/__init__.py b/bibliopixel/colors/__init__.py
index 781ca22..0152821 100644
--- a/bibliopixel/colors/__init__.py
+++ b/bibliopixel/colors/__init__.py
@@ -45,7 +45,7 @@ is also of the "constant apparent brightness" variety.
 More details here: https://github.com/FastLED/FastLED/wiki/FastLED-HSV-Colors
 """
 
-from . names import COLORS
+from . colors import COLORS
 
 from .. util import deprecated
 if deprecated.allowed():  # pragma: no cover
diff --git a/bibliopixel/colors/colors.py b/bibliopixel/colors/colors.py
new file mode 100644
index 0000000..18722c3
--- /dev/null
+++ b/bibliopixel/colors/colors.py
@@ -0,0 +1,53 @@
+from . import tables
+
+
+class Colors:
+    """
+    Colors is a "magic" color name object.
+
+     To get a color from a name, use ``COLORS.<colorname>`` - for example
+
+    ::
+
+        COLORS.red
+        COLORS.ochre
+
+    or if the name is a variable or has a space in it,
+
+    ::
+
+        COLORS['violet red 4']
+
+    To get a name from a color, use
+
+    ::
+
+        COLOR((0, 255, 255))
+    """
+
+    def __getitem__(self, name):
+        value = tables.get_color(name)
+        if value:
+            return value
+        raise KeyError(name)
+
+    def __getattr__(self, name):
+        value = tables.get_color(name)
+        if value:
+            return value
+        raise AttributeError("COLORS has no attribute '%s'" % name)
+
+    def __setitem__(self, name, value):
+        raise KeyError('Cannot change COLORS')
+
+    def __setattr__(self, name, value):
+        raise AttributeError('Cannot change COLORS')
+
+    def __iter__(self):
+        return tables.all_named_colors()
+
+    def __contains__(self, x):
+        return tables.contains(x)
+
+
+COLORS = Colors()
diff --git a/bibliopixel/colors/make.py b/bibliopixel/colors/make.py
index 07b3edb..4455632 100644
--- a/bibliopixel/colors/make.py
+++ b/bibliopixel/colors/make.py
@@ -1,5 +1,5 @@
 import functools, numbers
-from . names import COLORS
+from . import names
 from . import palette
 
 
@@ -36,7 +36,7 @@ def _(c):
 @color.register(str)
 def _(c):
     try:
-        return COLORS[c]
+        return names.name_to_color(c)
     except:
         raise ValueError('Don\'t understand color name "%s"' % c, _COLOR_USAGE)
 
diff --git a/bibliopixel/colors/names.py b/bibliopixel/colors/names.py
index 8c8d63f..6704d4d 100644
--- a/bibliopixel/colors/names.py
+++ b/bibliopixel/colors/names.py
@@ -80,23 +80,6 @@ def toggle(s):
     return color_to_name(c) if is_numeric else str(c)
 
 
-class _Colors(object):
-    def __getitem__(self, name):
-        try:
-            return name_to_color(name)
-        except:
-            raise KeyError(name)
-
-    def __getattr__(self, name):
-        try:
-            return name_to_color(name)
-        except:
-            raise AttributeError("COLORS has no attribute' %s'" % name)
-
-    def __call__(self, color):
-        return color_to_name(color)
-
-
 def _from_number(s):
     s = s.strip()
     for prefix in '0x', '#':
@@ -130,26 +113,6 @@ def to_color(c):
     raise ValueError('Cannot create color from "%s"' % c)
 
 
-"""
-COLOR is a "magic" color name variable.
-
- To get a color from a name, use ``COLORS.<colorname>`` - for example
-
-::
-
-    COLORS.red
-    COLORS.ochre
-
-or if the name is a variable or has a space in it,
-
-::
-
-    COLORS['violet red 4']
-
-To get a name from a color, use
-
-::
-
-    COLOR((0, 255, 255))
-"""
-COLORS = _Colors()
+from .. util import deprecated
+if deprecated.allowed():
+    from . colors import COLORS
diff --git a/bibliopixel/colors/palette.py b/bibliopixel/colors/palette.py
index bb5ecb8..7bc9fec 100644
--- a/bibliopixel/colors/palette.py
+++ b/bibliopixel/colors/palette.py
@@ -116,6 +116,10 @@ class Palette(list):
 
         return r1 + fade * dr, g1 + fade * dg, b1 + fade * db
 
+    def __setitem__(self, i, color):
+        from . import make
+        super().__setitem__(i, make.color(color))
+
     def __eq__(self, other):
         return (isinstance(other, Palette) and
                 super().__eq__(other) and
diff --git a/bibliopixel/colors/tables.py b/bibliopixel/colors/tables.py
index b2d3a55..4c5cccc 100644
--- a/bibliopixel/colors/tables.py
+++ b/bibliopixel/colors/tables.py
@@ -2,7 +2,7 @@
 Tables of named colors
 """
 
-import re
+import itertools, re
 from . import juce, classic
 
 
@@ -37,6 +37,24 @@ def get_name(color):
     return _TO_NAME_USER.get(color) or _TO_NAME.get(color)
 
 
+def all_named_colors():
+    """Return an iteration over all name, color pairs in tables"""
+    yield from _TO_COLOR_USER.items()
+    for name, color in _TO_COLOR.items():
+        if name not in _TO_COLOR_USER:
+            yield name, color
+
+
+def contains(x):
+    """Return true if this string or integer tuple appears in tables"""
+    if isinstance(x, str):
+        x = canonical_name(x)
+        return x in _TO_COLOR_USER or x in _TO_COLOR
+    else:
+        x = tuple(x)
+        return x in _TO_NAME_USER or x in _TO_NAME
+
+
 def _classic_pairs():
     find = re.compile(r'[A-Z][a-z0-9]+').finditer
 
@@ -57,9 +75,10 @@ A dictionary of every color by name.
 """
 
 COLOR_DICT = dict(_CLASSIC_COLORS, **_JUCE_COLORS)
+CANONICAL_DICT = {canonical_name(k): v for k, v in COLOR_DICT.items()}
 
 _SECONDARY_NAMES = juce.SECONDARY_NAMES.union({'off', 'on'})
 _TO_NAME = {v: k for k, v in COLOR_DICT.items() if k not in _SECONDARY_NAMES}
-_TO_COLOR = {k.replace(' ', ''): v for k, v in COLOR_DICT.items()}
+_TO_COLOR = {canonical_name(k): v for k, v in COLOR_DICT.items()}
 _TO_NAME_USER = {}
 _TO_COLOR_USER = {}
diff --git a/bibliopixel/commands/kill.py b/bibliopixel/commands/kill.py
index e4e421b..3af6f12 100644
--- a/bibliopixel/commands/kill.py
+++ b/bibliopixel/commands/kill.py
@@ -19,8 +19,5 @@ Example:
 
 """
 
-from . import restart
-
-
-def add_arguments(parser):
-    restart.add_signal_arguments(parser, 'SIGKILL')
+from .. util.signal_handler import make_command
+add_arguments, run = make_command('SIGKILL')
diff --git a/bibliopixel/commands/restart.py b/bibliopixel/commands/restart.py
index 2114e13..f40b940 100644
--- a/bibliopixel/commands/restart.py
+++ b/bibliopixel/commands/restart.py
@@ -16,36 +16,5 @@ Example: ``$ bp restart``
 
 """
 
-import os, signal
-from .. util import log, pid_context
-
-CHOICES = sorted(d for d in dir(signal) if d.startswith('SIG') and '_' not in d)
-
-
-def run(args):
-    try:
-        pid = pid_context.get_pid(args.pid_filename)
-    except:
-        log.error('No bp process running')
-        log.debug('Could not find file %s', args.pid_filename)
-        return -1
-
-    try:
-        os.kill(pid, getattr(signal, args.signal))
-    except:
-        log.error('Failed to send signal %s to bp process %s', args.signal, pid)
-        return -1
-
-    log.info('%s sent to bp process %s', args.signal, pid)
-
-
-def add_signal_arguments(parser, default, help=''):
-    parser.add_argument(
-        'signal', nargs='?', default=default, choices=CHOICES,
-        help='Signal to send.' + help)
-    pid_context.add_arguments(parser)
-    parser.set_defaults(run=run)
-
-
-def add_arguments(parser):
-    add_signal_arguments(parser, 'SIGHUP', ' Default SIGHUP restarts bp.')
+from .. util.signal_handler import make_command
+add_arguments, run = make_command('SIGHUP', ' Default SIGHUP restarts bp.')
diff --git a/bibliopixel/commands/run.py b/bibliopixel/commands/run.py
index efb6dea..01ff67e 100644
--- a/bibliopixel/commands/run.py
+++ b/bibliopixel/commands/run.py
@@ -4,22 +4,12 @@ Run specified project from file or URL
 
 from .. main import project_flags
 from .. project import project_runner
-from .. util import log, pid_context, signal_handler
-
-SIGNALS = 'SIGHUP', 'SIGINT', 'SIGTERM'
-SIGNAL_DICT = {s: project_runner.stop for s in SIGNALS}
+from .. util import signal_handler
 
 
 def run(args):
-    with pid_context.pid_context(args.pid_filename):
-        with signal_handler.context(**SIGNAL_DICT) as signals:
-            while True:
-                project_runner.run(args)
-                if signals:
-                    log.info('Received signal %s', ' '.join(signals))
-
-                if not signals.pop('SIGHUP', False):
-                    break
+    for i in signal_handler.run(args.pid_filename, project_runner.stop):
+        project_runner.run(args)
 
 
 def add_arguments(parser):
diff --git a/bibliopixel/commands/shutdown.py b/bibliopixel/commands/shutdown.py
index e722ba4..0b27c9f 100644
--- a/bibliopixel/commands/shutdown.py
+++ b/bibliopixel/commands/shutdown.py
@@ -15,8 +15,5 @@ Example:
 
 """
 
-from . import restart
-
-
-def add_arguments(parser):
-    restart.add_signal_arguments(parser, 'SIGKINT')
+from .. util.signal_handler import make_command
+add_arguments, run = make_command('SIGINT')
diff --git a/bibliopixel/drivers/SimPixel/driver.py b/bibliopixel/drivers/SimPixel/driver.py
index 554cad4..f8e9c21 100644
--- a/bibliopixel/drivers/SimPixel/driver.py
+++ b/bibliopixel/drivers/SimPixel/driver.py
@@ -6,6 +6,11 @@ from ... main import args
 DEFAULT_SIMPIXEL_URL = 'http://simpixel.io'
 
 
+def open_browser(url=DEFAULT_SIMPIXEL_URL, new=0, autoraise=True):
+    if url and not url.startswith('no'):
+        webbrowser.open(url, new=new, autoraise=autoraise)
+
+
 class SimPixelOpenerServer(websocket.Server):
     def __init__(self, port, selectInterval):
         super().__init__(port, selectInterval)
@@ -13,8 +18,7 @@ class SimPixelOpenerServer(websocket.Server):
             args.ARGS.simpixel or (args.ARGS.s and DEFAULT_SIMPIXEL_URL))
         if url is True:
             url = DEFAULT_SIMPIXEL_URL
-        if url and not url.startswith('no'):
-            webbrowser.open(url, new=0, autoraise=True)
+        open_browser(url)
 
 
 class SimPixel(ServerDriver):
@@ -34,7 +38,7 @@ class SimPixel(ServerDriver):
     SERVER_CLASS = SimPixelOpenerServer
     SERVER_KWDS = {'selectInterval': 0.001}
 
-    def __init__(self, num=1024, port=1337, **kwds):
+    def __init__(self, num=32, port=1337, **kwds):
         """
         Args:
             num:  number of LEDs being visualizer.
diff --git a/bibliopixel/main/main.py b/bibliopixel/main/main.py
index 8024165..f69cac5 100644
--- a/bibliopixel/main/main.py
+++ b/bibliopixel/main/main.py
@@ -28,7 +28,7 @@ def main(argv=None):
 
     from . args import set_args
     args = set_args(description, argv, module)
-    args.run(args)
+    module.run(args)
 
 
 DEFAULT_COMMAND = 'run'
diff --git a/bibliopixel/project/alias_lists.py b/bibliopixel/project/alias_lists.py
index d212d8c..e69de29 100644
--- a/bibliopixel/project/alias_lists.py
+++ b/bibliopixel/project/alias_lists.py
@@ -1,21 +0,0 @@
-import operator, os
-from .. util import log
-
-PROJECT_ALIASES = {}
-
-BUILTIN_ALIASES = {
-    'apa102': 'bibliopixel.drivers.SPI.APA102.APA102',
-    'lpd8806': 'bibliopixel.drivers.SPI.LPD8806.LPD8806',
-    'pi_ws281x': 'bibliopixel.drivers.PiWS281X.PiWS281X',
-    'serial': 'bibliopixel.drivers.serial.Serial',
-    'sk9822': 'bibliopixel.drivers.SPI.APA102.APA102',
-    'spi': 'bibliopixel.drivers.SPI.SPI',
-    'ws2801': 'bibliopixel.drivers.SPI.WS2801.WS2801',
-    'ws281x': 'bibliopixel.drivers.SPI.WS281X.WS281X',
-
-    'reprocess': 'bibliopixel.animation.reprocess.reprocess.Reprocess',
-}
-
-
-def get_alias(alias):
-    return PROJECT_ALIASES.get(alias) or BUILTIN_ALIASES.get(alias)
diff --git a/bibliopixel/project/aliases.py b/bibliopixel/project/aliases.py
index f8f4292..0ebf827 100644
--- a/bibliopixel/project/aliases.py
+++ b/bibliopixel/project/aliases.py
@@ -1,16 +1,34 @@
-import re
+import operator, os, re
 from .. util import log
-from . import alias_lists
 
 ALIAS_MARKERS = '@$'
 SEPARATORS = re.compile(r'([./#]|[^./#]+)')
 
+PROJECT_ALIASES = {}
+
+BUILTIN_ALIASES = {
+    'apa102': 'bibliopixel.drivers.SPI.APA102.APA102',
+    'lpd8806': 'bibliopixel.drivers.SPI.LPD8806.LPD8806',
+    'pi_ws281x': 'bibliopixel.drivers.PiWS281X.PiWS281X',
+    'serial': 'bibliopixel.drivers.serial.Serial',
+    'sk9822': 'bibliopixel.drivers.SPI.APA102.APA102',
+    'spi': 'bibliopixel.drivers.SPI.SPI',
+    'ws2801': 'bibliopixel.drivers.SPI.WS2801.WS2801',
+    'ws281x': 'bibliopixel.drivers.SPI.WS281X.WS281X',
+
+    'bpa': 'BiblioPixelAnimations',
+}
+
+
+def get_alias(alias):
+    return PROJECT_ALIASES.get(alias) or BUILTIN_ALIASES.get(alias)
+
 
 def resolve(typename, aliases=None):
     aliases = aliases or {}
 
     def get(s):
-        return aliases.get(s) or alias_lists.get_alias(s) or s
+        return aliases.get(s) or get_alias(s) or s
 
     def get_all(typename):
         for part in SEPARATORS.split(typename):
diff --git a/bibliopixel/project/builder.py b/bibliopixel/project/builder.py
new file mode 100644
index 0000000..6916036
--- /dev/null
+++ b/bibliopixel/project/builder.py
@@ -0,0 +1,250 @@
+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 abffaf3..d1ff52f 100644
--- a/bibliopixel/project/fill.py
+++ b/bibliopixel/project/fill.py
@@ -4,7 +4,7 @@ so they have exactly the right constructor.
 """
 
 import copy
-from . import aliases, alias_lists, construct, merge
+from . import aliases, construct, merge
 from .. import layout
 from .. util import deprecated, exception, log
 from .. colors import make, palettes, tables
@@ -45,7 +45,7 @@ def _fill_aliases(desc):
         return k
 
     al = desc.pop('aliases', {})
-    alias_lists.PROJECT_ALIASES = {unalias(k): v for k, v in al.items()}
+    aliases.PROJECT_ALIASES = {unalias(k): v for k, v in al.items()}
     return desc
 
 
@@ -157,7 +157,7 @@ def _fill_shape(desc):
 
 
 def _fill_numbers(desc):
-    numbers = desc.pop('numbers', 'python')
+    numbers = desc.pop('numbers', None) or 'python'
     if numbers != 'python':
         desc.setdefault('maker', {})['numpy_dtype'] = numbers
 
diff --git a/bibliopixel/project/merge.py b/bibliopixel/project/merge.py
index 52018c2..49837c0 100644
--- a/bibliopixel/project/merge.py
+++ b/bibliopixel/project/merge.py
@@ -6,12 +6,12 @@ from . import construct, load
 # For example, animation is at the bottom which are  dependent on run and
 # layout, which depend on drivers, and everything depends on path and typename.
 DEFAULT_PROJECT = collections.OrderedDict((
-    ('aliases', {'bpa': 'BiblioPixelAnimations'}),
+    ('aliases', {}),
     ('colors', {}),
     ('palettes', {}),
     ('path', ''),
     ('typename', 'bibliopixel.project.project.Project'),
-    ('numbers', 'python'),
+    ('numbers', ''),
     ('maker', 'bibliopixel.project.data_maker.Maker'),
     ('driver', {}),
     ('drivers', []),
diff --git a/bibliopixel/util/pid_context.py b/bibliopixel/util/pid_context.py
index 96ad91f..5299252 100644
--- a/bibliopixel/util/pid_context.py
+++ b/bibliopixel/util/pid_context.py
@@ -11,14 +11,15 @@ def add_arguments(parser):
 
 
 @contextlib.contextmanager
-def pid_context(pid_filename=DEFAULT_PID_FILENAME):
+def pid_context(pid_filename=None):
     """
     For the duration of this context manager, put the PID for this process into
     `pid_filename`, and then remove the file at the end.
     """
+    pid_filename = pid_filename or DEFAULT_PID_FILENAME
     if os.path.exists(pid_filename):
         contents = open(pid_filename).read(16)
-        log.warning('pid_filename %s already exists with contents %s...',
+        log.warning('pid_filename %s already exists with contents %s',
                     pid_filename, contents)
 
     with open(pid_filename, 'w') as fp:
@@ -35,9 +36,9 @@ def pid_context(pid_filename=DEFAULT_PID_FILENAME):
                       e, pid_filename)
 
 
-def get_pid(pid_filename=DEFAULT_PID_FILENAME):
+def get_pid(pid_filename=None):
     """
     Return the integer PID for the current bp process, or raise an exception if
     there is no such process or it hasn't registered a PID.
     """
-    return int(open(pid_filename).read(16))
+    return int(open(pid_filename or DEFAULT_PID_FILENAME).read(16))
diff --git a/bibliopixel/util/signal_handler.py b/bibliopixel/util/signal_handler.py
index 5789e7d..5d6b77a 100644
--- a/bibliopixel/util/signal_handler.py
+++ b/bibliopixel/util/signal_handler.py
@@ -1,10 +1,15 @@
-import contextlib, signal
+import contextlib, os, signal
+from . import log, pid_context
 
 SIGNAL_NUMBERS = {k: v for k, v in signal.__dict__.items()
                   if k.startswith('SIG') and '_' not in k}
 
 SIGNAL_NAMES = {v: k for k, v in SIGNAL_NUMBERS.items()}
 
+STOP_SIGNALS = 'SIGINT', 'SIGTERM'
+RESTART_SIGNALS = 'SIGHUP',
+HANDLED_SIGNALS = STOP_SIGNALS + RESTART_SIGNALS
+
 
 @contextlib.contextmanager
 def context(**handlers):
@@ -16,14 +21,69 @@ def context(**handlers):
         handler = handlers[signame]
         handler()
 
-    for signame in handlers:
-        if signame in SIGNAL_NUMBERS:  # Windows doesn't have all signals
-            signal.signal(SIGNAL_NUMBERS[signame], handler)
+    def set_all(handler):
+        for signame in handlers:
+            if signame in SIGNAL_NUMBERS:  # Windows doesn't have all signals
+                signal.signal(SIGNAL_NUMBERS[signame], handler)
 
+    set_all(handler)
     try:
         yield signals
 
     finally:
-        for signame in handlers:
-            if signame in SIGNAL_NUMBERS:  # Windows doesn't have all signals
-                signal.signal(SIGNAL_NUMBERS[signame], signal.SIG_DFL)
+        set_all(signal.SIG_DFL)
+
+
+def send_signal(sig, pid_filename=None):
+    sig = SIGNAL_NUMBERS.get(sig, sig)
+
+    if sig not in SIGNAL_NAMES:
+        log.error('No signal %s', sig)
+        return -1
+
+    try:
+        pid = pid_context.get_pid(pid_filename)
+    except:
+        log.error('No bp process running')
+        log.debug('Could not find file %s', pid_filename)
+        return -1
+
+    try:
+        os.kill(pid, sig)
+    except:
+        log.error('Failed to send signal %s to bp process %s', sig, pid)
+        return -1
+
+    log.info('%s sent to bp process %s', sig, pid)
+    return 0
+
+
+def run(filename, stopper,
+        stop_signals=STOP_SIGNALS, restart_signals=RESTART_SIGNALS):
+    signal_dict = {s: stopper for s in stop_signals + restart_signals}
+
+    with pid_context.pid_context(filename):
+        with context(**signal_dict) as signals:
+            running = True
+            while running:
+                yield
+                if signals:
+                    log.info('Received signal %s', ' '.join(signals))
+                running = all(s in restart_signals for s in signals)
+                signals.clear()
+
+
+def make_command(default_signal, help=''):
+    # Handle legacy commands restart, kill, shutdown
+
+    def add_arguments(parser):
+        parser.add_argument(
+            'signal', nargs='?', default=default_signal,
+            choices=sorted(SIGNAL_NUMBERS),
+            help='Signal to send.' + help)
+        pid_context.add_arguments(parser)
+
+    def run(args):
+        return send_signal(args.signal, args.pid_filename)
+
+    return add_arguments, run
diff --git a/doc/features.rst b/doc/features.rst
index 9ee2e4b..3b2e07a 100644
--- a/doc/features.rst
+++ b/doc/features.rst
@@ -8,12 +8,14 @@ BiblioPixel has some snappy features:
 
 * It has drivers for almost every popular LED strip, and many other lights.
 
-* Lighting projects can be put together without any programming...
+* Lighting Projects can be put together without any programming...
 
 * ...and it's easy to reuse projects...
 
 * ...but if you program, it's also easy to write your own Animations, Controls,
-  Drivers and Layouts in Python.
+  Drivers and Layouts in Python,
+
+* ...or there's a Project Builder to let you experiment from the command line.
 
 * There's a fast and free `WebGL visualizer <http://simpixel.io>`_ driver,
 
diff --git a/doc/tutorial/getting-started/the-project-builder.rst b/doc/tutorial/getting-started/the-project-builder.rst
new file mode 100644
index 0000000..7c89a12
--- /dev/null
+++ b/doc/tutorial/getting-started/the-project-builder.rst
@@ -0,0 +1,71 @@
+The Project Builder
+------------------------
+
+The Project Builder is a Python class lets you use Projects and write your own
+main program in Python, or experiment in the Python interpreter
+
+
+How to use the Project Builder from the Python interpreter
+================================================================================
+
+Using the Project Builder from the Python interpreter is a lot of fun, and
+anything you do there can be cut-and-pasted into your own scripts.
+
+There's automatically generated class documentation here[TODO], but the
+walkthrough below will probably be more entertaining.
+
+.. code-block:: python
+
+    $ python3
+
+    from bibliopixel.project.builder import Builder
+    pb = Builder()
+    print(pb)
+    # Nothing in it yet...
+
+    # Open a simpixel in your browser.
+    pb.simpixel()
+
+    # Let's start it!
+    pb.start()
+    # You're starting an empty project, which works perfectly well but does nothing.
+    # Hit Control-C to stop it
+
+    pb.shape = 12
+    pb.animation = '.tests.PixelTester'
+    # This resolves to bibliopixel.animation.tests.PixelTester
+
+    pb.start()
+    # Now you should see stuff!
+    # This blocks - you don't get a Python prompt while it's running.
+    # Hit Control-C to stop it
+
+    # Let's run in the background, like this:
+    pb.threaded = True
+    pb.start()
+    # Now you can keep going!
+
+    pb.shape = 128
+    pb.animation = '$bpa.strip.Wave'
+    # Resolves to BiblioPixelAnimations.strip.Wave.Wave
+
+    # Restart the project with the changes
+    pb.start()
+    print(pb)
+
+    pb.save('test.yml')  # Save the data in a file
+
+    dr = Builder()       # Let's make a new project, just for driver info
+    dr.driver = '.serial.driver.Serial'
+    dr.driver.update(c_order='RGB', device_id=10)
+
+    dr.start()
+
+    dr.running_builder.stop()  #
+
+    dr.save('driver.yml')  # I'm going to merge it into
+
+    print(pb)
+    driver: {c_order: RGB, device_id: 10, typename: .serial.driver.Serial}
+
+    (pb + dr).start()  # Restarts the Project with a new driver


Test patch:
diff --git a/scripts/release_test/arguments.py b/scripts/release_test/arguments.py
index d337b59..7690997 100644
--- a/scripts/release_test/arguments.py
+++ b/scripts/release_test/arguments.py
@@ -13,10 +13,14 @@ def arguments(argv=sys.argv[1:]):
 
     features = ', '.join(FEATURES)
     parser.add_argument(
-        '--features', '-f', default=[], action='append',
+        '--features', default=[], action='append',
         help='A list of features separated by colons.  Features are: ' +
         features)
 
+    parser.add_argument(
+        '--force', '-f', action='store_true',
+        help='Do not wait for a prompt')
+
     parser.add_argument(
         '--verbose', '-v', action='store_true',
         help='More verbose output')
@@ -38,7 +42,7 @@ def arguments(argv=sys.argv[1:]):
     else:
         features = get_features()
 
-    return all_tests, features, args.verbose
+    return all_tests, features, args.verbose, args.force
 
 
 if __name__ == '__main__':
diff --git a/scripts/release_test/common.py b/scripts/release_test/common.py
index f471770..ecaa0d8 100644
--- a/scripts/release_test/common.py
+++ b/scripts/release_test/common.py
@@ -4,6 +4,12 @@ import features
 ROOT = os.path.dirname(__file__)
 printer = print  # noqa: T001
 VERBOSE = False
+USE_PROMPT = True
+
+
+def prompt(prompt):
+    if USE_PROMPT:
+        input(prompt + ': ')
 
 
 def execute(*args):
diff --git a/scripts/release_test/release_test b/scripts/release_test/release_test
index f099510..72a68a0 100755
--- a/scripts/release_test/release_test
+++ b/scripts/release_test/release_test
@@ -12,7 +12,8 @@ TEST_PREFIX = 'tests.'
 
 
 def run_tests():
-    all_tests, features, common.VERBOSE = arguments.arguments()
+    all_tests, features, common.VERBOSE, force = arguments.arguments()
+    common.USE_PROMPT = not force
 
     common.printer('Features:', *sorted(features))
 
diff --git a/scripts/release_test/tests/__init__.py b/scripts/release_test/tests/__init__.py
index e76e448..2d1027d 100644
--- a/scripts/release_test/tests/__init__.py
+++ b/scripts/release_test/tests/__init__.py
@@ -1,6 +1,6 @@
 from . import (
-    demo, new, unit, simpixel, rest, remote, keyboard, j12k, midi, all_pixel,
-    bp)
+    builder, demo, new, unit, simpixel, rest, remote, keyboard, j12k, midi,
+    all_pixel, bp)
 
-__all__ = ['demo', 'new', 'unit', 'simpixel', 'keyboard', 'rest', 'midi',
-           'j12k', 'bp', 'all_pixel', 'remote']
+__all__ = ['builder', 'demo', 'new', 'unit', 'simpixel', 'keyboard', 'rest',
+           'midi', 'j12k', 'bp', 'all_pixel', 'remote']
diff --git a/scripts/release_test/tests/all_pixel.py b/scripts/release_test/tests/all_pixel.py
index f7be16c..7b860c6 100644
--- a/scripts/release_test/tests/all_pixel.py
+++ b/scripts/release_test/tests/all_pixel.py
@@ -4,10 +4,10 @@ FEATURES = 'all_pixel',
 
 LEDTYPES = 'WS2801', 'LPD8806', 'SK6812', 'WS2812', 'SK9822', 'APA102'
 SYNONYMS = {'SK6812': 'WS2812B'}
-MESSAGE = '%d: connect %s and press return. '
+MESSAGE = '%d: connect %s and press return'
 
 
 def run():
     for i, t in enumerate(LEDTYPES):
-        input(MESSAGE % (i, t))
+        common.prompt(MESSAGE % (i, t))
         common.run_project('all_pixel.yml', flag='-t=' + SYNONYMS.get(t, t))
diff --git a/scripts/release_test/tests/builder.py b/scripts/release_test/tests/builder.py
new file mode 100644
index 0000000..44d662a
--- /dev/null
+++ b/scripts/release_test/tests/builder.py
@@ -0,0 +1,43 @@
+import os, tempfile, threading, time
+import common
+from bibliopixel.project.builder import Builder
+from bibliopixel.animation.tests import StripChannelTest
+
+SAVE_FILE = 'save.yml'
+
+
+def run():
+    common.prompt('Press return to start builder test')
+
+    with tempfile.TemporaryDirectory() as td:
+        save_file = os.path.join(td, SAVE_FILE)
+        pb = Builder()
+        pb.shape = 32
+        pb.animation = '.tests.PixelTester'
+        pb.simpixel()
+
+        def stop():
+            time.sleep(2)
+            pb.stop()
+
+        def start_and_stop():
+            threading.Thread(daemon=True, target=stop).start()
+            pb.start()
+
+        start_and_stop()
+
+        pb.save(save_file)
+        pb.clear()
+
+        pb = Builder(save_file)
+        start_and_stop()
+
+        pb.animation = StripChannelTest
+        start_and_stop()
+
+        pb.animation = '$bpa.strip.Wave'
+        pb.threaded = True
+        pb.start()
+        time.sleep(2)
+        pb.project.animation.palette[0] = 'green'
+        stop()
diff --git a/scripts/release_test/tests/demo.py b/scripts/release_test/tests/demo.py
index 870fb33..ce01a74 100644
--- a/scripts/release_test/tests/demo.py
+++ b/scripts/release_test/tests/demo.py
@@ -2,7 +2,7 @@ import common
 
 
 def run():
-    input('Press return to start demo tests')
+    common.prompt('Press return to start demo tests')
     for i, demo in enumerate(('bloom', 'circle', 'cube', 'matrix')):
         if i:
             common.execute('bp', 'demo', demo, '--pl=3', '--simpixel=no')
diff --git a/scripts/release_test/tests/keyboard.py b/scripts/release_test/tests/keyboard.py
index 8b061fb..85d8a3a 100644
--- a/scripts/release_test/tests/keyboard.py
+++ b/scripts/release_test/tests/keyboard.py
@@ -4,5 +4,5 @@ FEATURES = 'keyboard',
 
 
 def run():
-    input('Press return to start keyboard test. ')
+    common.prompt('Press return to start keyboard test')
     common.run_project('keyboard.yml')
diff --git a/scripts/release_test/tests/remote.py b/scripts/release_test/tests/remote.py
index bc9b3ae..fcaff4c 100644
--- a/scripts/release_test/tests/remote.py
+++ b/scripts/release_test/tests/remote.py
@@ -5,5 +5,5 @@ PROJECT = 'remote.yml'
 
 
 def run():
-    input('Open a simpixel browser window and press return. ')
+    common.prompt('Open a simpixel browser window and press return')
     common.run_project(PROJECT)
diff --git a/scripts/release_test/tests/simpixel.py b/scripts/release_test/tests/simpixel.py
index fe2b056..38c11b1 100644
--- a/scripts/release_test/tests/simpixel.py
+++ b/scripts/release_test/tests/simpixel.py
@@ -5,5 +5,5 @@ PROJECTS = 'sim-strip.yml', 'sim-matrix.yml', 'sim-cube.yml', 'sim-circle.yml',
 
 
 def run():
-    input('Open a simpixel browser window and press return. ')
+    common.prompt('Open a simpixel browser window and press return')
     common.run_project(*PROJECTS)
diff --git a/test/bibliopixel/colors/color_test.py b/test/bibliopixel/colors/color_test.py
index 9ab52a4..f0e8962 100644
--- a/test/bibliopixel/colors/color_test.py
+++ b/test/bibliopixel/colors/color_test.py
@@ -50,8 +50,9 @@ class NamesTest(unittest.TestCase):
         self.assertEqual(COLORS.red, (255, 0, 0))
         self.assertEqual(COLORS.OFF, (0, 0, 0))
         self.assertEqual(COLORS['o n'], (255, 255, 255))
-        self.assertEqual(COLORS((0, 0, 0)), 'black')
-        self.assertEqual(COLORS((0x8a, 0x36, 0x0f)), 'burnt sienna')
+        self.assertEqual(names.color_to_name((0, 0, 0)), 'black')
+        self.assertEqual(
+            names.color_to_name((0x8a, 0x36, 0x0f)), 'burnt sienna')
 
     def test_toggle(self):
         self.assertEqual(names.toggle('red'), '(255, 0, 0)')
diff --git a/test/bibliopixel/colors/palette_test.py b/test/bibliopixel/colors/palette_test.py
index 9bb3065..0e2ff76 100644
--- a/test/bibliopixel/colors/palette_test.py
+++ b/test/bibliopixel/colors/palette_test.py
@@ -37,6 +37,15 @@ class PaletteTest(unittest.TestCase):
             (0, 255, 0)]
         self.assertEqual(result, expected)
 
+    def test_palette_setitem(self):
+        p = Palette([Red, Green, Blue])
+        p[0] = Green
+        p[1] = 'blue'
+        p[2] = '0xFF0000'
+        with self.assertRaises(IndexError):
+            p[3] = Red
+        self.assertEqual(p, Palette([Green, Blue, Red]))
+
     def test_get_serpentine(self):
         p = Palette([Red, Green, Blue], serpentine=True, continuous=True)
         result = [p(-1 + Fraction(i) / 2) for i in range(12)]
diff --git a/test/bibliopixel/colors/tables_test.py b/test/bibliopixel/colors/tables_test.py
index abe6179..6c9fc44 100644
--- a/test/bibliopixel/colors/tables_test.py
+++ b/test/bibliopixel/colors/tables_test.py
@@ -1,5 +1,5 @@
 import unittest
-from bibliopixel.colors import tables
+from bibliopixel.colors import tables, COLORS
 
 
 class TablesTest(unittest.TestCase):
@@ -39,3 +39,19 @@ class TablesTest(unittest.TestCase):
 
         self.assertEqual(tables.get_color('RED'), (255, 0, 0))
         self.assertEqual(tables.get_name((255, 0, 0)), 'red')
+
+    def test_all_named_colors(self):
+        all_colors = sorted(COLORS)
+        self.assertEqual(486, len(all_colors))
+        actual = all_colors[:4] + all_colors[-4:]
+        expected = [
+            ('aliceblue', (240, 248, 255)),
+            ('amethyst', (153, 102, 204)),
+            ('antiquewhite', (250, 235, 215)),
+            ('antiquewhite1', (255, 239, 219)),
+            ('yellow2', (238, 238, 0)),
+            ('yellow3', (205, 205, 0)),
+            ('yellow4', (139, 139, 0)),
+            ('yellowgreen', (154, 205, 50))]
+
+        self.assertEqual(actual, expected)
diff --git a/test/bibliopixel/project/aliases_test.py b/test/bibliopixel/project/aliases_test.py
index 86f1c32..29e5ce1 100644
--- a/test/bibliopixel/project/aliases_test.py
+++ b/test/bibliopixel/project/aliases_test.py
@@ -1,11 +1,13 @@
 import unittest
 import test.bibliopixel.patch
-from bibliopixel.project import aliases, alias_lists, importer
+from bibliopixel.project import aliases, importer
 from bibliopixel.util import log
 
+NOT_ANIMATIONS = ['bpa']
+
 
 def patch(**kwds):
-    return test.bibliopixel.patch.patch(alias_lists, 'PROJECT_ALIASES', kwds)
+    return test.bibliopixel.patch.patch(aliases, 'PROJECT_ALIASES', kwds)
 
 
 class AliasTest(unittest.TestCase):
@@ -44,15 +46,15 @@ class AliasTest(unittest.TestCase):
 
     def test_existence(self):
         failed = []
-        for cl in alias_lists.BUILTIN_ALIASES.values():
+        for name, cl in aliases.BUILTIN_ALIASES.items():
             try:
                 importer.import_symbol(cl)
             except:
-                failed.append(cl)
+                failed.append(name)
 
-        if failed:
+        if failed != NOT_ANIMATIONS:
             log.printer('Failed', *failed, sep='\n')
-        self.assertFalse(failed)
+            self.assertTrue(False)
 
     def test_additional_aliases(self):
         additional = {'foo': 'bar', 'remote': 'distance'}
@@ -63,8 +65,8 @@ class AliasTest(unittest.TestCase):
         just_one = ''
         failed, not_equal = [], []
 
-        for alias, path in alias_lists.BUILTIN_ALIASES.items():
-            if just_one and alias != just_one:
+        for alias, path in aliases.BUILTIN_ALIASES.items():
+            if alias in NOT_ANIMATIONS or (just_one and alias != just_one):
                 continue
             python_path = '.'.join(path.split('.', 2)[:2])
             expected = importer.import_symbol(path)
diff --git a/test/bibliopixel/project/fill_test.py b/test/bibliopixel/project/fill_test.py
index 7fa26f7..f022afa 100644
--- a/test/bibliopixel/project/fill_test.py
+++ b/test/bibliopixel/project/fill_test.py
@@ -57,7 +57,7 @@ class FillTest(unittest.TestCase):
         }
         actual = fill_after(copy.deepcopy(source))
         expected = {
-            'aliases': {'bpa': 'BiblioPixelAnimations'},
+            'aliases': {},
             'shape': (),
             'driver': {},
             'drivers': [],
@@ -66,7 +66,7 @@ class FillTest(unittest.TestCase):
                 'height': 32,
                 'datatype': matrix.Matrix,
             },
-            'numbers': 'python',
+            'numbers': '',
             'colors': {},
             'palettes': {},
             'run': {},
diff --git a/test/bibliopixel/project/merge_test.py b/test/bibliopixel/project/merge_test.py
index 81b9ffc..f3bf417 100644
--- a/test/bibliopixel/project/merge_test.py
+++ b/test/bibliopixel/project/merge_test.py
@@ -29,14 +29,14 @@ class MergeTest(unittest.TestCase):
                 'path': 'path/to/dir'
             })
         expected = {
-            'aliases': {'bpa': 'BiblioPixelAnimations'},
+            'aliases': {},
             'animation': {'typename': 'bfoo'},
             'controls': [],
             'shape': (),
             'driver': {'typename': 'bar'},
             'drivers': ['bang', 'bop'],
             'layout': {},
-            'numbers': 'python',
+            'numbers': '',
             'maker': {
                 'numpy_dtype': 'float',
                 'typename': 'bibliopixel.project.data_maker.Maker'
diff --git a/test/bibliopixel/project/project2_test.py b/test/bibliopixel/project/project2_test.py
index bc811ee..f260c39 100644
--- a/test/bibliopixel/project/project2_test.py
+++ b/test/bibliopixel/project/project2_test.py
@@ -16,11 +16,8 @@ class Project2Test(unittest.TestCase):
 
     def test_single(self):
         source = {
-            'animation': {
-                'typename': 'bibliopixel.animation.matrix.Matrix',
-                'width': 23,
-                'height': 32,
-            }
+            'animation': 'bibliopixel.animation.matrix.Matrix',
+            'shape': [23, 32],
         }
         pr = project.project(source)