From 2d952264ae6ae6e7013bf15f47b7a3c7c5976e85 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sat, 13 Aug 2022 14:06:46 -0700 Subject: [PATCH 1/5] use repr + ast.literal_eval so we can support bytes and sets --- belay/device.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/belay/device.py b/belay/device.py index 6835f5c..2567841 100644 --- a/belay/device.py +++ b/belay/device.py @@ -1,28 +1,27 @@ +import ast import binascii import hashlib -import json import linecache import tempfile from abc import ABC, abstractmethod from functools import wraps from pathlib import Path -from typing import Callable, Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Set, Union from ._minify import minify as minify_code from .inspect import getsource from .pyboard import Pyboard, PyboardException # Typing -JsonSerializeable = Union[None, bool, int, float, str, List, Dict] +JsonSerializeable = Union[None, bool, bytes, int, float, str, List, Dict, Set] # MicroPython Code Snippets _BELAY_PREFIX = "_belay_" -_BELAY_STARTUP_CODE = f"""import json -def __belay_json(f): +_BELAY_STARTUP_CODE = f"""def __belay_json(f): def belay_interface(*args, **kwargs): res = f(*args, **kwargs) - print(json.dumps(res, separators=(',', ':'))) + print(repr(res)) return res globals()["{_BELAY_PREFIX}" + f.__name__] = belay_interface return f @@ -164,7 +163,7 @@ def __call__( @wraps(f) def executer(*args, **kwargs): - cmd = f"{_BELAY_PREFIX + name}(*{args}, **{kwargs})" + cmd = f"{_BELAY_PREFIX + name}(*{repr(args)}, **{repr(kwargs)})" return self._belay_device._traceback_execute( src_file, src_lineno, name, cmd @@ -229,7 +228,7 @@ def __call__( @wraps(f) def executer(*args, **kwargs): - cmd = f"import _thread; _thread.start_new_thread({name}, {args}, {kwargs})" + cmd = f"import _thread; _thread.start_new_thread({name}, {repr(args)}, {repr(kwargs)})" self._belay_device._traceback_execute(src_file, src_lineno, name, cmd) @wraps(f) @@ -311,7 +310,7 @@ def __call__( if deserialize: if res: - return json.loads(res) + return ast.literal_eval(res) else: return None else: @@ -368,7 +367,7 @@ def sync( # All other files, just sync over. local_hash = local_hash_file(src) - remote_hash = self(f"__belay_hash_file({json.dumps(dst)})") + remote_hash = self(f"__belay_hash_file({repr(dst)})") if local_hash != remote_hash: self._board.fs_put(src, dst) self(f'all_files.discard("{dst}")') From 1d62295cd61b8443fd28ea5eb2a0ae4a4aaab17b Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sat, 13 Aug 2022 14:32:13 -0700 Subject: [PATCH 2/5] remove all references to json serializing since we now use repr and ast.literal_eval --- belay/device.py | 20 ++++++++++---------- docs/source/How Belay Works.rst | 16 ++++++++-------- docs/source/api.rst | 4 ++-- examples/03_read_adc/README.rst | 5 ++--- tests/test_device.py | 5 ++--- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/belay/device.py b/belay/device.py index 2567841..3459ff9 100644 --- a/belay/device.py +++ b/belay/device.py @@ -13,12 +13,12 @@ from .pyboard import Pyboard, PyboardException # Typing -JsonSerializeable = Union[None, bool, bytes, int, float, str, List, Dict, Set] +PythonLiteral = Union[None, bool, bytes, int, float, str, List, Dict, Set] # MicroPython Code Snippets _BELAY_PREFIX = "_belay_" -_BELAY_STARTUP_CODE = f"""def __belay_json(f): +_BELAY_STARTUP_CODE = f"""def __belay(f): def belay_interface(*args, **kwargs): res = f(*args, **kwargs) print(repr(res)) @@ -126,17 +126,17 @@ def __call__(self): class _TaskExecuter(_Executer): def __call__( self, - f: Optional[Callable[..., JsonSerializeable]] = None, + f: Optional[Callable[..., PythonLiteral]] = None, /, minify: bool = True, register: bool = True, - ) -> Callable[..., JsonSerializeable]: + ) -> Callable[..., PythonLiteral]: """Decorator that send code to device that executes when decorated function is called on-host. Parameters ---------- f: Callable - Function to decorate. + Function to decorate. Can only accept and return python literals. minify: bool Minify ``cmd`` code prior to sending. Defaults to ``True``. @@ -155,8 +155,8 @@ def __call__( name = f.__name__ src_code, src_lineno, src_file = getsource(f) - # Add the json_decorator decorator for handling serialization. - src_code = "@__belay_json\n" + src_code + # Add the __belay decorator for handling result serialization. + src_code = "@__belay\n" + src_code # Send the source code over to the device. self._belay_device(src_code, minify=minify) @@ -204,7 +204,7 @@ def __call__( Parameters ---------- f: Callable - Function to decorate. + Function to decorate. Can only accept python literals as arguments. minify: bool Minify ``cmd`` code prior to sending. Defaults to ``True``. @@ -284,7 +284,7 @@ def __call__( cmd: str, deserialize: bool = True, minify: bool = True, - ) -> JsonSerializeable: + ) -> PythonLiteral: """Execute code on-device. Parameters @@ -292,7 +292,7 @@ def __call__( cmd: str Python code to execute. deserialize: bool - Deserialize the received bytestream from device stdout as JSON data. + Deserialize the received bytestream to a python literal. Defaults to ``True``. minify: bool Minify ``cmd`` code prior to sending. diff --git a/docs/source/How Belay Works.rst b/docs/source/How Belay Works.rst index af4cfaa..046afd6 100644 --- a/docs/source/How Belay Works.rst +++ b/docs/source/How Belay Works.rst @@ -63,12 +63,12 @@ After minification, the code looks like: The ``0`` is just a one character way of saying ``pass``, in case the removed docstring was the entire body. This reduces the number of transmitted characters from 158 to just 53, offering a 3x speed boost. -After minification, the ``@__belay_json`` decorator is added. On-device, this defines a variant of the function, ``_belay_FUNCTION_NAME`` +After minification, the ``@__belay`` decorator is added. On-device, this defines a variant of the function, ``_belay_FUNCTION_NAME`` that performs the following actions: - 1. Takes the returned value of the function, and serializes it to json data. Json was chosen since its built into micropython and is "good enough." + 1. Takes the returned value of the function, and serializes it to a string using ``repr``. - 2. Prints the resulting json data to stdout, so it can be read by the host computer. + 2. Prints the resulting string to stdout, so it can be read by the host computer and deserialized via ``ast.literal_eval``. Conceptually, its as if the following code ran on-device (minification removed for clarity): @@ -81,7 +81,7 @@ Conceptually, its as if the following code ran on-device (minification removed f def _belay_set_led(*args, **kwargs): res = set_led(*args, **kwargs) - print(json.dumps(res)) + print(repr(res)) A separate private function is defined with this serialization in case another on-device function calls ``set_led``. @@ -99,16 +99,16 @@ and then parses back the response. The complete lifecycle looks like this: 3. Belay sends this command over serial to the REPL, causing it to execute on-device. -4. On-device, the result of ``set_led(True)`` is ``None``. This gets json-serialized to ``null``, which gets printed to stdout. +4. On-device, the result of ``set_led(True)`` is ``None``. This gets serialized to the string ``None``, which gets printed to stdout. -5. Belay reads this response form stdout, and deserializes it back to ``None``. +5. Belay reads this response form stdout, and deserializes it back to the ``None`` object. 6. ``None`` is returned on host from the ``set_led(True)`` call. This has a few limitations, namely: -1. Each passed in argument must be completely reconstructable by their string representation. This is true for basic python builtins like numbers, strings, lists, dicts, and sets. +1. Each passed in argument must be completely reconstructable by their printable representation. This is true for python literals like numbers, strings, lists, dicts, and sets. 2. The invoked function cannot be printing to stdout, otherwise the host-side parsing of the result won't work. -3. The returned data of the function must be json-serializeable. +3. The returned data of the function must be a python literal(s). diff --git a/docs/source/api.rst b/docs/source/api.rst index 93e401d..fd0224a 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -8,7 +8,7 @@ API Decorator that send code to device that executes when decorated function is called on-host. - :param Callable f: Function to decorate. + :param Callable f: Function to decorate. Can only accept and return python literals. :param bool minify: Minify ``cmd`` code prior to sending. Defaults to ``True``. :param bool register: Assign an attribute to ``self.task`` with same name as ``f``. Defaults to ``True``. @@ -16,7 +16,7 @@ API Decorator that send code to device that spawns a thread when executed. - :param Callable f: Function to decorate. + :param Callable f: Function to decorate. Can only accept python literals as arguments. :param bool minify: Minify ``cmd`` code prior to sending. Defaults to ``True``. :param bool register: Assign an attribute to ``self.thread`` with same name as ``f``. Defaults to ``True``. diff --git a/examples/03_read_adc/README.rst b/examples/03_read_adc/README.rst index 12710a1..52c0995 100644 --- a/examples/03_read_adc/README.rst +++ b/examples/03_read_adc/README.rst @@ -4,8 +4,7 @@ Example 03: Read ADC This example reads the temperature in celsius from the RP2040's internal temperature sensor. To do this, we explore a new concept: functions can return a value. -Internally, the values returned by a function executed on-device are serialized to json and sent to the computer. -The computer then deserializes the data and returned the value. +Return values are serialized on-device and deserialized on-host by Belay. This is seamless to the user; the function ``read_temperature`` returns a float on-device, and that same float is returned on the host. -An implication of this is that only json-compatible datatypes (booleans, numbers, strings, lists, and dicts) can be returned. +Due to how Belay serializes and deserializes data, only python literals (`None`, booleans, bytes, numbers, strings, sets, lists, and dicts) can be returned. diff --git a/tests/test_device.py b/tests/test_device.py index 35d1cce..9a3cece 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,4 +1,3 @@ -import json from unittest.mock import call import pytest @@ -35,7 +34,7 @@ def test_device_task(mocker, mock_device): def foo(a, b): c = a + b # noqa: F841 - mock_device._board.exec.assert_any_call("@__belay_json\ndef foo(a,b):\n c=a+b\n") + mock_device._board.exec.assert_any_call("@__belay\ndef foo(a,b):\n c=a+b\n") foo(1, 2) assert ( @@ -136,7 +135,7 @@ def sync_path(tmp_path): def test_device_sync_empty_remote(mocker, mock_device, sync_path): - payload = bytes(json.dumps("0" * 64), encoding="utf8") + payload = bytes(repr("0" * 64), encoding="utf8") mock_device._board.exec = mocker.MagicMock(return_value=payload) mock_device.sync(sync_path) From 3b0bc2aedc1f7d1c84962b9b42ff98525b00abcb Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sat, 13 Aug 2022 14:35:59 -0700 Subject: [PATCH 3/5] fix tests --- tests/test_device.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_device.py b/tests/test_device.py index 9a3cece..74611de 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -9,7 +9,7 @@ def mock_pyboard(mocker): mocker.patch("belay.device.Pyboard.__init__", return_value=None) mocker.patch("belay.device.Pyboard.enter_raw_repl", return_value=None) - mocker.patch("belay.device.Pyboard.exec", return_value=b"null") + mocker.patch("belay.device.Pyboard.exec", return_value=b"None") mocker.patch("belay.device.Pyboard.fs_put") @@ -141,11 +141,11 @@ def test_device_sync_empty_remote(mocker, mock_device, sync_path): mock_device.sync(sync_path) expected_cmds = [ - '__belay_hash_file("/alpha.py")', - '__belay_hash_file("/bar.txt")', - '__belay_hash_file("/folder1/file1.txt")', - '__belay_hash_file("/folder1/folder1_1/file1_1.txt")', - '__belay_hash_file("/foo.txt")', + "__belay_hash_file('/alpha.py')", + "__belay_hash_file('/bar.txt')", + "__belay_hash_file('/folder1/file1.txt')", + "__belay_hash_file('/folder1/folder1_1/file1_1.txt')", + "__belay_hash_file('/foo.txt')", ] call_args_list = mock_device._board.exec.call_args_list[1:] assert len(expected_cmds) <= len(call_args_list) From 081f38b686cd0491b23472a1eb10775f63a92cef Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sat, 13 Aug 2022 14:42:41 -0700 Subject: [PATCH 4/5] remove unnecessary lines from startup code --- belay/device.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/belay/device.py b/belay/device.py index 3459ff9..1d31286 100644 --- a/belay/device.py +++ b/belay/device.py @@ -20,9 +20,7 @@ _BELAY_STARTUP_CODE = f"""def __belay(f): def belay_interface(*args, **kwargs): - res = f(*args, **kwargs) - print(repr(res)) - return res + print(repr(f(*args, **kwargs))) globals()["{_BELAY_PREFIX}" + f.__name__] = belay_interface return f """ From 359830949fce7fe892069628c532354f75af3d66 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sat, 13 Aug 2022 14:52:36 -0700 Subject: [PATCH 5/5] update docs --- belay/__init__.py | 1 + belay/device.py | 17 ++++++++++++++--- docs/source/How Belay Works.rst | 6 +++--- examples/03_read_adc/README.rst | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/belay/__init__.py b/belay/__init__.py index 9165ce3..2b0dd23 100644 --- a/belay/__init__.py +++ b/belay/__init__.py @@ -5,6 +5,7 @@ "minify", "Device", "SpecialFilenameError", + "SpecialFunctionNameError", "PyboardException", ] from ._minify import minify diff --git a/belay/device.py b/belay/device.py index 1d31286..ed5e418 100644 --- a/belay/device.py +++ b/belay/device.py @@ -82,11 +82,18 @@ def enumerate_fs(path=""): class SpecialFilenameError(Exception): - """Not allowed filename like ``boot.py`` or ``main.py``.""" + """Reserved filename like ``boot.py`` or ``main.py`` that may impact Belay functionality.""" class SpecialFunctionNameError(Exception): - """Not allowed function name.""" + """Reserved function name that may impact Belay functionality. + + Currently limited to: + + * Names that start and end with double underscore, ``__``. + + * Names that start with ``_belay`` or ``__belay`` + """ def local_hash_file(fn): @@ -106,7 +113,11 @@ def __init__(self, device): object.__setattr__(self, "_belay_device", device) def __setattr__(self, name: str, value: Callable): - if name.startswith("_belay") or (name.startswith("__") and name.endswith("__")): + if ( + name.startswith("_belay") + or name.startswith("__belay") + or (name.startswith("__") and name.endswith("__")) + ): raise SpecialFunctionNameError( f'Not allowed to register function named "{name}".' ) diff --git a/docs/source/How Belay Works.rst b/docs/source/How Belay Works.rst index 046afd6..9b2b343 100644 --- a/docs/source/How Belay Works.rst +++ b/docs/source/How Belay Works.rst @@ -107,8 +107,8 @@ and then parses back the response. The complete lifecycle looks like this: This has a few limitations, namely: -1. Each passed in argument must be completely reconstructable by their printable representation. This is true for python literals like numbers, strings, lists, dicts, and sets. +1. Each passed in argument must be a python literals (``None``, booleans, bytes, numbers, strings, sets, lists, and dicts). -2. The invoked function cannot be printing to stdout, otherwise the host-side parsing of the result won't work. +2. The invoked code cannot ``print``. Belay uses stdout for data transfer and spurious prints will corrupt the data sent to host. -3. The returned data of the function must be a python literal(s). +3. The returned data of the function must also be a python literal(s). diff --git a/examples/03_read_adc/README.rst b/examples/03_read_adc/README.rst index 52c0995..3541f27 100644 --- a/examples/03_read_adc/README.rst +++ b/examples/03_read_adc/README.rst @@ -7,4 +7,4 @@ To do this, we explore a new concept: functions can return a value. Return values are serialized on-device and deserialized on-host by Belay. This is seamless to the user; the function ``read_temperature`` returns a float on-device, and that same float is returned on the host. -Due to how Belay serializes and deserializes data, only python literals (`None`, booleans, bytes, numbers, strings, sets, lists, and dicts) can be returned. +Due to how Belay serializes and deserializes data, only python literals (``None``, booleans, bytes, numbers, strings, sets, lists, and dicts) can be returned.