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

pybricks / pybricksdev / 16748212402

05 Aug 2025 11:02AM UTC coverage: 53.606% (-0.01%) from 53.618%
16748212402

push

github

laurensvalk
setup: Don't flake8 pystone.

This got included since a poetry update but it should not be. Also delete unused Jupyter notebook.

63 of 241 branches covered (26.14%)

Branch coverage included in aggregate %.

1959 of 3531 relevant lines covered (55.48%)

0.55 hits per line

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

44.93
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 sys
1✔
12
from abc import ABC, abstractmethod
1✔
13
from os import PathLike, path
1✔
14
from tempfile import NamedTemporaryFile
1✔
15
from typing import ContextManager, TextIO
1✔
16

17
import argcomplete
1✔
18
from argcomplete.completers import FilesCompleter
1✔
19

20
from pybricksdev import __name__ as MODULE_NAME
1✔
21
from pybricksdev import __version__ as MODULE_VERSION
1✔
22

23
PROG_NAME = (
1✔
24
    f"{path.basename(sys.executable)} -m {MODULE_NAME}"
25
    if sys.argv[0].endswith("__main__.py")
26
    else path.basename(sys.argv[0])
27
)
28

29

30
class Tool(ABC):
1✔
31
    """Common base class for tool implementations."""
32

33
    @abstractmethod
1✔
34
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
35
        """
36
        Overriding methods must at least do the following::
37

38
            parser = subparsers.add_parser('tool', ...)
39
            parser.tool = self
40

41
        Then additional arguments can be added using the ``parser`` object.
42
        """
43
        pass
×
44

45
    @abstractmethod
1✔
46
    async def run(self, args: argparse.Namespace):
1✔
47
        """
48
        Overriding methods should provide an implementation to run the tool.
49
        """
50
        pass
×
51

52

53
def _get_script_path(file: TextIO) -> ContextManager[PathLike]:
1✔
54
    """
55
    Gets the path to a script on the file system.
56

57
    If the file is ``sys.stdin``, the contents are copied to a temporary file
58
    and the path to the temporary file is returned. Otherwise, the file is closed
59
    and the path is returned.
60

61
    The context manager will delete the temporary file, if applicable.
62
    """
63
    if file is sys.stdin:
1✔
64
        # Have to close the temp file so that mpy-cross can read it, so we
65
        # create our own context manager to delete the file when we are done
66
        # using it.
67

68
        @contextlib.contextmanager
×
69
        def temp_context():
×
70
            try:
×
71
                with NamedTemporaryFile(suffix=".py", delete=False) as temp:
×
72
                    temp.write(file.buffer.read())
×
73

74
                yield temp.name
×
75
            finally:
76
                try:
×
77
                    os.remove(temp.name)
×
78
                except NameError:
×
79
                    # if NamedTemporaryFile() throws, temp is not defined
80
                    pass
×
81
                except OSError:
×
82
                    # file was already deleted or other strangeness
83
                    pass
×
84

85
        return temp_context()
×
86

87
    file.close()
1✔
88
    return contextlib.nullcontext(file.name)
1✔
89

90

91
class Compile(Tool):
1✔
92
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
93
        parser = subparsers.add_parser(
×
94
            "compile",
95
            help="compile a Pybricks program without running it",
96
        )
97
        parser.add_argument(
×
98
            "file",
99
            metavar="<file>",
100
            help="path to a MicroPython script or `-` for stdin",
101
            type=argparse.FileType(),
102
        )
103
        parser.add_argument(
×
104
            "--abi",
105
            metavar="<n>",
106
            help="the MPY ABI version, one of %(choices)s (default: %(default)s)",
107
            default=6,
108
            choices=[5, 6],
109
            type=int,
110
        )
111
        parser.tool = self
×
112

113
    async def run(self, args: argparse.Namespace):
1✔
114
        from pybricksdev.compile import compile_multi_file, print_mpy
×
115

116
        with _get_script_path(args.file) as script_path:
×
117
            mpy = await compile_multi_file(script_path, args.abi)
×
118
        print_mpy(mpy)
×
119

120

121
class Run(Tool):
1✔
122
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
123
        parser = subparsers.add_parser(
1✔
124
            "run",
125
            help="run a Pybricks program",
126
        )
127
        parser.tool = self
1✔
128
        parser.add_argument(
1✔
129
            "conntype",
130
            metavar="<connection type>",
131
            help="connection type: %(choices)s",
132
            choices=["ble", "usb"],
133
        )
134
        parser.add_argument(
1✔
135
            "file",
136
            metavar="<file>",
137
            help="path to a MicroPython script or `-` for stdin",
138
            type=argparse.FileType(),
139
        )
140
        parser.add_argument(
1✔
141
            "-n",
142
            "--name",
143
            metavar="<name>",
144
            required=False,
145
            help="Bluetooth device name or Bluetooth address for BLE connection; "
146
            "serial port name for USB connection",
147
        )
148

149
        parser.add_argument(
1✔
150
            "--start",
151
            help="Start the program immediately after downloading it.",
152
            action=argparse.BooleanOptionalAction,
153
            default=True,
154
        )
155

156
        parser.add_argument(
1✔
157
            "--wait",
158
            help="Wait for the program to complete before disconnecting. Only applies when starting program right away.",
159
            action=argparse.BooleanOptionalAction,
160
            default=True,
161
        )
162

163
    async def run(self, args: argparse.Namespace):
1✔
164

165
        # Pick the right connection
166
        if args.conntype == "ble":
1✔
167
            from pybricksdev.ble import find_device as find_ble
1✔
168
            from pybricksdev.connections.pybricks import PybricksHubBLE
1✔
169

170
            # It is a Pybricks Hub with BLE. Device name or address is given.
171
            print(f"Searching for {args.name or 'any hub with Pybricks service'}...")
1✔
172
            device_or_address = await find_ble(args.name)
1✔
173
            hub = PybricksHubBLE(device_or_address)
1✔
174
        elif args.conntype == "usb":
1✔
175
            from usb.core import find as find_usb
1✔
176

177
            from pybricksdev.connections.pybricks import PybricksHubUSB
1✔
178
            from pybricksdev.usb import (
1✔
179
                EV3_USB_PID,
180
                LEGO_USB_VID,
181
                MINDSTORMS_INVENTOR_USB_PID,
182
                NXT_USB_PID,
183
                SPIKE_ESSENTIAL_USB_PID,
184
                SPIKE_PRIME_USB_PID,
185
            )
186

187
            def is_pybricks_usb(dev):
1✔
188
                return (
×
189
                    (dev.idVendor == LEGO_USB_VID)
190
                    and (
191
                        dev.idProduct
192
                        in [
193
                            NXT_USB_PID,
194
                            EV3_USB_PID,
195
                            SPIKE_PRIME_USB_PID,
196
                            SPIKE_ESSENTIAL_USB_PID,
197
                            MINDSTORMS_INVENTOR_USB_PID,
198
                        ]
199
                    )
200
                    and dev.product.endswith("Pybricks")
201
                )
202

203
            device_or_address = find_usb(custom_match=is_pybricks_usb)
1✔
204

205
            if device_or_address is None:
1✔
206
                print("Pybricks Hub not found.", file=sys.stderr)
×
207
                exit(1)
×
208

209
            hub = PybricksHubUSB(device_or_address)
1✔
210
        else:
211
            raise ValueError(f"Unknown connection type: {args.conntype}")
×
212

213
        # Connect to the address and run the script
214
        await hub.connect()
1✔
215
        try:
1✔
216
            with _get_script_path(args.file) as script_path:
1✔
217
                if args.start:
1✔
218
                    await hub.run(script_path, args.wait)
×
219
                else:
220
                    await hub.download(script_path)
1✔
221
        finally:
222
            await hub.disconnect()
1✔
223

224

225
class Flash(Tool):
1✔
226
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
227
        parser = subparsers.add_parser(
×
228
            "flash", help="flash firmware on a LEGO Powered Up device"
229
        )
230
        parser.tool = self
×
231

232
        parser.add_argument(
×
233
            "firmware",
234
            metavar="<firmware-file>",
235
            type=argparse.FileType(mode="rb"),
236
            help="the firmware .zip file",
237
        ).completer = FilesCompleter(allowednames=(".zip",))
238

239
        parser.add_argument(
×
240
            "-n", "--name", metavar="<name>", type=str, help="a custom name for the hub"
241
        )
242

243
    def run(self, args: argparse.Namespace):
1✔
244
        from pybricksdev.cli.flash import flash_firmware
×
245

246
        return flash_firmware(args.firmware, args.name)
×
247

248

249
class DFUBackup(Tool):
1✔
250
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
251
        parser = subparsers.add_parser("backup", help="backup firmware using DFU")
×
252
        parser.tool = self
×
253
        parser.add_argument(
×
254
            "firmware",
255
            metavar="<firmware-file>",
256
            type=argparse.FileType(mode="wb"),
257
            help="the firmware .bin file",
258
        ).completer = FilesCompleter(allowednames=(".bin",))
259

260
    async def run(self, args: argparse.Namespace):
1✔
261
        from pybricksdev.dfu import backup_dfu
×
262

263
        backup_dfu(args.firmware)
×
264

265

266
class DFURestore(Tool):
1✔
267
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
268
        parser = subparsers.add_parser(
×
269
            "restore",
270
            help="restore firmware using DFU",
271
        )
272
        parser.tool = self
×
273
        parser.add_argument(
×
274
            "firmware",
275
            metavar="<firmware-file>",
276
            type=argparse.FileType(mode="rb"),
277
            help="the firmware .bin file",
278
        ).completer = FilesCompleter(allowednames=(".bin",))
279

280
    async def run(self, args: argparse.Namespace):
1✔
281
        from pybricksdev.dfu import restore_dfu
×
282

283
        restore_dfu(args.firmware)
×
284

285

286
class DFU(Tool):
1✔
287
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
288
        self.parser = subparsers.add_parser(
×
289
            "dfu",
290
            help="use DFU to backup or restore firmware",
291
        )
292
        self.parser.tool = self
×
293
        self.subparsers = self.parser.add_subparsers(
×
294
            metavar="<action>", dest="action", help="the action to perform"
295
        )
296

297
        for tool in DFUBackup(), DFURestore():
×
298
            tool.add_parser(self.subparsers)
×
299

300
    def run(self, args: argparse.Namespace):
1✔
301
        if args.action not in self.subparsers.choices:
×
302
            self.parser.error(
×
303
                f'Missing name of action: {"|".join(self.subparsers.choices.keys())}'
304
            )
305

306
        return self.subparsers.choices[args.action].tool.run(args)
×
307

308

309
class OADFlash(Tool):
1✔
310
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
311
        parser = subparsers.add_parser(
×
312
            "flash",
313
            help="update firmware on a LEGO Powered Up device using TI OAD",
314
        )
315
        parser.tool = self
×
316
        parser.add_argument(
×
317
            "firmware",
318
            metavar="<firmware-file>",
319
            type=argparse.FileType(mode="rb"),
320
            help="the firmware .oda file",
321
        ).completer = FilesCompleter(allowednames=(".oda",))
322

323
    async def run(self, args: argparse.Namespace):
1✔
324
        from pybricksdev.cli.oad import flash_oad_image
×
325

326
        await flash_oad_image(args.firmware)
×
327

328

329
class OADInfo(Tool):
1✔
330
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
331
        parser = subparsers.add_parser(
×
332
            "info",
333
            help="get information about firmware on a LEGO Powered Up device using TI OAD",
334
        )
335
        parser.tool = self
×
336

337
    async def run(self, args: argparse.Namespace):
1✔
338
        from pybricksdev.cli.oad import dump_oad_info
×
339

340
        await dump_oad_info()
×
341

342

343
class OAD(Tool):
1✔
344
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
345
        self.parser = subparsers.add_parser(
×
346
            "oad",
347
            help="update firmware on a LEGO Powered Up device using TI OAD",
348
        )
349
        self.parser.tool = self
×
350
        self.subparsers = self.parser.add_subparsers(
×
351
            metavar="<action>", dest="action", help="the action to perform"
352
        )
353

354
        for tool in OADFlash(), OADInfo():
×
355
            tool.add_parser(self.subparsers)
×
356

357
    def run(self, args: argparse.Namespace):
1✔
358
        if args.action not in self.subparsers.choices:
×
359
            self.parser.error(
×
360
                f'Missing name of action: {"|".join(self.subparsers.choices.keys())}'
361
            )
362

363
        return self.subparsers.choices[args.action].tool.run(args)
×
364

365

366
class LWP3Repl(Tool):
1✔
367
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
368
        parser = subparsers.add_parser(
×
369
            "repl",
370
            help="interactive REPL for sending and receiving LWP3 messages",
371
        )
372
        parser.tool = self
×
373

374
    def run(self, args: argparse.Namespace):
1✔
375
        from pybricksdev.cli.lwp3.repl import repl, setup_repl_logging
×
376

377
        setup_repl_logging()
×
378
        return repl()
×
379

380

381
class LWP3(Tool):
1✔
382
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
383
        self.parser = subparsers.add_parser(
×
384
            "lwp3", help="interact with devices using LWP3"
385
        )
386
        self.parser.tool = self
×
387
        self.subparsers = self.parser.add_subparsers(
×
388
            metavar="<lwp3-tool>", dest="lwp3_tool", help="the tool to run"
389
        )
390

391
        for tool in (LWP3Repl(),):
×
392
            tool.add_parser(self.subparsers)
×
393

394
    def run(self, args: argparse.Namespace):
1✔
395
        if args.lwp3_tool not in self.subparsers.choices:
×
396
            self.parser.error(
×
397
                f'Missing name of tool: {"|".join(self.subparsers.choices.keys())}'
398
            )
399

400
        return self.subparsers.choices[args.lwp3_tool].tool.run(args)
×
401

402

403
class Udev(Tool):
1✔
404
    def add_parser(self, subparsers: argparse._SubParsersAction):
1✔
405
        parser = subparsers.add_parser("udev", help="print udev rules to stdout")
×
406
        parser.tool = self
×
407

408
    async def run(self, args: argparse.Namespace):
1✔
409
        from importlib.resources import read_text
×
410

411
        from pybricksdev import resources
×
412

413
        print(read_text(resources, resources.UDEV_RULES))
×
414

415

416
def main():
1✔
417
    """Runs ``pybricksdev`` command line interface."""
418

419
    if sys.platform == "win32":
×
420
        # Hack around bad side-effects of pythoncom on Windows
421
        try:
×
422
            from bleak_winrt._winrt import MTA, init_apartment
×
423
        except ImportError:
×
424
            from winrt._winrt import MTA, init_apartment
×
425

426
        init_apartment(MTA)
×
427

428
    # Provide main description and help.
429
    parser = argparse.ArgumentParser(
×
430
        prog=PROG_NAME,
431
        description="Utilities for Pybricks developers.",
432
        epilog="Run `%(prog)s <tool> --help` for tool-specific arguments.",
433
    )
434

435
    parser.add_argument(
×
436
        "-v", "--version", action="version", version=f"{MODULE_NAME} v{MODULE_VERSION}"
437
    )
438
    parser.add_argument(
×
439
        "-d", "--debug", action="store_true", help="enable debug logging"
440
    )
441

442
    subparsers = parser.add_subparsers(
×
443
        metavar="<tool>",
444
        dest="tool",
445
        help="the tool to use",
446
    )
447

448
    for tool in Compile(), Run(), Flash(), DFU(), OAD(), LWP3(), Udev():
×
449
        tool.add_parser(subparsers)
×
450

451
    argcomplete.autocomplete(parser)
×
452
    args = parser.parse_args()
×
453

454
    logging.basicConfig(
×
455
        format="%(asctime)s: %(levelname)s: %(name)s: %(message)s",
456
        level=logging.DEBUG if args.debug else logging.WARNING,
457
    )
458

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

462
    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