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/session.py ADDED
@@ -0,0 +1,813 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """An interactive session with whatever is running on a hub.
5
+
6
+ The same terminal, whether what is on the other end is the hub's interpreter or
7
+ a program that was just sent to it. Four things happen at once and each is a
8
+ task of its own: the hub's output goes to the screen, the keyboard goes to the
9
+ hub, a timer keeps the status line honest about the window size, and the whole
10
+ thing ends when any one of them decides it should. They share one cancel scope,
11
+ so whichever finishes first takes the others with it -- there is no way for the
12
+ session to half-end.
13
+
14
+ Two orderings in here are load-bearing rather than incidental. The terminal is
15
+ put into raw mode **last**, so it is restored **first**: anything printed while
16
+ the connection is being taken down lands on a terminal that works. And the hub
17
+ is attached to **before** anything is started on it, so the first thing it
18
+ prints is already being listened for when it arrives.
19
+
20
+ What differs between a session with the interpreter and a session with a
21
+ program is only what starts it and what it is called, both of which the caller
22
+ supplies. Everything else -- the escape key, the status line, stopping what we
23
+ started on the way out, and noticing that the hub has gone -- is the same
24
+ session either way, which is the reason this is a module of its own rather than
25
+ part of ``pb repl``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import sys
32
+ from collections.abc import AsyncIterator, Awaitable, Callable
33
+ from dataclasses import dataclass, replace
34
+
35
+ import anyio
36
+ from anyio.abc import TaskGroup
37
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
38
+
39
+ import pb_client
40
+ from pb_cli import banner as banner_module
41
+ from pb_cli import console as console_module
42
+ from pb_cli import escape, ui
43
+ from pb_cli.errors import EXIT_HUB_ERROR, EXIT_INTERRUPTED, EXIT_OK
44
+ from pb_cli.escape import Action
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ __all__ = ["Running", "attached", "have_terminal", "needs_terminal", "streamed"]
49
+
50
+ TICK_SECONDS = 0.25
51
+ """How often to check for things nothing announces, such as a resized window."""
52
+
53
+ FLUSH_SECONDS = 2.0
54
+ """How long leaving waits for what was already typed to reach the hub.
55
+
56
+ Typing a line and leaving in the same breath is an ordinary thing to do, and
57
+ throwing that line away because the session ended a few milliseconds before the
58
+ radio finished with it would be silently wrong. Bounded, because the other
59
+ possibility is a hub that has stopped reading, and then leaving has to win.
60
+ """
61
+
62
+ STOP_SECONDS = 3.0
63
+ """How long leaving waits for the hub to confirm that it has stopped."""
64
+
65
+ INPUT_QUEUE = 256
66
+ """Reads of the keyboard that may be waiting to reach the hub.
67
+
68
+ Keystrokes go to the hub through a queue rather than directly, so that a hub
69
+ which is slow to take input -- or has stopped taking it altogether -- cannot
70
+ stop ``pb`` from noticing the key that ends the session.
71
+ """
72
+
73
+ Announce = Callable[[str], None]
74
+ """Says one line to the user, wherever this session's lines go."""
75
+
76
+ StartFunc = Callable[[Announce], Awaitable[bool]]
77
+ """Starts whatever the session is here to talk to, once the screen is ready.
78
+
79
+ Returns whether it started something. False when there was already
80
+ something running to join, which is the interpreter case, and is what
81
+ decides whether leaving should stop it again. A return value rather than
82
+ a flag the caller sets on something shared: what a function did is the
83
+ thing a function is best at reporting.
84
+ """
85
+
86
+ TRACEBACK = b"Traceback (most recent call last):"
87
+ """How the hub begins the report of a program that ended in an exception.
88
+
89
+ Nothing in the protocol says whether a program finished or failed -- the status
90
+ report only stops saying that something is running -- so the only evidence is
91
+ what the firmware prints. ``print_final_exception`` prints a traceback for an
92
+ unhandled exception, "The program was stopped (SystemExit)." for a deliberate
93
+ stop, and nothing at all for a program that simply ended.
94
+
95
+ Matching on what the hub printed is a heuristic and is worth naming as one. It
96
+ is here because the alternative is worse: a command that ran a program which
97
+ died with an exception, in front of the user, and then reported success.
98
+
99
+ All of this would go away if the hub reported how a program ended -- see
100
+ "Wanted from the firmware" in the design notes. Until then this is the only
101
+ evidence there is.
102
+ """
103
+
104
+ INTERRUPT = b"KeyboardInterrupt"
105
+ """The exception a program ends with when somebody pressed Ctrl-C at it.
106
+
107
+ Worth telling apart from every other exception, because it is not a fault in
108
+ the program: it is what the user asked for. CPython exits 130 for it and 1 for
109
+ anything else, and this follows that.
110
+ """
111
+
112
+ TAIL_BYTES = 4096
113
+ """How much of the end of a program's output to keep, to see how it ended.
114
+
115
+ Enough for a traceback with several frames, and small enough that one printed
116
+ earlier and recovered from has scrolled out of it by the time the program ends.
117
+ """
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class Running:
122
+ """What the session knows about the thing it is talking to.
123
+
124
+ The hub runs one thing at a time and says so in every status report, which
125
+ makes this the answer to both of the questions a session keeps asking: is
126
+ stopping it on the way out ours to do, and is there still anything on the
127
+ other end.
128
+
129
+ A value, so every method here answers "what does this mean" without also
130
+ deciding when it happens. That matters most for the last question a session
131
+ asks -- how the program ended, which has to be read out of what it printed
132
+ -- because the reading is then a function of a value that a test can build
133
+ in one line, rather than something only a whole session can produce.
134
+ """
135
+
136
+ noun: str
137
+ """What this is called when the session has something to say about it."""
138
+
139
+ watch_for_crash: bool = False
140
+ """Whether ending in an exception should make the command fail.
141
+
142
+ True for a program, whose failure is the command's failure; false for the
143
+ interpreter, where an exception is an ordinary thing to type.
144
+ """
145
+
146
+ ours: bool = False
147
+ """Whether this session started it, and so should stop it again."""
148
+
149
+ arrived: bool = False
150
+ """Whether it has been seen running during this session.
151
+
152
+ Until it has, a hub reporting nothing running is the moment before the
153
+ start command took effect rather than the end of the session.
154
+ """
155
+
156
+ running: bool = True
157
+ """Whether the hub last said anything was running on it.
158
+
159
+ True to begin with: a session only gets this far with something either
160
+ started or already going.
161
+ """
162
+
163
+ gone: bool = False
164
+ """Whether it ran and has since stopped."""
165
+
166
+ tail: bytes = b""
167
+ """The end of what it printed, which is where it says how it ended.
168
+
169
+ Kept to :data:`TAIL_BYTES`, so carrying it in a value costs a bounded copy
170
+ per chunk of output rather than a growing one.
171
+ """
172
+
173
+ def we_started_it(self) -> Running:
174
+ """Note that this session is what set it going.
175
+
176
+ Returns:
177
+ The same, now ours to stop again on the way out.
178
+ """
179
+ return replace(self, ours=True)
180
+
181
+ def saw(self, data: bytes) -> Running:
182
+ """Take in some of what it printed.
183
+
184
+ Args:
185
+ data: The bytes, exactly as the hub sent them.
186
+
187
+ Returns:
188
+ The same, remembering the end of the output.
189
+ """
190
+
191
+ if not self.watch_for_crash:
192
+ return self
193
+
194
+ return replace(self, tail=(self.tail + data)[-TAIL_BYTES:])
195
+
196
+ def note(self, status: pb_client.Status) -> Running:
197
+ """Take in what the hub says about itself.
198
+
199
+ Args:
200
+ status: The hub's latest report.
201
+
202
+ Returns:
203
+ The same, brought up to date with the report. A report saying
204
+ nothing is running, from before it has been seen running, changes
205
+ nothing: it is the moment between asking the hub to start
206
+ something and the hub saying it has, and the session has to go on
207
+ believing there is something on the other end for as long as that
208
+ lasts. Leaving in that window otherwise walked away from a program
209
+ this command had started, which is the one thing a session must
210
+ never do -- a hub with something running does not advertise, so it
211
+ cannot then be found.
212
+ """
213
+
214
+ if status.program_running:
215
+ return replace(self, running=True, arrived=True)
216
+
217
+ if not self.arrived:
218
+ return self
219
+
220
+ return replace(self, running=False, gone=True)
221
+
222
+ def _final_exception(self) -> bytes:
223
+ """Name the exception the hub last reported, if it reported one.
224
+
225
+ Returns:
226
+ The line naming it, such as ``b"ZeroDivisionError: divide by
227
+ zero"``, or empty if the tail holds no traceback.
228
+ """
229
+
230
+ if not self.watch_for_crash:
231
+ return b""
232
+
233
+ start = self.tail.rfind(TRACEBACK)
234
+ if start < 0:
235
+ return b""
236
+
237
+ # A traceback's frames are indented and the exception that ends it is
238
+ # not, so the first unindented line after the header is the answer.
239
+ after = self.tail[start + len(TRACEBACK) :]
240
+
241
+ for line in after.splitlines():
242
+ if line and not line.startswith((b" ", b"\t")):
243
+ return line
244
+
245
+ return b""
246
+
247
+ @property
248
+ def crashed(self) -> bool:
249
+ """Whether it ended by raising something nothing caught."""
250
+ ending = self._final_exception()
251
+ return bool(ending) and not ending.startswith(INTERRUPT)
252
+
253
+ @property
254
+ def interrupted(self) -> bool:
255
+ """Whether what ended it was Ctrl-C rather than a fault of its own."""
256
+ return self._final_exception().startswith(INTERRUPT)
257
+
258
+
259
+ def have_terminal() -> bool:
260
+ """Whether there is a terminal to run a session on.
261
+
262
+ Returns:
263
+ True if one is available. Asked rather than assumed, because staying
264
+ with the program is what ``pb run`` does by default and the same
265
+ command has to keep working in a pipeline, a build or an editor pane.
266
+ """
267
+ return console_module.why_unavailable() is None
268
+
269
+
270
+ def needs_terminal(command: str) -> int | None:
271
+ """Refuse a session that has nowhere to draw itself.
272
+
273
+ Args:
274
+ command: What the user typed, for the message.
275
+
276
+ Returns:
277
+ An exit code if there is no usable terminal, or ``None`` if there is.
278
+ """
279
+ problem = console_module.why_unavailable()
280
+ if problem is None:
281
+ return None
282
+
283
+ # No markup in the message: an error's headline is printed literally,
284
+ # because it can carry a hub's name or a file path and a stray bracket in
285
+ # one of those would either vanish or colour the rest of the line.
286
+ # Suggestions are our own words, so those may still use it.
287
+ return ui.usage(
288
+ f"{command} needs a terminal, and {problem}.",
289
+ "Run it in Windows Terminal, PowerShell or cmd."
290
+ if problem.endswith("cannot do this)")
291
+ else "Run it in a terminal rather than through a pipe.",
292
+ )
293
+
294
+
295
+ async def attached(
296
+ service: pb_client.Service,
297
+ info: pb_client.HubInfo,
298
+ hub: str | None,
299
+ *,
300
+ replay: int,
301
+ running: Running,
302
+ start: StartFunc,
303
+ ) -> int:
304
+ """Attach to a hub, take the terminal, and run one session.
305
+
306
+ Args:
307
+ service: The connected service.
308
+ info: The hub being talked to.
309
+ hub: Which hub, as the user named it.
310
+ replay: How much earlier output to show first, in bytes.
311
+ running: What the session expects to be talking to.
312
+ start: Starts it, once the screen is ready to show what it says.
313
+
314
+ Returns:
315
+ The exit code: zero unless the hub went away or the program failed.
316
+ """
317
+
318
+ # Attached before anything is started, so that the first thing it prints is
319
+ # already being listened for by the time it arrives.
320
+ async with service.attach(hub_id=hub, replay_bytes=replay) as events:
321
+ with console_module.raw_console() as console:
322
+ return await _session(service, info, console, events, hub, running, start)
323
+
324
+
325
+ async def _session(
326
+ service: pb_client.Service,
327
+ info: pb_client.HubInfo,
328
+ console: console_module.Console,
329
+ events: AsyncIterator[pb_client.HubEvent],
330
+ hub: str | None,
331
+ running: Running,
332
+ start: StartFunc,
333
+ ) -> int:
334
+ """Run one session, returning when it ends.
335
+
336
+ Args:
337
+ service: The connected service.
338
+ info: The hub being talked to.
339
+ console: The terminal, already raw.
340
+ events: Everything the hub says.
341
+ hub: Which hub, as the user named it.
342
+ running: What the session is talking to.
343
+ start: Starts it, inside the banner and before the tasks.
344
+
345
+ Returns:
346
+ The exit code: zero unless the hub went away or the program failed.
347
+ """
348
+ ended = _Ending(running)
349
+ sender, receiver = anyio.create_memory_object_stream[bytes](INPUT_QUEUE)
350
+
351
+ with banner_module.Banner(console, info) as bar:
352
+ stop = escape.describe(escape.STOP_KEY)
353
+ # The stop key first, and said in full rather than abbreviated to a
354
+ # keystroke: it is the one line here that has to be remembered by
355
+ # somebody who is not reading the screen at the time.
356
+ bar.line(f"{stop} {stop} stops the {running.noun}.")
357
+ bar.line(f"{escape.describe()} q to leave, {escape.describe()} h for help.")
358
+
359
+ # Before the tasks, not inside them, so that leaving in the same breath
360
+ # as starting cannot race the decision about what to leave running.
361
+ if await start(bar.line):
362
+ ended.running = ended.running.we_started_it()
363
+
364
+ async with anyio.create_task_group() as tg:
365
+ tg.start_soon(_show_hub, events, bar, ended, tg.cancel_scope)
366
+ tg.start_soon(_send_input, service, hub, receiver, bar, ended)
367
+ tg.start_soon(_read_keys, service, hub, console, sender, bar, ended, tg)
368
+ tg.start_soon(_tick, bar)
369
+
370
+ running = ended.running
371
+ if running.ours and running.running and not ended.disconnected:
372
+ await _stop_what_we_started(service, hub, bar, running.noun)
373
+ elif running.ours and running.running:
374
+ # Nothing can be sent to a hub that has gone, so whatever it was
375
+ # doing, it is still doing -- and a hub with something running does
376
+ # not advertise. Why it went is not knowable from here: switched
377
+ # off, carried out of range, or its battery flat. Only the first of
378
+ # those needs no rescuing, so the sentence says which case it is
379
+ # about rather than asserting the hub is still on. The gentler fix
380
+ # comes first: the hub's button stops the program, and only a hub
381
+ # that will not answer that needs the power cycle.
382
+ bar.line(
383
+ f"If the hub is still switched on, the {running.noun} is still "
384
+ "running on it, and a hub with something running does not "
385
+ "advertise. Press the hub's button to stop it, or switch the "
386
+ "hub off and on."
387
+ )
388
+
389
+ return _ending(ended.disconnected, running)
390
+
391
+
392
+ def _ending(disconnected: bool, running: Running) -> int:
393
+ """Decide what the command should exit with.
394
+
395
+ Args:
396
+ disconnected: Whether the hub went away rather than the session ending.
397
+ running: What was on the other end, and how it ended.
398
+
399
+ Returns:
400
+ The exit code. Interrupting a program is not a failure of it -- it is
401
+ what the user asked for -- so it is told apart from an exception the
402
+ program did not catch, the way CPython tells them apart.
403
+ """
404
+
405
+ if disconnected:
406
+ return EXIT_HUB_ERROR
407
+
408
+ if running.interrupted:
409
+ return EXIT_INTERRUPTED
410
+
411
+ if running.crashed:
412
+ return EXIT_HUB_ERROR
413
+
414
+ return EXIT_OK
415
+
416
+
417
+ async def streamed(
418
+ service: pb_client.Service,
419
+ info: pb_client.HubInfo,
420
+ hub: str | None,
421
+ *,
422
+ replay: int = 0,
423
+ running: Running,
424
+ start: StartFunc,
425
+ ) -> int:
426
+ """Stay with a program when there is no terminal to do it on.
427
+
428
+ What is left of a session when there is nowhere to draw and nothing to type
429
+ at: the program's output as it is printed, and how it ended. This is what a
430
+ command in a pipeline or a build gets, and it is why that output goes to
431
+ stdout -- ``pb run demo.py > log.txt`` should capture what the program
432
+ printed and nothing else.
433
+
434
+ Args:
435
+ service: The connected service.
436
+ info: The hub being talked to. Unused, but taken so that this and
437
+ :func:`attached` can stand in for one another.
438
+ hub: Which hub.
439
+ replay: How much earlier output to print first, in bytes.
440
+ running: What the session expects to be talking to.
441
+ start: Starts it, once the output is being listened for.
442
+
443
+ Returns:
444
+ The exit code: zero unless the hub went away or the program failed.
445
+ """
446
+ del info
447
+ err = ui.console(err=True)
448
+ out = sys.stdout.buffer
449
+ disconnected = False
450
+
451
+ async with service.attach(hub_id=hub, replay_bytes=replay) as events:
452
+ try:
453
+ if await start(err.print):
454
+ running = running.we_started_it()
455
+
456
+ async for event in events:
457
+ if isinstance(event, pb_client.Output):
458
+ running = running.saw(event.data)
459
+ out.write(event.data)
460
+ out.flush()
461
+ elif isinstance(event, pb_client.Status):
462
+ running = running.note(event)
463
+ if running.gone:
464
+ break
465
+ elif not event.connected and event.state == "disconnected":
466
+ err.print(
467
+ f"The hub disconnected ({event.reason or 'no reason given'})."
468
+ )
469
+ disconnected = True
470
+ break
471
+ finally:
472
+ # However this ends -- the program finishing, Ctrl-C, a broken pipe
473
+ # -- a program this command started must not be left running on a
474
+ # hub that then cannot be found.
475
+ if running.ours and running.running and not disconnected:
476
+ await _stop_quietly(service, hub, running.noun)
477
+
478
+ return _ending(disconnected, running)
479
+
480
+
481
+ async def _stop_quietly(service: pb_client.Service, hub: str | None, noun: str) -> None:
482
+ """Stop what we started, saying nothing about it.
483
+
484
+ Args:
485
+ service: The connected service.
486
+ hub: Which hub.
487
+ noun: What is being stopped, for the log.
488
+ """
489
+
490
+ # Shielded and bounded, because the likeliest reason to be here is that the
491
+ # command was interrupted, and stopping the program is the whole point.
492
+ with anyio.CancelScope(shield=True), anyio.move_on_after(STOP_SECONDS):
493
+ try:
494
+ await service.stop_program(hub)
495
+ except pb_client.JsonRpcError as exc:
496
+ logger.info("could not stop the %s: %s", noun, exc)
497
+
498
+
499
+ async def _stop_what_we_started(
500
+ service: pb_client.Service,
501
+ hub: str | None,
502
+ bar: banner_module.Banner,
503
+ noun: str,
504
+ ) -> None:
505
+ """Stop what this session started, on the way out.
506
+
507
+ A hub only advertises when nothing is running on it. So a session that
508
+ walks away and leaves something going leaves a hub that nothing can find --
509
+ not the next command, not another machine, not the user wondering why their
510
+ hub has apparently switched itself off. Whoever started it puts it back.
511
+
512
+ Something this session merely joined, or a program somebody else started,
513
+ is left alone: it was not ours to stop.
514
+
515
+ Args:
516
+ service: The connected service.
517
+ hub: Which hub.
518
+ bar: The status line, for saying what was done and why.
519
+ noun: What to call what was stopped.
520
+ """
521
+
522
+ # Shielded and bounded: this runs while the session is already unwinding,
523
+ # and a hub that will not answer must not hold the command open.
524
+ with anyio.move_on_after(STOP_SECONDS, shield=True):
525
+ try:
526
+ await service.stop_program(hub)
527
+ except pb_client.JsonRpcError as exc:
528
+ logger.info("could not stop the %s: %s", noun, exc)
529
+ return
530
+
531
+ bar.line(f"{noun.capitalize()} stopped, so the hub can be found again.")
532
+ return
533
+
534
+ logger.info("the hub did not confirm the %s had stopped", noun)
535
+
536
+
537
+ class _Ending:
538
+ """What the session's tasks learn, in the one place they all can reach.
539
+
540
+ A session is four tasks and the code that reads the answer afterwards is
541
+ none of them, so something has to be shared. This is deliberately the only
542
+ thing that is: what each new state *means* is worked out by
543
+ :class:`Running`, which is a value, and this only holds the latest one.
544
+ """
545
+
546
+ def __init__(self, running: Running) -> None:
547
+ """Start out having ended for no reason yet.
548
+
549
+ Args:
550
+ running: What the session expects to be talking to.
551
+ """
552
+ self.running = running
553
+ """What is on the other end, as the tasks last saw it."""
554
+
555
+ self.disconnected = False
556
+ """Set when the hub went away rather than the user leaving."""
557
+
558
+ self.flushed = anyio.Event()
559
+ """Set once everything typed has reached the hub, or failed to."""
560
+
561
+
562
+ async def _show_hub(
563
+ events: AsyncIterator[pb_client.HubEvent],
564
+ bar: banner_module.Banner,
565
+ ended: _Ending,
566
+ scope: anyio.CancelScope,
567
+ ) -> None:
568
+ """Put everything the hub says on the screen, and notice when it stops.
569
+
570
+ Args:
571
+ events: The hub's output, status and state.
572
+ bar: The status line and the screen above it.
573
+ ended: What the session has learned, kept up to date here.
574
+ scope: The session's scope, cancelled when there is nothing left to
575
+ listen to.
576
+ """
577
+
578
+ async for event in events:
579
+ if isinstance(event, pb_client.Output):
580
+ ended.running = ended.running.saw(event.data)
581
+ bar.write(event.data)
582
+ elif isinstance(event, pb_client.Status):
583
+ ended.running = ended.running.note(event)
584
+ bar.update(event)
585
+
586
+ if ended.running.gone:
587
+ # A program ends by itself, and Ctrl-D ends the interpreter, as
588
+ # does stopping either from anywhere else. However it happened
589
+ # there is nothing left to type at, and a session sitting at a
590
+ # prompt that is not there would be the tool pretending
591
+ # otherwise.
592
+ bar.line(f"The {ended.running.noun} stopped, so the session is over.")
593
+ scope.cancel()
594
+ return
595
+ elif not event.connected and event.state == "disconnected":
596
+ bar.line(f"The hub disconnected ({event.reason or 'no reason given'}).")
597
+ ended.disconnected = True
598
+
599
+ # The stream only ends when the hub has gone, so there is nothing left to
600
+ # type at.
601
+ if not ended.disconnected:
602
+ bar.line("The hub is no longer connected.")
603
+ ended.disconnected = True
604
+
605
+ scope.cancel()
606
+
607
+
608
+ async def _send_input(
609
+ service: pb_client.Service,
610
+ hub: str | None,
611
+ keystrokes: MemoryObjectReceiveStream[bytes],
612
+ bar: banner_module.Banner,
613
+ ended: _Ending,
614
+ ) -> None:
615
+ """Pass what was typed to the hub, as much of it at a time as has arrived.
616
+
617
+ Its own task because a write can take a while: a hub whose input buffer is
618
+ full makes the service wait for the program to read. Reading the keyboard
619
+ must not wait behind that, or the key that ends the session would be the
620
+ one key that could not get through.
621
+
622
+ **Everything already waiting goes in one write**, which is what keeps the
623
+ echo up with the typing. A write is a Bluetooth write with response, so it
624
+ costs a round trip -- measured at about 70 ms to a City hub -- and sending
625
+ one queued read at a time meant one round trip per keystroke. Type quicker
626
+ than that and each key waits behind every key before it, so the delay grows
627
+ for as long as the typing goes on rather than settling: measured over a
628
+ 21-character line typed at 25 characters a second, the first character came
629
+ back in 79 ms and the last in 1479 ms, rising by about 70 ms each. Taking
630
+ the whole backlog instead held it between 108 ms and 228 ms, flat.
631
+
632
+ Nothing is waited for to make this happen, so it cannot add latency: this
633
+ takes what has already been typed and no more. The service breaks the
634
+ result into whatever the hub will accept, so a long line is its problem
635
+ rather than ours -- and a paste, which arrives as one read, has always
636
+ taken this path.
637
+
638
+ Args:
639
+ service: The connected service.
640
+ hub: Which hub.
641
+ keystrokes: What has been typed and not yet sent.
642
+ bar: The status line, for saying when the hub would not take it.
643
+ ended: Where to report that the queue has run dry.
644
+ """
645
+
646
+ async with keystrokes:
647
+ async for data in keystrokes:
648
+ try:
649
+ await service.write_stdin(_backlog(keystrokes, data), hub_id=hub)
650
+ except pb_client.JsonRpcError as exc:
651
+ logger.info("the hub would not take input: %s", exc)
652
+ bar.notice(str(exc))
653
+
654
+ # Nothing more is coming and everything that came has gone, so leaving no
655
+ # longer risks throwing away something that was typed.
656
+ ended.flushed.set()
657
+
658
+
659
+ def _backlog(keystrokes: MemoryObjectReceiveStream[bytes], first: bytes) -> bytes:
660
+ """Join one read to everything already queued behind it.
661
+
662
+ Args:
663
+ keystrokes: The queue, which is not waited on.
664
+ first: The read that has already been taken from it.
665
+
666
+ Returns:
667
+ All of it, in the order it was typed. Only what is there now: taking
668
+ nothing on an empty queue is what makes this free of any delay of its
669
+ own.
670
+ """
671
+ typed = bytearray(first)
672
+
673
+ while True:
674
+ try:
675
+ typed += keystrokes.receive_nowait()
676
+ except (anyio.WouldBlock, anyio.EndOfStream):
677
+ # Nothing more waiting, or the keyboard has finished. Either way
678
+ # this is everything there is to send.
679
+ return bytes(typed)
680
+
681
+
682
+ async def _read_keys(
683
+ service: pb_client.Service,
684
+ hub: str | None,
685
+ console: console_module.Console,
686
+ keystrokes: MemoryObjectSendStream[bytes],
687
+ bar: banner_module.Banner,
688
+ ended: _Ending,
689
+ tg: TaskGroup,
690
+ ) -> None:
691
+ """Read the keyboard, sorting keys for the hub from commands for us.
692
+
693
+ Args:
694
+ service: The connected service.
695
+ hub: Which hub.
696
+ console: The terminal.
697
+ keystrokes: Where to put the bytes bound for the hub.
698
+ bar: The status line.
699
+ ended: How the session is ending.
700
+ tg: The session's task group, cancelled when the user leaves.
701
+ """
702
+
703
+ try:
704
+ await _keys_until_done(service, hub, console, keystrokes, bar)
705
+ finally:
706
+ # Closed before waiting, so that what is queued is what gets sent and
707
+ # the writer knows nothing more is coming.
708
+ await keystrokes.aclose()
709
+
710
+ with anyio.move_on_after(FLUSH_SECONDS):
711
+ await ended.flushed.wait()
712
+
713
+ tg.cancel_scope.cancel()
714
+
715
+
716
+ async def _keys_until_done(
717
+ service: pb_client.Service,
718
+ hub: str | None,
719
+ console: console_module.Console,
720
+ keystrokes: MemoryObjectSendStream[bytes],
721
+ bar: banner_module.Banner,
722
+ ) -> None:
723
+ """Read the keyboard until the user asks to leave.
724
+
725
+ Args:
726
+ service: The connected service.
727
+ hub: Which hub.
728
+ console: The terminal.
729
+ keystrokes: Where to put the bytes bound for the hub.
730
+ bar: The status line.
731
+ """
732
+ keys = escape.Filter()
733
+
734
+ while True:
735
+ typed = console.read()
736
+ if not typed:
737
+ # Nothing waiting, so wait for something. A terminal reports more
738
+ # than typing, so a wait that yields no bytes is ordinary rather
739
+ # than a sign of trouble.
740
+ await console.wait()
741
+
742
+ continue
743
+
744
+ keys, for_hub, actions = keys.feed(typed)
745
+
746
+ for action in actions:
747
+ if await _act(action, service, hub, bar):
748
+ return
749
+
750
+ if for_hub:
751
+ try:
752
+ keystrokes.send_nowait(for_hub)
753
+ except anyio.WouldBlock:
754
+ # The hub is far enough behind that hundreds of reads are
755
+ # already waiting. Losing keys is bad; saying nothing about it
756
+ # is worse.
757
+ bar.notice("the hub is not keeping up; input was dropped")
758
+
759
+
760
+ async def _act(
761
+ action: Action,
762
+ service: pb_client.Service,
763
+ hub: str | None,
764
+ bar: banner_module.Banner,
765
+ ) -> bool:
766
+ """Carry out one escape command.
767
+
768
+ Args:
769
+ action: What was asked for.
770
+ service: The connected service.
771
+ hub: Which hub.
772
+ bar: The status line and the screen above it.
773
+
774
+ Returns:
775
+ True if the session should end.
776
+ """
777
+
778
+ if action is Action.QUIT:
779
+ return True
780
+
781
+ if action is Action.STOP:
782
+ try:
783
+ await service.stop_program(hub)
784
+ except pb_client.JsonRpcError as exc:
785
+ # A refusal is worth a word in the status line and nothing more:
786
+ # the session is still perfectly usable.
787
+ logger.info("the hub would not stop the program: %s", exc)
788
+ bar.notice(str(exc))
789
+ else:
790
+ bar.notice("stopped")
791
+ elif action is Action.HELP:
792
+ bar.line("")
793
+
794
+ for keystrokes, what in escape.help_lines():
795
+ bar.line(f" {keystrokes:<16} {what}")
796
+
797
+ bar.line("")
798
+ else:
799
+ bar.notice(f"no such command; {escape.describe()} h for help")
800
+
801
+ return False
802
+
803
+
804
+ async def _tick(bar: banner_module.Banner) -> None:
805
+ """Keep the status line right about the things nothing announces.
806
+
807
+ Args:
808
+ bar: The status line.
809
+ """
810
+
811
+ while True:
812
+ await anyio.sleep(TICK_SECONDS)
813
+ bar.tick()