Repository: rm-hull/luma.core

Issue:
add argparser utils from luma.examples
Being able to reuse the demo_opts, argparser code, in luma.examples would be beneficial for sub-projects. You would be able to use the parser code to create a command-line script that handles all compatible luma libraries.

I'm not sure if it belongs in core or a new luma.commandline-something llibrary. Thoughts?

Discussion:
thijstriemstra: work in progress..
thijstriemstra: I'm gonna add a few more tests but it's basically ready for review @rm-hull 

Patch:
diff --git a/.travis.yml b/.travis.yml
index 34d7fbe..b53e76e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -16,8 +16,6 @@ matrix:
           env: TOXENV=py37
         - python: 2.7
           env: TOXENV=qa
-    allow_failures:
-        - python: 3.7-dev
 
 addons:
   apt:
diff --git a/doc/api-documentation.rst b/doc/api-documentation.rst
index a5807de..01fbfbc 100644
--- a/doc/api-documentation.rst
+++ b/doc/api-documentation.rst
@@ -86,6 +86,13 @@ API Documentation
     :undoc-members:
     :show-inheritance:
 
+:mod:`luma.core.util`
+""""""""""""""""""""""""
+.. automodule:: luma.core.util
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 :mod:`luma.core.virtual`
 """"""""""""""""""""""""
 .. automodule:: luma.core.virtual
diff --git a/luma/core/device.py b/luma/core/device.py
index 112752c..ef3aad2 100644
--- a/luma/core/device.py
+++ b/luma/core/device.py
@@ -23,7 +23,7 @@ class device(mixin.capabilities):
         self._const = const or luma.core.const.common
         self._serial_interface = serial_interface or i2c()
 
-        def shutdown_hook():
+        def shutdown_hook():  # pragma: no cover
             try:
                 self.cleanup()
             except:
diff --git a/luma/core/lib.py b/luma/core/lib.py
index 921b074..ad79da7 100644
--- a/luma/core/lib.py
+++ b/luma/core/lib.py
@@ -8,7 +8,7 @@ import luma.core.error
 __all__ = ["rpi_gpio", "spidev"]
 
 
-def __spidev__(self):
+def __spidev__(self):  # pragma: no cover
     # spidev cant compile on macOS, so use a similar technique to
     # initialize (mainly so the tests run unhindered)
     import spidev
@@ -19,7 +19,7 @@ def __rpi_gpio__(self):
     # RPi.GPIO _really_ doesn't like being run on anything other than
     # a Raspberry Pi... this is imported here so we can swap out the
     # implementation for a mock
-    try:
+    try:  # pragma: no cover
         import RPi.GPIO as GPIO
         GPIO.setmode(GPIO.BCM)
         return GPIO
diff --git a/luma/core/serial.py b/luma/core/serial.py
index dbfbb65..c5e631c 100644
--- a/luma/core/serial.py
+++ b/luma/core/serial.py
@@ -225,8 +225,8 @@ class noop(object):
     def __getattr__(self, attr):
         return self.__noop
 
-    def __setattr__(self, attr, val):
+    def __setattr__(self, attr, val):  # pragma: no cover
         pass
 
-    def __noop(*args, **kwargs):
+    def __noop(self, *args, **kwargs):
         pass
diff --git a/luma/core/sprite_system.py b/luma/core/sprite_system.py
index 4b57fd9..a0c83cf 100755
--- a/luma/core/sprite_system.py
+++ b/luma/core/sprite_system.py
@@ -220,6 +220,8 @@ class framerate_regulator(object):
         :returns: the effective frame rate
         :rtype: float
         """
+        if self.start_time is None:
+            self.start_time = 0
         elapsed = monotonic() - self.start_time
         return self.called / elapsed
 
diff --git a/luma/core/threadpool.py b/luma/core/threadpool.py
index 331a235..285c01f 100644
--- a/luma/core/threadpool.py
+++ b/luma/core/threadpool.py
@@ -21,10 +21,7 @@ class worker(Thread):
     def run(self):
         while True:
             func, args, kargs = self.tasks.get()
-            try:
-                func(*args, **kargs)
-            except Exception as e:
-                print(e)
+            func(*args, **kargs)
             self.tasks.task_done()
 
 
diff --git a/luma/core/util.py b/luma/core/util.py
index 2393b0c..229344d 100644
--- a/luma/core/util.py
+++ b/luma/core/util.py
@@ -5,17 +5,23 @@
 import os
 import sys
 import time
+import inspect
 import warnings
+import argparse
+import importlib
 import ctypes.util
+from collections import OrderedDict
 
 
-__all__ = ["deprecation", "monotonic"]
+__all__ = ["deprecation", "monotonic", "get_choices", "get_supported_libraries",
+    "get_interface_types", "get_display_types", "get_transformer_choices",
+    "load_config", "make_serial", "create_device", "create_parser"]
 
 
 try:
     # only available since python 3.3
     monotonic = time.monotonic
-except AttributeError:
+except AttributeError:  # pragma: no cover
     if sys.platform == 'darwin':  # OS X, iOS
         libc = ctypes.CDLL('/usr/lib/libc.dylib', use_errno=True)
 
@@ -64,3 +70,196 @@ except AttributeError:
 
 def deprecation(message):
     warnings.warn(message, DeprecationWarning, stacklevel=2)
+
+
+def get_choices(module_name):
+    """
+    Retrieve members from ``module_name``'s ``__all__`` list.
+
+    :rtype: list
+    """
+    try:
+        module = importlib.import_module(module_name)
+        if hasattr(module, '__all__'):
+            return module.__all__
+        else:
+            return [name for name, _ in inspect.getmembers(module,
+                                                           inspect.isclass)]
+    except ImportError:
+        return []
+
+
+def get_supported_libraries():
+    """
+    Get list of supported libraries for the parser.
+
+    :rtype: list
+    """
+    return ['oled', 'lcd', 'led_matrix', 'emulator']
+
+
+def get_interface_types():
+    """
+    Get list of available interface types, e.g. ``['spi', 'i2c']``.
+
+    :rtype: list
+    """
+    return get_choices('luma.core.serial')
+
+
+def get_display_types():
+    """
+    Get ordered dict containg available display types from available luma
+    sub-projects.
+
+    :rtype: OrderedDict
+    """
+    display_types = OrderedDict()
+    for namespace in get_supported_libraries():
+        display_types[namespace] = get_choices('luma.{0}.device'.format(
+            namespace))
+
+    return display_types
+
+
+def get_transformer_choices():
+    """
+    :rtype: list
+    """
+    from luma.emulator.render import transformer
+    return [fn for fn in dir(transformer) if fn[0:2] != "__"]
+
+
+def load_config(path):
+    """
+    Load device configuration from file path and return list with parsed lines.
+
+    :param path: Location of configuration file.
+    :type path: str
+    :rtype: list
+    """
+    args = []
+    with open(path, 'r') as fp:
+        for line in fp.readlines():
+            if line.strip() and not line.startswith("#"):
+                args.append(line.replace("\n", ""))
+
+    return args
+
+
+class make_serial(object):
+    """
+    Serial factory.
+    """
+    def __init__(self, opts, gpio=None):
+        self.opts = opts
+        self.gpio = gpio
+
+    def i2c(self):
+        from luma.core.serial import i2c
+        return i2c(port=self.opts.i2c_port, address=self.opts.i2c_address)
+
+    def spi(self):
+        from luma.core.serial import spi
+        return spi(port=self.opts.spi_port,
+                   device=self.opts.spi_device,
+                   bus_speed_hz=self.opts.spi_bus_speed,
+                   gpio_DC=self.opts.gpio_data_command,
+                   gpio_RST=self.opts.gpio_reset,
+                   gpio=self.gpio)
+
+
+def create_device(args, dtypes=None):
+    """
+    Create and return device.
+    """
+    device = None
+    if dtypes is None:
+        dtypes = get_display_types()
+
+    if args.display in dtypes.get('oled'):
+        import luma.oled.device
+        Device = getattr(luma.oled.device, args.display)
+        Serial = getattr(make_serial(args), args.interface)
+        device = Device(Serial(), **vars(args))
+
+    elif args.display in dtypes.get('lcd'):
+        import luma.lcd.device
+        import luma.lcd.aux
+        Device = getattr(luma.lcd.device, args.display)
+        Serial = getattr(make_serial(args), args.interface)
+        luma.lcd.aux.backlight(gpio_LIGHT=args.gpio_backlight).enable(True)
+        device = Device(Serial(), **vars(args))
+
+    elif args.display in dtypes.get('led_matrix'):
+        import luma.led_matrix.device
+        from luma.core.serial import noop
+        Device = getattr(luma.led_matrix.device, args.display)
+        Serial = make_serial(args, gpio=noop()).spi
+        device = Device(serial_interface=Serial(), **vars(args))
+
+    elif args.display in dtypes.get('emulator'):
+        import luma.emulator.device
+        Device = getattr(luma.emulator.device, args.display)
+        device = Device(**vars(args))
+
+    return device
+
+
+def create_parser(description):
+    """
+    Create and return command-line argument parser.
+    """
+    parser = argparse.ArgumentParser(description=description,
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+    display_types = get_display_types()
+    display_choices = [display for k, v in display_types.items() for display in v]
+    interface_types = get_interface_types()
+    framebuffer_choices = get_choices("luma.core.framebuffer")
+
+    general_group = parser.add_argument_group('General')
+    general_group.add_argument('--config', '-f', type=str, help='Load configuration settings from a file')
+    general_group.add_argument('--display', '-d', type=str, default=display_choices[0], help='Display type, supports real devices or emulators', choices=display_choices)
+    general_group.add_argument('--width', type=int, default=128, help='Width of the device in pixels')
+    general_group.add_argument('--height', type=int, default=64, help='Height of the device in pixels')
+    general_group.add_argument('--rotate', '-r', type=int, default=0, help='Rotation factor', choices=[0, 1, 2, 3])
+    general_group.add_argument('--interface', '-i', type=str, default=interface_types[0], help='Serial interface type', choices=interface_types)
+
+    i2c_group = parser.add_argument_group('I2C')
+    i2c_group.add_argument('--i2c-port', type=int, default=1, help='I2C bus number')
+    i2c_group.add_argument('--i2c-address', type=str, default='0x3C', help='I2C display address')
+
+    spi_group = parser.add_argument_group('SPI')
+    spi_group.add_argument('--spi-port', type=int, default=0, help='SPI port number')
+    spi_group.add_argument('--spi-device', type=int, default=0, help='SPI device')
+    spi_group.add_argument('--spi-bus-speed', type=int, default=8000000, help='SPI max bus speed (Hz)')
+
+    gpio_group = parser.add_argument_group('GPIO')
+    gpio_group.add_argument('--gpio-data-command', type=int, default=24, help='GPIO pin for D/C RESET (SPI devices only)')
+    gpio_group.add_argument('--gpio-reset', type=int, default=25, help='GPIO pin for RESET (SPI devices only)')
+    gpio_group.add_argument('--gpio-backlight', type=int, default=18, help='GPIO pin for backlight (PCD8544 devices only)')
+
+    misc_group = parser.add_argument_group('Misc')
+    misc_group.add_argument('--block-orientation', type=str, default='horizontal', help='Fix 90° phase error (MAX7219 LED matrix only)', choices=['horizontal', 'vertical'])
+    misc_group.add_argument('--mode', type=str, default='RGB', help='Colour mode (SSD1322, SSD1325 and emulator only)', choices=['1', 'RGB', 'RGBA'])
+    misc_group.add_argument('--framebuffer', type=str, default=framebuffer_choices[0], help='Framebuffer implementation (SSD1331, SSD1322, ST7735 displays only)', choices=framebuffer_choices)
+    misc_group.add_argument('--bgr', type=bool, default=False, help='Set to True if LCD pixels laid out in BGR (ST7735 displays only)', choices=[True, False])
+
+    if len(display_types["emulator"]) > 0:
+        transformer_choices = get_transformer_choices()
+
+        emulator_group = parser.add_argument_group('Emulator')
+        emulator_group.add_argument('--transform', type=str, default='scale2x', help='Scaling transform to apply (emulator only)', choices=transformer_choices)
+        emulator_group.add_argument('--scale', type=int, default=2, help='Scaling factor to apply (emulator only)')
+        emulator_group.add_argument('--duration', type=float, default=0.01, help='Animation frame duration (gifanim emulator only)')
+        emulator_group.add_argument('--loop', type=int, default=0, help='Repeat loop, zero=forever (gifanim emulator only)')
+        emulator_group.add_argument('--max-frames', type=int, help='Maximum frames to record (gifanim emulator only)')
+
+    try:  # pragma: no cover
+        import argcomplete
+        argcomplete.autocomplete(parser)
+    except ImportError:
+        pass
+
+    return parser
diff --git a/setup.py b/setup.py
index 26fd387..ad3cb16 100644
--- a/setup.py
+++ b/setup.py
@@ -43,7 +43,7 @@ setup(
     tests_require=test_deps,
     extras_require={
         'docs': [
-            'sphinx >= 1.5.3'
+            'sphinx >= 1.5.1'
         ],
         'test': test_deps,
         'qa': [


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
index cf41d46..1fd014d 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -9,9 +9,18 @@ Test helpers.
 
 import os.path
 
+try:
+    from unittest.mock import patch, call, Mock
+except ImportError:
+    from mock import patch, call, Mock  # noqa: F401
 
-def get_reference_image(fname):
+
+def get_reference_file(fname):
     return os.path.abspath(os.path.join(
         os.path.dirname(__file__),
         'reference',
         fname))
+
+
+def get_reference_image(fname):
+    return get_reference_file(os.path.join('images', fname))
diff --git a/tests/reference/config-test.txt b/tests/reference/config-test.txt
new file mode 100644
index 0000000..f55ed7e
--- /dev/null
+++ b/tests/reference/config-test.txt
@@ -0,0 +1,4 @@
+--display=capture
+--width=800
+--height=8600
+--spi-bus-speed=16000000
diff --git a/tests/reference/alt_colors.png b/tests/reference/images/alt_colors.png
similarity index 100%
rename from tests/reference/alt_colors.png
rename to tests/reference/images/alt_colors.png
diff --git a/tests/reference/ansi_colors.png b/tests/reference/images/ansi_colors.png
similarity index 100%
rename from tests/reference/ansi_colors.png
rename to tests/reference/images/ansi_colors.png
diff --git a/tests/reference/control_chars.png b/tests/reference/images/control_chars.png
similarity index 100%
rename from tests/reference/control_chars.png
rename to tests/reference/images/control_chars.png
diff --git a/tests/reference/dither.png b/tests/reference/images/dither.png
similarity index 100%
rename from tests/reference/dither.png
rename to tests/reference/images/dither.png
diff --git a/tests/reference/hotspot.png b/tests/reference/images/hotspot.png
similarity index 100%
rename from tests/reference/hotspot.png
rename to tests/reference/images/hotspot.png
diff --git a/tests/reference/portrait.png b/tests/reference/images/portrait.png
similarity index 100%
rename from tests/reference/portrait.png
rename to tests/reference/images/portrait.png
diff --git a/tests/reference/quick_brown_fox.png b/tests/reference/images/quick_brown_fox.png
similarity index 100%
rename from tests/reference/quick_brown_fox.png
rename to tests/reference/images/quick_brown_fox.png
diff --git a/tests/reference/runner.png b/tests/reference/images/runner.png
similarity index 100%
rename from tests/reference/runner.png
rename to tests/reference/images/runner.png
diff --git a/tests/reference/scroll_text.png b/tests/reference/images/scroll_text.png
similarity index 100%
rename from tests/reference/scroll_text.png
rename to tests/reference/images/scroll_text.png
diff --git a/tests/reference/set_position.png b/tests/reference/images/set_position.png
similarity index 100%
rename from tests/reference/set_position.png
rename to tests/reference/images/set_position.png
diff --git a/tests/reference/tab_align.png b/tests/reference/images/tab_align.png
similarity index 100%
rename from tests/reference/tab_align.png
rename to tests/reference/images/tab_align.png
diff --git a/tests/test_framerate_regulator.py b/tests/test_framerate_regulator.py
index e89ef74..1919869 100644
--- a/tests/test_framerate_regulator.py
+++ b/tests/test_framerate_regulator.py
@@ -7,8 +7,6 @@
 Tests for the :py:class:`luma.core.sprite_system.framerate_regulator` class.
 """
 
-import pytest
-
 from luma.core.util import monotonic
 from luma.core.sprite_system import framerate_regulator
 
@@ -66,11 +64,11 @@ def test_sleep():
 
 def test_effective_FPS():
     regulator = framerate_regulator(fps=30)
-    with pytest.raises(TypeError):
-        regulator.effective_FPS()
+    assert regulator.effective_FPS() == 0
 
 
 def test_average_transit_time():
     regulator = framerate_regulator(fps=30)
-    with pytest.raises(ZeroDivisionError):
-        regulator.average_transit_time()
+    with regulator:
+        pass
+    assert regulator.average_transit_time() > 0
diff --git a/tests/test_legacy.py b/tests/test_legacy.py
index e651dfa..8266148 100644
--- a/tests/test_legacy.py
+++ b/tests/test_legacy.py
@@ -3,16 +3,12 @@
 # Copyright (c) 2014-17 Richard Hull and contributors
 # See LICENSE.rst for details.
 
-
-try:
-    from unittest.mock import call, Mock
-except ImportError:
-    from mock import call, Mock
-
 from luma.core.device import dummy
 from luma.core.legacy import text, textsize, show_message
 from luma.core.legacy.font import proportional, CP437_FONT, LCD_FONT
 
+from helpers import Mock, call
+
 
 def test_textsize():
     """
diff --git a/tests/test_serial.py b/tests/test_serial.py
index f9e237c..a708510 100644
--- a/tests/test_serial.py
+++ b/tests/test_serial.py
@@ -9,16 +9,14 @@ Tests for the :py:mod:`luma.core.serial` module.
 
 import errno
 
-try:
-    from unittest.mock import patch, call, Mock
-except ImportError:
-    from mock import patch, call, Mock
-
 import pytest
 import smbus2
 from luma.core.serial import i2c, spi
 import luma.core.error
 
+from helpers import Mock, patch, call
+
+
 smbus = Mock(unsafe=True)
 spidev = Mock(unsafe=True)
 gpio = Mock(unsafe=True)
diff --git a/tests/test_spritesheet.py b/tests/test_spritesheet.py
index fce267a..c533923 100644
--- a/tests/test_spritesheet.py
+++ b/tests/test_spritesheet.py
@@ -14,7 +14,7 @@ from luma.core.sprite_system import spritesheet
 
 
 data = {
-    'image': "tests/reference/runner.png",
+    'image': "tests/reference/images/runner.png",
     'frames': {
         'width': 64,
         'height': 67,
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000..adb27f1
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+"""
+Tests for the :py:mod:`luma.core.util` module.
+"""
+
+import sys
+
+import pytest
+
+from luma.core import util
+from luma.core.serial import __all__ as iface_types
+
+from helpers import get_reference_file, patch, Mock
+
+
+test_config_file = get_reference_file('config-test.txt')
+
+
+def test_deprecation():
+    """
+    ``deprecated`` creates a DeprecationWarning.
+    """
+    class DeprecatedClass(object):
+        def __init__(self):
+            self.msg = 'Deprecated; will be removed in 0.0.1'
+            util.deprecation(self.msg)
+
+    with pytest.deprecated_call() as c:
+        d = DeprecatedClass()
+        assert str(c.list[0].message) == d.msg
+
+
+def test_get_interface_types():
+    """
+    Enumerate interface types.
+    """
+    assert util.get_interface_types() == iface_types
+
+
+def test_get_display_types():
+    """
+    Enumerate display types.
+    """
+    assert list(util.get_display_types().keys()) == util.get_supported_libraries()
+
+
+def test_get_choices_unknown_module():
+    """
+    get_choices returns an empty list when trying to inspect an unknown module.
+    """
+    result = util.get_choices('foo')
+    assert result == []
+
+
+def test_load_config_file_parse():
+    """
+    load_config parses a text file and returns a list of arguments.
+    """
+    result = util.load_config(test_config_file)
+    assert result == [
+        '--display=capture',
+        '--width=800',
+        '--height=8600',
+        '--spi-bus-speed=16000000'
+    ]
+
+
+def test_create_parser():
+    """
+    create_parser returns an argument parser instance.
+    """
+    sys.modules['luma.emulator'] = Mock()
+    sys.modules['luma.emulator.render'] = Mock()
+
+    with patch('luma.core.util.get_display_types') as mocka:
+        mocka.return_value = {
+            'foo': ['a', 'b'],
+            'bar': ['c', 'd'],
+            'emulator': ['e', 'f']
+        }
+        parser = util.create_parser(description='test')
+        args = parser.parse_args(['-f', test_config_file])
+        assert args.config == test_config_file