Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d1c1309
cli/__init__.py: catch a syntax error when re-compiling the target file
shaggysa Oct 31, 2025
8b5eb9c
cli/__init__.py: slightly modify the error message warning
shaggysa Oct 31, 2025
e95fc0e
cli/__init__.py: refactor to allow the catching of syntax errors on t…
shaggysa Nov 1, 2025
c3f3c20
cli/__init__.py: clean up an if statement
shaggysa Nov 1, 2025
709e6f1
CHANGELOG.md: add an entry for the new changes to the run command
shaggysa Nov 26, 2025
aedee30
cli/__init__.py: avoid using newline escape characters
shaggysa Nov 28, 2025
7a1ca00
cli/__init__.py: move stay_connected_menu to separate function
shaggysa Nov 28, 2025
4d72397
Merge branch 'master' into feature/catch-syntax-error
dlech Nov 29, 2025
9f71013
explicitly pass args to the stay-connected menu from the run function
shaggysa Nov 29, 2025
972b4a7
Merge remote-tracking branch 'origin/feature/catch-syntax-error' into…
shaggysa Nov 29, 2025
b31f93c
Merge branch 'pybricks:master' into feature/catch-syntax-error
shaggysa Nov 29, 2025
1e0e8bc
catch a CalledProcessError and decode stderr
shaggysa Nov 29, 2025
d7cd4bd
fix typo in except block
shaggysa Nov 30, 2025
681acee
add a few unit tests related to stay_connected_menu
shaggysa Dec 1, 2025
ec917ab
make the open() call in the "Change Target File" option explicitly sp…
shaggysa Dec 1, 2025
45fed26
fix leaking coroutines in test_stay_connected_menu_interruptions
shaggysa Dec 1, 2025
9577550
add a test for the `Run Stored Program` option
shaggysa Dec 4, 2025
4e3f4b8
rearrange test order
shaggysa Dec 4, 2025
884adce
tweak mpy-cross error message and control flow
shaggysa Dec 17, 2025
6c083d3
create a syntax error organically in the unit test
shaggysa Dec 17, 2025
ff6799b
remove unused subprocess import
shaggysa Dec 17, 2025
11c7fdb
un-mock the stay-connected menu in the syntax error test
shaggysa Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Changed
- The `run` command now catches syntax errors in the input file. ([pybricksdev#126])
- Changed `pybricksdev.compile.compile_multi_file()` to use `mpy-tool` to find imports
instead of Python's `ModuleFinder`.

### Fixed
- Fixed compiling multi-file projects with implicit namespace packages.

[pybricksdev#126]: https://github.com/pybricks/pybricksdev/pull/126

## [2.3.0] - 2025-10-31

### Added
Expand Down
302 changes: 174 additions & 128 deletions pybricksdev/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import contextlib
import logging
import os
import subprocess
import sys
from abc import ABC, abstractmethod
from enum import IntEnum
Expand All @@ -25,6 +26,7 @@
from pybricksdev.connections.pybricks import (
HubDisconnectError,
HubPowerButtonPressedError,
PybricksHub,
)

PROG_NAME = (
Expand Down Expand Up @@ -182,6 +184,172 @@ def add_parser(self, subparsers: argparse._SubParsersAction):
default=False,
)

async def stay_connected_menu(self, hub: PybricksHub, args: argparse.Namespace):

if args.conntype == "ble":
from pybricksdev.ble import find_device as find_ble
from pybricksdev.connections.pybricks import PybricksHubBLE
else:
from usb.core import find as find_usb

from pybricksdev.connections.pybricks import PybricksHubUSB
from pybricksdev.usb import (
EV3_USB_PID,
LEGO_USB_VID,
MINDSTORMS_INVENTOR_USB_PID,
NXT_USB_PID,
SPIKE_ESSENTIAL_USB_PID,
SPIKE_PRIME_USB_PID,
)

def is_pybricks_usb(dev):
return (
(dev.idVendor == LEGO_USB_VID)
and (
dev.idProduct
in [
NXT_USB_PID,
EV3_USB_PID,
SPIKE_PRIME_USB_PID,
SPIKE_ESSENTIAL_USB_PID,
MINDSTORMS_INVENTOR_USB_PID,
]
)
and dev.product.endswith("Pybricks")
)

class ResponseOptions(IntEnum):
RECOMPILE_RUN = 0
RECOMPILE_DOWNLOAD = 1
RUN_STORED = 2
CHANGE_TARGET_FILE = 3
EXIT = 4

async def reconnect_hub():
if not await questionary.confirm(
"\nThe hub has been disconnected. Would you like to re-connect?"
).ask_async():
exit()

if args.conntype == "ble":
print(
f"Searching for {args.name or 'any hub with Pybricks service'}..."
)
device_or_address = await find_ble(args.name)
hub = PybricksHubBLE(device_or_address)
elif args.conntype == "usb":
device_or_address = find_usb(custom_match=is_pybricks_usb)
hub = PybricksHubUSB(device_or_address)

await hub.connect()
# re-enable echoing of the hub's stdout
hub._enable_line_handler = True
hub.print_output = True
return hub

response_options = [
"Recompile and Run",
"Recompile and Download",
"Run Stored Program",
"Change Target File",
"Exit",
]
# the entry that is selected by default when the menu opens
# this is overridden after the user picks an option
# so that the default option is always the one that was last chosen
default_response_option = (
ResponseOptions.RECOMPILE_RUN
if args.start
else ResponseOptions.RECOMPILE_DOWNLOAD
)

while True:
try:
if args.file is sys.stdin:
await hub.race_disconnect(
hub.race_power_button_press(
questionary.press_any_key_to_continue(
"The hub will stay connected and echo its output to the terminal. Press any key to exit."
).ask_async()
)
)
return
response = await hub.race_disconnect(
hub.race_power_button_press(
questionary.select(
f"Would you like to re-compile {os.path.basename(args.file.name)}?",
response_options,
default=(response_options[default_response_option]),
).ask_async()
)
)

default_response_option = response_options.index(response)

match response_options.index(response):

case ResponseOptions.RECOMPILE_RUN:
with _get_script_path(args.file) as script_path:
await hub.run(script_path, wait=True)

case ResponseOptions.RECOMPILE_DOWNLOAD:
with _get_script_path(args.file) as script_path:
await hub.download(script_path)

case ResponseOptions.RUN_STORED:
if hub.fw_version < Version("3.2.0-beta.4"):
print(
"Running a stored program remotely is only supported in the hub firmware version >= v3.2.0."
)
else:
await hub.start_user_program()
await hub._wait_for_user_program_stop()

case ResponseOptions.CHANGE_TARGET_FILE:
args.file.close()
while True:
try:
args.file = open(
await hub.race_disconnect(
hub.race_power_button_press(
questionary.path(
"What file would you like to use?"
).ask_async()
)
),
encoding="utf-8",
)
break
except FileNotFoundError:
print("The file was not found. Please try again.")
# send the new target file to the hub
with _get_script_path(args.file) as script_path:
await hub.download(script_path)

case _:
return

except subprocess.CalledProcessError as e:
print()
print("mpy-cross failed to compile the program:")
print(e.stderr.decode())

except HubPowerButtonPressedError:
# This means the user pressed the button on the hub to re-start the
# current program, so the menu was canceled and we are now printing
# the hub stdout until the user program ends on the hub.
try:
await hub._wait_for_power_button_release()
await hub._wait_for_user_program_stop()

except HubDisconnectError:
hub = await reconnect_hub()

except HubDisconnectError:
# let terminal cool off before making a new prompt
await asyncio.sleep(0.3)
hub = await reconnect_hub()

async def run(self, args: argparse.Namespace):

# Pick the right connection
Expand Down Expand Up @@ -246,136 +414,14 @@ def is_pybricks_usb(dev):
hub._enable_line_handler = True
await hub.download(script_path)

if not args.stay_connected:
return

class ResponseOptions(IntEnum):
RECOMPILE_RUN = 0
RECOMPILE_DOWNLOAD = 1
RUN_STORED = 2
CHANGE_TARGET_FILE = 3
EXIT = 4

async def reconnect_hub():
if not await questionary.confirm(
"\nThe hub has been disconnected. Would you like to re-connect?"
).ask_async():
exit()

if args.conntype == "ble":
print(
f"Searching for {args.name or 'any hub with Pybricks service'}..."
)
device_or_address = await find_ble(args.name)
hub = PybricksHubBLE(device_or_address)
elif args.conntype == "usb":
device_or_address = find_usb(custom_match=is_pybricks_usb)
hub = PybricksHubUSB(device_or_address)

await hub.connect()
# re-enable echoing of the hub's stdout
hub._enable_line_handler = True
hub.print_output = True
return hub

response_options = [
"Recompile and Run",
"Recompile and Download",
"Run Stored Program",
"Change Target File",
"Exit",
]
# the entry that is selected by default when the menu opens
# this is overridden after the user picks an option
# so that the default option is always the one that was last chosen
default_response_option = (
ResponseOptions.RECOMPILE_RUN
if args.start
else ResponseOptions.RECOMPILE_DOWNLOAD
)

while True:
try:
if args.file is sys.stdin:
await hub.race_disconnect(
hub.race_power_button_press(
questionary.press_any_key_to_continue(
"The hub will stay connected and echo its output to the terminal. Press any key to exit."
).ask_async()
)
)
return
response = await hub.race_disconnect(
hub.race_power_button_press(
questionary.select(
f"Would you like to re-compile {os.path.basename(args.file.name)}?",
response_options,
default=(response_options[default_response_option]),
).ask_async()
)
)

default_response_option = response_options.index(response)

match response_options.index(response):

case ResponseOptions.RECOMPILE_RUN:
with _get_script_path(args.file) as script_path:
await hub.run(script_path, wait=True)

case ResponseOptions.RECOMPILE_DOWNLOAD:
with _get_script_path(args.file) as script_path:
await hub.download(script_path)

case ResponseOptions.RUN_STORED:
if hub.fw_version < Version("3.2.0-beta.4"):
print(
"Running a stored program remotely is only supported in the hub firmware version >= v3.2.0."
)
else:
await hub.start_user_program()
await hub._wait_for_user_program_stop()

case ResponseOptions.CHANGE_TARGET_FILE:
args.file.close()
while True:
try:
args.file = open(
await hub.race_disconnect(
hub.race_power_button_press(
questionary.path(
"What file would you like to use?"
).ask_async()
)
)
)
break
except FileNotFoundError:
print("The file was not found. Please try again.")
# send the new target file to the hub
with _get_script_path(args.file) as script_path:
await hub.download(script_path)

case _:
return

except HubPowerButtonPressedError:
# This means the user pressed the button on the hub to re-start the
# current program, so the menu was canceled and we are now printing
# the hub stdout until the user program ends on the hub.
try:
await hub._wait_for_power_button_release()
await hub._wait_for_user_program_stop()

except HubDisconnectError:
hub = await reconnect_hub()

except HubDisconnectError:
# let terminal cool off before making a new prompt
await asyncio.sleep(0.3)
hub = await reconnect_hub()
except subprocess.CalledProcessError as e:
print()
print("mpy-cross failed to compile the program:")
print(e.stderr.decode())

finally:
if args.stay_connected:
await self.stay_connected_menu(hub, args)
await hub.disconnect()


Expand Down
Loading