pybricks-cli 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pb_cli/picker.py ADDED
@@ -0,0 +1,124 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Choosing a hub from the ones the radio can hear.
5
+
6
+ The list is being written while it is being read: hubs appear as they are
7
+ heard, and a hub already listed changes as its signal does. So the display is
8
+ redrawn on both counts, and the selection follows the hub it is on rather than
9
+ the row number, or switching a second hub on would move the highlight off the
10
+ one the user had settled on.
11
+
12
+ There is no time limit. The person may be fetching the hub from another room,
13
+ and a deadline on a person only ever expires at the worst moment. Leaving
14
+ without choosing is an ordinary outcome rather than an error, so Escape, ``q``
15
+ and Ctrl-C all simply return nothing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from collections.abc import AsyncIterator, Callable
22
+
23
+ import anyio
24
+ from rich.console import Console, Group, RenderableType
25
+ from rich.text import Text
26
+
27
+ import pb_client
28
+ from pb_cli import keys, ui
29
+ from pb_cli.keys import Key
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ __all__ = ["choose_hub"]
34
+
35
+ # The line under the list. What Enter does depends on who is asking --
36
+ # connecting to a hub and choosing which one to work with are different
37
+ # answers -- so the verb is the caller's to supply. A comment rather than a
38
+ # docstring: a documented attribute is rendered into the API reference, and
39
+ # the arrows in it cannot be encoded by the formatter the renderer shells out
40
+ # to on a Windows machine with a legacy code page.
41
+ HELP = "↑↓ to choose, Enter to {verb}, Ctrl-C to stop looking"
42
+
43
+
44
+ async def choose_hub(
45
+ err: Console,
46
+ devices: AsyncIterator[pb_client.DeviceInfo],
47
+ render: Callable[[list[pb_client.DeviceInfo], int], RenderableType],
48
+ verb: str = "connect",
49
+ ) -> pb_client.DeviceInfo | None:
50
+ """Show hubs as they are found and let the user pick one.
51
+
52
+ Args:
53
+ err: The console to draw on. Not stdout: a chooser is not data, and
54
+ the hub that comes out of it may well be piped somewhere.
55
+ devices: Hubs as they are heard, one item per advertisement.
56
+ render: Draws the list, given the hubs in the order first seen and the
57
+ index of the selected one. Passed in so that this module does not
58
+ have to know what a hub looks like on screen.
59
+ verb: What pressing Enter will do, for the help line.
60
+
61
+ Returns:
62
+ The hub the user chose, or ``None`` if they left without choosing.
63
+ """
64
+ seen: dict[str, pb_client.DeviceInfo] = {}
65
+ order: list[str] = []
66
+ selected = 0
67
+ chosen: pb_client.DeviceInfo | None = None
68
+
69
+ def frame() -> RenderableType:
70
+ listed = [seen[device_id] for device_id in order]
71
+ return Group(
72
+ render(listed, selected),
73
+ Text(HELP.format(verb=verb), style="dim"),
74
+ )
75
+
76
+ with keys.reading_keys(), ui.live(frame(), err) as live:
77
+ async with anyio.create_task_group() as tg:
78
+
79
+ async def watch() -> None:
80
+ async for device in devices:
81
+ if device.id not in seen:
82
+ order.append(device.id)
83
+
84
+ seen[device.id] = device
85
+ live.update(frame(), refresh=True)
86
+
87
+ tg.start_soon(watch)
88
+
89
+ try:
90
+ while True:
91
+ key = keys.poll_key()
92
+ if key is Key.CANCEL:
93
+ break
94
+
95
+ if key is Key.ENTER and order:
96
+ chosen = seen[order[selected]]
97
+ break
98
+
99
+ if key in (Key.UP, Key.DOWN) and order:
100
+ step = -1 if key is Key.UP else 1
101
+ # Wrapping, because a list of two with the highlight
102
+ # stuck at one end is a worse surprise than either
103
+ # choice here.
104
+ selected = (selected + step) % len(order)
105
+ live.update(frame(), refresh=True)
106
+ elif key is None:
107
+ # Nothing typed, so wait for something to be. Waiting
108
+ # rather than sleeping costs nothing here and is what
109
+ # the house rule asks for; the list goes on filling
110
+ # in either way, because the task above redraws it as
111
+ # each hub is heard rather than on a timer.
112
+ await keys.wait_for_key()
113
+ except KeyboardInterrupt:
114
+ # Caught here rather than left to the top level, because the
115
+ # task group would wrap it and because giving up on finding a
116
+ # hub is not a failure. Caught inside the group so that it is
117
+ # never wrapped in the first place.
118
+ logger.debug("nothing chosen: interrupted")
119
+ finally:
120
+ # The scan belongs to the caller, but nothing started here
121
+ # should outlive the choosing.
122
+ tg.cancel_scope.cancel()
123
+
124
+ return chosen
pb_cli/py.typed ADDED
File without changes