Repository: ntoll/uflash

Issue:
Allow for script minification
There should be a flag (`--minify` and `-m`) that causes uflash to "minify" the Python script using various heuristics so it'll fit into the limited amount of memory available.

See: https://github.com/mu-editor/mu/pull/385 for an example of what I mean.

Discussion:

Patch:
diff --git a/requirements.txt b/requirements.txt
index 40fe650..04c8918 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ pyflakes
 coverage
 sphinx
 pytest-cov
+nudatus>=0.0.2 
 
 # Mock is bundled as part of unittest since Python 3.3
 # mock_open can't read binary data in <= 3.4.2
diff --git a/uflash.py b/uflash.py
index 7e1872f..4d0d0b3 100644
--- a/uflash.py
+++ b/uflash.py
@@ -21,6 +21,13 @@ import sys
 from subprocess import check_output
 import time
 
+# nudatus is an optional dependancy
+can_minify = True
+try:
+    import nudatus
+except ImportError:  # pragma: no cover
+    can_minify = False
+
 #: The magic start address in flash memory for a Python script.
 _SCRIPT_ADDR = 0x3e000
 
@@ -41,6 +48,7 @@ Documentation is here: https://uflash.readthedocs.io/en/latest/
 
 #: MAJOR, MINOR, RELEASE, STATUS [alpha, beta, final], VERSION
 _VERSION = (1, 1, 1, )
+_MAX_SIZE = 8188
 
 
 def get_version():
@@ -50,6 +58,15 @@ def get_version():
     return '.'.join([str(i) for i in _VERSION])
 
 
+def get_minifier():
+    """
+    Report the minifier will be used when minify=True
+    """
+    if can_minify:
+        return 'nudatus'
+    return None
+
+
 def strfunc(raw):
     """
     Compatibility for 2 & 3 str()
@@ -57,7 +74,7 @@ def strfunc(raw):
     return str(raw) if sys.version_info[0] == 2 else str(raw, 'utf-8')
 
 
-def hexlify(script):
+def hexlify(script, minify=False):
     """
     Takes the byte content of a Python script and returns a hex encoded
     version of it.
@@ -69,11 +86,15 @@ def hexlify(script):
     # Convert line endings in case the file was created on Windows.
     script = script.replace(b'\r\n', b'\n')
     script = script.replace(b'\r', b'\n')
+    if minify:
+        if not can_minify:
+            raise ValueError("No minifier is available")
+        script = nudatus.mangle(script.decode('utf-8')).encode('utf-8')
     # Add header, pad to multiple of 16 bytes.
     data = b'MP' + struct.pack('<H', len(script)) + script
     # Padding with null bytes in a 2/3 compatible way
     data = data + (b'\x00' * (16 - len(data) % 16))
-    if len(data) > 8192:
+    if len(data) > _MAX_SIZE:
         # 'MP' = 2 bytes, script length is another 2 bytes.
         raise ValueError("Python script must be less than 8188 bytes.")
     # Convert to .hex format.
@@ -252,7 +273,7 @@ def save_hex(hex_file, path):
 
 
 def flash(path_to_python=None, paths_to_microbits=None,
-          path_to_runtime=None, python_script=None):
+          path_to_runtime=None, python_script=None, minify=False):
     """
     Given a path to or source of a Python file will attempt to create a hex
     file and then flash it onto the referenced BBC micro:bit.
@@ -285,9 +306,9 @@ def flash(path_to_python=None, paths_to_microbits=None,
         if not path_to_python.endswith('.py'):
             raise ValueError('Python files must end in ".py".')
         with open(path_to_python, 'rb') as python_script:
-            python_hex = hexlify(python_script.read())
+            python_hex = hexlify(python_script.read(), minify)
     elif python_script:
-        python_hex = hexlify(python_script)
+        python_hex = hexlify(python_script, minify)
 
     runtime = _RUNTIME
     # Load the hex for the runtime.
@@ -374,6 +395,9 @@ def main(argv=None):
     parser.add_argument('-w', '--watch',
                         action='store_true',
                         help='Watch the source file for changes.')
+    parser.add_argument('-m', '--minify',
+                        action='store_true',
+                        help='Minify the source')
     parser.add_argument('--version', action='version',
                         version='%(prog)s ' + get_version())
     args = parser.parse_args(argv)
@@ -402,7 +426,7 @@ def main(argv=None):
     else:
         try:
             flash(path_to_python=args.source, paths_to_microbits=args.target,
-                  path_to_runtime=args.runtime)
+                  path_to_runtime=args.runtime, minify=args.minify)
         except Exception as ex:
             error_message = (
                 "Error flashing {source} to {target}{runtime}: {error!s}"


Test patch:
diff --git a/tests/test_uflash.py b/tests/test_uflash.py
index d3746a3..38c561f 100644
--- a/tests/test_uflash.py
+++ b/tests/test_uflash.py
@@ -44,6 +44,17 @@ def test_get_version():
     assert result == '.'.join([str(i) for i in uflash._VERSION])
 
 
+def test_get_minifier():
+    """
+    When a minifier was loaded a string identifing it should be
+    returned, otherwise None
+    """
+    with mock.patch('uflash.can_minify', False):
+        assert uflash.get_minifier() is None
+    with mock.patch('uflash.can_minify', True):
+        assert len(uflash.get_minifier()) > 0
+
+
 def test_hexlify():
     """
     Ensure we get the expected .hex encoded result from a "good" call to the
@@ -383,7 +394,7 @@ def test_flash_with_python_script():
         with mock.patch('uflash.find_microbit', return_value='bar'):
             with mock.patch('uflash.hexlify') as mock_hexlify:
                 uflash.flash(python_script=python_script)
-                mock_hexlify.assert_called_once_with(python_script)
+                mock_hexlify.assert_called_once_with(python_script, False)
 
 
 def test_flash_cannot_find_microbit():
@@ -409,6 +420,25 @@ def test_flash_wrong_python():
             assert 'Will only run on Python ' in ex.value.args[0]
 
 
+def test_hexlify_minify_without_minifier():
+    """
+    When minification but no minifier is available a ValueError
+    should be raised
+    """
+    with pytest.raises(ValueError):
+        with mock.patch('uflash.can_minify', False):
+            uflash.hexlify(TEST_SCRIPT, minify=True)
+
+
+def test_hexlify_minify():
+    """
+    Check mangle is called as expected
+    """
+    with mock.patch('nudatus.mangle') as mangle:
+        uflash.hexlify(TEST_SCRIPT, minify=True)
+        mangle.assert_called_once_with(TEST_SCRIPT.decode('utf-8'))
+
+
 def test_main_no_args():
     """
     If there are no args into the main function, it simply calls flash with
@@ -419,7 +449,8 @@ def test_main_no_args():
             uflash.main()
             mock_flash.assert_called_once_with(path_to_python=None,
                                                paths_to_microbits=[],
-                                               path_to_runtime=None)
+                                               path_to_runtime=None,
+                                               minify=False)
 
 
 def test_main_first_arg_python():
@@ -431,7 +462,8 @@ def test_main_first_arg_python():
         uflash.main(argv=['foo.py'])
         mock_flash.assert_called_once_with(path_to_python='foo.py',
                                            paths_to_microbits=[],
-                                           path_to_runtime=None)
+                                           path_to_runtime=None,
+                                           minify=False)
 
 
 def test_main_first_arg_help(capsys):
@@ -557,7 +589,8 @@ def test_main_two_args():
         mock_flash.assert_called_once_with(
             path_to_python='foo.py',
             paths_to_microbits=['/media/foo/bar'],
-            path_to_runtime=None)
+            path_to_runtime=None,
+            minify=False)
 
 
 def test_main_multiple_microbits():
@@ -572,7 +605,8 @@ def test_main_multiple_microbits():
             path_to_python='foo.py',
             paths_to_microbits=[
                 '/media/foo/bar', '/media/foo/baz', '/media/foo/bob'],
-            path_to_runtime=None)
+            path_to_runtime=None,
+            minify=False)
 
 
 def test_main_runtime():
@@ -585,7 +619,8 @@ def test_main_runtime():
         mock_flash.assert_called_once_with(
             path_to_python='foo.py',
             paths_to_microbits=['/media/foo/bar'],
-            path_to_runtime='baz.hex')
+            path_to_runtime='baz.hex',
+            minify=False)
 
 
 def test_main_named_args():
@@ -596,7 +631,8 @@ def test_main_named_args():
         uflash.main(argv=['-r', 'baz.hex'])
         mock_flash.assert_called_once_with(path_to_python=None,
                                            paths_to_microbits=[],
-                                           path_to_runtime='baz.hex')
+                                           path_to_runtime='baz.hex',
+                                           minify=False)
 
 
 def test_main_watch_flag():