• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pybricks / pybricksdev / 20311043488

17 Dec 2025 05:08PM UTC coverage: 55.368% (+1.5%) from 53.832%
20311043488

Pull #126

github

web-flow
Merge 884adce5f into a1a9a71de
Pull Request #126: Catch a syntax error when parsing an input file with the --stay-connected flag active

82 of 264 branches covered (31.06%)

Branch coverage included in aggregate %.

2110 of 3695 relevant lines covered (57.1%)

0.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.83
pybricksdev/cli/__init__.py
1
# SPDX-License-Identifier: MIT
2
# Copyright (c) 2019-2024 The Pybricks Authors
3

4
"""Command line wrapper around pybricksdev library."""
5

6
import argparse
1✔
7
import asyncio
1✔
8
import contextlib
1✔
9
import logging
1✔
10
import os
1✔
11
import subprocess
1✔
12
import sys
1✔
13
from abc import ABC, abstractmethod
1✔
14
from enum import IntEnum
1✔
15
from os import PathLike, path
1✔
16
from tempfile import NamedTemporaryFile
1✔
17
from typing import ContextManager, TextIO
1✔
18

19
import argcomplete
1✔
20
import questionary
1✔
21
from argcomplete.completers import FilesCompleter
1✔
22
from packaging.version import Version
1✔
23

24
from pybricksdev import __name__ as MODULE_NAME
1✔
25
from pybricksdev import __version__ as MODULE_VERSION
1✔
26
from pybricksdev.connections.pybricks import (
1✔
27
    HubDisconnectError,
28
    HubPowerButtonPressedError,
29
    PybricksHub,
30
)
31

32
PROG_NAME = (
1✔
33
    f"{path.basename(sys.executable)} -m {MODULE_NAME}"
34
    if sys.argv[0].endswith("__main__.py")
35
    else path.basename(sys.argv[0])
36
)
37

38

39
class Tool(ABC):
1✔
40
    """Common base class for tool implementations."""
41

42
    @abstractmethod
1✔
43
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
44
        """
45
        Overriding methods must at least do the following::
46

47
            parser = subparsers.add_parser('tool', ...)
48
            parser.tool = self
49

50
        Then additional arguments can be added using the ``parser`` object.
51
        """
52
        pass
×
53

54
    @abstractmethod
1✔
55
    async def run(self, args: argparse.Namespace):
1✔
56
        """
57
        Overriding methods should provide an implementation to run the tool.
58
        """
59
        pass
×
60

61

62
def _get_script_path(file: TextIO) -> ContextManager[PathLike]:
1✔
63
    """
64
    Gets the path to a script on the file system.
65

66
    If the file is ``sys.stdin``, the contents are copied to a temporary file
67
    and the path to the temporary file is returned. Otherwise, the file is closed
68
    and the path is returned.
69

70
    The context manager will delete the temporary file, if applicable.
71
    """
72
    if file is sys.stdin:
1✔
73
        # Have to close the temp file so that mpy-cross can read it, so we
74
        # create our own context manager to delete the file when we are done
75
        # using it.
76

77
        @contextlib.contextmanager
×
78
        def temp_context():
×
79
            try:
×
80
                with NamedTemporaryFile("wb", suffix=".py", delete=False) as temp:
×
81
                    temp.write(file.buffer.read())
×
82

83
                yield temp.name
×
84
            finally:
85
                try:
×
86
                    os.remove(temp.name)
×
87
                except NameError:
×
88
                    # if NamedTemporaryFile() throws, temp is not defined
89
                    pass
×
90
                except OSError:
×
91
                    # file was already deleted or other strangeness
92
                    pass
×
93

94
        return temp_context()
×
95

96
    file.close()
1✔
97
    return contextlib.nullcontext(file.name)
1✔
98

99

100
class Compile(Tool):
1✔
101
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
102
        parser = subparsers.add_parser(
1✔
103
            "compile",
104
            help="compile a Pybricks program without running it",
105
        )
106
        parser.add_argument(
1✔
107
            "file",
108
            metavar="<file>",
109
            help="path to a MicroPython script or `-` for stdin",
110
            type=argparse.FileType(encoding="utf-8"),
111
        )
112
        parser.add_argument(
1✔
113
            "--abi",
114
            metavar="<n>",
115
            help="the MPY ABI version, one of %(choices)s (default: %(default)s)",
116
            default=6,
117
            choices=[5, 6],
118
            type=int,
119
        )
120
        parser.add_argument(
1✔
121
            "--bin",
122
            action="store_true",
123
            help="output unformatted binary data only (useful for pipes)",
124
        )
125
        parser.tool = self
1✔
126

127
    async def run(self, args: argparse.Namespace):
1✔
128
        from pybricksdev.compile import compile_multi_file, print_mpy
1✔
129

130
        with _get_script_path(args.file) as script_path:
1✔
131
            mpy = await compile_multi_file(script_path, args.abi)
1✔
132
        if args.bin:
1✔
133
            sys.stdout.buffer.write(mpy)
×
134
            return
×
135
        print_mpy(mpy)
1✔
136

137

138
class Run(Tool):
1✔
139
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
140
        parser = subparsers.add_parser(
1✔
141
            "run",
142
            help="run a Pybricks program",
143
        )
144
        parser.tool = self
1✔
145
        parser.add_argument(
1✔
146
            "conntype",
147
            metavar="<connection type>",
148
            help="connection type: %(choices)s",
149
            choices=["ble", "usb"],
150
        )
151
        parser.add_argument(
1✔
152
            "file",
153
            metavar="<file>",
154
            help="path to a MicroPython script or `-` for stdin",
155
            type=argparse.FileType(encoding="utf-8"),
156
        )
157
        parser.add_argument(
1✔
158
            "-n",
159
            "--name",
160
            metavar="<name>",
161
            required=False,
162
            help="Bluetooth device name or Bluetooth address for BLE connection; "
163
            "serial port name for USB connection",
164
        )
165

166
        parser.add_argument(
1✔
167
            "--start",
168
            help="Start the program immediately after downloading it.",
169
            action=argparse.BooleanOptionalAction,
170
            default=True,
171
        )
172

173
        parser.add_argument(
1✔
174
            "--wait",
175
            help="Wait for the program to complete before disconnecting. Only applies when starting program right away.",
176
            action=argparse.BooleanOptionalAction,
177
            default=True,
178
        )
179

180
        parser.add_argument(
1✔
181
            "--stay-connected",
182
            help="Add a menu option to resend the code with bluetooth instead of disconnecting from the robot after the program ends.",
183
            action=argparse.BooleanOptionalAction,
184
            default=False,
185
        )
186

187
    async def stay_connected_menu(self, hub: PybricksHub, args: argparse.Namespace):
1✔
188

189
        if args.conntype == "ble":
1✔
190
            from pybricksdev.ble import find_device as find_ble
1✔
191
            from pybricksdev.connections.pybricks import PybricksHubBLE
1✔
192
        else:
193
            from usb.core import find as find_usb
×
194

195
            from pybricksdev.connections.pybricks import PybricksHubUSB
×
196
            from pybricksdev.usb import (
×
197
                EV3_USB_PID,
198
                LEGO_USB_VID,
199
                MINDSTORMS_INVENTOR_USB_PID,
200
                NXT_USB_PID,
201
                SPIKE_ESSENTIAL_USB_PID,
202
                SPIKE_PRIME_USB_PID,
203
            )
204

205
            def is_pybricks_usb(dev):
×
206
                return (
×
207
                    (dev.idVendor == LEGO_USB_VID)
208
                    and (
209
                        dev.idProduct
210
                        in [
211
                            NXT_USB_PID,
212
                            EV3_USB_PID,
213
                            SPIKE_PRIME_USB_PID,
214
                            SPIKE_ESSENTIAL_USB_PID,
215
                            MINDSTORMS_INVENTOR_USB_PID,
216
                        ]
217
                    )
218
                    and dev.product.endswith("Pybricks")
219
                )
220

221
        class ResponseOptions(IntEnum):
1✔
222
            RECOMPILE_RUN = 0
1✔
223
            RECOMPILE_DOWNLOAD = 1
1✔
224
            RUN_STORED = 2
1✔
225
            CHANGE_TARGET_FILE = 3
1✔
226
            EXIT = 4
1✔
227

228
        async def reconnect_hub():
1✔
229
            if not await questionary.confirm(
1✔
230
                "\nThe hub has been disconnected. Would you like to re-connect?"
231
            ).ask_async():
232
                exit()
×
233

234
            if args.conntype == "ble":
1✔
235
                print(
1✔
236
                    f"Searching for {args.name or 'any hub with Pybricks service'}..."
237
                )
238
                device_or_address = await find_ble(args.name)
1✔
239
                hub = PybricksHubBLE(device_or_address)
1✔
240
            elif args.conntype == "usb":
×
241
                device_or_address = find_usb(custom_match=is_pybricks_usb)
×
242
                hub = PybricksHubUSB(device_or_address)
×
243

244
            await hub.connect()
1✔
245
            # re-enable echoing of the hub's stdout
246
            hub._enable_line_handler = True
1✔
247
            hub.print_output = True
1✔
248
            return hub
1✔
249

250
        response_options = [
1✔
251
            "Recompile and Run",
252
            "Recompile and Download",
253
            "Run Stored Program",
254
            "Change Target File",
255
            "Exit",
256
        ]
257
        # the entry that is selected by default when the menu opens
258
        # this is overridden after the user picks an option
259
        # so that the default option is always the one that was last chosen
260
        default_response_option = (
1✔
261
            ResponseOptions.RECOMPILE_RUN
262
            if args.start
263
            else ResponseOptions.RECOMPILE_DOWNLOAD
264
        )
265

266
        while True:
1✔
267
            try:
1✔
268
                if args.file is sys.stdin:
1✔
269
                    await hub.race_disconnect(
×
270
                        hub.race_power_button_press(
271
                            questionary.press_any_key_to_continue(
272
                                "The hub will stay connected and echo its output to the terminal. Press any key to exit."
273
                            ).ask_async()
274
                        )
275
                    )
276
                    return
×
277
                response = await hub.race_disconnect(
1✔
278
                    hub.race_power_button_press(
279
                        questionary.select(
280
                            f"Would you like to re-compile {os.path.basename(args.file.name)}?",
281
                            response_options,
282
                            default=(response_options[default_response_option]),
283
                        ).ask_async()
284
                    )
285
                )
286

287
                default_response_option = response_options.index(response)
1✔
288

289
                match response_options.index(response):
1✔
290

291
                    case ResponseOptions.RECOMPILE_RUN:
1✔
292
                        with _get_script_path(args.file) as script_path:
1✔
293
                            await hub.run(script_path, wait=True)
1✔
294

295
                    case ResponseOptions.RECOMPILE_DOWNLOAD:
1✔
296
                        with _get_script_path(args.file) as script_path:
1✔
297
                            await hub.download(script_path)
1✔
298

299
                    case ResponseOptions.RUN_STORED:
1✔
300
                        if hub.fw_version < Version("3.2.0-beta.4"):
1✔
301
                            print(
1✔
302
                                "Running a stored program remotely is only supported in the hub firmware version >= v3.2.0."
303
                            )
304
                        else:
305
                            await hub.start_user_program()
1✔
306
                            await hub._wait_for_user_program_stop()
1✔
307

308
                    case ResponseOptions.CHANGE_TARGET_FILE:
1✔
309
                        args.file.close()
1✔
310
                        while True:
1✔
311
                            try:
1✔
312
                                args.file = open(
1✔
313
                                    await hub.race_disconnect(
314
                                        hub.race_power_button_press(
315
                                            questionary.path(
316
                                                "What file would you like to use?"
317
                                            ).ask_async()
318
                                        )
319
                                    ),
320
                                    encoding="utf-8",
321
                                )
322
                                break
1✔
323
                            except FileNotFoundError:
×
324
                                print("The file was not found. Please try again.")
×
325
                        # send the new target file to the hub
326
                        with _get_script_path(args.file) as script_path:
1✔
327
                            await hub.download(script_path)
1✔
328

329
                    case _:
1✔
330
                        return
1✔
331

332
            except subprocess.CalledProcessError as e:
1✔
333
                print()
×
334
                print("mpy-cross failed to compile the program:")
×
335
                print(e.stderr.decode())
×
336

337
            except HubPowerButtonPressedError:
1✔
338
                # This means the user pressed the button on the hub to re-start the
339
                # current program, so the menu was canceled and we are now printing
340
                # the hub stdout until the user program ends on the hub.
341
                try:
1✔
342
                    await hub._wait_for_power_button_release()
1✔
343
                    await hub._wait_for_user_program_stop()
1✔
344

345
                except HubDisconnectError:
×
346
                    hub = await reconnect_hub()
×
347

348
            except HubDisconnectError:
1✔
349
                # let terminal cool off before making a new prompt
350
                await asyncio.sleep(0.3)
1✔
351
                hub = await reconnect_hub()
1✔
352

353
    async def run(self, args: argparse.Namespace):
1✔
354

355
        # Pick the right connection
356
        if args.conntype == "ble":
1✔
357
            from pybricksdev.ble import find_device as find_ble
1✔
358
            from pybricksdev.connections.pybricks import PybricksHubBLE
1✔
359

360
            # It is a Pybricks Hub with BLE. Device name or address is given.
361
            print(f"Searching for {args.name or 'any hub with Pybricks service'}...")
1✔
362
            device_or_address = await find_ble(args.name)
1✔
363
            hub = PybricksHubBLE(device_or_address)
1✔
364
        elif args.conntype == "usb":
1✔
365
            from usb.core import find as find_usb
1✔
366

367
            from pybricksdev.connections.pybricks import PybricksHubUSB
1✔
368
            from pybricksdev.usb import (
1✔
369
                EV3_USB_PID,
370
                LEGO_USB_VID,
371
                MINDSTORMS_INVENTOR_USB_PID,
372
                NXT_USB_PID,
373
                SPIKE_ESSENTIAL_USB_PID,
374
                SPIKE_PRIME_USB_PID,
375
            )
376

377
            def is_pybricks_usb(dev):
1✔
378
                return (
×
379
                    (dev.idVendor == LEGO_USB_VID)
380
                    and (
381
                        dev.idProduct
382
                        in [
383
                            NXT_USB_PID,
384
                            EV3_USB_PID,
385
                            SPIKE_PRIME_USB_PID,
386
                            SPIKE_ESSENTIAL_USB_PID,
387
                            MINDSTORMS_INVENTOR_USB_PID,
388
                        ]
389
                    )
390
                    and dev.product.endswith("Pybricks")
391
                )
392

393
            device_or_address = find_usb(custom_match=is_pybricks_usb)
1✔
394

395
            if device_or_address is None:
1✔
396
                print("Pybricks Hub not found.", file=sys.stderr)
×
397
                exit(1)
×
398

399
            hub = PybricksHubUSB(device_or_address)
1✔
400
        else:
401
            raise ValueError(f"Unknown connection type: {args.conntype}")
×
402

403
        # Connect to the address and run the script
404
        await hub.connect()
1✔
405
        try:
1✔
406
            with _get_script_path(args.file) as script_path:
1✔
407
                if args.start:
1✔
408
                    await hub.run(script_path, args.wait or args.stay_connected)
1✔
409
                else:
410
                    if args.stay_connected:
1✔
411
                        # if the user later starts the program by pressing the button on the hub,
412
                        # we still want the hub stdout to print to Python's stdout
413
                        hub.print_output = True
×
414
                        hub._enable_line_handler = True
×
415
                    await hub.download(script_path)
1✔
416

417
        except subprocess.CalledProcessError as e:
1✔
418
            print()
1✔
419
            print("mpy-cross failed to compile the program:")
1✔
420
            print(e.stderr.decode())
1✔
421

422
        finally:
423
            if args.stay_connected:
1✔
424
                await self.stay_connected_menu(hub, args)
1✔
425
            await hub.disconnect()
1✔
426

427

428
class Flash(Tool):
1✔
429

430
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
431
        parser = subparsers.add_parser(
×
432
            "flash", help="flash firmware on a LEGO Powered Up device"
433
        )
434
        parser.tool = self
×
435

436
        parser.add_argument(
×
437
            "firmware",
438
            metavar="<firmware-file>",
439
            type=argparse.FileType(mode="rb"),
440
            help="the firmware .zip file",
441
        ).completer = FilesCompleter(allowednames=(".zip",))
442

443
        parser.add_argument(
×
444
            "-n", "--name", metavar="<name>", type=str, help="a custom name for the hub"
445
        )
446

447
    def run(self, args: argparse.Namespace):
1✔
448
        from pybricksdev.cli.flash import flash_firmware
×
449

450
        return flash_firmware(args.firmware, args.name)
×
451

452

453
class DFUBackup(Tool):
1✔
454
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
455
        parser = subparsers.add_parser("backup", help="backup firmware using DFU")
×
456
        parser.tool = self
×
457
        parser.add_argument(
×
458
            "firmware",
459
            metavar="<firmware-file>",
460
            type=argparse.FileType(mode="wb"),
461
            help="the firmware .bin file",
462
        ).completer = FilesCompleter(allowednames=(".bin",))
463

464
    async def run(self, args: argparse.Namespace):
1✔
465
        from pybricksdev.dfu import backup_dfu
×
466

467
        backup_dfu(args.firmware)
×
468

469

470
class DFURestore(Tool):
1✔
471
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
472
        parser = subparsers.add_parser(
×
473
            "restore",
474
            help="restore firmware using DFU",
475
        )
476
        parser.tool = self
×
477
        parser.add_argument(
×
478
            "firmware",
479
            metavar="<firmware-file>",
480
            type=argparse.FileType(mode="rb"),
481
            help="the firmware .bin file",
482
        ).completer = FilesCompleter(allowednames=(".bin",))
483

484
    async def run(self, args: argparse.Namespace):
1✔
485
        from pybricksdev.dfu import restore_dfu
×
486

487
        restore_dfu(args.firmware)
×
488

489

490
class DFU(Tool):
1✔
491
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
492
        self.parser = subparsers.add_parser(
×
493
            "dfu",
494
            help="use DFU to backup or restore firmware",
495
        )
496
        self.parser.tool = self
×
497
        self.subparsers = self.parser.add_subparsers(
×
498
            metavar="<action>", dest="action", help="the action to perform"
499
        )
500

501
        for tool in DFUBackup(), DFURestore():
×
502
            tool.add_parser(self.subparsers)
×
503

504
    def run(self, args: argparse.Namespace):
1✔
505
        if args.action not in self.subparsers.choices:
×
506
            self.parser.error(
×
507
                f'Missing name of action: {"|".join(self.subparsers.choices.keys())}'
508
            )
509

510
        return self.subparsers.choices[args.action].tool.run(args)
×
511

512

513
class OADFlash(Tool):
1✔
514
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
515
        parser = subparsers.add_parser(
×
516
            "flash",
517
            help="update firmware on a LEGO Powered Up device using TI OAD",
518
        )
519
        parser.tool = self
×
520
        parser.add_argument(
×
521
            "firmware",
522
            metavar="<firmware-file>",
523
            type=argparse.FileType(mode="rb"),
524
            help="the firmware .oda file",
525
        ).completer = FilesCompleter(allowednames=(".oda",))
526

527
    async def run(self, args: argparse.Namespace):
1✔
528
        from pybricksdev.cli.oad import flash_oad_image
×
529

530
        await flash_oad_image(args.firmware)
×
531

532

533
class OADInfo(Tool):
1✔
534
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
535
        parser = subparsers.add_parser(
×
536
            "info",
537
            help="get information about firmware on a LEGO Powered Up device using TI OAD",
538
        )
539
        parser.tool = self
×
540

541
    async def run(self, args: argparse.Namespace):
1✔
542
        from pybricksdev.cli.oad import dump_oad_info
×
543

544
        await dump_oad_info()
×
545

546

547
class OAD(Tool):
1✔
548
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
549
        self.parser = subparsers.add_parser(
×
550
            "oad",
551
            help="update firmware on a LEGO Powered Up device using TI OAD",
552
        )
553
        self.parser.tool = self
×
554
        self.subparsers = self.parser.add_subparsers(
×
555
            metavar="<action>", dest="action", help="the action to perform"
556
        )
557

558
        for tool in OADFlash(), OADInfo():
×
559
            tool.add_parser(self.subparsers)
×
560

561
    def run(self, args: argparse.Namespace):
1✔
562
        if args.action not in self.subparsers.choices:
×
563
            self.parser.error(
×
564
                f'Missing name of action: {"|".join(self.subparsers.choices.keys())}'
565
            )
566

567
        return self.subparsers.choices[args.action].tool.run(args)
×
568

569

570
class LWP3Repl(Tool):
1✔
571
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
572
        parser = subparsers.add_parser(
×
573
            "repl",
574
            help="interactive REPL for sending and receiving LWP3 messages",
575
        )
576
        parser.tool = self
×
577

578
    def run(self, args: argparse.Namespace):
1✔
579
        from pybricksdev.cli.lwp3.repl import repl, setup_repl_logging
×
580

581
        setup_repl_logging()
×
582
        return repl()
×
583

584

585
class LWP3(Tool):
1✔
586
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
587
        self.parser = subparsers.add_parser(
×
588
            "lwp3", help="interact with devices using LWP3"
589
        )
590
        self.parser.tool = self
×
591
        self.subparsers = self.parser.add_subparsers(
×
592
            metavar="<lwp3-tool>", dest="lwp3_tool", help="the tool to run"
593
        )
594

595
        for tool in (LWP3Repl(),):
×
596
            tool.add_parser(self.subparsers)
×
597

598
    def run(self, args: argparse.Namespace):
1✔
599
        if args.lwp3_tool not in self.subparsers.choices:
×
600
            self.parser.error(
×
601
                f'Missing name of tool: {"|".join(self.subparsers.choices.keys())}'
602
            )
603

604
        return self.subparsers.choices[args.lwp3_tool].tool.run(args)
×
605

606

607
class Udev(Tool):
1✔
608
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
609
        parser = subparsers.add_parser("udev", help="print udev rules to stdout")
1✔
610
        parser.tool = self
1✔
611

612
    async def run(self, args: argparse.Namespace):
1✔
613
        from importlib.resources import read_text
1✔
614

615
        from pybricksdev import resources
1✔
616

617
        print(read_text(resources, resources.UDEV_RULES))
1✔
618

619

620
def main():
1✔
621
    """Runs ``pybricksdev`` command line interface."""
622

623
    if sys.platform == "win32":
×
624
        # Hack around bad side-effects of pythoncom on Windows
625
        try:
×
626
            from bleak_winrt._winrt import MTA, init_apartment
×
627
        except ImportError:
×
628
            from winrt._winrt import MTA, init_apartment
×
629

630
        init_apartment(MTA)
×
631

632
    # Provide main description and help.
633
    parser = argparse.ArgumentParser(
×
634
        prog=PROG_NAME,
635
        description="Utilities for Pybricks developers.",
636
        epilog="Run `%(prog)s <tool> --help` for tool-specific arguments.",
637
    )
638

639
    parser.add_argument(
×
640
        "-v", "--version", action="version", version=f"{MODULE_NAME} v{MODULE_VERSION}"
641
    )
642
    parser.add_argument(
×
643
        "-d", "--debug", action="store_true", help="enable debug logging"
644
    )
645

646
    subparsers = parser.add_subparsers(
×
647
        metavar="<tool>",
648
        dest="tool",
649
        help="the tool to use",
650
    )
651

652
    for tool in Compile(), Run(), Flash(), DFU(), OAD(), LWP3(), Udev():
×
653
        tool.add_parser(subparsers)
×
654

655
    argcomplete.autocomplete(parser)
×
656
    args = parser.parse_args()
×
657

658
    logging.basicConfig(
×
659
        format="%(asctime)s: %(levelname)s: %(name)s: %(message)s",
660
        level=logging.DEBUG if args.debug else logging.WARNING,
661
    )
662

663
    if not args.tool:
×
664
        parser.error(f'Missing name of tool: {"|".join(subparsers.choices.keys())}')
×
665

666
    asyncio.run(subparsers.choices[args.tool].tool.run(args))
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc