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

pybricks / pybricksdev / 19934285220

04 Dec 2025 03:27PM UTC coverage: 55.401% (+1.6%) from 53.832%
19934285220

Pull #126

github

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

83 of 265 branches covered (31.32%)

Branch coverage included in aggregate %.

2112 of 3697 relevant lines covered (57.13%)

0.57 hits per line

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

61.2
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("A syntax error occurred while parsing your 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
            if args.stay_connected:
1✔
418
                await self.stay_connected_menu(hub, args)
1✔
419

420
        except subprocess.CalledProcessError as e:
1✔
421
            print()
1✔
422
            print("A syntax error occurred while parsing your program:")
1✔
423
            print(e.stderr.decode())
1✔
424
            if args.stay_connected:
1✔
425
                await self.stay_connected_menu(hub, args)
1✔
426

427
        finally:
428
            await hub.disconnect()
1✔
429

430

431
class Flash(Tool):
1✔
432

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

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

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

450
    def run(self, args: argparse.Namespace):
1✔
451
        from pybricksdev.cli.flash import flash_firmware
×
452

453
        return flash_firmware(args.firmware, args.name)
×
454

455

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

467
    async def run(self, args: argparse.Namespace):
1✔
468
        from pybricksdev.dfu import backup_dfu
×
469

470
        backup_dfu(args.firmware)
×
471

472

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

487
    async def run(self, args: argparse.Namespace):
1✔
488
        from pybricksdev.dfu import restore_dfu
×
489

490
        restore_dfu(args.firmware)
×
491

492

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

504
        for tool in DFUBackup(), DFURestore():
×
505
            tool.add_parser(self.subparsers)
×
506

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

513
        return self.subparsers.choices[args.action].tool.run(args)
×
514

515

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

530
    async def run(self, args: argparse.Namespace):
1✔
531
        from pybricksdev.cli.oad import flash_oad_image
×
532

533
        await flash_oad_image(args.firmware)
×
534

535

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

544
    async def run(self, args: argparse.Namespace):
1✔
545
        from pybricksdev.cli.oad import dump_oad_info
×
546

547
        await dump_oad_info()
×
548

549

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

561
        for tool in OADFlash(), OADInfo():
×
562
            tool.add_parser(self.subparsers)
×
563

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

570
        return self.subparsers.choices[args.action].tool.run(args)
×
571

572

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

581
    def run(self, args: argparse.Namespace):
1✔
582
        from pybricksdev.cli.lwp3.repl import repl, setup_repl_logging
×
583

584
        setup_repl_logging()
×
585
        return repl()
×
586

587

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

598
        for tool in (LWP3Repl(),):
×
599
            tool.add_parser(self.subparsers)
×
600

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

607
        return self.subparsers.choices[args.lwp3_tool].tool.run(args)
×
608

609

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

615
    async def run(self, args: argparse.Namespace):
1✔
616
        from importlib.resources import read_text
1✔
617

618
        from pybricksdev import resources
1✔
619

620
        print(read_text(resources, resources.UDEV_RULES))
1✔
621

622

623
def main():
1✔
624
    """Runs ``pybricksdev`` command line interface."""
625

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

633
        init_apartment(MTA)
×
634

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

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

649
    subparsers = parser.add_subparsers(
×
650
        metavar="<tool>",
651
        dest="tool",
652
        help="the tool to use",
653
    )
654

655
    for tool in Compile(), Run(), Flash(), DFU(), OAD(), LWP3(), Udev():
×
656
        tool.add_parser(subparsers)
×
657

658
    argcomplete.autocomplete(parser)
×
659
    args = parser.parse_args()
×
660

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

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

669
    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