├── belay ├── py.typed ├── snippets │ ├── __init__.py │ ├── ilistdir_micropython.py │ ├── time_monotonic_micropython.py │ ├── emitter_check.py │ ├── ilistdir_circuitpython.py │ ├── hf_nativemodule.py │ ├── convenience_imports_circuitpython.py │ ├── convenience_imports_micropython.py │ ├── time_monotonic_circuitpython.py │ ├── hf.py │ ├── hf_native.py │ ├── hf_viper.py │ ├── startup.py │ └── sync_begin.py ├── cli │ ├── new_template │ │ ├── README.md │ │ ├── packagename │ │ │ └── __init__.py │ │ ├── main.py │ │ └── pyproject.toml │ ├── __init__.py │ ├── terminal.py │ ├── info.py │ ├── exec.py │ ├── clean.py │ ├── update.py │ ├── common.py │ ├── run.py │ ├── sync.py │ ├── cache.py │ ├── main.py │ ├── latency.py │ ├── install.py │ └── questionary_ext.py ├── nativemodule_fnv1a32 │ ├── __init__.py │ ├── mpy1.22-x64.mpy │ ├── mpy1.22-x86.mpy │ ├── mpy1.23-x64.mpy │ ├── mpy1.23-x86.mpy │ ├── mpy1.24-x64.mpy │ ├── mpy1.24-x86.mpy │ ├── mpy1.25-x64.mpy │ ├── mpy1.25-x86.mpy │ ├── mpy1.26-x64.mpy │ ├── mpy1.26-x86.mpy │ ├── mpy1.27-x64.mpy │ ├── mpy1.27-x86.mpy │ ├── mpy1.22-armv6m.mpy │ ├── mpy1.22-armv7m.mpy │ ├── mpy1.22-xtensa.mpy │ ├── mpy1.23-armv6m.mpy │ ├── mpy1.23-armv7m.mpy │ ├── mpy1.23-xtensa.mpy │ ├── mpy1.24-armv6m.mpy │ ├── mpy1.24-armv7m.mpy │ ├── mpy1.24-xtensa.mpy │ ├── mpy1.25-armv6m.mpy │ ├── mpy1.25-armv7m.mpy │ ├── mpy1.25-rv32imc.mpy │ ├── mpy1.25-xtensa.mpy │ ├── mpy1.26-armv6m.mpy │ ├── mpy1.26-armv7m.mpy │ ├── mpy1.26-rv32imc.mpy │ ├── mpy1.26-xtensa.mpy │ ├── mpy1.27-armv6m.mpy │ ├── mpy1.27-armv7m.mpy │ ├── mpy1.27-rv32imc.mpy │ ├── mpy1.27-xtensa.mpy │ ├── mpy1.22-armv7emdp.mpy │ ├── mpy1.22-armv7emsp.mpy │ ├── mpy1.22-xtensawin.mpy │ ├── mpy1.23-armv7emdp.mpy │ ├── mpy1.23-armv7emsp.mpy │ ├── mpy1.23-xtensawin.mpy │ ├── mpy1.24-armv7emdp.mpy │ ├── mpy1.24-armv7emsp.mpy │ ├── mpy1.24-xtensawin.mpy │ ├── mpy1.25-armv7emdp.mpy │ ├── mpy1.25-armv7emsp.mpy │ ├── mpy1.25-xtensawin.mpy │ ├── mpy1.26-armv7emdp.mpy │ ├── mpy1.26-armv7emsp.mpy │ ├── mpy1.26-xtensawin.mpy │ ├── mpy1.27-armv7emdp.mpy │ ├── mpy1.27-armv7emsp.mpy │ └── mpy1.27-xtensawin.mpy ├── __main__.py ├── packagemanager │ ├── __init__.py │ ├── downloaders │ │ ├── __init__.py │ │ ├── _gitlab.py │ │ ├── _github.py │ │ ├── _retry.py │ │ └── common.py │ └── sync.py ├── typing.py ├── hash.py ├── utils.py ├── __init__.py ├── exceptions.py ├── helpers.py ├── usb_specifier.py ├── _minify.py ├── project.py ├── device_sync_support.py ├── device_meta.py └── inspect.py ├── docs ├── source │ ├── _static │ │ ├── .gitkeep │ │ └── custom.css │ ├── Installation.rst │ ├── index.rst │ ├── api.rst │ ├── CircuitPython.rst │ ├── conf.py │ └── Proxy Objects.rst ├── Makefile └── make.bat ├── tests ├── cli │ ├── test_identify.py │ ├── test_exec.py │ ├── test_run.py │ ├── test_sync.py │ ├── test_info.py │ ├── test_run_exec.py │ ├── test_update.py │ ├── test_clean.py │ ├── test_install.py │ └── test_latency.py ├── github_download_folder │ ├── __init__.py │ ├── submodule │ │ ├── __init__.py │ │ └── sub1.py │ ├── file1.py │ └── file2.txt ├── test_hash.py ├── integration │ ├── test_call.py │ ├── README.rst │ ├── test_function_decorators_exception.py │ ├── test_stdout_forwarding.py │ └── test_function_decorators.py ├── test_inspect │ └── foo.py ├── test_usb_specifier.py ├── packagemanager │ ├── downloaders │ │ ├── test_github.py │ │ └── test_gitlab.py │ └── test_group.py ├── test_project.py ├── test_minify.py └── conftest.py ├── examples ├── 09_webrepl │ ├── board │ │ ├── webrepl_cfg.py │ │ └── boot.py │ ├── main.py │ └── README.rst ├── 06_external_modules_and_file_sync │ ├── board │ │ ├── hello_world.txt │ │ ├── led.py │ │ └── somemodule │ │ │ └── led.py │ ├── board_circuitpython │ │ ├── hello_world.txt │ │ └── led.py │ ├── README.rst │ ├── circuitpython.py │ └── main.py ├── 07_lcd │ ├── images │ │ └── lcd_demo.jpeg │ ├── README.rst │ └── main.py ├── 05_exception │ ├── main.py │ ├── circuitpython.py │ └── README.rst ├── 11_proxy_objects │ ├── README.rst │ └── main.py ├── 08B_device_subclassing │ ├── main.py │ ├── main_multiple_devices.py │ ├── mydevice.py │ └── README.rst ├── 10_generators │ ├── circuitpython.py │ ├── main.py │ └── README.rst ├── 03_read_adc │ ├── circuitpython.py │ ├── README.rst │ └── main.py ├── README.rst ├── 04_thread │ ├── circuitpython.py │ ├── README.rst │ └── main.py ├── 02_blink_neopixel │ ├── README.rst │ ├── main.py │ └── circuitpython.py ├── 08_device_subclassing │ ├── main_multiple_devices.py │ ├── README.rst │ └── main.py ├── 01_blink_led │ ├── circuitpython.py │ ├── main.py │ └── README.rst └── 12_time_sync │ ├── README.rst │ └── main.py ├── .codecov.yml ├── .dockerignore ├── assets ├── favicon.png ├── logo_page_white.png ├── logo_white_200w.png ├── logo_white_400w.png ├── logo_loose_white.png ├── logo_tight_white.png ├── logo_loose_transparent.png └── logo_tight_transparent.png ├── CITATION.cff ├── .github ├── FUNDING.yml ├── workflows │ ├── pre-commit.yaml │ └── deploy.yaml └── contributing.md ├── tools ├── Dockerfile └── update-fnv1a32.py ├── Makefile ├── .readthedocs.yaml ├── .pre-commit-config.yaml └── .gitignore /belay/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /belay/snippets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/cli/test_identify.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /belay/cli/new_template/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/github_download_folder/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /belay/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import app 2 | -------------------------------------------------------------------------------- /belay/cli/new_template/packagename/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/github_download_folder/submodule/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /belay/snippets/ilistdir_micropython.py: -------------------------------------------------------------------------------- 1 | __belay_ilistdir = os.ilistdir 2 | -------------------------------------------------------------------------------- /examples/09_webrepl/board/webrepl_cfg.py: -------------------------------------------------------------------------------- 1 | PASS = "python" # nosec 2 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | patch: off 5 | -------------------------------------------------------------------------------- /tests/github_download_folder/file1.py: -------------------------------------------------------------------------------- 1 | print("belay test file for downloading.") 2 | -------------------------------------------------------------------------------- /tests/github_download_folder/file2.txt: -------------------------------------------------------------------------------- 1 | File for testing non-python downloads. 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .git/ 3 | .pytest_cache/ 4 | .benchmarks/ 5 | __pycache__/ 6 | -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /belay/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli.main import run_app 2 | 3 | run_app(prog_name="belay") 4 | -------------------------------------------------------------------------------- /tests/github_download_folder/submodule/sub1.py: -------------------------------------------------------------------------------- 1 | foo = "testing recursive download abilities." 2 | -------------------------------------------------------------------------------- /assets/logo_page_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_page_white.png -------------------------------------------------------------------------------- /assets/logo_white_200w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_white_200w.png -------------------------------------------------------------------------------- /assets/logo_white_400w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_white_400w.png -------------------------------------------------------------------------------- /assets/logo_loose_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_loose_white.png -------------------------------------------------------------------------------- /assets/logo_tight_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_tight_white.png -------------------------------------------------------------------------------- /assets/logo_loose_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_loose_transparent.png -------------------------------------------------------------------------------- /assets/logo_tight_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/assets/logo_tight_transparent.png -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/board/hello_world.txt: -------------------------------------------------------------------------------- 1 | Hello World 2 | The Quick Brown Fox Jumped Over The Lazy Dog. 3 | -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-x64.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-x64.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-x86.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-x86.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-x64.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-x64.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-x86.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-x86.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-x64.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-x64.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-x86.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-x86.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-x64.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-x64.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-x86.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-x86.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-x64.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-x64.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-x86.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-x86.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-x64.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-x64.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-x86.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-x86.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-armv6m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-armv6m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-armv7m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-armv7m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-xtensa.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-xtensa.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-armv6m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-armv6m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-armv7m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-armv7m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-xtensa.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-xtensa.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-armv6m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-armv6m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-armv7m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-armv7m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-xtensa.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-xtensa.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-armv6m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-armv6m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-armv7m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-armv7m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-rv32imc.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-rv32imc.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-xtensa.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-xtensa.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-armv6m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-armv6m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-armv7m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-armv7m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-rv32imc.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-rv32imc.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-xtensa.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-xtensa.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-armv6m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-armv6m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-armv7m.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-armv7m.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-rv32imc.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-rv32imc.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-xtensa.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-xtensa.mpy -------------------------------------------------------------------------------- /belay/packagemanager/__init__.py: -------------------------------------------------------------------------------- 1 | from . import downloaders 2 | from .group import Group, GroupConfig 3 | from .models import BelayConfig 4 | -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-armv7emdp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-armv7emdp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-armv7emsp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-armv7emsp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.22-xtensawin.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.22-xtensawin.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-armv7emdp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-armv7emdp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-armv7emsp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-armv7emsp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.23-xtensawin.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.23-xtensawin.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-armv7emdp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-armv7emdp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-armv7emsp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-armv7emsp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.24-xtensawin.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.24-xtensawin.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-armv7emdp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-armv7emdp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-armv7emsp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-armv7emsp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.25-xtensawin.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.25-xtensawin.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-armv7emdp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-armv7emdp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-armv7emsp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-armv7emsp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.26-xtensawin.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.26-xtensawin.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-armv7emdp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-armv7emdp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-armv7emsp.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-armv7emsp.mpy -------------------------------------------------------------------------------- /belay/nativemodule_fnv1a32/mpy1.27-xtensawin.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrianPugh/belay/HEAD/belay/nativemodule_fnv1a32/mpy1.27-xtensawin.mpy -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/board_circuitpython/hello_world.txt: -------------------------------------------------------------------------------- 1 | Hello World 2 | The Quick Brown Fox Jumped Over The Lazy Dog. 3 | -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/board/led.py: -------------------------------------------------------------------------------- 1 | from machine import Pin 2 | 3 | 4 | def set(pin, value): 5 | Pin(pin, Pin.OUT).value(value) 6 | -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/board/somemodule/led.py: -------------------------------------------------------------------------------- 1 | from machine import Pin 2 | 3 | 4 | def set(pin, value): 5 | Pin(pin, Pin.OUT).value(value) 6 | -------------------------------------------------------------------------------- /belay/snippets/time_monotonic_micropython.py: -------------------------------------------------------------------------------- 1 | import time 2 | __belay_monotonic=time.ticks_ms 3 | __belay_ticks_diff=time.ticks_diff 4 | __belay_ticks_add=time.ticks_add 5 | -------------------------------------------------------------------------------- /belay/snippets/emitter_check.py: -------------------------------------------------------------------------------- 1 | @micropython.native 2 | def __belay_emitter_test(a, b): return a + b 3 | @micropython.viper 4 | def __belay_emitter_test(a, b): return a + b 5 | -------------------------------------------------------------------------------- /examples/07_lcd/images/lcd_demo.jpeg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ad214c396f4766c3b89408940f4cf4e1dd75844bde96648b74215defe823d054 3 | size 175394 4 | -------------------------------------------------------------------------------- /belay/cli/new_template/main.py: -------------------------------------------------------------------------------- 1 | import packagename 2 | from machine import Pin 3 | 4 | 5 | def main(): 6 | pass 7 | 8 | 9 | if __name__ == "__main__": 10 | main() 11 | -------------------------------------------------------------------------------- /belay/cli/new_template/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.belay] 2 | name = "packagename" 3 | 4 | [tool.belay.dependencies] 5 | 6 | [tool.pytest.ini_options] 7 | pythonpath = ".belay/dependencies/main" 8 | -------------------------------------------------------------------------------- /belay/snippets/ilistdir_circuitpython.py: -------------------------------------------------------------------------------- 1 | def __belay_ilistdir(x): 2 | for name in os.listdir(x): 3 | stat = os.stat(x + "/" + name) # noqa: PL116 4 | yield (name, stat[0], stat[1]) 5 | -------------------------------------------------------------------------------- /belay/snippets/hf_nativemodule.py: -------------------------------------------------------------------------------- 1 | import _belay_fnv1a32 2 | def __belay_hf(fn, buf): 3 | try: 4 | with open(fn, "rb") as f: 5 | h = _belay_fnv1a32.fnv1a32(f, buffer=buf) 6 | except OSError: 7 | h = 0 8 | return h 9 | -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/board_circuitpython/led.py: -------------------------------------------------------------------------------- 1 | import board 2 | import digitalio 3 | 4 | led = digitalio.DigitalInOut(board.LED) 5 | led.direction = digitalio.Direction.OUTPUT 6 | 7 | 8 | def set(value): 9 | led.value = value 10 | -------------------------------------------------------------------------------- /belay/snippets/convenience_imports_circuitpython.py: -------------------------------------------------------------------------------- 1 | import os, board, digitalio 2 | from time import sleep 3 | try: 4 | import analogio 5 | except ImportError: 6 | pass 7 | try: 8 | from busio import I2C 9 | except ImportError: 10 | pass 11 | try: 12 | from busio import SPI 13 | except ImportError: 14 | pass 15 | -------------------------------------------------------------------------------- /belay/cli/terminal.py: -------------------------------------------------------------------------------- 1 | from belay import Device 2 | from belay.cli.common import PasswordStr, PortStr 3 | 4 | 5 | def terminal(port: PortStr, *, password: PasswordStr = ""): 6 | """Open up an interactive REPL. 7 | 8 | Press ctrl+] to exit. 9 | """ 10 | with Device(port, password=password) as device: 11 | device.terminal() 12 | -------------------------------------------------------------------------------- /tests/test_hash.py: -------------------------------------------------------------------------------- 1 | import belay.hash 2 | 3 | 4 | def test_sync_local_belay_hf(tmp_path): 5 | """Test local FNV-1a hash implementation. 6 | 7 | Test vector from: http://www.isthe.com/chongo/src/fnv/test_fnv.c 8 | """ 9 | f = tmp_path / "test_file" 10 | f.write_text("foobar") 11 | actual = belay.hash.fnv1a(f) 12 | assert actual == 0xBF9CF968 13 | -------------------------------------------------------------------------------- /examples/05_exception/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | 13 | @device.task 14 | def f(): 15 | raise Exception("This is raised on-device.") 16 | 17 | 18 | f() 19 | -------------------------------------------------------------------------------- /tests/integration/test_call.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import belay 4 | 5 | 6 | def test_call_various(emulated_device): 7 | assert emulated_device("foo = 25") is None 8 | assert emulated_device("foo") == 25 9 | 10 | with pytest.raises(belay.PyboardException): 11 | emulated_device("bar") 12 | 13 | assert emulated_device("baz = 10", minify=False) is None 14 | -------------------------------------------------------------------------------- /examples/05_exception/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | 13 | @device.task 14 | def f(): 15 | raise Exception("This is raised on-device.") 16 | 17 | 18 | f() 19 | -------------------------------------------------------------------------------- /belay/typing.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | from pathlib import Path 3 | from typing import Callable, Union 4 | 5 | PythonLiteral = Union[None, bool, bytes, int, float, str, list, dict, set] 6 | BelayGenerator = Generator[PythonLiteral, None, None] 7 | BelayReturn = Union[BelayGenerator, PythonLiteral] 8 | BelayCallable = Callable[..., BelayReturn] 9 | 10 | PathType = Union[str, Path] 11 | -------------------------------------------------------------------------------- /belay/snippets/convenience_imports_micropython.py: -------------------------------------------------------------------------------- 1 | import os, machine 2 | from time import sleep 3 | from micropython import const 4 | from machine import Pin, PWM, Timer 5 | try: 6 | from machine import I2C 7 | except ImportError: 8 | pass 9 | try: 10 | from machine import SPI 11 | except ImportError: 12 | pass 13 | try: 14 | from machine import ADC 15 | except ImportError: 16 | pass 17 | -------------------------------------------------------------------------------- /tests/cli/test_exec.py: -------------------------------------------------------------------------------- 1 | from belay.cli.main import app 2 | from tests.conftest import run_cli 3 | 4 | 5 | def test_exec_basic(mocker, mock_device): 6 | mock_device.patch("belay.cli.exec.Device") 7 | exit_code = run_cli(app, ["exec", "/dev/ttyUSB0", "print('hello world')", "--password", "password"]) 8 | assert exit_code == 0 9 | mock_device.cls_assert_common() 10 | mock_device.inst.assert_called_once_with("print('hello world')") 11 | -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | .wy-nav-content { 2 | max-width: none; 3 | } 4 | 5 | /* override table width restrictions */ 6 | .wy-table-responsive table td, .wy-table-responsive table th { 7 | /* !important prevents the common CSS stylesheets from 8 | overriding this as on RTD they are loaded after this stylesheet */ 9 | white-space: normal !important; 10 | } 11 | 12 | .wy-table-responsive { 13 | overflow: visible !important; 14 | } 15 | -------------------------------------------------------------------------------- /belay/cli/info.py: -------------------------------------------------------------------------------- 1 | from belay import Device 2 | from belay.cli.common import PasswordStr, PortStr 3 | 4 | 5 | def info(port: PortStr, *, password: PasswordStr = ""): 6 | """Display device firmware information.""" 7 | device = Device(port, password=password) 8 | version_str = "v" + ".".join(str(x) for x in device.implementation.version) 9 | print(f"{device.implementation.name} {version_str} - {device.implementation.platform}") 10 | device.close() 11 | -------------------------------------------------------------------------------- /belay/snippets/time_monotonic_circuitpython.py: -------------------------------------------------------------------------------- 1 | import supervisor 2 | __belay_monotonic = supervisor.ticks_ms 3 | 4 | _BELAY_TICKS_MAX = (1<<29)-1 5 | _BELAY_TICKS_HALFPERIOD = (1<<28) 6 | 7 | def __belay_ticks_add(ticks, delta): 8 | return (ticks + delta) & _BELAY_TICKS_MAX 9 | 10 | def __belay_ticks_diff(ticks1, ticks2): 11 | diff = (ticks1 - ticks2) & _BELAY_TICKS_MAX 12 | diff = ((diff + _BELAY_TICKS_HALFPERIOD) & _BELAY_TICKS_MAX) - _BELAY_TICKS_HALFPERIOD 13 | return diff 14 | -------------------------------------------------------------------------------- /examples/11_proxy_objects/README.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Example 11: Proxy Objects 3 | ========================= 4 | 5 | This example shows how to use ``belay.ProxyObject``. A proxy object forwards all attribute read/writes, as well as all method calls, to the remote micropython object. 6 | 7 | .. code-block:: bash 8 | 9 | $ python main.py --port /dev/ttyUSB0 10 | We got the attribute "Bob Smith". 11 | We executed the method "greetings" and got the result: "Hello Bob Smith!" 12 | -------------------------------------------------------------------------------- /belay/cli/exec.py: -------------------------------------------------------------------------------- 1 | from belay import Device 2 | from belay.cli.common import PasswordStr, PortStr, remove_stacktrace 3 | 4 | 5 | def exec(port: PortStr, statement: str, *, password: PasswordStr = ""): 6 | """Execute python statement on-device. 7 | 8 | Parameters 9 | ---------- 10 | statement : str 11 | Statement to execute on-device. 12 | """ 13 | device = Device(port, password=password) 14 | with remove_stacktrace(): 15 | device(statement) 16 | device.close() 17 | -------------------------------------------------------------------------------- /belay/snippets/hf.py: -------------------------------------------------------------------------------- 1 | def __belay_hf(fn, buf): 2 | # inherently is inherently modulo 32-bit arithmetic 3 | mod = (1 << 32) 4 | h = 0x811c9dc5 5 | try: 6 | f = open(fn, "rb") 7 | while True: 8 | n = f.readinto(buf) 9 | if n == 0: 10 | break 11 | for b in buf[:n]: 12 | h = ((h ^ b) * 0x01000193) % mod # todo: investigate fast mm 13 | f.close() 14 | except OSError: 15 | h = 0 16 | return h 17 | -------------------------------------------------------------------------------- /examples/08B_device_subclassing/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | from mydevice import MyDevice 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | 11 | device = MyDevice(args.port) 12 | 13 | while True: 14 | device.set_led(True) 15 | temperature = device.read_temperature() 16 | print(f"Temperature: {temperature:.1f}C") 17 | time.sleep(0.5) 18 | device.set_led(False) 19 | time.sleep(0.5) 20 | -------------------------------------------------------------------------------- /examples/10_generators/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | 13 | @device.task 14 | def count(): 15 | i = 0 16 | while True: 17 | yield i 18 | if i >= 10: 19 | break 20 | i += 1 21 | 22 | 23 | for index in count(): 24 | time.sleep(0.5) 25 | print(index) 26 | -------------------------------------------------------------------------------- /belay/hash.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | 5 | def fnv1a(fn: Union[str, Path]) -> int: 6 | """Compute the FNV-1a 32-bit hash of a file.""" 7 | fn = Path(fn) 8 | h = 0x811C9DC5 9 | size = 1 << 32 10 | with fn.open("rb") as f: 11 | while True: 12 | data = f.read(65536) 13 | if not data: 14 | break 15 | for byte in data: 16 | h = h ^ byte 17 | h = (h * 0x01000193) % size 18 | return h 19 | -------------------------------------------------------------------------------- /belay/snippets/hf_native.py: -------------------------------------------------------------------------------- 1 | @micropython.native 2 | def __belay_hf(fn, buf): 3 | # inherently is inherently modulo 32-bit arithmetic 4 | mod = (1 << 32) 5 | h = 0x811c9dc5 6 | try: 7 | f = open(fn, "rb") 8 | while True: 9 | n = f.readinto(buf) 10 | if n == 0: 11 | break 12 | for b in buf[:n]: 13 | h = ((h ^ b) * 0x01000193) % mod # todo: investigate fast mm 14 | f.close() 15 | except OSError: 16 | h = 0 17 | return h 18 | -------------------------------------------------------------------------------- /examples/07_lcd/README.rst: -------------------------------------------------------------------------------- 1 | Example 07: LCD 2 | =============== 3 | 4 | .. image:: images/lcd_demo.jpeg 5 | 6 | This example uses the Waveshare 0.96" 160x80 RGB LCD RP2040 module (RP2040-LCD-0.96). 7 | 8 | The driver code in the ``board/`` directory was provided by Waveshare. 9 | Their code inside ``if __name__ == "__main__"`` is unnecessary, but was left in for reference. 10 | 11 | The code in this example is fairly self-explanatory given previous examples. 12 | This primarily demonstrates a slightly more complicated hardware perhipheral. 13 | -------------------------------------------------------------------------------- /tests/cli/test_run.py: -------------------------------------------------------------------------------- 1 | from belay.cli.main import app 2 | from tests.conftest import run_cli 3 | 4 | 5 | def test_run_basic(mocker, mock_device, tmp_path): 6 | mock_device.patch("belay.cli.run.Device") 7 | py_file = tmp_path / "foo.py" 8 | py_file.write_text("print('hello')\nprint('world')") 9 | exit_code = run_cli(app, ["run", "/dev/ttyUSB0", str(py_file), "--password", "password"]) 10 | assert exit_code == 0 11 | mock_device.cls_assert_common() 12 | mock_device.inst.assert_called_once_with("print('hello')\nprint('world')") 13 | -------------------------------------------------------------------------------- /examples/09_webrepl/board/boot.py: -------------------------------------------------------------------------------- 1 | def do_connect(ssid, pwd): 2 | import network 3 | 4 | sta_if = network.WLAN(network.STA_IF) 5 | if not sta_if.isconnected(): 6 | print("connecting to network...") 7 | sta_if.active(True) 8 | sta_if.connect(ssid, pwd) 9 | while not sta_if.isconnected(): 10 | pass 11 | print("network config:", sta_if.ifconfig()) 12 | 13 | 14 | # Attempt to connect to WiFi network 15 | do_connect("your wifi ssid", "your password") 16 | 17 | import webrepl 18 | 19 | webrepl.start() 20 | -------------------------------------------------------------------------------- /docs/source/Installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Belay requires Python ``>=3.8`` and can be installed from pypi via: 5 | 6 | .. code-block:: bash 7 | 8 | python -m pip install belay 9 | 10 | 11 | To install directly from github, you can run: 12 | 13 | .. code-block:: bash 14 | 15 | python -m pip install git+https://github.com/BrianPugh/belay.git 16 | 17 | For development, its recommended to use Poetry: 18 | 19 | .. code-block:: bash 20 | 21 | git clone https://github.com/BrianPugh/belay.git 22 | cd belay 23 | poetry install 24 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Belay's documentation! 2 | ================================= 3 | 4 | .. include:: ../../README.rst 5 | :start-after: inclusion-marker-do-not-remove 6 | 7 | .. toctree:: 8 | :maxdepth: 3 9 | :caption: Contents: 10 | 11 | Installation 12 | Quick Start 13 | Proxy Objects 14 | CircuitPython 15 | Connections 16 | Time Synchronization 17 | Package Manager 18 | How Belay Works 19 | api 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /examples/03_read_adc/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | device("import microcontroller") 13 | 14 | 15 | @device.task 16 | def read_temperature(): 17 | return microcontroller.cpu.temperature 18 | 19 | 20 | while True: 21 | temperature = read_temperature() 22 | print(f"Temperature: {temperature:.1f}C") 23 | sleep(0.5) 24 | -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Belay requires a microcontroller running the MicroPython firmware. See the `official MicroPython Website`_ for details. 5 | 6 | Unless otherwise specified, these examples were developed for MicroPython running on the Pi Pico board. 7 | 8 | Some examples contain a ``circuitpython.py`` example equivalent. 9 | 10 | It is recommended to go through the examples sequentially; concepts covered in previous examples will not be explained in subsequent examples. 11 | 12 | .. _official MicroPython Website: http://www.micropython.org/download/ 13 | -------------------------------------------------------------------------------- /examples/04_thread/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | print("") 13 | print("*******************************************************") 14 | print("This example does not work. It will raise an exception.") 15 | print("*******************************************************") 16 | print("") 17 | 18 | 19 | @device.thread 20 | def run_led_loop(period): 21 | pass 22 | -------------------------------------------------------------------------------- /tests/cli/test_sync.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from belay.cli.main import app 4 | from tests.conftest import run_cli 5 | 6 | 7 | def test_sync_basic(mocker, mock_device): 8 | mock_device.patch("belay.cli.sync.Device") 9 | exit_code = run_cli(app, ["sync", "/dev/ttyUSB0", "foo", "--password", "password"]) 10 | assert exit_code == 0 11 | mock_device.cls_assert_common() 12 | mock_device.inst.sync.assert_called_once_with( 13 | Path("foo"), 14 | dst="/", 15 | keep=None, 16 | ignore=None, 17 | mpy_cross_binary=None, 18 | progress_update=mocker.ANY, 19 | ) 20 | -------------------------------------------------------------------------------- /examples/03_read_adc/README.rst: -------------------------------------------------------------------------------- 1 | Example 03: Read ADC 2 | ==================== 3 | 4 | This example reads the temperature in celsius from the RP2040's internal temperature sensor. 5 | To do this, we explore a new concept: functions can return a value. 6 | 7 | Return values are serialized on-device and deserialized on-host by Belay. 8 | This is seamless to the user; the function ``read_temperature`` returns a float on-device, and that same float is returned on the host. 9 | 10 | Due to how Belay serializes and deserializes data, only python literals (``None``, booleans, bytes, numbers, strings, sets, lists, and dicts) can be returned. 11 | -------------------------------------------------------------------------------- /belay/snippets/hf_viper.py: -------------------------------------------------------------------------------- 1 | @micropython.native 2 | def __belay_hf(fn, buf): 3 | # is inherently modulo 32-bit arithmetic 4 | @micropython.viper 5 | def xor_mm(data, state: uint, prime: uint) -> uint: 6 | for b in data: 7 | state = uint((state ^ uint(b)) * prime) 8 | return state 9 | 10 | h = 0x811c9dc5 11 | try: 12 | f = open(fn, "rb") 13 | while True: 14 | n = f.readinto(buf) 15 | if n == 0: 16 | break 17 | h = xor_mm(buf[:n], h, 0x01000193) 18 | f.close() 19 | except OSError: 20 | h = 0 21 | return h 22 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it using these metadata." 3 | title: "Belay: A Python Library for Rapid Hardware Development with MicroPython and CircuitPython" 4 | abstract: "Task and dependency management system for Python." 5 | type: software 6 | authors: 7 | - family-names: "Pugh" 8 | given-names: "Brian" 9 | orcid: 0000-0003-0561-3829 10 | repository-code: "https://github.com/BrianPugh/belay" 11 | url: "https://github.com/BrianPugh/belay" 12 | keywords: 13 | - python 14 | - micropython 15 | - robotics 16 | - hardware 17 | - physical-computing 18 | license: Apache-2.0 19 | -------------------------------------------------------------------------------- /tests/cli/test_info.py: -------------------------------------------------------------------------------- 1 | from belay.cli.main import app 2 | from tests.conftest import run_cli 3 | 4 | 5 | def test_info_basic(mocker, mock_device, capsys): 6 | mock_device.patch("belay.cli.info.Device") 7 | mock_device.inst.implementation.name = "testingpython" 8 | mock_device.inst.implementation.version = (4, 7, 9) 9 | mock_device.inst.implementation.platform = "pytest" 10 | exit_code = run_cli(app, ["info", "/dev/ttyUSB0", "--password", "password"]) 11 | assert exit_code == 0 12 | mock_device.cls_assert_common() 13 | captured = capsys.readouterr() 14 | assert captured.out == "testingpython v4.7.9 - pytest\n" 15 | -------------------------------------------------------------------------------- /examples/02_blink_neopixel/README.rst: -------------------------------------------------------------------------------- 1 | Example 02: Blink NeoPixel 2 | ========================== 3 | 4 | This example is similar to example 1, but with a neopixel (intended for the RP2040-Zero board). 5 | 6 | However, this example does introduce one new concept. 7 | You can execute an arbitrary string python command on the board by calling your device object: 8 | 9 | .. code-block:: python 10 | 11 | device("import neopixel") 12 | 13 | This will execute ``import neopixel`` on-device, so the ``neopixel`` module will be available inside of the ``set_neopixel`` function. 14 | Typically, this technique is used for importing modules and defining global variables. 15 | -------------------------------------------------------------------------------- /belay/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Literal 3 | 4 | 5 | def env_parse_bool(env_var, default_value=False): 6 | if env_var in os.environ: 7 | env_value = os.environ[env_var].lower() 8 | return env_value == "true" or env_value == "1" 9 | else: 10 | return default_value 11 | 12 | 13 | class SentinelMeta(type): 14 | def __repr__(cls) -> str: 15 | return f"<{cls.__name__}>" 16 | 17 | def __bool__(cls) -> Literal[False]: 18 | return False 19 | 20 | 21 | class Sentinel(metaclass=SentinelMeta): 22 | def __new__(cls): 23 | raise ValueError("Sentinel objects are not intended to be instantiated. Subclass instead.") 24 | -------------------------------------------------------------------------------- /tests/cli/test_run_exec.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from belay.cli.main import run_exec 4 | 5 | 6 | @pytest.fixture 7 | def project_dir(tmp_cwd): 8 | (tmp_cwd / "pyproject.toml").write_text( 9 | """ 10 | [tool.belay.dependencies] 11 | foo = "foo_uri" 12 | 13 | [tool.belay.group.dev.dependencies] 14 | bar = "bar_uri" 15 | """ 16 | ) 17 | 18 | 19 | def test_run_exec(project_dir, mocker): 20 | command = ["micropython", "-m", "module"] 21 | mock_run = mocker.patch("belay.cli.main.subprocess.run") 22 | run_exec(command) 23 | mock_run.assert_called_once_with( 24 | command, 25 | env=mocker.ANY, 26 | check=True, 27 | ) 28 | -------------------------------------------------------------------------------- /belay/cli/clean.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | from belay.project import find_dependencies_folder, load_groups 4 | 5 | 6 | def clean(): 7 | """Remove any downloaded dependencies if they are no longer specified in pyproject.""" 8 | groups = load_groups() 9 | dependencies_folder = find_dependencies_folder() 10 | 11 | existing_group_folders = {x for x in dependencies_folder.glob("*") if x.is_dir()} 12 | 13 | # Remove missing dependencies in each group 14 | for group in groups: 15 | group.clean() 16 | existing_group_folders.discard(group.folder) 17 | 18 | # Remove missing group folders 19 | for group_folder in existing_group_folders: 20 | shutil.rmtree(group_folder) 21 | -------------------------------------------------------------------------------- /examples/08_device_subclassing/main_multiple_devices.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | from belay import Device 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--device1", default="/dev/ttyUSB0") 8 | parser.add_argument("--device2", default="/dev/ttyUSB1") 9 | args = parser.parse_args() 10 | 11 | 12 | class MyDevice(Device): 13 | @Device.task 14 | def set_led(state): 15 | Pin(25, Pin.OUT).value(state) 16 | 17 | 18 | device1 = MyDevice(args.device1) 19 | device2 = MyDevice(args.device2) 20 | 21 | while True: 22 | device1.set_led(True) 23 | device2.set_led(False) 24 | time.sleep(0.5) 25 | device1.set_led(False) 26 | device2.set_led(True) 27 | time.sleep(0.5) 28 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= poetry run sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /belay/packagemanager/downloaders/__init__.py: -------------------------------------------------------------------------------- 1 | # isort: skip_file 2 | # Import order matters: GitProviderUrl must be imported before subclasses 3 | from .common import ( 4 | NonMatchingURI, 5 | download_uri, 6 | ) 7 | from .git import ( 8 | GitProviderUrl, 9 | InvalidGitUrlError, 10 | rewrite_url, 11 | ) 12 | 13 | # Import subclasses to register them with the Registry. 14 | # These must be imported AFTER GitProviderUrl to ensure proper registration. 15 | from ._github import GitHubUrl 16 | from ._gitlab import GitLabUrl 17 | 18 | __all__ = [ 19 | "GitHubUrl", 20 | "GitLabUrl", 21 | "GitProviderUrl", 22 | "InvalidGitUrlError", 23 | "NonMatchingURI", 24 | "download_uri", 25 | "rewrite_url", 26 | ] 27 | -------------------------------------------------------------------------------- /belay/cli/update.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | from belay.cli.clean import clean 4 | from belay.project import load_groups 5 | 6 | 7 | def update(*packages: str): 8 | """Download new versions of dependencies. 9 | 10 | Parameters 11 | ---------- 12 | *packages : str 13 | Specific package(s) to update. 14 | """ 15 | console = Console() 16 | groups = load_groups() 17 | packages = packages if packages else None 18 | 19 | for group in groups: 20 | group_packages = None if packages is None else [x for x in packages if x in group.config.dependencies] 21 | 22 | group.download( 23 | packages=group_packages, 24 | console=console, 25 | ) 26 | 27 | clean() 28 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. autoclass:: belay.Device 5 | :special-members: __pre_autoinit__, __post_init__ 6 | :members: 7 | :undoc-members: 8 | :exclude-members: clear, get, items, keys, values 9 | 10 | .. autoclass:: belay.ProxyObject 11 | :members: 12 | :undoc-members: 13 | :member-order: bysource 14 | :special-members: __init__, __getitem__, __setitem__, __len__, __contains__, __iter__, __call__, __str__, __repr__, __eq__, __ne__, __lt__, __le__, __gt__, __ge__, __hash__ 15 | 16 | .. autoclass:: belay.Implementation 17 | :members: 18 | :undoc-members: 19 | 20 | .. autofunction:: belay.list_devices 21 | 22 | .. automodule:: belay.exceptions 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | -------------------------------------------------------------------------------- /examples/03_read_adc/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | 13 | @device.task 14 | def read_temperature() -> float: 15 | # ADC4 is attached to an internal temperature sensor 16 | sensor_temp = ADC(4) 17 | reading = sensor_temp.read_u16() 18 | reading *= 3.3 / 65535 # Convert reading to a voltage. 19 | temperature = 27 - (reading - 0.706) / 0.001721 # Convert voltage to Celsius 20 | return temperature 21 | 22 | 23 | while True: 24 | temperature = read_temperature() 25 | print(f"Temperature: {temperature:.1f}C") 26 | sleep(0.5) 27 | -------------------------------------------------------------------------------- /examples/04_thread/README.rst: -------------------------------------------------------------------------------- 1 | Example 04: Thread 2 | ================== 3 | 4 | The REPL loop that Belay interacts with is constantly blocking, waiting for new code. 5 | If you wish to run a loop in the background, and if your board supports it, you can use the ``thread`` decorator to run a single function in the background. 6 | 7 | Like the ``task`` decorator, the ``thread`` decorator sends the function's code over to the device. 8 | Explicitly invoking the function, ``run_led_loop(0.5)``, will spawn the thread on-device and execute the function. 9 | ``run_led_loop`` will return immediately for the host, and other tasks like ``read_temeprature`` can still be executed. 10 | 11 | Functions decorated with ``thread`` are more difficult to debug, since their exceptions won't be caught be Belay. 12 | -------------------------------------------------------------------------------- /tests/integration/README.rst: -------------------------------------------------------------------------------- 1 | All commands are to be ran at this root of this project. 2 | 3 | To build the integration tester image: 4 | 5 | .. code-block:: bash 6 | 7 | make integration-build 8 | 9 | Alternatively, simply clone the rp2040js repo into the root of this project 10 | and follow it's readme. In summary: 11 | 12 | .. code-block:: bash 13 | 14 | # in the root Belay directory. 15 | git clone https://github.com/wokwi/rp2040js.git 16 | cd rp2040js 17 | curl -OJ https://micropython.org/resources/firmware/RPI_PICO-20210902-v1.17.uf2 18 | npm install 19 | 20 | Once built, run: 21 | 22 | .. code-block:: bash 23 | 24 | make integration-test 25 | 26 | or, more explicitly: 27 | 28 | .. code-block:: bash 29 | 30 | poetry run python -m pytest tests/integration 31 | -------------------------------------------------------------------------------- /examples/08B_device_subclassing/main_multiple_devices.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | from mydevice import MyDevice 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--device1", default="/dev/ttyUSB0") 8 | parser.add_argument("--device2", default="/dev/ttyUSB1") 9 | args = parser.parse_args() 10 | 11 | 12 | device1 = MyDevice(args.device1) 13 | device2 = MyDevice(args.device2) 14 | 15 | while True: 16 | device1.set_led(True) 17 | device2.set_led(False) 18 | temperature = device1.read_temperature() 19 | print(f"Temperature 1: {temperature:.1f}C") 20 | 21 | time.sleep(0.5) 22 | device1.set_led(False) 23 | device2.set_led(True) 24 | temperature = device2.read_temperature() 25 | print(f"Temperature 2: {temperature:.1f}C") 26 | time.sleep(0.5) 27 | -------------------------------------------------------------------------------- /belay/cli/common.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Annotated 3 | 4 | from cyclopts import Parameter 5 | 6 | from belay.pyboard import PyboardException 7 | 8 | # Custom annotated types for consistent CLI parameter help 9 | PortStr = Annotated[ 10 | str, 11 | Parameter(help="Port (like /dev/ttyUSB0) or WebSocket (like ws://192.168.1.100) of device."), 12 | ] 13 | PasswordStr = Annotated[ 14 | str, 15 | Parameter(help="Password for communication methods (like WebREPL) that require authentication."), 16 | ] 17 | 18 | 19 | @contextmanager 20 | def remove_stacktrace(): 21 | """Context manager that suppresses PyboardException stack traces and prints only the error message.""" 22 | try: 23 | yield 24 | except PyboardException as e: 25 | print(e) 26 | # Exception is handled, don't re-raise 27 | -------------------------------------------------------------------------------- /belay/snippets/startup.py: -------------------------------------------------------------------------------- 1 | import os, sys, time 2 | __belay_obj_counter=0 3 | def __belay_next(x, val): 4 | try: 5 | return x.send(val) 6 | except StopIteration: 7 | print("_BELAYS") 8 | def __belay_timed_repr(expr): 9 | t1=__belay_monotonic() 10 | result=repr(expr) 11 | t2=__belay_monotonic() 12 | diff=__belay_ticks_diff(t2,t1) 13 | avg=__belay_ticks_add(t1,diff>>1) 14 | return str(avg)+"|"+result 15 | def __belay_obj_create(result): 16 | t = str(__belay_monotonic()) 17 | if isinstance(result, (int, float, str, bool, bytes, type(None))): 18 | print("_BELAYR|"+t+"|"+repr(result)) 19 | else: 20 | global __belay_obj_counter 21 | globals()["__belay_obj_" + str(__belay_obj_counter)] = result 22 | print("_BELAYR"+str(__belay_obj_counter)+"|"+t+"|") 23 | __belay_obj_counter += 1 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [BrianPugh]# Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /tools/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | ENV POETRY_HOME="/opt/poetry" 4 | ENV PATH="$POETRY_HOME/bin:$PATH" 5 | 6 | RUN apt-get update && apt-get install -y \ 7 | build-essential \ 8 | curl \ 9 | git \ 10 | nodejs \ 11 | npm \ 12 | python3 \ 13 | python3-dev \ 14 | python3-pip \ 15 | python3-venv \ 16 | && \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | # Install Poetry 20 | RUN curl -sSL https://install.python-poetry.org | python3 - 21 | 22 | # Install RP2040 emulator 23 | RUN cd / \ 24 | && git clone https://github.com/wokwi/rp2040js.git \ 25 | && cd rp2040js \ 26 | && curl -OJ https://micropython.org/resources/firmware/rp2-pico-20210902-v1.17.uf2 \ 27 | && npm install 28 | 29 | WORKDIR /belay 30 | 31 | # To ignore a possibly existing ".venv" folder in the mapped volume. 32 | RUN poetry config virtualenvs.in-project false 33 | -------------------------------------------------------------------------------- /belay/cli/run.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from belay import Device 4 | from belay.cli.common import PasswordStr, PortStr, remove_stacktrace 5 | 6 | 7 | def run(port: PortStr, file: Path, *, password: PasswordStr = ""): 8 | """Run file on-device. 9 | 10 | If the first argument, `port`, is resolvable to an executable, 11 | the remainder of the command will be interpreted as a shell command 12 | that will be executed in a pseudo-micropython-virtual-environment. 13 | As of right now, this just sets `MICROPYPATH` to all of the dependency 14 | groups' folders. E.g: 15 | 16 | ```bash 17 | belay run micropython -m unittest 18 | ``` 19 | 20 | Parameters 21 | ---------- 22 | file : Path 23 | File to run on-device. 24 | """ 25 | content = file.read_text(encoding="utf-8") 26 | with Device(port, password=password) as device, remove_stacktrace(): 27 | device(content) 28 | -------------------------------------------------------------------------------- /examples/10_generators/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | 13 | @device.task 14 | def count(): 15 | i = 0 16 | while True: 17 | Pin(25, Pin.OUT).value(i % 2) 18 | yield i 19 | if i >= 10: 20 | break 21 | i += 1 22 | 23 | 24 | @device.task 25 | def communicate(x): 26 | new_val = yield "Device: " + str(x) 27 | new_val = yield "Device: " + str(new_val) 28 | new_val = yield "Device: " + str(new_val) 29 | 30 | 31 | for index in count(): 32 | time.sleep(0.5) 33 | print(index) 34 | 35 | # Demonstrate the generator send command 36 | generator = communicate("foo") 37 | print(generator.send(None)) 38 | print(generator.send("bar")) 39 | print(generator.send("baz")) 40 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /examples/07_lcd/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import belay 4 | 5 | parser = argparse.ArgumentParser() 6 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 7 | args = parser.parse_args() 8 | 9 | device = belay.Device(args.port) 10 | 11 | device.sync("board/") 12 | 13 | device( 14 | """ 15 | from pico_lcd_0_96 import LCD_0inch96 16 | lcd = LCD_0inch96() 17 | """ 18 | ) 19 | 20 | # color is BGR 21 | RED = 0x00F8 22 | GREEN = 0xE007 23 | BLUE = 0x1F00 24 | WHITE = 0xFFFF 25 | BLACK = 0x0000 26 | 27 | 28 | @device.task 29 | def display_text(text, x, y, text_color, bg_color): 30 | if bg_color is not None: 31 | lcd.fill(bg_color) 32 | lcd.text(text, x, y, text_color) 33 | lcd.display() 34 | 35 | 36 | display_text("This is Belay!", 0, 15, WHITE, BLACK) 37 | display_text("Belay makes it easy", 0, 30, RED, None) 38 | display_text("to control hardware", 0, 45, GREEN, None) 39 | display_text("from a python script.", 0, 60, BLUE, None) 40 | -------------------------------------------------------------------------------- /examples/10_generators/README.rst: -------------------------------------------------------------------------------- 1 | Example 10: Generators 2 | ====================== 3 | 4 | In Python, a Generator is a function that behaves like an iterator via the ``yield`` keyword. 5 | 6 | The following sends the function ``count`` to device. 7 | ``count`` counts from 0 to 10 (inclusive), on even numbers turns the LED off, and on odd numbers turns the LED on. 8 | 9 | .. code-block:: python 10 | 11 | @device.task 12 | def count(): 13 | i = 0 14 | while True: 15 | Pin(25, Pin.OUT).value(i % 2) 16 | yield i 17 | if i >= 10: 18 | break 19 | i += 1 20 | 21 | On-host, invoking this iterator outputs the yielded value: 22 | 23 | .. code-block:: python 24 | 25 | for index in count(): 26 | time.sleep(0.5) 27 | print(index) 28 | 29 | results in: 30 | 31 | .. code-block:: text 32 | 33 | 0 34 | 1 35 | 2 36 | 3 37 | 4 38 | 5 39 | 6 40 | 7 41 | 8 42 | 9 43 | 10 44 | -------------------------------------------------------------------------------- /tests/integration/test_function_decorators_exception.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import belay 4 | 5 | 6 | def test_task_exception(emulated_device, mocker): 7 | @emulated_device.task 8 | def foo(val): 9 | bar = 5 10 | baz # Should cause an exception here! 11 | return 2 * val 12 | 13 | with pytest.raises(belay.PyboardException) as e: 14 | foo(10) 15 | 16 | expected_message_micropython = f'Traceback (most recent call last):\r\n File "", line 1, in \r\n File "{__file__}", line 10, in foo\n baz # Should cause an exception here!\nNameError: name \'baz\' isn\'t defined\r\n' 17 | expected_message_circuitpython = f'Traceback (most recent call last):\r\n File "", line 1, in \r\n File "{__file__}", line 10, in foo\n baz # Should cause an exception here!\nNameError: name \'baz\' is not defined\r\n' 18 | 19 | assert e.value.args[0] == expected_message_micropython or e.value.args[0] == expected_message_circuitpython 20 | -------------------------------------------------------------------------------- /belay/snippets/sync_begin.py: -------------------------------------------------------------------------------- 1 | # Creates and populates two set[str]: all_files, all_dirs 2 | def __belay_hfs(fns): 3 | buf = memoryview(bytearray(4096)) 4 | return [__belay_hf(fn, buf) for fn in fns] 5 | def __belay_mkdirs(fns): 6 | for fn in fns: 7 | try: 8 | os.mkdir(fn) 9 | except OSError: 10 | pass 11 | def __belay_del_fs(path="/", keep=(), check=True): 12 | if not path: 13 | path = "/" 14 | elif not path.endswith("/"): 15 | path += "/" 16 | if check: 17 | try: 18 | os.stat(path) 19 | except OSError: 20 | return 21 | for name, mode, *_ in __belay_ilistdir(path): 22 | full_name = path + name 23 | if full_name in keep: 24 | continue 25 | if mode & 0x4000: # is_dir 26 | __belay_del_fs(full_name, keep, check=False) 27 | try: 28 | os.rmdir(full_name) 29 | except OSError: 30 | pass 31 | else: 32 | os.remove(full_name) 33 | -------------------------------------------------------------------------------- /examples/09_webrepl/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="ws://192.168.1.100") 8 | parser.add_argument("--password", default="python") 9 | args = parser.parse_args() 10 | 11 | print("Connecting to device") 12 | device = belay.Device(args.port, password=args.password) 13 | 14 | print("Syncing filesystem.") 15 | # Sync our WiFi information and WebREPL configuration. 16 | device.sync("board/") 17 | 18 | 19 | print("Sending set_led task") 20 | 21 | 22 | @device.task 23 | def set_led(counter, state): 24 | # Configuration for a Pi Pico board. 25 | Pin(25, Pin.OUT).value(state) 26 | return counter 27 | 28 | 29 | for counter in range(10_000): 30 | print("led on ", end="") 31 | res = set_led(counter, True) 32 | print(f"Counter: {res}", end="\r") 33 | sleep(0.5) 34 | 35 | print("led off ", end="") 36 | res = set_led(counter, False) 37 | print(f"Counter: {res}", end="\r") 38 | sleep(0.5) 39 | -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/README.rst: -------------------------------------------------------------------------------- 1 | Example 06: External Modules and File Sync 2 | ========================================== 3 | 4 | This example introduces the ``sync`` method. 5 | ``sync`` takes in a string path to a local folder, and will synchronize the contents to the root of the device's filesystem. 6 | For example, if the local filesystem looks like: 7 | 8 | :: 9 | 10 | project 11 | ├── main.py 12 | └── board 13 | ├── foo.py 14 | └── bar 15 | └── baz.py 16 | 17 | Then, after ``device.sync("board")`` is ran, the root of the remote filesystem will look like: 18 | 19 | :: 20 | 21 | foo.py 22 | bar 23 | └── baz.py 24 | 25 | 26 | ``sync`` only pushes files who's hash has changed since the last sync. 27 | At the end of ``sync``, all files and folders that exist in the device's filesystem that do not have a corresponding file/folder in the local path will be deleted. 28 | 29 | Now that files have been synced, we can import python modules like normal, and we can read synced-in files. 30 | -------------------------------------------------------------------------------- /examples/11_proxy_objects/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import belay 4 | 5 | parser = argparse.ArgumentParser() 6 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 7 | args = parser.parse_args() 8 | 9 | device = belay.Device(args.port) 10 | 11 | 12 | @device.setup 13 | def setup(): 14 | class User: 15 | def __init__(self, name): 16 | self.name = name 17 | 18 | def greetings(self): 19 | return f"Hello {self.name}!" 20 | 21 | user = User("Bob Smith") 22 | 23 | 24 | setup() 25 | 26 | # Create a ProxyObject for the micropython object "user" that was defined in setup() . 27 | # This is just a thin wrapper for calling belay.ProxyObject(device, "user") . 28 | user = device.proxy("user") 29 | 30 | user_name = user.name 31 | print(f'We got the attribute "{user_name}".') 32 | # We got the attribute "Bob Smith". 33 | 34 | result = user.greetings() 35 | print(f'We executed the method "greetings" and got the result: "{result}"') 36 | # We executed the method "greetings" and got the result: "Hello Bob Smith!" 37 | -------------------------------------------------------------------------------- /examples/01_blink_led/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | # Setup the connection with the micropython board. 11 | # This also executes a few common imports on-device. 12 | device = belay.Device(args.port) 13 | 14 | 15 | @device.setup 16 | def setup(): # The function name doesn't matter, but is "setup" by convention. 17 | import board 18 | import digitalio 19 | 20 | led = digitalio.DigitalInOut(board.LED) 21 | led.direction = digitalio.Direction.OUTPUT 22 | 23 | 24 | # This sends the function's code over to the board. 25 | # Calling the local ``set_led`` function will 26 | # execute it on-device. 27 | @device.task 28 | def set_led(state): 29 | print(f"Printing from device; turning LED to {state}.") 30 | led.value = state 31 | 32 | 33 | setup() 34 | 35 | while True: 36 | set_led(True) 37 | time.sleep(0.5) 38 | set_led(False) 39 | time.sleep(0.5) 40 | -------------------------------------------------------------------------------- /examples/02_blink_neopixel/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | # Setup the connection with the micropython board. 11 | # This also executes a few common imports on-device. 12 | device = belay.Device(args.port) 13 | 14 | 15 | # Executes string on-device in a global context. 16 | @device.setup 17 | def setup(): 18 | import neopixel 19 | 20 | # Configuration for a RP2040-ZERO board. 21 | pixel = neopixel.NeoPixel(Pin(16), 1) 22 | 23 | 24 | # This sends the function's code over to the board. 25 | # Calling the local ``set_neopixel`` function will 26 | # execute it on-device. 27 | @device.task 28 | def set_neopixel(r, g, b): 29 | pixel[0] = (r, g, b) 30 | pixel.write() 31 | 32 | 33 | setup() 34 | while True: 35 | set_neopixel(255, 0, 0) 36 | time.sleep(0.5) 37 | set_neopixel(0, 255, 0) 38 | time.sleep(0.5) 39 | set_neopixel(0, 0, 255) 40 | time.sleep(0.5) 41 | -------------------------------------------------------------------------------- /examples/05_exception/README.rst: -------------------------------------------------------------------------------- 1 | Example 05: Exceptions 2 | ====================== 3 | 4 | This example shows what happens when an uncaught exception occurs on-device. 5 | 6 | When an uncaught exception occurs on-device, a ``PyboardException`` is raised on the host. 7 | The message of the ``PyboardException`` contains the on-device stack trace. 8 | The stack trace is modified by Belay to reinterpret file and line numbers to their original sources defined in the program on-host. 9 | 10 | The on-host traceback should look like: 11 | 12 | .. code-block:: bash 13 | 14 | File "/home/user/.local/lib/python3.8/site-packages/belay/pyboard.py", line 475, in exec_ 15 | raise PyboardException(ret_err.decode()) 16 | belay.pyboard.PyboardException: 17 | 18 | Traceback (most recent call last): 19 | File "", line 1, in 20 | File "", line 4, in belay_interface 21 | File "/projects/belay/examples/05_exception/main.py", line 15, in f 22 | raise Exception("This is raised on-device.") 23 | Exception: This is raised on-device. 24 | -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | device.sync("board_circuitpython/") 13 | 14 | print('Using synced "led.py" via explicit commands.') 15 | device("import led") 16 | for _ in range(3): 17 | device("led.set(True)") 18 | sleep(0.5) 19 | device("led.set(False)") 20 | sleep(0.5) 21 | 22 | 23 | print('Using synced "led.py" via task decorator.') 24 | 25 | 26 | @device.task 27 | def set_led(value): 28 | import led 29 | 30 | led.set(value) 31 | 32 | 33 | for _ in range(3): 34 | set_led(True) 35 | sleep(0.5) 36 | set_led(False) 37 | sleep(0.5) 38 | 39 | 40 | print("Reading a synced file via task decorator. Contents:") 41 | 42 | 43 | @device.task 44 | def read_file(fn): 45 | with open(fn) as f: 46 | return f.read() 47 | 48 | 49 | print(read_file("hello_world.txt")) 50 | -------------------------------------------------------------------------------- /examples/08B_device_subclassing/mydevice.py: -------------------------------------------------------------------------------- 1 | from belay import Device 2 | 3 | 4 | class MyDevice(Device): 5 | # NOTE: ``Device`` is capatalized here! 6 | @Device.setup( 7 | autoinit=True 8 | ) # ``autoinit=True`` means this method will automatically be called during object creation. 9 | def setup(): 10 | # Code here is executed on-device in a global context. 11 | try: 12 | # RP2040 wifi plus others 13 | led_pin = Pin.board.LED 14 | except (TypeError, AttributeError): 15 | led_pin = Pin(25, Pin.OUT) # Example RP2040 w/o wifi 16 | # ADC4 is attached to an internal temperature sensor on the Pi Pico 17 | sensor_temp = ADC(4) 18 | 19 | @Device.task 20 | def set_led(state): 21 | led_pin.value(state) 22 | 23 | @Device.task 24 | def read_temperature(): 25 | reading = sensor_temp.read_u16() 26 | reading *= 3.3 / 65535 # Convert reading to a voltage. 27 | temperature = 27 - (reading - 0.706) / 0.001721 # Convert voltage to Celsius 28 | return temperature 29 | -------------------------------------------------------------------------------- /examples/02_blink_neopixel/circuitpython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | # Setup the connection with the micropython board. 11 | # This also executes a few common imports on-device. 12 | device = belay.Device(args.port) 13 | 14 | # Executes string on-device in a global context. 15 | device("from neopixel_write import neopixel_write") 16 | # Configuration for a RP2040-ZERO board. 17 | device("pin = digitalio.DigitalInOut(board.GP16)") 18 | device("pin.direction = digitalio.Direction.OUTPUT") 19 | 20 | 21 | # This sends the function's code over to the board. 22 | # Calling the local ``set_neopixel`` function will 23 | # execute it on-device. 24 | @device.task 25 | def set_neopixel(r, g, b): 26 | neopixel_write(pin, bytearray([r, g, b])) 27 | 28 | 29 | while True: 30 | set_neopixel(255, 0, 0) 31 | time.sleep(0.5) 32 | set_neopixel(0, 255, 0) 33 | time.sleep(0.5) 34 | set_neopixel(0, 0, 255) 35 | time.sleep(0.5) 36 | -------------------------------------------------------------------------------- /examples/08_device_subclassing/README.rst: -------------------------------------------------------------------------------- 1 | Example 08: Device Subclassing 2 | ============================== 3 | It may be convenient to organize your Belay tasks into a class 4 | rather than decorated standalone functions. 5 | To accomplish this, have your class inherit from ``Device``, 6 | and mark methods with the ``@Device.task`` decorator. 7 | Source code of marked methods are sent to the device and executers 8 | are created when the ``Device`` object is instantiated. 9 | This also allows for multiple devices to share the same task definitions 10 | by instantiating multiple objeccts. 11 | 12 | Methods marked with ``@Device.setup`` are executed in a global scope. Essentially 13 | the contents of the method are extracted and then executed on the device. 14 | This means any variables created in ``@Device.setup`` are available to any of the 15 | other functions run on the device. 16 | 17 | Methods marked with ``@Device.task`` are similar to ``@staticmethod`` in that 18 | they do **not** contain ``self`` in the method signature. 19 | To the device, each marked method is equivalent to an independent function. 20 | -------------------------------------------------------------------------------- /examples/04_thread/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | 13 | @device.thread 14 | def run_led_loop(period): 15 | # Configuration for a Pi Pico board. 16 | led = Pin(25, Pin.OUT) 17 | while True: 18 | led.toggle() 19 | sleep(period) 20 | 21 | 22 | @device.task 23 | def read_temperature(): 24 | # ADC4 is attached to an internal temperature sensor 25 | sensor_temp = ADC(4) 26 | reading = sensor_temp.read_u16() 27 | reading *= 3.3 / 65535 # Convert reading to a voltage. 28 | temperature = 27 - (reading - 0.706) / 0.001721 # Convert voltage to Celsius 29 | return temperature 30 | 31 | 32 | # ``device.thread`` functions will run in the background. 33 | run_led_loop(0.5) 34 | 35 | # This runs in the foreground. 36 | while True: 37 | temperature = read_temperature() 38 | print(f"Temperature: {temperature:.1f}C") 39 | sleep(0.5) 40 | -------------------------------------------------------------------------------- /tests/test_inspect/foo.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | 4 | def decorator(f=None, **kwargs): 5 | if f is None: 6 | return decorator 7 | 8 | @wraps(f) 9 | def inner(*args, **kwargs): 10 | return f(*args, **kwargs) 11 | 12 | return inner 13 | 14 | 15 | def foo(arg1, arg2): 16 | return arg1 + arg2 17 | 18 | 19 | @decorator 20 | def foo_decorated_1(arg1, arg2): 21 | return arg1 + arg2 22 | 23 | 24 | @decorator() 25 | def foo_decorated_2(arg1, arg2): 26 | return arg1 + arg2 27 | 28 | 29 | @decorator(some_kwarg="test") 30 | def foo_decorated_3(arg1, arg2): 31 | return arg1 + arg2 32 | 33 | 34 | @decorator() 35 | def foo_decorated_4( 36 | arg1, 37 | arg2, 38 | ): 39 | return arg1 + arg2 40 | 41 | 42 | if True: 43 | 44 | @decorator 45 | def foo_decorated_5(arg1, arg2): 46 | return arg1 + arg2 47 | 48 | 49 | @decorator 50 | @decorator 51 | def foo_decorated_6(arg1, arg2): 52 | return arg1 + arg2 53 | 54 | 55 | @decorator 56 | def foo_decorated_7(arg1, arg2): 57 | return """This 58 | is 59 | a 60 | multiline 61 | string. 62 | """ 63 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | download-firmware: 2 | @echo "Downloading firmware files for integration tests..." 3 | @mkdir -p rp2040js 4 | curl -L https://micropython.org/resources/firmware/RPI_PICO-20210902-v1.17.uf2 -o rp2040js/micropython-v1.17.uf2 5 | curl -L https://downloads.circuitpython.org/bin/raspberry_pi_pico/en_US/adafruit-circuitpython-raspberry_pi_pico-en_US-7.3.3.uf2 -o rp2040js/circuitpython-v7.3.3.uf2 6 | curl -L https://downloads.circuitpython.org/bin/raspberry_pi_pico/en_US/adafruit-circuitpython-raspberry_pi_pico-en_US-8.0.0.uf2 -o rp2040js/circuitpython-v8.0.0.uf2 7 | @echo "Firmware download complete!" 8 | 9 | integration-build-amd64: 10 | docker buildx build --platform linux/amd64 -t belay-integration-tester -f tools/Dockerfile . 11 | 12 | integration-build: 13 | docker buildx build -t belay-integration-tester -f tools/Dockerfile . 14 | 15 | integration-test: 16 | docker run \ 17 | -v $(PWD):/belay \ 18 | belay-integration-tester \ 19 | /bin/bash -c "poetry install && poetry run python -m pytest tests/integration" 20 | 21 | integration-bash: 22 | # For debugging purposes 23 | docker run -it \ 24 | -v $(PWD):/belay \ 25 | belay-integration-tester 26 | -------------------------------------------------------------------------------- /tests/integration/test_stdout_forwarding.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import belay 4 | 5 | 6 | class StreamOut: 7 | def __init__(self): 8 | self.out = "" 9 | 10 | def write(self, data): 11 | self.out += data 12 | 13 | 14 | def test_print_basic(emulated_device, mocker): 15 | spy_parse_belay_response = mocker.spy(belay.device, "parse_belay_response") 16 | 17 | @emulated_device.task 18 | def foo(): 19 | print("print from belay task.") 20 | 21 | stream_out = StreamOut() 22 | res = emulated_device("foo()", stream_out=stream_out) 23 | 24 | # Verify the correct calls were made 25 | assert len(spy_parse_belay_response.call_args_list) == 2 26 | assert spy_parse_belay_response.call_args_list[0][0][0] == "print from belay task.\r\n" 27 | 28 | # Second call should match pattern: _BELAYR|{timestamp}|None\r\n 29 | second_call = spy_parse_belay_response.call_args_list[1][0][0] 30 | assert re.match( 31 | r"^_BELAYR\|\d+\|None\r\n$", second_call 32 | ), f"Expected pattern '_BELAYR||None\\r\\n' but got: {second_call!r}" 33 | 34 | assert stream_out.out == "print from belay task.\r\n" 35 | assert res is None 36 | -------------------------------------------------------------------------------- /examples/01_blink_led/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | # Setup the connection with the micropython board. 11 | # This also executes a few common imports on-device. 12 | device = belay.Device(args.port) 13 | 14 | 15 | @device.setup 16 | def setup(): # The function name doesn't matter, but is "setup" by convention. 17 | from machine import Pin 18 | 19 | led = Pin(25, Pin.OUT) 20 | 21 | 22 | # This sends the function's code over to the board. 23 | # Calling the local ``set_led`` function will 24 | # execute it on-device. 25 | @device.task 26 | def set_led(state): 27 | # Configuration for a Pi Pico board. 28 | print(f"Printing from device; turning LED to {state}.") 29 | led.value(state) 30 | 31 | 32 | setup() 33 | 34 | while True: 35 | set_led(True) # Preferred way of executing function. 36 | time.sleep(0.5) 37 | device.task.set_led(False) # Also equally good. 38 | time.sleep(0.5) 39 | device("set_led(True)") # Also works, but is uglier. 40 | time.sleep(0.5) 41 | set_led(False) 42 | time.sleep(0.5) 43 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | # Derived from: https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-poetry 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: "ubuntu-22.04" 10 | tools: 11 | python: "3.10" 12 | jobs: 13 | post_create_environment: 14 | # Install poetry 15 | # https://python-poetry.org/docs/#installing-manually 16 | - pip install poetry 17 | - poetry self add poetry-dynamic-versioning 18 | post_install: 19 | # Install dependencies with 'docs' dependency group 20 | # https://python-poetry.org/docs/managing-dependencies/#dependency-groups 21 | # VIRTUAL_ENV needs to be set manually for now. 22 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 23 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --without=dev --with=docs 24 | 25 | # Build documentation in the docs/ directory with Sphinx 26 | sphinx: 27 | configuration: docs/source/conf.py 28 | fail_on_warning: true 29 | 30 | # If using Sphinx, optionally build your docs in additional formats such as PDF 31 | formats: 32 | - pdf 33 | -------------------------------------------------------------------------------- /examples/09_webrepl/README.rst: -------------------------------------------------------------------------------- 1 | Example 09: WebREPL 2 | =================== 3 | 4 | First, run the code using the serial port so that the configurations in the ``board/`` folder are synced to device. 5 | 6 | .. code-block: bash 7 | 8 | python main.py --port /dev/ttyUSB0 9 | 10 | You can then get the IP address by examining the terminal output (using a tool like ``minicom`` or ``mpremote``) on boot, or looking at your router configuration. 11 | 12 | .. code-block: bash 13 | 14 | mpremote connect /dev/ttyUSB0 15 | 16 | This should return something like: 17 | 18 | .. code-block: text 19 | 20 | Connected to MicroPython at /dev/ttyUSB0 21 | Use Ctrl-] to exit this shell 22 | OK 23 | MPY: soft reboot 24 | connecting to network... 25 | network config: ('192.168.1.110', '255.255.255.0', '192.168.1.1', '192.168.1.1') 26 | WebREPL daemon started on ws://192.168.1.110:8266 27 | Started webrepl in normal mode 28 | raw REPL; CTRL-B to exit 29 | > 30 | 31 | Now that a WebREPL server is running on-device, and we know the device's IP address, we can wirelessly run the script via: 32 | 33 | .. code-block: bash 34 | 35 | python main.py --port ws://192.168.1.110 36 | 37 | 38 | https://micropython.org/webrepl/ 39 | -------------------------------------------------------------------------------- /examples/08_device_subclassing/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | from belay import Device 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | 11 | class MyDevice(Device): 12 | # NOTE: ``Device`` is capatalized here! 13 | @Device.setup( 14 | autoinit=True 15 | ) # ``autoinit=True`` means this method will automatically be called during object creation. 16 | def setup(): 17 | # Code here is executed on-device in a global context. 18 | led_pin = Pin(25, Pin.OUT) 19 | sensor_temp = ADC(4) # ADC4 is attached to an internal temperature sensor on the Pi Pico 20 | 21 | @Device.task 22 | def set_led(state): 23 | led_pin.value(state) 24 | 25 | @Device.task 26 | def read_temperature(): 27 | reading = sensor_temp.read_u16() 28 | reading *= 3.3 / 65535 # Convert reading to a voltage. 29 | temperature = 27 - (reading - 0.706) / 0.001721 # Convert voltage to Celsius 30 | return temperature 31 | 32 | 33 | device = MyDevice(args.port) 34 | 35 | while True: 36 | device.set_led(True) 37 | temperature = device.read_temperature() 38 | print(f"Temperature: {temperature:.1f}C") 39 | time.sleep(0.5) 40 | device.set_led(False) 41 | time.sleep(0.5) 42 | -------------------------------------------------------------------------------- /tests/cli/test_update.py: -------------------------------------------------------------------------------- 1 | from belay.cli.main import app 2 | from belay.packagemanager import Group 3 | from tests.conftest import run_cli 4 | 5 | 6 | def test_update(mocker, tmp_cwd): 7 | (tmp_cwd / "pyproject.toml").touch() 8 | 9 | groups = [Group("name", dependencies={"foo": "foo.py"})] 10 | mock_download = mocker.patch.object(groups[0], "download") 11 | mock_load_groups = mocker.patch("belay.cli.update.load_groups", return_value=groups) 12 | 13 | exit_code = run_cli(app, ["update"]) 14 | assert exit_code == 0 15 | 16 | mock_load_groups.assert_called_once_with() 17 | mock_download.assert_called_once_with( 18 | packages=None, 19 | console=mocker.ANY, 20 | ) 21 | 22 | 23 | def test_update_specific_packages(mocker, tmp_cwd): 24 | (tmp_cwd / "pyproject.toml").touch() 25 | 26 | groups = [Group("name", dependencies={"foo": "foo.py", "bar": "bar.py", "baz": "baz.py"})] 27 | mock_download = mocker.patch.object(groups[0], "download") 28 | mock_load_groups = mocker.patch("belay.cli.update.load_groups", return_value=groups) 29 | 30 | exit_code = run_cli(app, ["update", "bar", "baz"]) 31 | assert exit_code == 0 32 | 33 | mock_load_groups.assert_called_once_with() 34 | mock_download.assert_called_once_with( 35 | packages=["bar", "baz"], 36 | console=mocker.ANY, 37 | ) 38 | -------------------------------------------------------------------------------- /examples/08B_device_subclassing/README.rst: -------------------------------------------------------------------------------- 1 | Example 08B: Device Subclassing 2 | ============================== 3 | This is an alternative version of Example 08 that explicitly separates 4 | code that runs on the host (``main.py``, ``main_multiple_devices``) 5 | from the class that contains the code (``MyDevice`` in ``mydevice.py``) 6 | that will be pushed to the microcontroller. 7 | 8 | It may be convenient to organize your Belay tasks into a class 9 | rather than decorated standalone functions. 10 | To accomplish this, have your class inherit from ``Device``, 11 | and mark methods with the ``@Device.task`` decorator. 12 | Source code of marked methods are sent to the device and executers 13 | are created when the ``Device`` object is instantiated. 14 | This also allows for multiple devices to share the same task definitions 15 | by instantiating multiple objeccts. 16 | 17 | Methods marked with ``@Device.setup`` are executed in a global scope. Essentially 18 | the contents of the method are extracted and then executed on the device. 19 | This means any variables created in ``@Device.setup`` are available to any of the 20 | other functions run on the device. 21 | 22 | Methods marked with ``@Device.task`` are similar to ``@staticmethod`` in that 23 | they do **not** contain ``self`` in the method signature. 24 | To the device, each marked method is equivalent to an independent function. 25 | -------------------------------------------------------------------------------- /belay/__init__.py: -------------------------------------------------------------------------------- 1 | # Don't manually change, let poetry-dynamic-versioning-plugin handle it. 2 | __version__ = "0.0.0" 3 | 4 | __all__ = [ 5 | "AuthenticationError", 6 | "BelayException", 7 | "ConnectionFailedError", 8 | "ConnectionLost", 9 | "Device", 10 | "DeviceMeta", 11 | "DeviceNotFoundError", 12 | "FeatureUnavailableError", 13 | "Implementation", 14 | "InsufficientSpecifierError", 15 | "MaxHistoryLengthError", 16 | "NoMatchingExecuterError", 17 | "NotBelayResponseError", 18 | "NO_RESULT", 19 | "ProxyObject", 20 | "PyboardError", 21 | "PyboardException", 22 | "SpecialFunctionNameError", 23 | "UNPARSABLE_RESULT", 24 | "UsbSpecifier", 25 | "list_devices", 26 | "minify", 27 | ] 28 | from ._minify import minify 29 | from .device import NO_RESULT, UNPARSABLE_RESULT, Device, Implementation 30 | from .device_meta import DeviceMeta 31 | from .exceptions import ( 32 | AuthenticationError, 33 | BelayException, 34 | ConnectionFailedError, 35 | ConnectionLost, 36 | DeviceNotFoundError, 37 | FeatureUnavailableError, 38 | InsufficientSpecifierError, 39 | MaxHistoryLengthError, 40 | NoMatchingExecuterError, 41 | NotBelayResponseError, 42 | SpecialFunctionNameError, 43 | ) 44 | from .proxy_object import ProxyObject 45 | from .pyboard import PyboardError, PyboardException 46 | from .usb_specifier import UsbSpecifier, list_devices 47 | -------------------------------------------------------------------------------- /examples/06_external_modules_and_file_sync/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from time import sleep 3 | 4 | import belay 5 | 6 | parser = argparse.ArgumentParser() 7 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 8 | args = parser.parse_args() 9 | 10 | device = belay.Device(args.port) 11 | 12 | device.sync("board/") 13 | 14 | print('Using synced "led.py" via explicit commands.') 15 | device("import led") 16 | for _ in range(3): 17 | device("led.set(25, True)") 18 | sleep(0.5) 19 | device("led.set(25, False)") 20 | sleep(0.5) 21 | 22 | 23 | print('Using synced "led.py" via task decorator.') 24 | 25 | 26 | @device.task 27 | def set_led(pin, value): 28 | import led 29 | 30 | led.set(pin, value) 31 | 32 | 33 | for _ in range(3): 34 | set_led(25, True) 35 | sleep(0.5) 36 | set_led(25, False) 37 | sleep(0.5) 38 | 39 | 40 | print('Using synced "somemodule/led.py" via task decorator.') 41 | 42 | 43 | @device.task 44 | def set_somemodule_led(pin, value): 45 | import somemodule.led 46 | 47 | somemodule.led.set(pin, value) 48 | 49 | 50 | for _ in range(3): 51 | set_somemodule_led(25, True) 52 | sleep(0.5) 53 | set_somemodule_led(25, False) 54 | sleep(0.5) 55 | 56 | 57 | print("Reading a synced file via task decorator. Contents:") 58 | 59 | 60 | @device.task 61 | def read_file(fn): 62 | with open(fn) as f: 63 | return f.read() 64 | 65 | 66 | print(read_file("hello_world.txt")) 67 | -------------------------------------------------------------------------------- /belay/cli/sync.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from rich.progress import Progress 6 | 7 | from belay import Device 8 | from belay.cli.common import PasswordStr, PortStr 9 | 10 | 11 | def sync_device(device, folder, progress_update, **kwargs): 12 | device.sync(folder, progress_update=progress_update, **kwargs) 13 | progress_update(description="Complete.") 14 | 15 | 16 | def sync( 17 | port: PortStr, 18 | folder: Path, 19 | *, 20 | dst: str = "/", 21 | password: PasswordStr = "", 22 | keep: Optional[list[str]] = None, 23 | ignore: Optional[list[str]] = None, 24 | mpy_cross_binary: Optional[Path] = None, 25 | ): 26 | """Synchronize a folder to device. 27 | 28 | Parameters 29 | ---------- 30 | folder : Path 31 | Path of local file or folder to sync. 32 | dst : str 33 | Destination directory to unpack folder contents to. 34 | keep : Optional[list[str]] 35 | Files to keep. 36 | ignore : Optional[list[str]] 37 | Files to ignore. 38 | mpy_cross_binary : Optional[Path] 39 | Compile py files with this executable. 40 | """ 41 | with Device(port, password=password) as device, Progress() as progress: 42 | task_id = progress.add_task("") 43 | 44 | def progress_update(description=None, **kwargs): 45 | return progress.update(task_id, description=description, **kwargs) 46 | 47 | sync_device( 48 | device, 49 | folder, 50 | progress_update, 51 | dst=dst, 52 | keep=keep, 53 | ignore=ignore, 54 | mpy_cross_binary=mpy_cross_binary, 55 | ) 56 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit workflow 2 | # 3 | # Ensures the codebase passes the pre-commit stack. 4 | # We run this on GHA to catch issues in commits from contributors who haven't 5 | # set up pre-commit. 6 | 7 | name: pre-commit 8 | 9 | on: 10 | push: 11 | branches: 12 | - main 13 | pull_request: 14 | 15 | jobs: 16 | pre-commit: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Cache $HOME/.local # Significantly speeds up Poetry Install 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.local 25 | key: dotlocal-${{ runner.os }}-${{ hashFiles('.github/workflows/tests.yml') }} 26 | 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.12" 30 | 31 | - name: Install Poetry 32 | uses: snok/install-poetry@v1 33 | with: 34 | version: 1.8.5 35 | virtualenvs-create: true 36 | virtualenvs-in-project: true 37 | installer-parallel: true 38 | 39 | - name: Load pip cache 40 | uses: actions/cache@v4 41 | with: 42 | path: ~/.cache/pip 43 | key: pip-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }} 44 | restore-keys: ${{ runner.os }}-pip 45 | 46 | - name: Load cached venv 47 | id: cached-poetry-dependencies 48 | uses: actions/cache@v4 49 | with: 50 | path: .venv 51 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 52 | 53 | - name: Install Belay 54 | run: poetry install --no-interaction 55 | 56 | - uses: pre-commit/action@v3.0.1 57 | -------------------------------------------------------------------------------- /tests/cli/test_clean.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from belay.cli.clean import clean 4 | from belay.packagemanager import Group 5 | 6 | 7 | @pytest.fixture 8 | def project_folder(tmp_cwd): 9 | (tmp_cwd / ".belay").mkdir() 10 | (tmp_cwd / ".belay" / "dependencies").mkdir() 11 | (tmp_cwd / ".belay" / "dependencies" / "dev").mkdir() 12 | (tmp_cwd / ".belay" / "dependencies" / "dev" / "bar").touch() 13 | (tmp_cwd / ".belay" / "dependencies" / "dev" / "baz").touch() 14 | (tmp_cwd / ".belay" / "dependencies" / "main").mkdir() 15 | (tmp_cwd / ".belay" / "dependencies" / "main" / "foo").touch() 16 | 17 | (tmp_cwd / "pyproject.toml").touch() 18 | 19 | return tmp_cwd 20 | 21 | 22 | def test_clean_basic(project_folder, mocker): 23 | groups = [ 24 | Group("main", dependencies={"foo": "foo_uri"}), 25 | Group("dev", dependencies={"bar": "bar_uri"}), 26 | ] 27 | mocker.patch("belay.cli.clean.load_groups", return_value=groups) 28 | 29 | dependencies_folder = project_folder / ".belay" / "dependencies" 30 | 31 | clean() 32 | 33 | assert (dependencies_folder / "main" / "foo").exists() 34 | 35 | assert (dependencies_folder / "dev").exists() 36 | assert (dependencies_folder / "dev" / "bar").exists() 37 | assert not (dependencies_folder / "dev" / "baz").exists() 38 | 39 | 40 | def test_clean_missing_group(project_folder, mocker): 41 | groups = [ 42 | Group("main", dependencies={"foo": "foo_uri"}), 43 | ] 44 | mocker.patch("belay.cli.clean.load_groups", return_value=groups) 45 | 46 | dependencies_folder = project_folder / ".belay" / "dependencies" 47 | 48 | clean() 49 | 50 | assert (dependencies_folder / "main" / "foo").exists() 51 | 52 | assert not (dependencies_folder / "dev").exists() 53 | -------------------------------------------------------------------------------- /belay/exceptions.py: -------------------------------------------------------------------------------- 1 | class BelayException(Exception): # noqa: N818 2 | """Root Belay exception class.""" 3 | 4 | 5 | class AuthenticationError(BelayException): 6 | """Invalid password or similar.""" 7 | 8 | 9 | class FeatureUnavailableError(BelayException): 10 | """Feature unavailable for your board's implementation.""" 11 | 12 | 13 | class SpecialFunctionNameError(BelayException): 14 | """Attempted to use a reserved Belay function name. 15 | 16 | The following name rules are reserved: 17 | 18 | * Names that start and end with double underscore, ``__``. 19 | 20 | * Names that start with ``_belay`` or ``__belay`` 21 | """ 22 | 23 | 24 | class MaxHistoryLengthError(BelayException): 25 | """Too many commands were given.""" 26 | 27 | 28 | class DeviceNotFoundError(BelayException): 29 | """Unable to find specified device.""" 30 | 31 | 32 | class InsufficientSpecifierError(BelayException): 33 | """Specifier wasn't unique enough to determine a single device.""" 34 | 35 | 36 | class ConnectionFailedError(BelayException): 37 | """Unable to connect to specified device.""" 38 | 39 | 40 | class ConnectionLost(ConnectionFailedError): # noqa: N818 41 | """Lost connection to device.""" 42 | 43 | 44 | class InternalError(BelayException): 45 | """Internal to Belay logic error.""" 46 | 47 | 48 | class NotBelayResponseError(BelayException): 49 | """Parsed response wasn't for Belay.""" 50 | 51 | 52 | class NoMatchingExecuterError(BelayException): 53 | """No valid executer found for the given board Implementation.""" 54 | 55 | 56 | class PackageNotFoundError(BelayException): 57 | """Package could not be found in index or URL.""" 58 | 59 | 60 | class IntegrityError(BelayException): 61 | """File integrity verification failed (hash mismatch).""" 62 | -------------------------------------------------------------------------------- /belay/packagemanager/downloaders/_gitlab.py: -------------------------------------------------------------------------------- 1 | """GitLab URL handling for package downloads.""" 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from typing import Optional 6 | 7 | from belay.packagemanager.downloaders.git import GitProviderUrl 8 | 9 | 10 | @dataclass 11 | class GitLabUrl(GitProviderUrl): 12 | """Parsed GitLab URL (shorthand or full HTTPS).""" 13 | 14 | # Patterns for full HTTPS URLs (4 groups: user, repo, branch, path) 15 | https_patterns = ( 16 | re.compile(r"gitlab\.com/(.+?)/(.+?)/-/raw/(.+?)/(.*)"), # raw 17 | re.compile(r"gitlab\.com/(.+?)/(.+?)/-/blob/(.+?)/(.*)"), # blob view 18 | re.compile(r"gitlab\.com/(.+?)/(.+?)/-/tree/(.+?)/(.*)"), # tree view 19 | ) 20 | 21 | # Pattern for repo root URLs (2 groups: user, repo only) 22 | _repo_root_pattern = re.compile(r"gitlab\.com/([^/]+)/([^/]+?)(?:\.git)?/?$") 23 | 24 | @classmethod 25 | def _parse_https(cls, url: str) -> Optional["GitLabUrl"]: 26 | """Parse full HTTPS URL, including repo root URLs. 27 | 28 | Extends base class to handle repo root URLs like 29 | ``https://gitlab.com/user/repo`` which don't have blob/tree paths. 30 | """ 31 | # Try standard patterns first (4 groups: user, repo, branch, path) 32 | result = super()._parse_https(url) 33 | if result is not None: 34 | return result 35 | 36 | # Try repo root pattern (2 groups: user, repo) 37 | match = cls._repo_root_pattern.search(url) 38 | if match: 39 | user, repo = match.groups() 40 | return cls(user=user, repo=repo, path="", branch="HEAD") 41 | 42 | return None 43 | 44 | @property 45 | def scheme(self) -> str: 46 | return "gitlab" 47 | 48 | @property 49 | def raw_url(self) -> str: 50 | """Raw content URL at gitlab.com.""" 51 | base = f"https://gitlab.com/{self.user}/{self.repo}/-/raw/{self.branch}" 52 | return f"{base}/{self.path}" if self.path else base 53 | -------------------------------------------------------------------------------- /belay/packagemanager/downloaders/_github.py: -------------------------------------------------------------------------------- 1 | """GitHub URL handling for package downloads.""" 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from typing import Optional 6 | 7 | from belay.packagemanager.downloaders.git import GitProviderUrl 8 | 9 | 10 | @dataclass 11 | class GitHubUrl(GitProviderUrl): 12 | """Parsed GitHub URL (shorthand or full HTTPS).""" 13 | 14 | # Patterns for full HTTPS URLs (4 groups: user, repo, branch, path) 15 | https_patterns = ( 16 | re.compile(r"github\.com/(.+?)/(.+?)/blob/(.+?)/(.*)"), # blob view 17 | re.compile(r"github\.com/(.+?)/(.+?)/tree/(.+?)/(.*)"), # tree view 18 | re.compile(r"raw\.githubusercontent\.com/(.+?)/(.+?)/(.+?)/(.*)"), # raw 19 | ) 20 | 21 | # Pattern for repo root URLs (2 groups: user, repo only) 22 | _repo_root_pattern = re.compile(r"github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$") 23 | 24 | @classmethod 25 | def _parse_https(cls, url: str) -> Optional["GitHubUrl"]: 26 | """Parse full HTTPS URL, including repo root URLs. 27 | 28 | Extends base class to handle repo root URLs like 29 | ``https://github.com/user/repo`` which don't have blob/tree paths. 30 | """ 31 | # Try standard patterns first (4 groups: user, repo, branch, path) 32 | result = super()._parse_https(url) 33 | if result is not None: 34 | return result 35 | 36 | # Try repo root pattern (2 groups: user, repo) 37 | match = cls._repo_root_pattern.search(url) 38 | if match: 39 | user, repo = match.groups() 40 | return cls(user=user, repo=repo, path="", branch="HEAD") 41 | 42 | return None 43 | 44 | @property 45 | def scheme(self) -> str: 46 | return "github" 47 | 48 | @property 49 | def raw_url(self) -> str: 50 | """Raw content URL at raw.githubusercontent.com.""" 51 | base = f"https://raw.githubusercontent.com/{self.user}/{self.repo}/{self.branch}" 52 | return f"{base}/{self.path}" if self.path else base 53 | -------------------------------------------------------------------------------- /tests/test_usb_specifier.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | from belay import UsbSpecifier 7 | from belay.exceptions import DeviceNotFoundError, InsufficientSpecifierError 8 | 9 | 10 | @dataclass 11 | class ListPortInfo: 12 | device: str 13 | name: str 14 | description: str = "n/a" 15 | hwid: str = "n/a" 16 | vid: Optional[int] = None 17 | pid: Optional[int] = None 18 | serial_number: Optional[str] = None 19 | location: Optional[str] = None 20 | manufacturer: Optional[str] = None 21 | product: Optional[str] = None 22 | interface: Optional[str] = None 23 | 24 | 25 | @pytest.fixture 26 | def mock_comports(mocker): 27 | mock_comports = mocker.patch( 28 | "belay.usb_specifier.comports", 29 | return_value=iter( 30 | [ 31 | ListPortInfo( 32 | device="/dev/ttyUSB0", 33 | name="ttyUSB0", 34 | pid=10, 35 | vid=20, 36 | manufacturer="Belay Industries", 37 | serial_number="abc123", 38 | ), 39 | ListPortInfo( 40 | device="/dev/ttyUSB1", 41 | name="ttyUSB1", 42 | pid=11, 43 | vid=21, 44 | manufacturer="Belay Industries", 45 | serial_number="xyz987", 46 | ), 47 | ] 48 | ), 49 | ) 50 | return mock_comports 51 | 52 | 53 | def test_usb_specifier_serial_number_only(mock_comports): 54 | spec = UsbSpecifier(serial_number="abc123") 55 | assert spec.to_port() == "/dev/ttyUSB0" 56 | 57 | 58 | def test_usb_specifier_no_matches(mock_comports): 59 | with pytest.raises(DeviceNotFoundError): 60 | UsbSpecifier(manufacturer="Foo").to_port() 61 | 62 | 63 | def test_usb_specifier_multiple_matches(mock_comports): 64 | with pytest.raises(InsufficientSpecifierError): 65 | UsbSpecifier(manufacturer="Belay Industries").to_port() 66 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^belay/telnetlib.py 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | # Ruff version. 5 | rev: "v0.5.1" 6 | hooks: 7 | - id: ruff 8 | args: [] 9 | exclude: ^(belay/snippets/) 10 | 11 | - repo: https://github.com/psf/black 12 | rev: 24.4.2 13 | hooks: 14 | - id: black 15 | args: 16 | - "--target-version=py38" 17 | - "--target-version=py39" 18 | - "--target-version=py310" 19 | - "--line-length=120" 20 | types: [python] 21 | exclude: ^(belay/snippets/) 22 | 23 | - repo: https://github.com/asottile/blacken-docs 24 | rev: 1.18.0 25 | hooks: 26 | - id: blacken-docs 27 | exclude: ^(docs/source/How Belay Works.rst) 28 | 29 | - repo: https://github.com/pre-commit/pre-commit-hooks 30 | rev: v4.6.0 31 | hooks: 32 | - id: check-added-large-files 33 | - id: check-ast 34 | - id: check-builtin-literals 35 | - id: check-case-conflict 36 | - id: check-docstring-first 37 | - id: check-shebang-scripts-are-executable 38 | exclude: ^(belay/pyboard.py) 39 | - id: check-merge-conflict 40 | - id: check-json 41 | - id: check-toml 42 | - id: check-xml 43 | - id: check-yaml 44 | - id: debug-statements 45 | - id: destroyed-symlinks 46 | - id: detect-private-key 47 | - id: end-of-file-fixer 48 | exclude: ^LICENSE|\.(html|csv|txt|svg|py)$ 49 | - id: pretty-format-json 50 | args: ["--autofix", "--no-ensure-ascii", "--no-sort-keys"] 51 | - id: requirements-txt-fixer 52 | - id: trailing-whitespace 53 | args: [--markdown-linebreak-ext=md] 54 | exclude: \.(html|svg)$ 55 | 56 | - repo: https://github.com/fredrikaverpil/creosote.git 57 | rev: v3.0.2 58 | hooks: 59 | - id: creosote 60 | 61 | - repo: https://github.com/codespell-project/codespell 62 | rev: v2.3.0 63 | hooks: 64 | - id: codespell 65 | exclude: ^(poetry.lock) 66 | args: ["-L", "ser,"] 67 | -------------------------------------------------------------------------------- /belay/packagemanager/downloaders/_retry.py: -------------------------------------------------------------------------------- 1 | """Shared retry utilities for downloaders.""" 2 | 3 | import requests 4 | from tenacity import ( 5 | retry, 6 | retry_if_exception, 7 | stop_after_attempt, 8 | wait_exponential, 9 | ) 10 | 11 | 12 | def _is_retryable_exception(exc: BaseException) -> bool: 13 | """Determine if an exception should trigger a retry. 14 | 15 | Retries on: 16 | - Timeout errors 17 | - Connection errors 18 | - HTTP 429 (rate limit) 19 | - HTTP 5xx (server errors) 20 | 21 | Does NOT retry on: 22 | - HTTP 4xx (client errors, except 429) 23 | """ 24 | if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)): 25 | return True 26 | if isinstance(exc, requests.exceptions.HTTPError) and exc.response is not None: 27 | status = exc.response.status_code 28 | return status == 429 or status >= 500 29 | return False 30 | 31 | 32 | @retry( 33 | retry=retry_if_exception(_is_retryable_exception), 34 | stop=stop_after_attempt(3), 35 | wait=wait_exponential(multiplier=1, min=1, max=10), 36 | reraise=True, 37 | ) 38 | def fetch_url(url: str, timeout: float = 30.0) -> requests.Response: 39 | """Fetch URL with retries on transient failures. 40 | 41 | Retries up to 3 times with exponential backoff (1s, 2s, 4s... max 10s) 42 | on timeout, connection errors, rate limits (429), and server errors (5xx). 43 | 44 | Parameters 45 | ---------- 46 | url 47 | URL to fetch. 48 | timeout 49 | Request timeout in seconds. 50 | 51 | Returns 52 | ------- 53 | requests.Response 54 | Response object (may have non-200 status code for 4xx errors). 55 | 56 | Raises 57 | ------ 58 | requests.exceptions.RequestException 59 | If request fails after retries. 60 | """ 61 | response = requests.get(url, timeout=timeout) 62 | # Raise for 5xx errors and 429 (rate limit) to trigger retry, but not for other 4xx 63 | if response.status_code >= 500 or response.status_code == 429: 64 | response.raise_for_status() 65 | return response 66 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Build package and push to PyPi 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # Includes getting tags 17 | 18 | - name: Cache $HOME/.local # Significantly speeds up Poetry Install 19 | uses: actions/cache@v4 20 | with: 21 | path: ~/.local 22 | key: dotlocal-${{ runner.os }}-${{ hashFiles('.github/workflows/deploy.yml') }} 23 | 24 | - name: Set up python 3.12 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.12" 28 | 29 | - name: Install poetry 30 | uses: snok/install-poetry@v1 31 | with: 32 | version: 1.8.5 33 | virtualenvs-create: true 34 | virtualenvs-in-project: true 35 | installer-parallel: true 36 | 37 | - name: Add Poetry Plugins 38 | run: | 39 | poetry self add poetry-dynamic-versioning[plugin] 40 | 41 | - name: Load cached venv 42 | id: cached-poetry-dependencies 43 | uses: actions/cache@v4 44 | with: 45 | path: .venv 46 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 47 | 48 | - name: Install dependencies 49 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 50 | run: poetry install --no-interaction --no-root --without=dev 51 | 52 | - name: Install project 53 | run: poetry install --no-interaction --without=dev 54 | 55 | - name: Build package 56 | run: poetry build 57 | 58 | - name: Publish package 59 | if: github.event_name != 'workflow_dispatch' 60 | run: | 61 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 62 | poetry publish 63 | 64 | - uses: actions/upload-artifact@v4 65 | if: always() 66 | with: 67 | name: dist 68 | path: dist/ 69 | -------------------------------------------------------------------------------- /tests/cli/test_install.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from belay.cli.main import app 4 | from tests.conftest import run_cli 5 | 6 | 7 | def test_install_no_pkg(tmp_path, mocker, mock_device): 8 | toml = {} 9 | main_py = tmp_path / "main.py" 10 | main_py.write_text("foo = 1") 11 | 12 | mock_load_toml = mocker.patch("belay.project.load_toml", return_value=toml) 13 | mock_device.patch("belay.cli.install.Device") 14 | 15 | exit_code = run_cli(app, ["install", "/dev/ttyUSB0", "--run", str(main_py), "--password", "password"]) 16 | assert exit_code == 0 17 | 18 | mock_load_toml.assert_called_once() 19 | mock_device.cls_assert_common() 20 | mock_device.inst.sync.assert_called_once_with( # Dependencies sync 21 | mocker.ANY, 22 | progress_update=mocker.ANY, 23 | mpy_cross_binary=None, 24 | dst="/lib", 25 | ) 26 | 27 | mock_device.inst.assert_called_once_with("foo = 1") 28 | 29 | 30 | def test_install_basic(tmp_path, mocker, mock_device): 31 | dependencies_folder = tmp_path / ".belay" / "dependencies" 32 | 33 | toml = {"name": "my_pkg_name"} 34 | 35 | mock_load_toml = mocker.patch("belay.project.load_toml", return_value=toml) 36 | mocker.patch("belay.project.find_dependencies_folder", return_value=dependencies_folder) 37 | mocker.patch("belay.cli.install.find_project_folder", return_value=Path()) 38 | mock_device.patch("belay.cli.install.Device") 39 | 40 | exit_code = run_cli(app, ["install", "/dev/ttyUSB0", "--password", "password"]) 41 | assert exit_code == 0 42 | 43 | mock_load_toml.assert_called_once() 44 | mock_device.cls_assert_common() 45 | mock_device.inst.sync.assert_has_calls( 46 | [ 47 | mocker.call( 48 | mocker.ANY, 49 | progress_update=mocker.ANY, 50 | mpy_cross_binary=None, 51 | dst="/lib", 52 | ), 53 | mocker.call( 54 | mocker.ANY, 55 | progress_update=mocker.ANY, 56 | mpy_cross_binary=None, 57 | dst="/my_pkg_name", 58 | ignore=[], 59 | ), 60 | ] 61 | ) 62 | -------------------------------------------------------------------------------- /examples/12_time_sync/README.rst: -------------------------------------------------------------------------------- 1 | Example 12: Time Synchronization 2 | ================================= 3 | 4 | This example demonstrates Belay's time synchronization feature. 5 | Also demonstrates using ``return_time=True`` with tasks. 6 | 7 | Do You Actually Need Time Synchronization? 8 | ------------------------------------------- 9 | 10 | For most applications, it's **simpler to just use host-side timestamps** instead of 11 | synchronizing device time. Simply record the current host time when you receive data 12 | from the device: 13 | 14 | .. code-block:: python 15 | 16 | import time 17 | 18 | value = device_task() # Get data from device 19 | timestamp = time.time() # Host timestamp - simple! 20 | 21 | This approach is accurate enough for most use cases and has zero overhead. 22 | 23 | Only use Belay's time-sync feature if: 24 | 25 | * You need high time accuracy (< 5ms between device event and timestamp) 26 | * You're collecting data on-device and need timestamps for events that occurred in the past 27 | * You need to correlate precise timing between multiple devices 28 | 29 | Test Your Connection Latency First 30 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 31 | 32 | Use the ``belay latency`` command to measure your device/connection round-trip time: 33 | 34 | .. code-block:: bash 35 | 36 | belay latency /dev/ttyUSB0 37 | 38 | If the latency is acceptable for your application (e.g., > 5ms), host-side 39 | timestamps are the simpler choice. 40 | 41 | The return_time Parameter 42 | ------------------------- 43 | 44 | The simplest way to get timestamps for task results is using ``return_time=True``: 45 | 46 | .. code-block:: python 47 | 48 | @device.task(return_time=True) 49 | def read_sensor(): 50 | return sensor.read() 51 | 52 | 53 | # Returns (value, datetime) tuple automatically 54 | value, timestamp = read_sensor() 55 | 56 | This also works with generator tasks - each yielded value becomes a ``(value, datetime)`` tuple. 57 | 58 | Running This Example 59 | -------------------- 60 | 61 | To run this demo: 62 | 63 | .. code-block:: bash 64 | 65 | python main.py --port YOUR_DEVICE_PORT_HERE 66 | 67 | For example: 68 | 69 | .. code-block:: bash 70 | 71 | python main.py --port /dev/ttyUSB0 72 | -------------------------------------------------------------------------------- /belay/packagemanager/sync.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import hashlib 3 | import shutil 4 | from pathlib import Path 5 | 6 | from belay.typing import PathType 7 | 8 | 9 | def _sha256sum(path: PathType): 10 | path = Path(path) 11 | h = hashlib.sha256() 12 | mv = memoryview(bytearray(128 * 1024)) 13 | with path.open("rb", buffering=0) as f: 14 | while n := f.readinto(mv): 15 | h.update(mv[:n]) 16 | return h.hexdigest() 17 | 18 | 19 | def sync(src_folder: PathType, dst_folder: PathType) -> bool: 20 | """Make ``dst_folder`` have the same contents as ``src_folder``. 21 | 22 | Returns 23 | ------- 24 | bool 25 | ``True`` if contents of ``dst`` have changed; ``False`` otherwise. 26 | """ 27 | changed = False 28 | src_folder, dst_folder = Path(src_folder), Path(dst_folder) 29 | 30 | src_files = {x.relative_to(src_folder) for x in src_folder.rglob("*") if x.is_file()} 31 | 32 | dst_files, dst_subfolders = set(), set() 33 | for dst_item in dst_folder.rglob("*"): 34 | dst_item_rel = dst_item.relative_to(dst_folder) 35 | if dst_item.is_file(): 36 | dst_files.add(dst_item_rel) 37 | else: 38 | dst_subfolders.add(dst_item_rel) 39 | 40 | common_files = src_files.intersection(dst_files) 41 | src_only_files = src_files - dst_files 42 | dst_only_files = dst_files - src_files 43 | 44 | # compare common files and copy over on change 45 | for f in common_files: 46 | src = src_folder / f 47 | dst = dst_folder / f 48 | 49 | if _sha256sum(src) != _sha256sum(dst): 50 | changed = True 51 | shutil.copy(src, dst) 52 | 53 | # copy over src_only_files 54 | for f in src_only_files: 55 | changed = True 56 | src = src_folder / f 57 | dst = dst_folder / f 58 | dst.parent.mkdir(parents=True, exist_ok=True) 59 | shutil.copy(src, dst) 60 | 61 | # Remove files that only exist in the destination 62 | for f in dst_only_files: 63 | changed = True 64 | dst = dst_folder / f 65 | dst.unlink() 66 | 67 | # Remove all empty dst directories. 68 | for folder in dst_subfolders: 69 | with contextlib.suppress(OSError): 70 | folder.rmdir() 71 | 72 | return changed 73 | -------------------------------------------------------------------------------- /docs/source/CircuitPython.rst: -------------------------------------------------------------------------------- 1 | CircuitPython 2 | ============= 3 | 4 | Belay also supports CircuitPython_. 5 | Unlike MicroPython, CircuitPython automatically mounts the device's filesystem as a USB drive. 6 | This is usually convenient, but it makes the filesystem read-only to the python interpreter. 7 | To get around this, we need to manually add the following lines to ``boot.py`` on-device. 8 | 9 | .. code-block:: python 10 | 11 | import storage 12 | 13 | storage.remount("/") 14 | 15 | Afterwards, reset the device and it's prepared for Belay. 16 | 17 | 18 | Reverting 19 | ^^^^^^^^^ 20 | 21 | To revert this configuration, there are multiple options: 22 | 23 | 1. Edit ``boot.py`` using Thonny_, then reboot. Thonny (like Belay), operates via the REPL, 24 | so it has write-access since it's operating through circuitpython. 25 | 26 | 2. Through circuitpython's REPL via an interactive shell, such as ``rshell`` or ``python -m serial.tools.miniterm``: 27 | 28 | .. code-block:: python 29 | 30 | import os 31 | 32 | os.remove("boot.py") 33 | 34 | 3. Using Belay in an interactive python prompt on host: 35 | 36 | .. code-block:: python 37 | 38 | from belay import Device 39 | 40 | device = Device("/dev/ttyUSB0") # replace with appropriate port 41 | device("os.remove('boot.py')") 42 | # Then reboot. 43 | 44 | Physical Configuration 45 | ^^^^^^^^^^^^^^^^^^^^^^ 46 | Storage mounting can be configured based on a physical pin state. 47 | Adding the following contents to ``/boot.py`` will configure the system to: 48 | 49 | * Be in "normal" circuitpython mode if pin 14 is floating/high (due to 50 | configured pullup) on boot. 51 | 52 | * Be in Belay-compatible mode if pin 14 is connected to ground on boot. 53 | This could be done, for example, by a push-button or a toggle switch. 54 | 55 | .. code-block:: python 56 | 57 | import board 58 | import storage 59 | from digitalio import DigitalInOut, Pull 60 | 61 | op_mode = DigitalInOut(board.GP14) # Choose whichever pin you would like 62 | op_mode.switch_to_input(Pull.UP) 63 | 64 | if not op_mode.value: 65 | # Mount system in host-readonly, circuitpython-writeable mode (Belay compatible). 66 | storage.remount("/") 67 | 68 | 69 | 70 | .. _CircuitPython: https://circuitpython.org 71 | .. _Thonny: https://thonny.org 72 | -------------------------------------------------------------------------------- /belay/helpers.py: -------------------------------------------------------------------------------- 1 | import importlib.resources as importlib_resources 2 | import secrets 3 | import string 4 | import sys 5 | from functools import lru_cache, partial, wraps 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | from . import nativemodule_fnv1a32, snippets 10 | 11 | _python_identifier_chars = string.ascii_uppercase + string.ascii_lowercase + string.digits 12 | 13 | 14 | def wraps_partial(f, *args, **kwargs): 15 | """Wrap and partial of a function.""" 16 | return wraps(f)(partial(f, *args, **kwargs)) 17 | 18 | 19 | def random_python_identifier(n=16): 20 | return "_" + "".join(secrets.choice(_python_identifier_chars) for _ in range(n)) 21 | 22 | 23 | @lru_cache 24 | def read_snippet(name): 25 | resource = f"{name}.py" 26 | return importlib_resources.files(snippets).joinpath(resource).read_text(encoding="utf-8") 27 | 28 | 29 | def get_fnv1a32_native_path(implementation) -> Optional[Path]: 30 | if implementation.name != "micropython": 31 | return None 32 | mpy_fn = f"mpy{implementation.version[0]}.{implementation.version[1]}-{implementation.arch}.mpy" 33 | filepath = importlib_resources.files(nativemodule_fnv1a32).joinpath(mpy_fn) 34 | return filepath if filepath.exists() else None 35 | 36 | 37 | def sanitize_package_name(name: str) -> str: 38 | """Convert string to valid Python identifier. 39 | 40 | Strips file extensions (.py, .mpy) and replaces hyphens with underscores. 41 | 42 | Parameters 43 | ---------- 44 | name 45 | Raw name (e.g., from a URI path component). 46 | 47 | Returns 48 | ------- 49 | str 50 | Sanitized package name suitable as a Python identifier. 51 | 52 | Raises 53 | ------ 54 | ValueError 55 | If name cannot be converted to a valid identifier. 56 | 57 | Examples 58 | -------- 59 | >>> sanitize_package_name("my-package") 60 | 'my_package' 61 | >>> sanitize_package_name("module.py") 62 | 'module' 63 | >>> sanitize_package_name("sensor.mpy") 64 | 'sensor' 65 | """ 66 | # Remove common file extensions 67 | for ext in (".py", ".mpy"): 68 | if name.endswith(ext): 69 | name = name[: -len(ext)] 70 | break 71 | # Replace hyphens with underscores 72 | name = name.replace("-", "_") 73 | # Validate result 74 | if not name.isidentifier(): 75 | raise ValueError(f"Cannot convert '{name}' to valid package name.") 76 | return name 77 | -------------------------------------------------------------------------------- /belay/cli/cache.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import contextlib 3 | 4 | with contextlib.suppress(ImportError): 5 | import readline 6 | import shutil 7 | import sys 8 | from typing import Annotated 9 | 10 | from cyclopts import App, Parameter 11 | 12 | from belay.project import find_cache_folder 13 | 14 | app = App(help="Perform action's on Belay's cache.") 15 | 16 | 17 | @app.command 18 | def clear( 19 | prefix: str = "", 20 | *, 21 | yes: Annotated[bool, Parameter(alias="-y")] = False, 22 | all: Annotated[bool, Parameter(alias="-a")] = False, 23 | ): 24 | """Clear cache. 25 | 26 | Parameters 27 | ---------- 28 | prefix : str 29 | Clear all caches that start with this. 30 | yes : bool 31 | Automatically answer "yes" to all confirmation prompts. 32 | all : bool 33 | Clear all caches. 34 | """ 35 | if (not prefix and not all) or (prefix and all): 36 | print('Either provide a prefix OR set the "--all" flag.') 37 | sys.exit(1) 38 | 39 | cache_folder = find_cache_folder() 40 | 41 | prefix += "*" 42 | cache_paths = builtins.list(cache_folder.glob(prefix)) 43 | cache_names = [x.name for x in cache_paths] 44 | 45 | if not cache_paths: 46 | print(f'No caches found starting with "{prefix}"') 47 | sys.exit(1) 48 | 49 | if not yes: 50 | print("Found caches:") 51 | for cache_name in cache_names: 52 | print(f" • {cache_name}") 53 | response = input("Clear these caches? [y/N]: ") 54 | if response.lower() not in ("y", "yes"): 55 | sys.exit(1) 56 | 57 | for path in cache_paths: 58 | if path.is_file(): 59 | path.unlink() 60 | else: 61 | shutil.rmtree(path) 62 | 63 | 64 | @app.command 65 | def list(): 66 | """List cache elements.""" 67 | cache_folder = find_cache_folder() 68 | items = [x.name for x in cache_folder.glob("*")] 69 | 70 | for item in items: 71 | print(item) 72 | 73 | 74 | @app.command 75 | def info(): 76 | """Display cache location and size.""" 77 | cache_folder = find_cache_folder() 78 | 79 | print(f"Location: {cache_folder}") 80 | 81 | n_elements = len(builtins.list(cache_folder.glob("*"))) 82 | print(f"Elements: {n_elements}") 83 | 84 | size_in_bytes = sum(f.stat().st_size for f in cache_folder.glob("**/*") if f.is_file()) 85 | size_in_megabytes = size_in_bytes / (1 << 20) 86 | print(f"Total Size: {size_in_megabytes:0.3}MB") 87 | -------------------------------------------------------------------------------- /examples/01_blink_led/README.rst: -------------------------------------------------------------------------------- 1 | Example 01: Blink LED 2 | ===================== 3 | 4 | Belay projects are a little bit different from normal python projects. 5 | You are actually writing two different python programs at the same time: 6 | 7 | 1. Code that will run in the MicroPython environment on your microcontroller. 8 | 9 | 2. Code that will run on your computer and interact with the code in (1). 10 | 11 | To run this demo, run: 12 | 13 | .. code-block:: bash 14 | 15 | python main.py --port YOUR_DEVICE_PORT_HERE 16 | 17 | For example: 18 | 19 | .. code-block:: bash 20 | 21 | python main.py --port /dev/ttyUSB0 22 | 23 | 24 | Code Explanation 25 | ^^^^^^^^^^^^^^^^ 26 | 27 | First import Belay. 28 | 29 | .. code-block:: python 30 | 31 | import belay 32 | 33 | Next, we will create a ``Device`` object that will connect to the board. 34 | 35 | .. code-block:: python 36 | 37 | device = belay.Device("/dev/ttyUSB0") 38 | 39 | Belay contains a small collections of decorators that shuttle code and commands to a micropython board. 40 | The body of decorated functions *always* execute on-device; never on host. 41 | 42 | Using the ``setup`` decorator, define a function to import the ``Pin`` class and create an object representing an LED. 43 | We don't strictly need to import ``Pin`` since its included in Belay's `convenience imports`_, but do so here for clarity. 44 | The pin number may vary, depending on your hardware setup. 45 | ``setup`` decorated functions do not do anything until invoked. 46 | 47 | .. code-block:: python 48 | 49 | @device.setup 50 | def setup(): # The function name doesn't matter, but is "setup" by convention. 51 | from machine import Pin 52 | 53 | led = Pin(25, Pin.OUT) 54 | 55 | 56 | Next, we will decorate a function with the ``task`` decorator. 57 | The source code of the function decorated by ``task`` will be *immediately* sent to the board. 58 | 59 | .. code-block:: python 60 | 61 | @device.task 62 | def set_led(state): 63 | print(f"Printing from device; turning LED to {state}.") 64 | led.value(state) 65 | 66 | Now that the function ``set_led`` is defined in the board's current environment, we can execute it. 67 | Calling ``set_led(True)`` won't invoke the function on the host, but will send a command to execute it on-device with the argument ``True``. 68 | On-device ``print`` calls have their results forwarded to the host's ``stdout``. 69 | This results in the LED turning on. 70 | 71 | .. _convenience imports: https://github.com/BrianPugh/belay/blob/main/belay/snippets/convenience_imports_micropython.py 72 | -------------------------------------------------------------------------------- /tests/packagemanager/downloaders/test_github.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from belay.packagemanager.downloaders import GitHubUrl, GitProviderUrl 4 | 5 | 6 | @pytest.mark.network 7 | def test_download_github_folder(mocker, tmp_path): 8 | mocker.patch("belay.project.find_cache_folder", return_value=tmp_path / ".belay-cache") 9 | 10 | dst_path = tmp_path / "dst" 11 | 12 | uri = "https://github.com/BrianPugh/belay/tree/main/tests/github_download_folder" 13 | parsed = GitProviderUrl.parse(uri) 14 | assert isinstance(parsed, GitHubUrl) 15 | parsed.download(dst_path) 16 | 17 | assert (dst_path / "__init__.py").exists() 18 | assert (dst_path / "file1.py").read_text() == 'print("belay test file for downloading.")\n' 19 | assert (dst_path / "file2.txt").read_text() == "File for testing non-python downloads.\n" 20 | assert (dst_path / "submodule" / "__init__.py").exists() 21 | assert (dst_path / "submodule" / "sub1.py").read_text() == 'foo = "testing recursive download abilities."\n' 22 | 23 | 24 | @pytest.mark.network 25 | def test_download_github_single(tmp_path): 26 | uri = "https://github.com/BrianPugh/belay/blob/main/tests/github_download_folder/file1.py" 27 | parsed = GitProviderUrl.parse(uri) 28 | assert isinstance(parsed, GitHubUrl) 29 | parsed.download(tmp_path) 30 | 31 | assert (tmp_path / "file1.py").read_text() == 'print("belay test file for downloading.")\n' 32 | 33 | 34 | @pytest.mark.network 35 | def test_download_github_shorthand_single(tmp_path): 36 | """Test downloading a single file using github: shorthand.""" 37 | uri = "github:BrianPugh/belay/tests/github_download_folder/file1.py@main" 38 | parsed = GitProviderUrl.parse(uri) 39 | assert isinstance(parsed, GitHubUrl) 40 | parsed.download(tmp_path) 41 | 42 | assert (tmp_path / "file1.py").read_text() == 'print("belay test file for downloading.")\n' 43 | 44 | 45 | @pytest.mark.network 46 | def test_download_github_shorthand_folder(mocker, tmp_path): 47 | """Test downloading a folder using github: shorthand.""" 48 | mocker.patch("belay.project.find_cache_folder", return_value=tmp_path / ".belay-cache") 49 | 50 | dst_path = tmp_path / "dst" 51 | 52 | uri = "github:BrianPugh/belay/tests/github_download_folder@main" 53 | parsed = GitProviderUrl.parse(uri) 54 | assert isinstance(parsed, GitHubUrl) 55 | parsed.download(dst_path) 56 | 57 | assert (dst_path / "__init__.py").exists() 58 | assert (dst_path / "file1.py").read_text() == 'print("belay test file for downloading.")\n' 59 | assert (dst_path / "file2.txt").read_text() == "File for testing non-python downloads.\n" 60 | -------------------------------------------------------------------------------- /belay/cli/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess # nosec 4 | import sys 5 | from tempfile import TemporaryDirectory 6 | from typing import Annotated 7 | 8 | from cyclopts import App, Parameter 9 | 10 | import belay 11 | from belay.cli.add import add 12 | from belay.cli.cache import app as cache_app 13 | from belay.cli.clean import clean 14 | from belay.cli.exec import exec 15 | from belay.cli.info import info 16 | from belay.cli.install import install 17 | from belay.cli.latency import latency 18 | from belay.cli.new import new 19 | from belay.cli.run import run 20 | from belay.cli.select import select 21 | from belay.cli.sync import sync 22 | from belay.cli.terminal import terminal 23 | from belay.cli.update import update 24 | from belay.project import load_groups 25 | 26 | app = App(version_flags=("--version", "-v"), help_format="markdown") 27 | app.command(add) 28 | app.command(cache_app, name="cache") 29 | app.command(clean) 30 | app.command(exec) 31 | app.command(info) 32 | app.command(install) 33 | app.command(latency) 34 | app.command(new, alias="init") 35 | app.command(run) 36 | app.command(select) 37 | app.command(sync) 38 | app.command(terminal) 39 | app.command(update) 40 | 41 | 42 | def run_exec(command: list[str]): 43 | """Enable virtual-environment and run command.""" 44 | groups = load_groups() 45 | virtual_env = os.environ.copy() 46 | # Add all dependency groups to the micropython path. 47 | # This flattens all dependencies to a single folder and fetches fresh 48 | # copies of dependencies in `develop` mode. 49 | with TemporaryDirectory() as tmp_dir: 50 | virtual_env["MICROPYPATH"] = f".:{tmp_dir}" 51 | for group in groups: 52 | group.copy_to(tmp_dir) 53 | 54 | try: 55 | subprocess.run( # nosec 56 | command, 57 | env=virtual_env, 58 | check=True, 59 | ) 60 | except subprocess.CalledProcessError as e: 61 | sys.exit(e.returncode) 62 | 63 | 64 | def _get(indexable, index, default=None): 65 | try: 66 | return indexable[index] 67 | except IndexError: 68 | return default 69 | 70 | 71 | def run_app(*args, **kwargs): 72 | """Add CLI hacks that are not Cyclopts-friendly here.""" 73 | command = _get(sys.argv, 1) 74 | if command == "run": 75 | try: 76 | exec_path = shutil.which(sys.argv[2]) 77 | except IndexError: 78 | exec_path = None 79 | if exec_path is not None: 80 | run_exec(sys.argv[2:]) 81 | else: 82 | app(*args, **kwargs) 83 | else: 84 | # Common-case; use Cyclopts functionality. 85 | app(*args, **kwargs) 86 | -------------------------------------------------------------------------------- /tests/packagemanager/test_group.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import belay.packagemanager 4 | from belay.packagemanager.group import Group, _verify_files 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def tmp_path_find_dependencies_folder(tmp_path, mocker): 9 | dependencies_folder = tmp_path / ".belay" / "dependencies" 10 | dependencies_folder.mkdir(parents=True) 11 | mocker.patch( 12 | "belay.project.find_dependencies_folder", 13 | return_value=dependencies_folder, 14 | ) 15 | 16 | 17 | @pytest.fixture 18 | def spy_ast(mocker): 19 | return mocker.spy(belay.packagemanager.group, "ast") 20 | 21 | 22 | @pytest.fixture 23 | def main_group(tmp_path): 24 | foo_path = tmp_path / "foo_url" / "foo.py" 25 | foo_path.parent.mkdir(parents=True) 26 | foo_path.write_text("def foo(): return 0") 27 | 28 | bar_path = tmp_path / "bar_url" / "bar.py" 29 | bar_path.parent.mkdir(parents=True) 30 | bar_path.write_text("def bar(): return 1") 31 | 32 | return Group( 33 | name="main", 34 | dependencies={ 35 | "foo": str(foo_path), 36 | "bar": str(bar_path), 37 | }, 38 | ) 39 | 40 | 41 | def test_download_all(main_group, mocker, spy_ast): 42 | main_group.download() 43 | 44 | assert spy_ast.parse.mock_calls == [ 45 | mocker.call("def foo(): return 0"), 46 | mocker.call("def bar(): return 1"), 47 | ] 48 | 49 | actual_content = (main_group.folder / "foo" / "__init__.py").read_text() 50 | assert actual_content == "def foo(): return 0" 51 | 52 | actual_content = (main_group.folder / "bar" / "__init__.py").read_text() 53 | assert actual_content == "def bar(): return 1" 54 | 55 | 56 | def test_download_specific(main_group, spy_ast): 57 | main_group.download(packages=["bar"]) 58 | 59 | spy_ast.parse.assert_called_once_with("def bar(): return 1") 60 | 61 | actual_content = (main_group.folder / "bar" / "__init__.py").read_text() 62 | assert actual_content == "def bar(): return 1" 63 | 64 | 65 | def test_group_clean(main_group): 66 | main_group.folder.mkdir() 67 | (main_group.folder / "foo").mkdir() 68 | (main_group.folder / "baz").mkdir() 69 | 70 | main_group.clean() 71 | 72 | assert (main_group.folder / "foo").exists() 73 | assert not (main_group.folder / "baz").exists() 74 | 75 | 76 | def test_verify_files_micropython_viper(tmp_path): 77 | code_path = tmp_path / "code.py" 78 | code_path.write_text( 79 | """ 80 | @micropython.viper 81 | def foo(self, arg: int) -> int: 82 | buf = ptr8(self.linebuf) # self.linebuf is a bytearray or bytes object 83 | for x in range(20, 30): 84 | bar = buf[x] # Access a data item through the pointer 85 | """ 86 | ) 87 | _verify_files(code_path) 88 | -------------------------------------------------------------------------------- /belay/cli/latency.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import statistics 3 | import time 4 | from pathlib import Path 5 | from typing import Annotated, Optional 6 | 7 | from cyclopts import Parameter 8 | 9 | from belay import Device 10 | from belay.cli.common import PasswordStr, PortStr 11 | 12 | 13 | def latency( 14 | port: PortStr, 15 | *, 16 | password: PasswordStr = "", 17 | count: Annotated[int, Parameter(alias="-c")] = 10, 18 | verbose: Annotated[bool, Parameter(alias="-v")] = False, 19 | output: Annotated[Optional[Path], Parameter(alias="-o")] = None, 20 | with_timing: Annotated[bool, Parameter(alias="-t", negative=("--no-with-timing", "--without-timing"))] = True, 21 | ): 22 | """Measure round-trip latency between host and device. 23 | 24 | Performs multiple round-trip measurements and reports statistics. 25 | 26 | Parameters 27 | ---------- 28 | count : int 29 | Number of round-trip measurements to perform. 30 | verbose : bool 31 | Show individual measurements in addition to statistics. 32 | output : Path, optional 33 | Export individual latency measurements to a CSV file. 34 | with_timing: bool 35 | With the additional per-call time synchronization logic. 36 | """ 37 | device = Device(port, password=password, auto_sync_time=with_timing) 38 | 39 | latencies = [] 40 | if verbose: 41 | print(f"Measuring latency with {count} iterations...") 42 | 43 | for i in range(count): 44 | start = time.perf_counter() 45 | device("0") # Short, no-op statement 46 | end = time.perf_counter() 47 | latency_ms = (end - start) * 1000 48 | latencies.append(latency_ms) 49 | if verbose: 50 | print(f" {i + 1:2d}: {latency_ms:6.2f} ms") 51 | 52 | # Calculate statistics 53 | min_latency = min(latencies) 54 | max_latency = max(latencies) 55 | avg_latency = statistics.mean(latencies) 56 | median_latency = statistics.median(latencies) 57 | stdev_latency = statistics.stdev(latencies) if len(latencies) > 1 else 0.0 58 | 59 | if verbose: 60 | print() 61 | print(f"Statistics ({count} samples):") 62 | print(f" Min: {min_latency:6.2f} ms") 63 | print(f" Max: {max_latency:6.2f} ms") 64 | print(f" Average: {avg_latency:6.2f} ms") 65 | print(f" Median: {median_latency:6.2f} ms") 66 | print(f" Std Dev: {stdev_latency:6.2f} ms") 67 | 68 | # Export to CSV if requested 69 | if output is not None: 70 | with output.open("w", newline="") as csvfile: 71 | writer = csv.writer(csvfile) 72 | writer.writerow(["latency_ms"]) 73 | for latency_ms in latencies: 74 | writer.writerow([f"{latency_ms:.2f}"]) 75 | 76 | device.close() 77 | -------------------------------------------------------------------------------- /tests/test_project.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from belay.packagemanager import Group 5 | from belay.project import find_pyproject, load_groups, load_pyproject, load_toml 6 | 7 | 8 | @pytest.fixture 9 | def toml_file_standard(tmp_path): 10 | fn = tmp_path / "pyproject.toml" 11 | fn.write_text( 12 | """ 13 | [tool.belay] 14 | name = "bar" 15 | """ 16 | ) 17 | return fn 18 | 19 | 20 | def test_load_toml_standard(toml_file_standard): 21 | actual = load_toml(toml_file_standard) 22 | assert actual == {"name": "bar"} 23 | 24 | 25 | def test_find_pyproject_parents(tmp_cwd, toml_file_standard, monkeypatch): 26 | fn = tmp_cwd / "folder1" / "folder2" / "folder3" / "pyproject.toml" 27 | fn.parent.mkdir(exist_ok=True, parents=True) 28 | monkeypatch.chdir(fn.parent) 29 | 30 | actual = find_pyproject() 31 | assert actual == toml_file_standard 32 | 33 | actual = load_pyproject() 34 | assert actual.name == "bar" 35 | 36 | 37 | def test_load_toml_no_belay_section(tmp_path): 38 | fn = tmp_path / "pyproject.toml" 39 | fn.write_text( 40 | """ 41 | [not_belay] 42 | foo = "bar" 43 | """ 44 | ) 45 | actual = load_toml(fn) 46 | assert not actual 47 | 48 | 49 | @pytest.fixture 50 | def mock_load_toml(mocker): 51 | return mocker.patch("belay.project.load_toml") 52 | 53 | 54 | def test_load_dependency_groups_empty(mock_load_toml): 55 | mock_load_toml.return_value = {} 56 | assert load_groups() == [Group("main")] 57 | 58 | 59 | def test_load_dependency_groups_main_only(mock_load_toml): 60 | mock_load_toml.return_value = { 61 | "dependencies": {"foo": "foo_uri"}, 62 | } 63 | assert load_groups() == [ 64 | Group("main", dependencies={"foo": "foo_uri"}), 65 | ] 66 | 67 | 68 | def test_load_dependency_groups_main_group(mock_load_toml): 69 | mock_load_toml.return_value = { 70 | "group": { 71 | "main": { 72 | "dependencies": { 73 | "foo": "foo_uri", 74 | } 75 | }, 76 | }, 77 | } 78 | with pytest.raises(ValidationError): 79 | load_groups() 80 | 81 | 82 | def test_load_dependency_groups_multiple(mock_load_toml): 83 | mock_load_toml.return_value = { 84 | "dependencies": {"foo": "foo_uri"}, 85 | "group": { 86 | "dev": { 87 | "dependencies": { 88 | "bar": "bar_uri", 89 | } 90 | }, 91 | "doc": {}, # This group doesn't have a "dependencies" field. 92 | }, 93 | } 94 | assert load_groups() == [ 95 | Group("dev", dependencies={"bar": "bar_uri"}), 96 | Group("doc", dependencies={}), 97 | Group("main", dependencies={"foo": "foo_uri"}), 98 | ] 99 | -------------------------------------------------------------------------------- /tests/packagemanager/downloaders/test_gitlab.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from belay.packagemanager.downloaders import GitLabUrl, GitProviderUrl 4 | 5 | 6 | @pytest.mark.network 7 | def test_download_gitlab_single(tmp_path): 8 | """Test downloading a single file from GitLab using full URL.""" 9 | uri = "https://gitlab.com/pages/plain-html/-/blob/main/public/index.html" 10 | parsed = GitProviderUrl.parse(uri) 11 | assert isinstance(parsed, GitLabUrl) 12 | parsed.download(tmp_path) 13 | 14 | content = (tmp_path / "index.html").read_text() 15 | assert "" in content 16 | assert "Hello World!" in content 17 | 18 | 19 | @pytest.mark.network 20 | def test_download_gitlab_single_raw(tmp_path): 21 | """Test downloading a single file from GitLab using raw URL.""" 22 | uri = "https://gitlab.com/pages/plain-html/-/raw/main/public/index.html" 23 | parsed = GitProviderUrl.parse(uri) 24 | assert isinstance(parsed, GitLabUrl) 25 | parsed.download(tmp_path) 26 | 27 | content = (tmp_path / "index.html").read_text() 28 | assert "" in content 29 | assert "Hello World!" in content 30 | 31 | 32 | @pytest.mark.network 33 | def test_download_gitlab_folder(mocker, tmp_path): 34 | """Test downloading a folder from GitLab using full URL.""" 35 | mocker.patch("belay.project.find_cache_folder", return_value=tmp_path / ".belay-cache") 36 | 37 | dst_path = tmp_path / "dst" 38 | 39 | uri = "https://gitlab.com/pages/plain-html/-/tree/main/public" 40 | parsed = GitProviderUrl.parse(uri) 41 | assert isinstance(parsed, GitLabUrl) 42 | parsed.download(dst_path) 43 | 44 | assert (dst_path / "index.html").exists() 45 | content = (dst_path / "index.html").read_text() 46 | assert "Hello World!" in content 47 | 48 | 49 | @pytest.mark.network 50 | def test_download_gitlab_shorthand_single(tmp_path): 51 | """Test downloading a single file using gitlab: shorthand.""" 52 | uri = "gitlab:pages/plain-html/public/index.html@main" 53 | parsed = GitProviderUrl.parse(uri) 54 | assert isinstance(parsed, GitLabUrl) 55 | parsed.download(tmp_path) 56 | 57 | content = (tmp_path / "index.html").read_text() 58 | assert "" in content 59 | assert "Hello World!" in content 60 | 61 | 62 | @pytest.mark.network 63 | def test_download_gitlab_shorthand_folder(mocker, tmp_path): 64 | """Test downloading a folder using gitlab: shorthand.""" 65 | mocker.patch("belay.project.find_cache_folder", return_value=tmp_path / ".belay-cache") 66 | 67 | dst_path = tmp_path / "dst" 68 | 69 | uri = "gitlab:pages/plain-html/public@main" 70 | parsed = GitProviderUrl.parse(uri) 71 | assert isinstance(parsed, GitLabUrl) 72 | parsed.download(dst_path) 73 | 74 | assert (dst_path / "index.html").exists() 75 | content = (dst_path / "index.html").read_text() 76 | assert "Hello World!" in content 77 | -------------------------------------------------------------------------------- /belay/usb_specifier.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | from serial.tools.list_ports import comports 5 | 6 | from .exceptions import DeviceNotFoundError, InsufficientSpecifierError 7 | 8 | 9 | def _normalize(val): 10 | """Normalize ``val`` for comparison.""" 11 | if isinstance(val, str): 12 | return val.casefold() 13 | return val 14 | 15 | 16 | def _dict_is_subset(subset: dict, superset: dict) -> bool: 17 | """Tests if ``subset`` dictionary is a subset of ``superset`` dictionary.""" 18 | for subset_key, subset_value in subset.items(): 19 | try: 20 | superset_value = superset[subset_key] 21 | except KeyError: 22 | return False 23 | 24 | if _normalize(subset_value) != _normalize(superset_value): 25 | return False 26 | return True 27 | 28 | 29 | class UsbSpecifier(BaseModel): 30 | """Usb port metadata.""" 31 | 32 | vid: Optional[int] = None 33 | pid: Optional[int] = None 34 | serial_number: Optional[str] = None 35 | manufacturer: Optional[str] = None 36 | product: Optional[str] = None 37 | location: Optional[str] = None 38 | 39 | device: Optional[str] = Field(None, exclude=True) 40 | 41 | def __repr__(self): 42 | return f'{self.__class__.__name__}({", ".join(f"{k}={v!r}" for k, v in self.model_dump().items() if v is not None)})' 43 | 44 | def to_port(self) -> str: 45 | if self.device: 46 | return self.device 47 | 48 | spec = self.model_dump(exclude_none=True) 49 | possible_matches = [] 50 | 51 | for port_info in list_devices(): 52 | if _dict_is_subset(spec, vars(port_info)): 53 | possible_matches.append(port_info) 54 | if not possible_matches: 55 | raise DeviceNotFoundError 56 | elif len(possible_matches) > 1: 57 | message = "Multiple potential devices found:\n" + "\n".join(f" {vars(x)}" for x in possible_matches) 58 | raise InsufficientSpecifierError(message) 59 | 60 | return possible_matches[0].device 61 | 62 | def populated(self): 63 | # some ports, like wlan and bluetooth on macos, 64 | # don't populate any meaningful fields. 65 | return bool(self.model_dump(exclude_none=True)) 66 | 67 | 68 | def list_devices() -> list[UsbSpecifier]: 69 | """Lists available device ports. 70 | 71 | Returns 72 | ------- 73 | List[UsbSpecifier] 74 | Available devices identifiers. 75 | """ 76 | devices = [ 77 | UsbSpecifier( 78 | vid=port.vid, 79 | pid=port.pid, 80 | serial_number=port.serial_number, 81 | manufacturer=port.manufacturer, 82 | product=port.product, 83 | location=port.location, 84 | device=port.device, 85 | ) 86 | for port in comports() 87 | ] 88 | return [x for x in devices if x.populated()] 89 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | ## Environment Setup 2 | 3 | 1. We use [Poetry](https://python-poetry.org/docs/#installation) for managing virtual environments and dependencies. 4 | Once Poetry is installed, run `poetry install` in this repo to get started. 5 | 2. For managing linters, static-analysis, and other tools, we use [pre-commit](https://pre-commit.com/#installation). 6 | Once Pre-commit is installed, run `pre-commit install` in this repo to install the hooks. 7 | Using pre-commit ensures PRs match the linting requirements of the codebase. 8 | 9 | ## Documentation 10 | Whenever possible, please add docstrings to your code! 11 | We use [numpy-style napoleon docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/#google-vs-numpy). 12 | To confirm docstrings are valid, build the docs by running `poetry run make html` in the `docs/` folder. 13 | 14 | I typically write dosctrings first, it will act as a guide to limit scope and encourage unit-testable code. 15 | Good docstrings include information like: 16 | 17 | 1. If not immediately obvious, what is the intended use-case? When should this function be used? 18 | 2. What happens during errors/edge-cases. 19 | 3. When dealing with physical values, include units. 20 | 21 | ## Unit Tests 22 | We use the [pytest](https://docs.pytest.org/) framework for unit testing. Ideally, all new code is partners with 23 | new unit tests to exercise that code. If fixing a bug, consider writing the test first to confirm the existence of the 24 | bug, and to confirm that the new code fixes it. 25 | 26 | Unit tests should only test a single concise body of code. If this is hard to do, there are two solutions that can help: 27 | 1. Restructure the code. Keep inputs/outputs to be simple variables. Avoid complicated interactions with state. 28 | 2. Use [pytest-mock](https://pytest-mock.readthedocs.io/en/latest/) to mock out external interactions. 29 | 30 | ## Coding Style 31 | In an attempt to keep consistency and maintainability in the code-base, here are some high-level guidelines for code that might not be enforced by linters. 32 | 33 | * Use f-strings. 34 | * Keep/cast path variables as `pathlib.Path` objects. 35 | Do not use `os.path`. 36 | For public-facing functions, cast path arguments immediately to `Path`. 37 | * Use magic-methods when appropriate. It might be better to implement ``MyClass.__call__()`` instead of ``MyClass.run()``. 38 | * Do not return sentinel values for error-states like `-1` or `None`. Instead, raise an exception. 39 | * Avoid deeply nested code. Techniques like returning early and breaking up a complicated function into multiple functions results in easier to read and test code. 40 | * Consider if you are double-name-spacing and how modules are meant to be imported. 41 | E.g. it might be better to name a function `read` instead of `image_read` in the module `my_package/image.py`. 42 | Consider the module name-space and whether or not it's flattened in `__init__.py`. 43 | * Only use multiple-inheritance if using a mixin. Mixin classes should end in `"Mixin"`. 44 | -------------------------------------------------------------------------------- /tests/test_minify.py: -------------------------------------------------------------------------------- 1 | from belay._minify import minify 2 | 3 | expected = """def foo(): 4 | if True: 5 | 0 6 | """ 7 | 8 | 9 | def test_minify_simple(): 10 | res = minify( 11 | """def foo(): 12 | if True: 13 | pass 14 | """ 15 | ) 16 | assert res == expected 17 | 18 | 19 | def test_minify_leading_indent(): 20 | res = minify( 21 | """ def foo(): 22 | if True: 23 | pass 24 | """ 25 | ) 26 | assert res == expected 27 | 28 | 29 | def test_minify_remove_inline_comments(): 30 | res = minify( 31 | """def foo(): 32 | if True: # This is a comment 33 | pass 34 | """ 35 | ) 36 | assert res == expected 37 | 38 | 39 | def test_minify_remove_whole_line_comments(): 40 | res = minify( 41 | """def foo(): 42 | # This is a comment 43 | if True: 44 | pass 45 | """ 46 | ) 47 | expected = """def foo(): 48 | 49 | if True: 50 | 0 51 | """ 52 | 53 | assert res == expected 54 | 55 | 56 | def test_minify_docstring(): 57 | res = minify( 58 | """def foo(): 59 | ''' 60 | This is a multiline docstring 61 | ''' 62 | if True: 63 | return "this is a literal" 64 | """ 65 | ) 66 | expected = """def foo(): 67 | 0 68 | 69 | 70 | if True: 71 | return "this is a literal" 72 | """ 73 | 74 | assert res == expected 75 | 76 | 77 | def test_minify_leading_indent_docstring(): 78 | res = minify( 79 | """ def foo(): 80 | ''' 81 | This is a multiline docstring 82 | ''' 83 | if True: 84 | return "this is a literal" 85 | """ 86 | ) 87 | expected = """def foo(): 88 | 0 89 | 90 | 91 | if True: 92 | return "this is a literal" 93 | """ 94 | 95 | assert res == expected 96 | 97 | 98 | def test_minify_ops(): 99 | res = minify( 100 | """def foo(): 101 | bar = 5 * 6 @ 1 - 2 102 | if bar <= 6: 103 | baz = True 104 | return "test test" 105 | """ 106 | ) 107 | expected = """def foo(): 108 | bar=5*6@1-2 109 | if bar<=6: 110 | baz=True 111 | return "test test" 112 | """ 113 | assert res == expected 114 | 115 | 116 | def test_minify_newline_pass_0(): 117 | """Replicated issue https://github.com/BrianPugh/belay/issues/167.""" 118 | res = minify( 119 | """ 120 | print(src_code) 121 | l1 =[ 122 | 'foo','bar'] 123 | l2 =['foo','bar'] 124 | l3 =['foo', 125 | 'bar'] 126 | l4 =['foo', 127 | 'bar', 128 | 'baz'] 129 | l5 =[ 130 | 'foo','foo', 131 | 'bar','bar', 132 | 'baz','baz'] 133 | print ('l1',l1 ) 134 | print ('l2',l2 ) 135 | print ('l3',l3 ) 136 | print ('l4',l4 ) 137 | print ('l5',l5 ) 138 | """ 139 | ) 140 | expected = """ 141 | print(src_code) 142 | l1=[ 143 | 'foo','bar'] 144 | l2=['foo','bar'] 145 | l3=['foo', 146 | 'bar'] 147 | l4=['foo', 148 | 'bar', 149 | 'baz'] 150 | l5=[ 151 | 'foo','foo', 152 | 'bar','bar', 153 | 'baz','baz'] 154 | print('l1',l1) 155 | print('l2',l2) 156 | print('l3',l3) 157 | print('l4',l4) 158 | print('l5',l5) 159 | """ 160 | assert res == expected 161 | 162 | 163 | def test_minify_leading_string_expression(): 164 | res = minify("'b' in foo") 165 | assert res == "'b' in foo" 166 | -------------------------------------------------------------------------------- /tests/integration/test_function_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import belay 4 | from belay import Device 5 | 6 | 7 | def test_setup_basic(emulated_device): 8 | @emulated_device.setup 9 | def setup(config): 10 | foo = config["bar"] # noqa: F841 11 | 12 | setup({"bar": 25}) 13 | 14 | assert emulated_device("config") == {"bar": 25} 15 | assert emulated_device("foo") == 25 16 | 17 | 18 | def test_task_basic(emulated_device, mocker): 19 | spy_parse_belay_response = mocker.spy(belay.device, "parse_belay_response") 20 | 21 | @emulated_device.task 22 | def foo(val): 23 | return 2 * val 24 | 25 | @emulated_device.task 26 | def bytes_task(): 27 | return b"\x00\x01\x02" 28 | 29 | assert foo(5) == 10 30 | 31 | # Response format is _BELAYR|{time}|{value} 32 | call_args = spy_parse_belay_response.call_args[0][0] 33 | assert call_args.startswith("_BELAYR|") 34 | assert "|10\r\n" in call_args 35 | 36 | assert bytes_task() == b"\x00\x01\x02" 37 | 38 | 39 | def test_task_basic_trusted(emulated_device, mocker): 40 | @emulated_device.task(trusted=True) 41 | def foo(): 42 | return bytearray(b"\x01") 43 | 44 | res = foo() 45 | assert isinstance(res, bytearray) 46 | assert res == bytearray(b"\x01") 47 | 48 | 49 | def test_task_generators_basic(emulated_device, mocker): 50 | spy_parse_belay_response = mocker.spy(belay.device, "parse_belay_response") 51 | 52 | @emulated_device.task 53 | def my_gen(val): 54 | i = 0 55 | while True: 56 | yield i 57 | i += 1 58 | if i == val: 59 | break 60 | 61 | actual = list(my_gen(3)) 62 | assert actual == [0, 1, 2] 63 | # Check that we got calls with the expected values (timestamp format will vary) 64 | calls = [str(call) for call in spy_parse_belay_response.call_args_list] 65 | assert any("|0\\r\\n" in call for call in calls) 66 | assert any("|1\\r\\n" in call for call in calls) 67 | assert any("|2\\r\\n" in call for call in calls) 68 | 69 | 70 | def test_task_generators_communicate(emulated_device): 71 | @emulated_device.task 72 | def my_gen(x): 73 | x = yield x 74 | x = yield x 75 | 76 | generator = my_gen(5) 77 | actual = [] 78 | actual.append(generator.send(None)) 79 | actual.append(generator.send(25)) 80 | with pytest.raises(StopIteration): 81 | generator.send(50) 82 | assert actual == [5, 25] 83 | 84 | 85 | def test_teardown(emulated_device, mocker): 86 | @emulated_device.teardown 87 | def foo(): 88 | pass 89 | 90 | mock_teardown = mocker.MagicMock() 91 | assert len(emulated_device._belay_teardown._belay_executers) == 1 92 | emulated_device._belay_teardown._belay_executers[0] = mock_teardown 93 | 94 | emulated_device.close() 95 | 96 | mock_teardown.assert_called_once() 97 | 98 | 99 | def test_classdecorator_setup(): 100 | @Device.setup 101 | def foo1(): 102 | pass 103 | 104 | @Device.setup() 105 | def foo2(): 106 | pass 107 | 108 | @Device.setup(autoinit=True) 109 | def foo3(): 110 | pass 111 | 112 | with pytest.raises(ValueError): 113 | # Provided an arg with autoinit=True is not allowed. 114 | 115 | @Device.setup(autoinit=True) 116 | def foo(arg1=1): 117 | pass 118 | -------------------------------------------------------------------------------- /belay/packagemanager/downloaders/common.py: -------------------------------------------------------------------------------- 1 | """Common download utilities.""" 2 | 3 | import shutil 4 | from pathlib import Path 5 | from urllib.parse import urlparse 6 | 7 | import fsspec 8 | 9 | from belay.packagemanager.downloaders.git import GitProviderUrl, InvalidGitUrlError 10 | from belay.typing import PathType 11 | 12 | 13 | class NonMatchingURI(Exception): # noqa: N818 14 | """Provided URI does not match downloading function.""" 15 | 16 | 17 | def _download_generic(dst: Path, uri: str) -> Path: 18 | """Downloads a single file or folder to ``dst / ``.""" 19 | parsed = urlparse(uri) 20 | 21 | if parsed.scheme in ("", "file"): 22 | # Local file, make it relative to project root 23 | uri_path = Path(uri) 24 | 25 | if not uri_path.is_absolute(): 26 | from belay.project import find_project_folder 27 | 28 | uri_path = find_project_folder() / uri 29 | 30 | uri = str(uri_path) 31 | 32 | if Path(uri).is_dir(): # local 33 | shutil.copytree(uri, dst, dirs_exist_ok=True) 34 | else: 35 | with fsspec.open(uri, "rb") as f: 36 | data = f.read() 37 | 38 | dst /= Path(uri).name 39 | with dst.open("wb") as f: 40 | f.write(data) 41 | 42 | return dst 43 | 44 | 45 | def download_uri(dst_folder: PathType, uri: str) -> Path: 46 | """Download ``uri`` to destination folder. 47 | 48 | Tries providers in order: 49 | 1. Git providers (GitHub, GitLab) via GitProviderUrl 50 | 2. Package.json packages (mip:, github:user/repo without path) 51 | 3. Generic download (local files, http URLs) 52 | 53 | Parameters 54 | ---------- 55 | dst_folder 56 | Destination folder. 57 | uri 58 | URI to download. 59 | 60 | Returns 61 | ------- 62 | Path 63 | Path to downloaded content. 64 | """ 65 | dst_folder = Path(dst_folder) 66 | 67 | # Try git providers for single file downloads 68 | try: 69 | parsed = GitProviderUrl.parse(uri) 70 | if parsed.has_file_extension(): 71 | # Has a file extension - download as single file 72 | return parsed.download(dst_folder) 73 | except InvalidGitUrlError: 74 | pass 75 | 76 | # Try package.json handler (for mip: and package references) 77 | from belay.packagemanager.downloaders._package_json import download_package_json 78 | 79 | # Load project config for package index settings (if pyproject.toml exists) 80 | try: 81 | from belay.project import load_pyproject 82 | 83 | config = load_pyproject() 84 | indices = config.package_indices 85 | mpy_version = config.mpy_version 86 | except FileNotFoundError: 87 | # No pyproject.toml found - use defaults (this is expected for standalone use) 88 | indices = None 89 | mpy_version = None 90 | except Exception: 91 | # Config parsing errors (TOML syntax, validation, etc.) - use defaults 92 | # rather than failing the download. The user will see errors when they 93 | # explicitly run config-dependent commands. 94 | indices = None 95 | mpy_version = None 96 | 97 | try: 98 | return download_package_json(dst_folder, uri, indices=indices, mpy_version=mpy_version) 99 | except NonMatchingURI: 100 | pass 101 | 102 | # Fall back to generic download 103 | return _download_generic(dst_folder, uri) 104 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import sys 10 | from datetime import date 11 | from pathlib import Path 12 | 13 | sys.path.insert(0, str(Path("../..").absolute())) 14 | 15 | 16 | from belay import __version__ 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "Belay" 21 | copyright = f"{date.today().year}, Brian Pugh" 22 | author = "Brian Pugh" 23 | 24 | # The short X.Y version. 25 | version = __version__ 26 | # The full version, including alpha/beta/rc tags 27 | release = __version__ 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx_rtd_theme", 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.napoleon", 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ["_templates"] 43 | 44 | # List of patterns, relative to source directory, that match files and 45 | # directories to ignore when looking for source files. 46 | # This pattern also affects html_static_path and html_extra_path. 47 | exclude_patterns = [] 48 | 49 | smartquotes = False 50 | add_module_names = False 51 | 52 | # Napoleon settings 53 | napoleon_google_docstring = True 54 | napoleon_numpy_docstring = True 55 | napoleon_include_init_with_doc = False 56 | napoleon_include_private_with_doc = False 57 | napoleon_include_special_with_doc = True 58 | napoleon_use_admonition_for_examples = False 59 | napoleon_use_admonition_for_notes = False 60 | napoleon_use_admonition_for_references = False 61 | napoleon_use_ivar = False 62 | napoleon_use_param = True 63 | napoleon_use_rtype = True 64 | napoleon_preprocess_types = False 65 | napoleon_type_aliases = None 66 | napoleon_attr_annotations = True 67 | 68 | # -- Options for HTML output ------------------------------------------------- 69 | 70 | # The theme to use for HTML and HTML Help pages. See the documentation for 71 | # a list of builtin themes. 72 | # 73 | html_theme = "sphinx_rtd_theme" 74 | 75 | # Add any paths that contain custom static files (such as style sheets) here, 76 | # relative to this directory. They are copied after the builtin static files, 77 | # so a file named "default.css" will overwrite the builtin "default.css". 78 | html_static_path = ["_static"] 79 | 80 | html_title = project 81 | html_logo = "../../assets/logo_white_400w.png" 82 | html_favicon = "../../assets/favicon.png" 83 | 84 | html_theme_options = { 85 | # "analytics_id": "G-XXXXXXXXXX", # Provided by Google in your dashboard 86 | # "analytics_anonymize_ip": False, 87 | "logo_only": True, 88 | "display_version": False, 89 | "prev_next_buttons_location": "bottom", 90 | "style_external_links": False, 91 | "vcs_pageview_mode": "", 92 | "style_nav_header_background": "white", 93 | # Toc options 94 | "collapse_navigation": True, 95 | "sticky_navigation": True, 96 | "navigation_depth": 4, 97 | "includehidden": True, 98 | "titles_only": False, 99 | } 100 | 101 | html_css_files = [ 102 | "custom.css", 103 | ] 104 | -------------------------------------------------------------------------------- /tests/cli/test_latency.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from pathlib import Path 3 | 4 | from belay.cli.main import app 5 | from tests.conftest import run_cli 6 | 7 | 8 | def test_latency_verbose(mocker, mock_device, capsys): 9 | mock_device.patch("belay.cli.latency.Device") 10 | exit_code = run_cli(app, ["latency", "/dev/ttyUSB0", "--password", "password", "--count", "3", "--verbose"]) 11 | assert exit_code == 0 12 | mock_device.cls.assert_called_once_with("/dev/ttyUSB0", password="password", auto_sync_time=True) 13 | captured = capsys.readouterr() 14 | # Check that we got output with the expected format 15 | assert "Measuring latency with 3 iterations" in captured.out 16 | assert "Statistics (3 samples)" in captured.out 17 | assert "Min:" in captured.out 18 | assert "Max:" in captured.out 19 | assert "Average:" in captured.out 20 | assert "Median:" in captured.out 21 | assert "Std Dev:" in captured.out 22 | # Should have 3 measurement lines 23 | assert captured.out.count(" ms\n") >= 8 # 3 measurements + 5 stats 24 | 25 | 26 | def test_latency_non_verbose(mocker, mock_device, capsys): 27 | mock_device.patch("belay.cli.latency.Device") 28 | exit_code = run_cli(app, ["latency", "/dev/ttyUSB0", "--password", "password", "--count", "3"]) 29 | assert exit_code == 0 30 | mock_device.cls.assert_called_once_with("/dev/ttyUSB0", password="password", auto_sync_time=True) 31 | captured = capsys.readouterr() 32 | # Should NOT show "Measuring latency" message in non-verbose mode 33 | assert "Measuring latency" not in captured.out 34 | # Should show statistics 35 | assert "Statistics (3 samples)" in captured.out 36 | assert "Min:" in captured.out 37 | assert "Max:" in captured.out 38 | assert "Average:" in captured.out 39 | assert "Median:" in captured.out 40 | assert "Std Dev:" in captured.out 41 | # Should only have 5 stats lines, no individual measurements 42 | assert captured.out.count(" ms\n") == 5 # Only 5 stats, no individual measurements 43 | 44 | 45 | def test_latency_default_count(mocker, mock_device, capsys): 46 | mock_device.patch("belay.cli.latency.Device") 47 | exit_code = run_cli(app, ["latency", "/dev/ttyUSB0", "--password", "password"]) 48 | assert exit_code == 0 49 | captured = capsys.readouterr() 50 | # Default count should be 10 51 | assert "Statistics (10 samples)" in captured.out 52 | # Should NOT show "Measuring latency" in non-verbose mode 53 | assert "Measuring latency" not in captured.out 54 | 55 | 56 | def test_latency_export_csv(mocker, mock_device, capsys, tmp_path): 57 | mock_device.patch("belay.cli.latency.Device") 58 | output_file = tmp_path / "latency.csv" 59 | exit_code = run_cli( 60 | app, ["latency", "/dev/ttyUSB0", "--password", "password", "--count", "5", "--output", str(output_file)] 61 | ) 62 | assert exit_code == 0 63 | mock_device.cls.assert_called_once_with("/dev/ttyUSB0", password="password", auto_sync_time=True) 64 | 65 | # Check that the file was created 66 | assert output_file.exists() 67 | 68 | # Read and verify CSV contents 69 | with output_file.open("r") as csvfile: 70 | reader = csv.reader(csvfile) 71 | rows = list(reader) 72 | 73 | # Should have header + 5 data rows 74 | assert len(rows) == 6 75 | assert rows[0] == ["latency_ms"] 76 | 77 | # Verify each row has correct format (just the latency value) 78 | for row in rows[1:]: 79 | assert len(row) == 1 # Only one column 80 | # Check that latency_ms is a valid float string 81 | assert float(row[0]) >= 0 82 | -------------------------------------------------------------------------------- /docs/source/Proxy Objects.rst: -------------------------------------------------------------------------------- 1 | Proxy Objects 2 | ============= 3 | 4 | :class:`~belay.ProxyObject` provides a way to interact with remote objects on your MicroPython or CircuitPython device as if they were local Python objects. This is useful for hardware peripherals, custom classes, or modules that cannot be serialized. 5 | 6 | Overview 7 | ^^^^^^^^ 8 | 9 | When executing code with :meth:`~belay.Device.task` or :meth:`~belay.Device.__call__`, return values must typically be serializable literals (numbers, strings, lists, dicts, etc.). :class:`~belay.ProxyObject` creates a transparent wrapper for non-serializable objects, forwarding operations to the device automatically. 10 | 11 | Creating Proxy Objects 12 | ^^^^^^^^^^^^^^^^^^^^^^^ 13 | 14 | Use :meth:`.Device.proxy` to create proxies: 15 | 16 | .. code-block:: python 17 | 18 | from belay import Device 19 | 20 | device = Device("/dev/ttyUSB0") 21 | 22 | # Create and proxy a remote object 23 | device("sensor = TemperatureSensor()") 24 | sensor = device.proxy("sensor") 25 | temp = sensor.temperature 26 | sensor.calibrate() 27 | 28 | # Import modules directly 29 | machine = device.proxy("import machine") 30 | pin = machine.Pin(25, machine.Pin.OUT) 31 | pin.on() 32 | 33 | Return Value Behavior 34 | ^^^^^^^^^^^^^^^^^^^^^^ 35 | 36 | Values are automatically returned directly or as proxies based on type: 37 | 38 | - **Immutable types** (bool, int, float, str, bytes, none) → **returned directly** 39 | - **Mutable types** (list, dict, custom objects) → **returned as** :class:`~belay.ProxyObject` 40 | 41 | .. code-block:: python 42 | 43 | temp = sensor.temperature # Returns float directly (e.g., 23.5) 44 | config = sensor.config # Returns ProxyObject wrapping dict 45 | threshold = config["threshold"] # Returns value directly 46 | 47 | Working with Collections 48 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 49 | 50 | Lists, dictionaries, and nested objects work transparently: 51 | 52 | .. code-block:: python 53 | 54 | # Lists 55 | device("data = [1, 2, 3, 4, 5]") 56 | data = device.proxy("data") 57 | print(data[0], data[-1]) # 1 5 58 | data[0] = 100 # Modify remotely 59 | print(len(data)) # 5 60 | for item in data: # Iterate 61 | print(item) 62 | 63 | # Dictionaries 64 | device("config = {'brightness': 10, 'mode': 'auto'}") 65 | config = device.proxy("config") 66 | brightness = config["brightness"] # 10 67 | config["brightness"] = 20 68 | if "mode" in config: 69 | print("Mode configured") 70 | 71 | Working with Methods and Modules 72 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 73 | 74 | Call methods and use modules naturally: 75 | 76 | .. code-block:: python 77 | 78 | # Methods 79 | device( 80 | """ 81 | class LED: 82 | def __init__(self, pin): 83 | self.state = False 84 | def on(self): 85 | self.state = True 86 | led = LED(25) 87 | """ 88 | ) 89 | led = device.proxy("led") 90 | led.on() 91 | print(led.state) # True 92 | 93 | # Modules 94 | machine = device.proxy("import machine") 95 | led_pin = machine.Pin(25, machine.Pin.OUT) # remotely creates an led_pin object 96 | led_pin.on() 97 | 98 | Memory Management 99 | ^^^^^^^^^^^^^^^^^ 100 | 101 | Proxies automatically delete remote references when garbage collected. Use ``delete=False`` to prevent this: 102 | 103 | .. code-block:: python 104 | 105 | # Auto-deletes micropython object when local object "temp" goes out-of-scope and garbage collected. 106 | temp = device.proxy("temp_obj").value 107 | 108 | # Never deletes micropython object 109 | machine = device.proxy("import machine", delete=False) 110 | -------------------------------------------------------------------------------- /belay/_minify.py: -------------------------------------------------------------------------------- 1 | import tokenize 2 | from collections import deque 3 | from io import StringIO 4 | from tokenize import COMMENT, DEDENT, INDENT, NAME, NEWLINE, OP, STRING 5 | 6 | _token_type_line_start = {NEWLINE, DEDENT, INDENT} 7 | 8 | 9 | def minify(code: str) -> str: 10 | """Minify python code. 11 | 12 | Naive code minifying that preserves names and linenos. Performs the following: 13 | 14 | * Removes docstrings. 15 | 16 | * Removes comments. 17 | 18 | * Removes unnecessary whitespace. 19 | 20 | Parameters 21 | ---------- 22 | code: str 23 | Python code to minify. 24 | 25 | Returns 26 | ------- 27 | str 28 | Minified code. 29 | """ 30 | out = [] 31 | last_lineno = -1 32 | last_col = 0 33 | prev_start_line = 0 34 | level = 0 35 | global_start_col = 0 36 | prev_token_types = deque([INDENT], maxlen=2) 37 | container_level = 0 38 | 39 | tokens = list(tokenize.generate_tokens(StringIO(code).readline)) 40 | for i, ( 41 | token_type, 42 | string, 43 | (start_line, start_col), 44 | (end_line, end_col), 45 | _, 46 | ) in enumerate(tokens): 47 | prev_token_types.append(token_type) 48 | prev_token_type = prev_token_types.popleft() 49 | 50 | if start_line > last_lineno: 51 | last_col = global_start_col 52 | 53 | if token_type == INDENT: 54 | if start_line == 1: 55 | # Whole code-block is indented. 56 | global_start_col = end_col 57 | else: 58 | level += 1 59 | continue 60 | elif token_type == DEDENT: 61 | level -= 1 62 | continue 63 | elif token_type == COMMENT: 64 | continue 65 | elif token_type == OP: 66 | if string in "([{": 67 | container_level += 1 68 | elif string in ")]}": 69 | container_level = max(0, container_level - 1) 70 | 71 | if ( 72 | token_type == STRING 73 | and container_level == 0 74 | and (prev_token_type in (NEWLINE, INDENT) or start_col == global_start_col) 75 | ): 76 | # Probably a docstring 77 | remove_docstring = True 78 | try: 79 | if tokens[i + 1].type == NAME: 80 | # prevents removing initial string in an expression like ``'b' in foo`` 81 | remove_docstring = False 82 | except IndexError: 83 | pass 84 | if remove_docstring: 85 | out.append(" " * level + "0" + "\n" * string.count("\n")) 86 | else: 87 | out.append(string) 88 | elif start_line > prev_start_line and token_type != NEWLINE: 89 | # First op of a line, needs its minimized indent 90 | out.append(" " * level) # Leading indent 91 | if string == "pass": 92 | out.append("0") # Shorter than a ``pass`` statement. 93 | else: 94 | out.append(string) 95 | elif token_type == OP and prev_token_type not in _token_type_line_start: 96 | # No need for a space before operators. 97 | out.append(string) 98 | elif start_col > last_col and token_type != NEWLINE: 99 | if prev_token_type != OP: 100 | out.append(" ") 101 | out.append(string) 102 | else: 103 | out.append(string) 104 | 105 | prev_start_line = start_line 106 | last_col = end_col 107 | last_lineno = end_line 108 | 109 | return "".join(out) 110 | -------------------------------------------------------------------------------- /belay/project.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import platform 3 | from pathlib import Path 4 | from typing import Callable, Optional, TypeVar, Union, overload 5 | 6 | import tomli 7 | 8 | from belay.packagemanager import BelayConfig, Group 9 | 10 | F = TypeVar("F", bound=Callable) 11 | 12 | 13 | class ProjectCache: 14 | """Decorator that caches functions and supports bulk cache clearing. 15 | 16 | Use this instead of ``@lru_cache`` for project config functions 17 | that need to be invalidated when pyproject.toml changes. 18 | """ 19 | 20 | def __init__(self) -> None: 21 | self._cached_functions: list[functools._lru_cache_wrapper] = [] 22 | 23 | @overload 24 | def __call__(self, func: F) -> F: ... 25 | 26 | @overload 27 | def __call__(self, func: None = None) -> "ProjectCache": ... 28 | 29 | def __call__(self, func: Optional[F] = None) -> Union[F, "ProjectCache"]: 30 | """Decorate a function with LRU caching and register it.""" 31 | if func is None: # Called as @project_cache() 32 | return self 33 | # Called as @project_cache 34 | cached_func = functools.lru_cache(func) 35 | self._cached_functions.append(cached_func) 36 | return cached_func # type: ignore[return-value] 37 | 38 | def clear(self) -> None: 39 | """Clear all registered caches.""" 40 | for func in self._cached_functions: 41 | func.cache_clear() 42 | 43 | 44 | project_cache = ProjectCache() 45 | 46 | 47 | @project_cache 48 | def find_pyproject() -> Path: 49 | path = Path("pyproject.toml").absolute() 50 | 51 | for parent in path.parents: 52 | candidate = parent / path.name 53 | if candidate.exists(): 54 | return candidate 55 | raise FileNotFoundError( 56 | f'Cannot find a pyproject.toml in the current directory "{Path().absolute()}" or any parent directory.' 57 | ) 58 | 59 | 60 | @project_cache 61 | def find_project_folder() -> Path: 62 | return find_pyproject().parent 63 | 64 | 65 | @project_cache 66 | def find_belay_folder() -> Path: 67 | return find_project_folder() / ".belay" 68 | 69 | 70 | @project_cache 71 | def find_dependencies_folder() -> Path: 72 | config = load_pyproject() 73 | return find_project_folder() / config.dependencies_path 74 | 75 | 76 | @project_cache 77 | def find_cache_folder() -> Path: 78 | system = platform.system() 79 | cache_folder = Path.home() 80 | 81 | if system == "Windows": 82 | cache_folder /= "AppData/Local/belay/Cache" 83 | elif system == "Darwin": 84 | cache_folder /= "Library/Caches/belay" 85 | else: 86 | cache_folder /= ".cache/belay" 87 | 88 | return cache_folder.absolute() 89 | 90 | 91 | @project_cache 92 | def find_cache_dependencies_folder() -> Path: 93 | return find_cache_folder() / "dependencies" 94 | 95 | 96 | @project_cache 97 | def load_toml(path: Union[str, Path]) -> dict: 98 | path = Path(path) 99 | with path.open("rb") as f: 100 | toml = tomli.load(f) 101 | 102 | try: 103 | toml = toml["tool"]["belay"] 104 | except KeyError: 105 | return {} 106 | 107 | return toml 108 | 109 | 110 | @project_cache 111 | def load_pyproject() -> BelayConfig: 112 | """Load the pyproject TOML file.""" 113 | pyproject_path = find_pyproject() 114 | belay_data = load_toml(pyproject_path) 115 | return BelayConfig(**belay_data) 116 | 117 | 118 | @project_cache 119 | def load_groups() -> list[Group]: 120 | config = load_pyproject() 121 | groups = [Group("main", dependencies=config.dependencies)] 122 | groups.extend(Group(name, **definition.model_dump()) for name, definition in config.group.items()) 123 | groups.sort(key=lambda x: x.name) 124 | return groups 125 | -------------------------------------------------------------------------------- /belay/device_sync_support.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | from typing import Optional, Union 4 | 5 | from pathspec import PathSpec 6 | from pathspec.util import append_dir_sep 7 | 8 | from ._minify import minify as minify_code 9 | from .hash import fnv1a 10 | from .typing import PathType 11 | 12 | 13 | def discover_files_dirs( 14 | remote_dir: str, 15 | local_file_or_folder: Path, 16 | ignore: Optional[list] = None, 17 | ): 18 | src_objects = [] 19 | if local_file_or_folder.is_dir(): 20 | if ignore is None: 21 | ignore = [] 22 | ignore_spec = PathSpec.from_lines("gitwildmatch", ignore) 23 | for src_object in local_file_or_folder.rglob("*"): 24 | if ignore_spec.match_file(append_dir_sep(src_object)): 25 | continue 26 | src_objects.append(src_object) 27 | # Sort so that folder creation comes before file sending. 28 | src_objects.sort() 29 | 30 | src_files, src_dirs = [], [] 31 | for src_object in src_objects: 32 | if src_object.is_dir(): 33 | src_dirs.append(src_object) 34 | else: 35 | src_files.append(src_object) 36 | dst_files = [remote_dir / src.relative_to(local_file_or_folder) for src in src_files] 37 | else: 38 | src_files = [local_file_or_folder] 39 | src_dirs = [] 40 | dst_files = [Path(remote_dir) / local_file_or_folder.name] 41 | 42 | return src_files, src_dirs, dst_files 43 | 44 | 45 | def preprocess_keep( 46 | keep: Union[None, list, str, bool], 47 | dst: str, 48 | ) -> list: 49 | if keep is None: 50 | keep = ["boot.py", "webrepl_cfg.py", "lib"] if dst == "/" else [] 51 | elif isinstance(keep, str): 52 | keep = [keep] 53 | elif isinstance(keep, (list, tuple)): 54 | pass 55 | elif isinstance(keep, bool): 56 | keep = [] 57 | else: 58 | raise TypeError 59 | keep = [(dst / Path(x)).as_posix() for x in keep] 60 | return keep 61 | 62 | 63 | def preprocess_ignore(ignore: Union[None, str, list, tuple]) -> list: 64 | if ignore is None: 65 | ignore = ["*.pyc", "__pycache__", ".DS_Store", ".pytest_cache"] 66 | elif isinstance(ignore, str): 67 | ignore = [ignore] 68 | elif isinstance(ignore, (list, tuple)): 69 | ignore = list(ignore) 70 | else: 71 | raise TypeError 72 | return ignore 73 | 74 | 75 | def preprocess_src_file( 76 | tmp_dir: PathType, 77 | src_file: PathType, 78 | minify: bool, 79 | mpy_cross_binary: Union[str, Path, None], 80 | ) -> Path: 81 | tmp_dir = Path(tmp_dir) 82 | src_file = Path(src_file) 83 | 84 | transformed = tmp_dir / src_file.relative_to(src_file.anchor) if src_file.is_absolute() else tmp_dir / src_file 85 | transformed.parent.mkdir(parents=True, exist_ok=True) 86 | 87 | if src_file.suffix == ".py": 88 | if mpy_cross_binary: 89 | transformed = transformed.with_suffix(".mpy") 90 | subprocess.check_output([mpy_cross_binary, "-o", transformed, src_file]) # nosec 91 | return transformed 92 | elif minify: 93 | minified = minify_code(src_file.read_text(encoding="utf-8")) 94 | transformed.write_text(minified) 95 | return transformed 96 | 97 | return src_file 98 | 99 | 100 | def preprocess_src_file_hash(*args, **kwargs): 101 | src_file = preprocess_src_file(*args, **kwargs) 102 | src_hash = fnv1a(src_file) 103 | return src_file, src_hash 104 | 105 | 106 | def generate_dst_dirs(dst, src, src_dirs) -> list: 107 | dst_dirs = [(dst / x.relative_to(src)).as_posix() for x in src_dirs] 108 | # Add all directories leading up to ``dst``. 109 | dst_prefix_tokens = dst.split("/") 110 | for i in range(2, len(dst_prefix_tokens) + (dst[-1] != "/")): 111 | dst_dirs.append("/".join(dst_prefix_tokens[:i])) 112 | dst_dirs.sort() 113 | return dst_dirs 114 | -------------------------------------------------------------------------------- /belay/device_meta.py: -------------------------------------------------------------------------------- 1 | """Metaclass to allow executer overloading. 2 | 3 | Inspired by mCoding example:: 4 | 5 | https://github.com/mCodingLLC/VideosSampleCode/blob/master/videos/077_metaclasses_in_python/overloading.py 6 | """ 7 | 8 | from autoregistry import RegistryMeta 9 | 10 | from .exceptions import NoMatchingExecuterError 11 | 12 | _MISSING = object() 13 | 14 | 15 | class OverloadList(list): 16 | """To separate user-lists from a list of overloaded methods.""" 17 | 18 | 19 | class OverloadDict(dict): 20 | """Dictionary where a key can be written to multiple times.""" 21 | 22 | def __setitem__(self, key, value): 23 | # Key: method name 24 | # Value: method 25 | 26 | if not isinstance(key, str): 27 | raise TypeError 28 | 29 | prior_val = self.get(key, _MISSING) 30 | method_metadata = getattr(value, "__belay__", False) 31 | 32 | if prior_val is _MISSING: 33 | # Register a new method name 34 | insert_val = OverloadList([value]) if method_metadata else value 35 | super().__setitem__(key, insert_val) 36 | elif isinstance(prior_val, OverloadList): 37 | # Add to a previously overloaded method. 38 | if not method_metadata: 39 | raise ValueError(f"Cannot mix non-executer and executer methods: {key}") 40 | 41 | # Check for a previous "catchall" method 42 | for f in prior_val: 43 | if not f.__belay__.implementation: 44 | raise ValueError(f"Cannot define another executor after catchall: {key}.") 45 | prior_val.append(value) 46 | else: 47 | # Overwrite a previous vanilla method 48 | if method_metadata: 49 | raise ValueError(f"Cannot mix non-executer and executer methods: {key}") 50 | super().__setitem__(key, value) 51 | 52 | 53 | class ExecuterMethod: 54 | def __set_name__(self, owner, name): 55 | self.owner = owner 56 | self.name = name 57 | 58 | def __init__(self, overload_list): 59 | if not isinstance(overload_list, OverloadList): 60 | raise TypeError("Must use OverloadList.") 61 | if not overload_list: 62 | raise ValueError("Empty OverloadList.") 63 | self.overload_list = overload_list 64 | 65 | def __repr__(self): 66 | return f"{self.__class__.__qualname__}({self.overload_list!r})" 67 | 68 | def __get__(self, instance, _owner=None): 69 | if instance is None: 70 | return self 71 | # Don't use owner == type(instance) 72 | # We want self.owner, which is the class from which get is being called 73 | for f in self.overload_list: 74 | imp = f.__belay__.implementation 75 | 76 | if not imp or imp == instance.implementation.name: 77 | return f 78 | 79 | # no matching overload in owner class, check next in line 80 | super_instance = super(self.owner, instance) 81 | super_call = getattr(super_instance, self.name, _MISSING) 82 | if super_call is not _MISSING: 83 | return super_call 84 | else: 85 | raise NoMatchingExecuterError() 86 | 87 | 88 | class DeviceMeta(RegistryMeta): 89 | @classmethod 90 | def __prepare__(cls, name, bases, **kwargs): 91 | return OverloadDict() 92 | 93 | def __new__(cls, name, bases, namespace, **kwargs): 94 | overload_namespace = { 95 | key: ExecuterMethod(val) if isinstance(val, OverloadList) else val for key, val in namespace.items() 96 | } 97 | output_cls = super().__new__(cls, name, bases, overload_namespace, **kwargs) 98 | return output_cls 99 | 100 | def mro(self): 101 | mro = super().mro() 102 | # Move ``Device`` to back, if it exists in the mro 103 | 104 | try: 105 | from belay.device import Device 106 | except ImportError: # circular import on first use 107 | return mro 108 | 109 | try: 110 | mro.remove(Device) 111 | except ValueError: # Device was not in ``bases`` 112 | return mro 113 | 114 | # Insert it right before ``object`` 115 | mro.insert(mro.index(object), Device) 116 | 117 | return mro 118 | -------------------------------------------------------------------------------- /belay/cli/install.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from functools import partial 3 | from pathlib import Path 4 | from tempfile import TemporaryDirectory 5 | from typing import Annotated, Optional 6 | 7 | from cyclopts import Parameter 8 | from rich.progress import Progress 9 | 10 | from belay import Device 11 | from belay.cli.common import PasswordStr, PortStr, remove_stacktrace 12 | from belay.cli.sync import sync_device as _sync_device 13 | from belay.project import find_project_folder, load_groups, load_pyproject 14 | 15 | 16 | def install( 17 | port: PortStr, 18 | *, 19 | password: PasswordStr = "", 20 | mpy_cross_binary: Optional[Path] = None, 21 | run: Optional[Path] = None, 22 | main: Optional[Path] = None, 23 | with_groups: Annotated[Optional[list[str]], Parameter(name="--with")] = None, 24 | follow: Annotated[bool, Parameter(alias="-f")] = False, 25 | ): 26 | """Sync dependencies and project itself to device. 27 | 28 | Parameters 29 | ---------- 30 | mpy_cross_binary : Optional[Path] 31 | Compile py files with this executable. 32 | run : Optional[Path] 33 | Run script on-device after installing. 34 | main : Optional[Path] 35 | Sync script to /main.py after installing. 36 | with_groups : Optional[list[str]] 37 | Include specified optional dependency group. 38 | follow : bool 39 | Follow the stdout after upload. 40 | """ 41 | with_groups = with_groups or [] 42 | if run and run.suffix != ".py": 43 | raise ValueError("Run script MUST be a python file.") 44 | if main and main.suffix != ".py": 45 | raise ValueError("Main script MUST be a python file.") 46 | 47 | config = load_pyproject() 48 | project_folder = find_project_folder() 49 | project_package = config.name 50 | groups = load_groups() 51 | 52 | with Device(port, password=password) as device: 53 | sync_device = partial( 54 | _sync_device, 55 | device, 56 | mpy_cross_binary=mpy_cross_binary, 57 | ) 58 | 59 | with TemporaryDirectory() as tmp_dir, Progress() as progress: 60 | tmp_dir = Path(tmp_dir) 61 | 62 | # Add all tasks to progress bar 63 | tasks = {} 64 | 65 | def create_task(key, task_description): 66 | task_id = progress.add_task(task_description) 67 | 68 | def progress_update(description=None, **kwargs): 69 | if description: 70 | description = task_description + description 71 | progress.update(task_id, description=description, **kwargs) 72 | 73 | tasks[key] = progress_update 74 | 75 | create_task("dependencies", "Dependencies: ") 76 | if project_package: 77 | create_task("project_package", f"{project_package}: ") 78 | if main: 79 | create_task("main", "main: ") 80 | 81 | # Aggregate dependencies to an intermediate temporary directory. 82 | for group in groups: 83 | if group.optional and group.name not in with_groups: 84 | continue 85 | group.copy_to(tmp_dir) 86 | 87 | sync_device( 88 | tmp_dir, 89 | dst="/lib", 90 | progress_update=tasks["dependencies"], 91 | ) 92 | 93 | if project_package: 94 | sync_device( 95 | folder=project_folder / project_package, 96 | dst=f"/{project_package}", 97 | progress_update=tasks["project_package"], 98 | ignore=config.ignore, 99 | ) 100 | 101 | if main: 102 | # Copy provided main to temporary directory in case it's not named main.py 103 | main_tmp = tmp_dir / "main.py" 104 | shutil.copy(main, main_tmp) 105 | sync_device(main_tmp, progress_update=tasks["main"]) 106 | 107 | if run: 108 | content = run.read_text(encoding="utf-8") 109 | with remove_stacktrace(): 110 | device(content) 111 | return 112 | 113 | # Reset device so `main.py` has a chance to execute. 114 | device.soft_reset() 115 | if follow: 116 | device.terminal() 117 | -------------------------------------------------------------------------------- /tools/update-fnv1a32.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script to update FNV1A32 native module files from GitHub releases. 3 | 4 | Downloads .mpy files from a specified release of micropython-fnv1a32 5 | and copies them to belay/nativemodule_fnv1a32/ with the correct naming convention. 6 | """ 7 | 8 | import argparse 9 | import shutil 10 | import tempfile 11 | from pathlib import Path 12 | from urllib.request import urlopen, urlretrieve 13 | 14 | 15 | def get_release_assets(version: str) -> list[dict]: 16 | """Get release assets from GitHub API.""" 17 | if not version.startswith("v"): 18 | version = f"v{version}" 19 | 20 | url = f"https://api.github.com/repos/BrianPugh/micropython-fnv1a32/releases/tags/{version}" 21 | 22 | try: 23 | with urlopen(url) as response: 24 | import json 25 | 26 | data = json.loads(response.read()) 27 | return data.get("assets", []) 28 | except Exception as e: 29 | print(f"Error fetching release data: {e}") 30 | return [] 31 | 32 | 33 | def download_and_extract_mpy_files(assets: list[dict], temp_dir: Path) -> list[Path]: 34 | """Download and extract .mpy files from release assets.""" 35 | mpy_files = [] 36 | 37 | for asset in assets: 38 | name = asset["name"] 39 | download_url = asset["browser_download_url"] 40 | 41 | if name.endswith(".mpy"): 42 | print(f"Downloading {name}...") 43 | file_path = temp_dir / name 44 | urlretrieve(download_url, file_path) 45 | mpy_files.append(file_path) 46 | 47 | return mpy_files 48 | 49 | 50 | def convert_filename(original_name: str) -> str: 51 | """Convert GitHub release filename to belay naming convention. 52 | 53 | From: fnv1a32-v2.1.0-mpy1.22-armv6m.mpy 54 | To: mpy1.22-armv6m.mpy 55 | """ 56 | parts = original_name.split("-") 57 | if len(parts) >= 4 and parts[0] == "fnv1a32": 58 | # Extract mpy version and architecture 59 | mpy_version = parts[2] # e.g., "mpy1.22" 60 | architecture = parts[3].replace(".mpy", "") # e.g., "armv6m" 61 | return f"{mpy_version}-{architecture}.mpy" 62 | 63 | return original_name 64 | 65 | 66 | def copy_files_to_destination(mpy_files: list[Path], dest_dir: Path): 67 | """Copy .mpy files to the destination directory with proper naming.""" 68 | dest_dir.mkdir(parents=True, exist_ok=True) 69 | 70 | for mpy_file in mpy_files: 71 | new_name = convert_filename(mpy_file.name) 72 | dest_path = dest_dir / new_name 73 | 74 | print(f"Copying {mpy_file.name} -> {new_name}") 75 | shutil.copy2(mpy_file, dest_path) 76 | 77 | 78 | def main(): 79 | parser = argparse.ArgumentParser(description="Update FNV1A32 native module files from GitHub releases") 80 | parser.add_argument("version", help="Release version to download (e.g., 'v2.1.0' or '2.1.0')") 81 | 82 | args = parser.parse_args() 83 | 84 | dest_dir = Path(__file__).parent.parent / "belay" / "nativemodule_fnv1a32" 85 | 86 | print(f"Fetching release information for version {args.version}...") 87 | assets = get_release_assets(args.version) 88 | 89 | if not assets: 90 | print("No assets found for this release or release does not exist.") 91 | return 1 92 | 93 | # Filter for .mpy files 94 | mpy_assets = [asset for asset in assets if asset["name"].endswith(".mpy")] 95 | 96 | if not mpy_assets: 97 | print("No .mpy files found in this release.") 98 | return 1 99 | 100 | print(f"Found {len(mpy_assets)} .mpy files:") 101 | for asset in mpy_assets: 102 | old_name = asset["name"] 103 | new_name = convert_filename(old_name) 104 | print(f" {old_name} -> {new_name}") 105 | 106 | with tempfile.TemporaryDirectory() as temp_dir: 107 | temp_path = Path(temp_dir) 108 | 109 | print("\nDownloading files to temporary directory...") 110 | mpy_files = download_and_extract_mpy_files(mpy_assets, temp_path) 111 | 112 | if mpy_files: 113 | print(f"\nCopying files to {dest_dir}...") 114 | copy_files_to_destination(mpy_files, dest_dir) 115 | print(f"\nSuccessfully updated {len(mpy_files)} .mpy files!") 116 | else: 117 | print("No files were downloaded.") 118 | return 1 119 | 120 | return 0 121 | 122 | 123 | if __name__ == "__main__": 124 | exit(main()) 125 | -------------------------------------------------------------------------------- /examples/12_time_sync/main.py: -------------------------------------------------------------------------------- 1 | """Time Synchronization Example 2 | 3 | This example demonstrates how to synchronize time between the host computer 4 | and a MicroPython/CircuitPython device, and how to use the return_time feature 5 | for accurate time-series data collection. 6 | """ 7 | 8 | import argparse 9 | import time 10 | 11 | import belay 12 | 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("--port", "-p", default="/dev/ttyUSB0") 15 | args = parser.parse_args() 16 | 17 | # Connect to device. Time synchronization happens automatically by default. 18 | device = belay.Device(args.port) 19 | 20 | print(f" Device Time Offset: {device.time_offset:.6f} seconds") 21 | print(f" (Device time - Host time)") 22 | print() 23 | 24 | # ============================================================================ 25 | # Method 1: Using return_time=True (Recommended) 26 | # ============================================================================ 27 | print("=" * 60) 28 | print("Method 1: Using return_time=True (Recommended)") 29 | print("=" * 60) 30 | 31 | 32 | # Setup sensor reading task (simulated with random data) 33 | @device.setup 34 | def setup_sensor(): 35 | import random 36 | 37 | def read_sensor(): 38 | """Simulate reading a sensor value.""" 39 | return random.random() * 100 # Random value 0-100 40 | 41 | 42 | setup_sensor() 43 | 44 | 45 | # The return_time=True parameter automatically includes timestamps 46 | @device.task(return_time=True) 47 | def get_reading(): 48 | return read_sensor() 49 | 50 | 51 | print("Collecting 3 sensor readings with automatic timestamps...") 52 | print() 53 | print(f"{'#':<4} {'Host Timestamp':<30} {'Value':<10}") 54 | print("-" * 50) 55 | 56 | for i in range(3): 57 | # Returns (value, datetime) tuple automatically! 58 | value, timestamp = get_reading() 59 | 60 | print(f"{i+1:<4} " f"{timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]:<30} " f"{value:<10.2f}") 61 | 62 | time.sleep(1) 63 | 64 | print() 65 | 66 | # ============================================================================ 67 | # Method 2: Manual timestamp conversion (for buffered data) 68 | # ============================================================================ 69 | print("=" * 60) 70 | print("Method 2: Manual Conversion (for buffered data)") 71 | print("=" * 60) 72 | 73 | 74 | # Use this approach when data is buffered on-device and returned later 75 | @device.task 76 | def get_buffered_readings(count): 77 | """Collect multiple readings with device timestamps.""" 78 | import time 79 | 80 | readings = [] 81 | for _ in range(count): 82 | timestamp_ms = time.ticks_ms() 83 | value = read_sensor() 84 | readings.append((timestamp_ms, value)) 85 | time.sleep_ms(1000) 86 | return readings 87 | 88 | 89 | print("Collecting 3 buffered readings...") 90 | print() 91 | print(f"{'#':<4} {'Device ms':<15} {'Host Timestamp':<30} {'Value':<10}") 92 | print("-" * 65) 93 | 94 | readings = get_buffered_readings(3) 95 | for i, (device_ms, value) in enumerate(readings): 96 | # Convert device timestamp to host time using the offset 97 | from datetime import datetime 98 | 99 | host_time_sec = (device_ms / 1000.0) - device.time_offset 100 | host_timestamp = datetime.fromtimestamp(host_time_sec) 101 | 102 | print( 103 | f"{i+1:<4} " 104 | f"{device_ms:<15} " 105 | f"{host_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]:<30} " 106 | f"{value:<10.2f}" 107 | ) 108 | 109 | print() 110 | 111 | # ============================================================================ 112 | # Method 3: Generator with return_time=True 113 | # ============================================================================ 114 | print("=" * 60) 115 | print("Method 3: Generator with return_time=True") 116 | print("=" * 60) 117 | 118 | 119 | @device.task(return_time=True) 120 | def stream_readings(count): 121 | """Stream readings with automatic timestamps.""" 122 | import time 123 | 124 | for _ in range(count): 125 | yield read_sensor() 126 | time.sleep_ms(1000) 127 | 128 | 129 | print("Streaming 3 readings...") 130 | print() 131 | print(f"{'#':<4} {'Host Timestamp':<30} {'Value':<10}") 132 | print("-" * 50) 133 | 134 | for i, (value, timestamp) in enumerate(stream_readings(3)): 135 | print(f"{i+1:<4} " f"{timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]:<30} " f"{value:<10.2f}") 136 | 137 | device.close() 138 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from contextlib import suppress 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | import belay 8 | import belay.cli.common 9 | import belay.project 10 | 11 | 12 | def run_cli(app, args): 13 | """Run a CLI app with support for both Cyclopts v3 and v4. 14 | 15 | Cyclopts v3 returns None on success. 16 | Cyclopts v4 raises SystemExit with code 0 on success. 17 | 18 | Parameters 19 | ---------- 20 | app : callable 21 | The CLI app to run. 22 | args : list 23 | Command line arguments. 24 | 25 | Returns 26 | ------- 27 | int 28 | Exit code (0 for success). 29 | """ 30 | try: 31 | result = app(args) 32 | # v3 behavior: returns None or int 33 | return result if isinstance(result, int) else 0 34 | except SystemExit as e: 35 | # v4 behavior: raises SystemExit 36 | return e.code if e.code is not None else 0 37 | 38 | 39 | class MockDevice: 40 | def __init__(self, mocker): 41 | self.mocker = mocker 42 | self.inst = mocker.MagicMock() 43 | self.inst.__enter__.return_value = self.inst # Support context manager use. 44 | self.cls = None 45 | 46 | def patch(self, target: str): 47 | self.cls = self.mocker.patch(target, return_value=self.inst) 48 | 49 | def cls_assert_common(self): 50 | self.cls.assert_called_once_with("/dev/ttyUSB0", password="password") 51 | 52 | 53 | @pytest.fixture(autouse=True) 54 | def cache_clear(): 55 | belay.project.project_cache.clear() 56 | 57 | 58 | @pytest.fixture 59 | def tmp_cwd(tmp_path, monkeypatch): 60 | """Change to a temporary directory for the duration of the test.""" 61 | monkeypatch.chdir(tmp_path) 62 | return tmp_path 63 | 64 | 65 | @pytest.fixture 66 | def mock_device(mocker): 67 | return MockDevice(mocker) 68 | 69 | 70 | ALL_FIRMWARES = [ 71 | "micropython-v1.17.uf2", 72 | "micropython-v1.24.1.uf2", 73 | "circuitpython-v7.3.3.uf2", 74 | "circuitpython-v8.0.0.uf2", 75 | "circuitpython-v9.2.0.uf2", 76 | ] 77 | SHORT_FIRMWARES = [ 78 | "micropython-v1.17.uf2", 79 | ] 80 | 81 | 82 | @pytest.fixture 83 | def emulate_command(request): 84 | firmware = request.param 85 | firmware_file = Path("rp2040js") / firmware 86 | if not firmware_file.exists(): 87 | pytest.fail( 88 | f"Firmware file not found: {firmware_file}. Run 'make download-firmware' to download required files." 89 | ) 90 | return f"exec:npm run --prefix rp2040js start:micropython -- --image={firmware}" 91 | 92 | 93 | def pytest_generate_tests(metafunc): 94 | if "emulate_command" in metafunc.fixturenames: 95 | if metafunc.config.getoption("--long-integration"): 96 | firmwares = ALL_FIRMWARES 97 | else: 98 | firmwares = SHORT_FIRMWARES 99 | metafunc.parametrize("emulate_command", firmwares, indirect=True) 100 | 101 | 102 | @pytest.fixture 103 | def emulated_device(emulate_command): 104 | device = None 105 | try: 106 | device = belay.Device(emulate_command) 107 | yield device 108 | finally: 109 | if device is not None: 110 | with suppress(Exception): 111 | device.close() 112 | 113 | 114 | @pytest.fixture 115 | def data_path(tmp_path, request): 116 | """Temporary copy of folder with same name as test module. 117 | 118 | Fixture responsible for searching a folder with the same name of test 119 | module and, if available, copying all contents to a temporary directory so 120 | tests can use them freely. 121 | """ 122 | filename = Path(request.module.__file__) 123 | test_dir = filename.parent / filename.stem 124 | if test_dir.is_dir(): 125 | shutil.copytree(test_dir, tmp_path, dirs_exist_ok=True) 126 | 127 | return tmp_path 128 | 129 | 130 | def pytest_addoption(parser): 131 | parser.addoption( 132 | "--network", 133 | action="store_true", 134 | help="Include tests that interact with network (marked with marker @network)", 135 | ) 136 | parser.addoption( 137 | "--long-integration", 138 | action="store_true", 139 | help="Run integration tests with all firmwares instead of just micropython-v1.17.", 140 | ) 141 | 142 | 143 | def pytest_runtest_setup(item): 144 | if "network" in item.keywords and not item.config.getoption("--network"): 145 | pytest.skip("need --network option to run this test") 146 | -------------------------------------------------------------------------------- /belay/cli/questionary_ext.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Any, Optional, Union 3 | 4 | from prompt_toolkit.application import Application 5 | from prompt_toolkit.key_binding import KeyBindings 6 | from prompt_toolkit.keys import Keys 7 | from prompt_toolkit.styles import Style, merge_styles 8 | from questionary import utils 9 | from questionary.constants import ( 10 | DEFAULT_QUESTION_PREFIX, 11 | DEFAULT_SELECTED_POINTER, 12 | DEFAULT_STYLE, 13 | ) 14 | from questionary.prompts import common 15 | from questionary.prompts.common import Choice, InquirerControl 16 | from questionary.question import Question 17 | 18 | 19 | def select_table( 20 | message: str, 21 | header: str, 22 | choices: Sequence[Union[str, Choice, dict[str, Any]]], 23 | default: Optional[Union[str, Choice, dict[str, Any]]] = None, 24 | qmark: str = DEFAULT_QUESTION_PREFIX, 25 | pointer: Optional[str] = DEFAULT_SELECTED_POINTER, 26 | style: Optional[Style] = None, 27 | use_indicator: bool = False, 28 | **kwargs: Any, 29 | ) -> Question: 30 | """A list of items to select **one** option from. 31 | 32 | Simplified to work better with a formatted table. 33 | 34 | Args: 35 | message: Question text 36 | header: Table header text 37 | choices: Items shown in the selection, this can contain `Choice` or 38 | or `Separator` objects or simple items as strings. Passing 39 | `Choice` objects, allows you to configure the item more 40 | (e.g. preselecting it or disabling it). 41 | default: A value corresponding to a selectable item in the choices, 42 | to initially set the pointer position to. 43 | qmark: Question prefix displayed in front of the question. 44 | By default this is a `?`. 45 | pointer: Pointer symbol in front of the currently highlighted element. 46 | By default this is a `»`. 47 | Use `None` to disable it. 48 | style: A custom color and style for the question parts. You can 49 | configure colors as well as font types for different elements. 50 | use_indicator: Flag to enable the small indicator in front of the 51 | list highlighting the current location of the selection 52 | cursor. 53 | 54 | Returns 55 | ------- 56 | Question instance, ready to be prompted (using `.ask()`). 57 | """ 58 | if choices is None or len(choices) == 0: 59 | raise ValueError("A list of choices needs to be provided.") 60 | 61 | merged_style = merge_styles([DEFAULT_STYLE, style]) 62 | 63 | ic = InquirerControl( 64 | choices, 65 | default, 66 | pointer=pointer, 67 | use_indicator=use_indicator, 68 | initial_choice=default, 69 | ) 70 | 71 | def get_prompt_tokens(): 72 | # noinspection PyListCreation 73 | tokens = [ 74 | ("class:qmark", qmark), 75 | ("class:question", f" {message} "), 76 | ("class:question", "\n " + header), 77 | ] 78 | 79 | if ic.is_answered: 80 | tokens.append(("class:answer", "\n " + ic.get_pointed_at().title)) 81 | 82 | return tokens 83 | 84 | layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs) 85 | 86 | bindings = KeyBindings() 87 | 88 | @bindings.add(Keys.ControlQ, eager=True) 89 | @bindings.add(Keys.ControlC, eager=True) 90 | def _(event): 91 | event.app.exit(exception=KeyboardInterrupt, style="class:aborting") 92 | 93 | @bindings.add(Keys.Down, eager=True) 94 | @bindings.add("j", eager=True) 95 | @bindings.add(Keys.ControlN, eager=True) 96 | def move_cursor_down(event): 97 | ic.select_next() 98 | while not ic.is_selection_valid(): 99 | ic.select_next() 100 | 101 | @bindings.add(Keys.Up, eager=True) 102 | @bindings.add("k", eager=True) 103 | @bindings.add(Keys.ControlP, eager=True) 104 | def move_cursor_up(event): 105 | ic.select_previous() 106 | while not ic.is_selection_valid(): 107 | ic.select_previous() 108 | 109 | @bindings.add(Keys.ControlM, eager=True) 110 | def set_answer(event): 111 | ic.is_answered = True 112 | event.app.exit(result=ic.get_pointed_at().value) 113 | 114 | @bindings.add(Keys.Any) 115 | def other(event): 116 | """Disallow inserting other text.""" 117 | 118 | return Question( 119 | Application( 120 | layout=layout, 121 | key_bindings=bindings, 122 | style=merged_style, 123 | **utils.used_kwargs(kwargs, Application.__init__), 124 | ) 125 | ) 126 | -------------------------------------------------------------------------------- /belay/inspect.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import inspect 3 | import re 4 | from collections.abc import Sequence 5 | from io import StringIO 6 | from tokenize import ( 7 | COMMENT, 8 | DEDENT, 9 | INDENT, 10 | NEWLINE, 11 | OP, 12 | STRING, 13 | generate_tokens, 14 | untokenize, 15 | ) 16 | 17 | _pat_no_decorators = re.compile(r"^(\s*def\s)|(\s*async\s+def\s)|(.*(? tuple[str, int, str]: 64 | """Get source code with mild post processing. 65 | 66 | * strips leading decorators. 67 | * Trims leading whitespace indent. 68 | 69 | Parameters 70 | ---------- 71 | f: Callable 72 | Function to get source code of. 73 | strip_signature: bool 74 | Remove the function signature from the returned code. 75 | 76 | Returns 77 | ------- 78 | src_code: str 79 | Source code. Always ends in a newline. 80 | src_lineno: int 81 | Line number of code begin. 82 | src_file: str 83 | Path to file containing source code. 84 | """ 85 | src_file = inspect.getsourcefile(f) 86 | if src_file is None: 87 | raise FileNotFoundError(f"Unable to get source file for {f}.") 88 | lines, src_lineno = inspect.getsourcelines(f) 89 | 90 | offset = 0 91 | for line in lines: 92 | if _pat_no_decorators.match(line): 93 | break 94 | offset += 1 95 | 96 | lines = lines[offset:] 97 | 98 | src_code = "".join(lines) 99 | src_lineno += offset 100 | 101 | src_code = _dedent(src_code) 102 | 103 | if strip_signature: 104 | src_code, lines_removed = _remove_signature(src_code) 105 | src_lineno += lines_removed 106 | 107 | src_code = _dedent(src_code) 108 | 109 | return src_code, src_lineno, src_file 110 | 111 | 112 | def isexpression(code: str) -> bool: 113 | """Checks if python code is an expression. 114 | 115 | Parameters 116 | ---------- 117 | code: str 118 | Python code to check if is an expression. 119 | 120 | Returns 121 | ------- 122 | bool 123 | ``True`` if ``code`` is an expression; returns 124 | ``False`` otherwise (statement or invalid). 125 | """ 126 | try: 127 | compile(code, "", "eval") 128 | except SyntaxError: 129 | return False 130 | 131 | return True 132 | 133 | 134 | def import_names(import_statement: str) -> Sequence[str]: 135 | """Determine imported object names from an import-statement. 136 | 137 | Parameters 138 | ---------- 139 | import_statement: str 140 | Import statement, for example:: 141 | 142 | import foo 143 | from foo import bar 144 | import foo as buzz 145 | """ 146 | if "*" in import_statement: 147 | return [] 148 | 149 | names = [] 150 | try: 151 | tree = ast.parse(import_statement) 152 | 153 | if isinstance(tree.body[0], ast.ImportFrom): 154 | # from foo import bar, baz [as alias], qux 155 | for alias in tree.body[0].names: 156 | symbol_name = alias.asname if alias.asname else alias.name 157 | names.append(symbol_name) 158 | 159 | elif isinstance(tree.body[0], ast.Import): 160 | # import foo, bar.baz [as alias], qux 161 | for alias in tree.body[0].names: 162 | if alias.asname: 163 | names.append(alias.asname) 164 | else: 165 | # For dotted imports like "import foo.bar", use just "foo" 166 | names.append(alias.name.split(".")[0]) 167 | except Exception: 168 | return [] 169 | else: 170 | return names 171 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ##--------------------------------------------------- 2 | # Automated documentation .gitignore files 3 | ##--------------------------------------------------- 4 | 5 | # Automatically generated API documentation stubs from sphinx-apidoc 6 | docs/source/packages 7 | 8 | # Automatically converting README from markdown to rST 9 | docs/bin 10 | docs/source/readme.rst 11 | docs/source/assets 12 | 13 | 14 | ##--------------------------------------------------- 15 | # Continuous Integration .gitignore files 16 | ##--------------------------------------------------- 17 | 18 | # Ignore test result XML files 19 | testresults.xml 20 | coverage.xml 21 | 22 | 23 | ##--------------------------------------------------- 24 | # Python default .gitignore 25 | ##--------------------------------------------------- 26 | 27 | # Byte-compiled / optimized / DLL files 28 | __pycache__/ 29 | *.py[cod] 30 | *$py.class 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | pip-wheel-metadata/ 50 | share/python-wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | MANIFEST 55 | 56 | # PyInstaller 57 | # Usually these files are written by a python script from a template 58 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 59 | *.manifest 60 | *.spec 61 | 62 | # Installer logs 63 | pip-log.txt 64 | pip-delete-this-directory.txt 65 | 66 | # Unit test / coverage reports 67 | htmlcov/ 68 | .tox/ 69 | .nox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *.cover 76 | .hypothesis/ 77 | .pytest_cache/ 78 | 79 | # Translations 80 | *.mo 81 | *.pot 82 | 83 | # Django stuff: 84 | *.log 85 | local_settings.py 86 | db.sqlite3 87 | 88 | # Flask stuff: 89 | instance/ 90 | .webassets-cache 91 | 92 | # Scrapy stuff: 93 | .scrapy 94 | 95 | # Sphinx documentation 96 | /docs/_build/ 97 | /docs/build/ 98 | 99 | # PyBuilder 100 | target/ 101 | 102 | # Jupyter Notebook 103 | .ipynb_checkpoints 104 | 105 | # IPython 106 | profile_default/ 107 | ipython_config.py 108 | 109 | # pyenv 110 | .python-version 111 | 112 | # pipenv 113 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 114 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 115 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 116 | # install all needed dependencies. 117 | #Pipfile.lock 118 | 119 | # celery beat schedule file 120 | celerybeat-schedule 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | 156 | ##--------------------------------------------------- 157 | # Windows default .gitignore 158 | ##--------------------------------------------------- 159 | 160 | # Windows thumbnail cache files 161 | Thumbs.db 162 | ehthumbs.db 163 | ehthumbs_vista.db 164 | 165 | # Dump file 166 | *.stackdump 167 | 168 | # Folder config file 169 | [Dd]esktop.ini 170 | 171 | # Recycle Bin used on file shares 172 | $RECYCLE.BIN/ 173 | 174 | # Windows Installer files 175 | *.cab 176 | *.msi 177 | *.msix 178 | *.msm 179 | *.msp 180 | 181 | # Windows shortcuts 182 | *.lnk 183 | 184 | 185 | ##--------------------------------------------------- 186 | # Linux default .gitignore 187 | ##--------------------------------------------------- 188 | 189 | # Editor backup files 190 | *~ 191 | 192 | # temporary files which can be created if a process still has a handle open of a deleted file 193 | .fuse_hidden* 194 | 195 | # KDE directory preferences 196 | .directory 197 | 198 | # Linux trash folder which might appear on any partition or disk 199 | .Trash-* 200 | 201 | # .nfs files are created when an open file is removed but is still being accessed 202 | .nfs* 203 | 204 | 205 | ##--------------------------------------------------- 206 | # Mac OSX default .gitignore 207 | ##--------------------------------------------------- 208 | 209 | # General 210 | .DS_Store 211 | .AppleDouble 212 | .LSOverride 213 | 214 | # Icon must end with two \r 215 | Icon 216 | 217 | # Thumbnails 218 | ._* 219 | 220 | # Files that might appear in the root of a volume 221 | .DocumentRevisions-V100 222 | .fseventsd 223 | .Spotlight-V100 224 | .TemporaryItems 225 | .Trashes 226 | .VolumeIcon.icns 227 | .com.apple.timemachine.donotpresent 228 | 229 | # Directories potentially created on remote AFP share 230 | .AppleDB 231 | .AppleDesktop 232 | Network Trash Folder 233 | Temporary Items 234 | .apdisk 235 | 236 | profile.json 237 | 238 | rp2040js/ 239 | 240 | .vscode/ 241 | *.lprof 242 | --------------------------------------------------------------------------------