Skip to content

Commit 2c4d5f7

Browse files
committed
add the ability to cancel a program via a keypress in the terminal
1 parent 45fed26 commit 2c4d5f7

2 files changed

Lines changed: 78 additions & 7 deletions

File tree

pybricksdev/cli/__init__.py

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
from enum import IntEnum
1515
from os import PathLike, path
1616
from tempfile import NamedTemporaryFile
17-
from typing import ContextManager, TextIO
17+
from typing import ContextManager, TextIO, Awaitable
1818

1919
import argcomplete
2020
import questionary
2121
from argcomplete.completers import FilesCompleter
2222
from packaging.version import Version
23+
from prompt_toolkit import Application
24+
from prompt_toolkit.key_binding import KeyBindings
25+
from prompt_toolkit.output import DummyOutput
2326

2427
from pybricksdev import __name__ as MODULE_NAME
2528
from pybricksdev import __version__ as MODULE_VERSION
@@ -36,6 +39,10 @@
3639
)
3740

3841

42+
class CancelProgramError(RuntimeError):
43+
"""Exception raised when a user interrupts the hub's running program from the cli."""
44+
45+
3946
class Tool(ABC):
4047
"""Common base class for tool implementations."""
4148

@@ -184,6 +191,55 @@ def add_parser(self, subparsers: argparse._SubParsersAction):
184191
default=False,
185192
)
186193

194+
async def race_keypress(self, awaitable: Awaitable) -> None:
195+
"""
196+
Races an awaitable against a keypress.
197+
The awaitable is cancelled and a CancelProgramError is raised if the key
198+
is pressed before the awaitable completes.
199+
The output of the awaitable is not passed to the caller.
200+
"""
201+
202+
async def stdin_monitor():
203+
kb = KeyBindings()
204+
205+
@kb.add("q")
206+
def _(event):
207+
event.app.exit()
208+
209+
app = Application(
210+
key_bindings=kb,
211+
full_screen=False,
212+
mouse_support=False,
213+
output=DummyOutput(),
214+
)
215+
216+
await app.run_async()
217+
218+
stop_task = asyncio.ensure_future(stdin_monitor())
219+
awaitable_task = asyncio.ensure_future(awaitable)
220+
221+
print("------press q to cancel the program------")
222+
223+
try:
224+
done, pending = await asyncio.wait(
225+
{awaitable_task, stop_task},
226+
return_when=asyncio.FIRST_COMPLETED,
227+
)
228+
except BaseException:
229+
awaitable_task.cancel()
230+
stop_task.cancel()
231+
raise
232+
233+
for t in pending:
234+
t.cancel()
235+
236+
# allow prompt-toolkit to unbind from the terminal
237+
await asyncio.sleep(0.1)
238+
239+
if stop_task in done:
240+
print("------aborting program------")
241+
raise CancelProgramError
242+
187243
async def stay_connected_menu(self, hub: PybricksHub, args: argparse.Namespace):
188244

189245
if args.conntype == "ble":
@@ -277,7 +333,7 @@ async def reconnect_hub():
277333
response = await hub.race_disconnect(
278334
hub.race_power_button_press(
279335
questionary.select(
280-
f"Would you like to re-compile {os.path.basename(args.file.name)}?",
336+
f"Would you like to re-compile {path.basename(args.file.name)}?",
281337
response_options,
282338
default=(response_options[default_response_option]),
283339
).ask_async()
@@ -290,7 +346,7 @@ async def reconnect_hub():
290346

291347
case ResponseOptions.RECOMPILE_RUN:
292348
with _get_script_path(args.file) as script_path:
293-
await hub.run(script_path, wait=True)
349+
await self.race_keypress(hub.run(script_path, wait=True))
294350

295351
case ResponseOptions.RECOMPILE_DOWNLOAD:
296352
with _get_script_path(args.file) as script_path:
@@ -303,7 +359,7 @@ async def reconnect_hub():
303359
)
304360
else:
305361
await hub.start_user_program()
306-
await hub._wait_for_user_program_stop()
362+
await self.race_keypress(hub._wait_for_user_program_stop())
307363

308364
case ResponseOptions.CHANGE_TARGET_FILE:
309365
args.file.close()
@@ -340,16 +396,22 @@ async def reconnect_hub():
340396
# the hub stdout until the user program ends on the hub.
341397
try:
342398
await hub._wait_for_power_button_release()
343-
await hub._wait_for_user_program_stop()
399+
await self.race_keypress(hub._wait_for_user_program_stop())
344400

345401
except HubDisconnectError:
346402
hub = await reconnect_hub()
347403

404+
except CancelProgramError:
405+
await hub.stop_user_program()
406+
348407
except HubDisconnectError:
349408
# let terminal cool off before making a new prompt
350409
await asyncio.sleep(0.3)
351410
hub = await reconnect_hub()
352411

412+
except CancelProgramError:
413+
await hub.stop_user_program()
414+
353415
async def run(self, args: argparse.Namespace):
354416

355417
# Pick the right connection
@@ -405,7 +467,9 @@ def is_pybricks_usb(dev):
405467
try:
406468
with _get_script_path(args.file) as script_path:
407469
if args.start:
408-
await hub.run(script_path, args.wait or args.stay_connected)
470+
await self.race_keypress(
471+
hub.run(script_path, args.wait or args.stay_connected)
472+
)
409473
else:
410474
if args.stay_connected:
411475
# if the user later starts the program by pressing the button on the hub,
@@ -424,6 +488,11 @@ def is_pybricks_usb(dev):
424488
if args.stay_connected:
425489
await self.stay_connected_menu(hub, args)
426490

491+
except CancelProgramError:
492+
await hub.stop_user_program()
493+
if args.stay_connected:
494+
await self.stay_connected_menu(hub, args)
495+
427496
finally:
428497
await hub.disconnect()
429498

pybricksdev/connections/pybricks.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,9 @@ async def run(
620620

621621
# Download the program if a path is provided
622622
if py_path is not None:
623-
await self.download(py_path)
623+
# prevent the hub's stored program from being corrupted
624+
# in the case that this function gets cancelled
625+
await asyncio.shield(self.download(py_path))
624626

625627
# Start the program
626628
await self.start_user_program()

0 commit comments

Comments
 (0)