Skip to content

Commit

Permalink
Merge pull request #6 from BrianPugh/byte-set-support
Browse files Browse the repository at this point in the history
Byte set support
  • Loading branch information
BrianPugh authored Aug 13, 2022
2 parents df94493 + 3598309 commit f226bd7
Show file tree
Hide file tree
Showing 6 changed files with 53 additions and 46 deletions.
1 change: 1 addition & 0 deletions belay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"minify",
"Device",
"SpecialFilenameError",
"SpecialFunctionNameError",
"PyboardException",
]
from ._minify import minify
Expand Down
54 changes: 31 additions & 23 deletions belay/device.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
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]
PythonLiteral = 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(f):
def belay_interface(*args, **kwargs):
res = f(*args, **kwargs)
print(json.dumps(res, separators=(',', ':')))
return res
print(repr(f(*args, **kwargs)))
globals()["{_BELAY_PREFIX}" + f.__name__] = belay_interface
return f
"""
Expand Down Expand Up @@ -85,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):
Expand All @@ -109,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}".'
)
Expand All @@ -127,17 +135,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``.
Expand All @@ -156,15 +164,15 @@ 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)

@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
Expand Down Expand Up @@ -205,7 +213,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``.
Expand All @@ -229,7 +237,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)
Expand Down Expand Up @@ -285,15 +293,15 @@ def __call__(
cmd: str,
deserialize: bool = True,
minify: bool = True,
) -> JsonSerializeable:
) -> PythonLiteral:
"""Execute code on-device.
Parameters
----------
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.
Expand All @@ -311,7 +319,7 @@ def __call__(

if deserialize:
if res:
return json.loads(res)
return ast.literal_eval(res)
else:
return None
else:
Expand Down Expand Up @@ -368,7 +376,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}")')
Expand Down
18 changes: 9 additions & 9 deletions docs/source/How Belay Works.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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``.

Expand All @@ -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 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 json-serializeable.
3. The returned data of the function must also be a python literal(s).
4 changes: 2 additions & 2 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ 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``.

.. method:: thread(f: Optional[Callable[..., None]] = None, /, minify: bool = True, register: bool = True)

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``.

Expand Down
5 changes: 2 additions & 3 deletions examples/03_read_adc/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 8 additions & 9 deletions tests/test_device.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
from unittest.mock import call

import pytest
Expand All @@ -10,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")


Expand All @@ -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 (
Expand Down Expand Up @@ -136,17 +135,17 @@ 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)

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)
Expand Down

0 comments on commit f226bd7

Please sign in to comment.