zettcode-broker 0.3.4__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.
@@ -0,0 +1,837 @@
1
+ """What a session uses to reach the broker: stay connected, say what this session is, send and receive.
2
+
3
+ It opens the session's WebSocket (which is what registers it), tells the broker what the session is now (and again
4
+ after every reconnection, so the broker always hears the latest and never an old value), keeps the roster the broker
5
+ pushes, hands each message that arrives to the shell, and sends the shell's messages with a plain ``POST /messages``
6
+ (see :mod:`.protocol`). Finding the broker, and starting it, is :mod:`.finder`'s.
7
+
8
+ The client lives on the shell's event loop and never blocks it. Nothing it does can fail the session: every error
9
+ ends as an unconnected client, or as a :class:`~.models.SendError` the caller reports. Delivery is at most once: a send
10
+ that was in flight when the connection ended fails with a reason that says the message may or may not have arrived,
11
+ and nothing is retried behind the caller's back.
12
+
13
+ When the connection ends it is made again: at once when the broker closed it with code 1012 (its way of saying it is
14
+ going away), after a growing delay otherwise, and slowly while the broker speaks a newer protocol than this client.
15
+
16
+ ``httpx`` and ``websockets`` are imported where they are first used, because the shell imports this module with the
17
+ rest of the application and they are only wanted once there is a broker to talk to.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import contextlib
24
+ import itertools
25
+ import json
26
+ import time
27
+ from collections import deque
28
+ from collections.abc import Callable, Sequence
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from zettcode.modes import MODE_FOLLOWUP, MODE_STEER, MODES
33
+
34
+ from .finder import Finder
35
+ from .models import Delivered, GroupDelivered, GroupInfo, Incoming, Receipt, RosterEntry, SendError, SessionInfo
36
+ from .protocol import (
37
+ ACK_TIMEOUT,
38
+ PROTOCOL_VERSION,
39
+ VIA_AGENT,
40
+ VIA_USER,
41
+ VIAS,
42
+ ImageData,
43
+ Limits,
44
+ PeerInfo,
45
+ Refused,
46
+ parse_images,
47
+ roster_from_wire,
48
+ )
49
+ from .text import clean_text
50
+
51
+ #: What the broker closes a connection with when it is going away (WebSocket's "service restart").
52
+ GOODBYE = 1012
53
+
54
+ #: Seconds to wait for the broker's answer to anything but a send: the greeting, and the description of this session.
55
+ REQUEST_TIMEOUT = 5.0
56
+
57
+ #: Seconds between attempts to reach the broker, growing to the last one.
58
+ BACKOFF = (0.2, 0.5, 1.0, 2.0, 5.0)
59
+
60
+ #: Seconds between attempts while the broker speaks a newer protocol than this client can, which is the only case
61
+ #: that is waited out: an older one is ended and replaced. Looking costs one file read, so this is short.
62
+ INCOMPATIBLE_DELAY = 5.0
63
+
64
+ #: Seconds the broker may have been out of reach before the client says so.
65
+ PROBLEM_AFTER = 8.0
66
+
67
+
68
+ class _Outage:
69
+ """Says, once, that the broker has been out of reach for a while, and once that it is back.
70
+
71
+ Args:
72
+ tell: Told the sentence, or ``None`` when the broker is back.
73
+ after: Seconds out of reach before it speaks, unless the broker is there but cannot be spoken to.
74
+ """
75
+
76
+ def __init__(self, tell: Callable[[str | None], None] | None, after: float) -> None:
77
+ self.tell = tell
78
+ self._after = after
79
+ self._since: float | None = None
80
+ self._said: str | None = None
81
+
82
+ def failed(self, reason: str, *, at_once: bool) -> None:
83
+ now = time.monotonic()
84
+ if self._since is None:
85
+ self._since = now
86
+ if now - self._since < self._after and not at_once:
87
+ return
88
+ if reason != self._said:
89
+ self._said = reason
90
+ self._speak(reason)
91
+
92
+ def recovered(self) -> None:
93
+ self._since = None
94
+ if self._said is not None:
95
+ self._said = None
96
+ self._speak(None)
97
+
98
+ def _speak(self, message: str | None) -> None:
99
+ if self.tell is not None:
100
+ with contextlib.suppress(Exception):
101
+ self.tell(message)
102
+
103
+
104
+ class BrokerClient:
105
+ """A session's connection to the broker.
106
+
107
+ Args:
108
+ on_message: Called, on the event loop, with each :class:`~.models.Incoming`; returns the
109
+ :class:`~.models.Receipt` that is sent back. It must not block. An exception becomes a refusal.
110
+ on_roster: Called with the roster each time it changes, and with an empty one when the connection is lost.
111
+ on_conflict: Called with a sentence when the broker refuses this session's registration because another
112
+ process holds it, and with ``None`` when that is no longer so.
113
+ on_problem: Called once with a sentence when the broker has been out of reach for ``problem_after``
114
+ seconds (or speaks another protocol), and with ``None`` when the connection is back.
115
+ directory: The broker's directory; :func:`~.files.broker_dir` by default.
116
+ spawner: Starts a broker when none answers; see :class:`~.finder.Finder`.
117
+ reply_timeout: Seconds to wait for the answer to a send.
118
+ request_timeout: Seconds to wait for the broker's answer to anything else.
119
+ connect_timeout: Seconds to wait for a connection.
120
+ spawn_wait: Seconds to wait for a broker that has just been started.
121
+ backoff: Seconds to wait between attempts, growing.
122
+ problem_after: Seconds out of reach before ``on_problem`` is told.
123
+ incompatible_delay: Seconds between attempts while the broker speaks a newer protocol.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ *,
129
+ on_message: Callable[[Incoming], Receipt] | None = None,
130
+ on_roster: Callable[[tuple[PeerInfo, ...]], None] | None = None,
131
+ on_conflict: Callable[[str | None], None] | None = None,
132
+ on_problem: Callable[[str | None], None] | None = None,
133
+ directory: Path | None = None,
134
+ spawner: Callable[[Path], None] | None = None,
135
+ reply_timeout: float = ACK_TIMEOUT + 5.0,
136
+ request_timeout: float = REQUEST_TIMEOUT,
137
+ connect_timeout: float = 2.0,
138
+ spawn_wait: float = 5.0,
139
+ backoff: tuple[float, ...] = BACKOFF,
140
+ problem_after: float = PROBLEM_AFTER,
141
+ incompatible_delay: float = INCOMPATIBLE_DELAY,
142
+ ) -> None:
143
+ self.on_message = on_message
144
+ self.on_roster = on_roster
145
+ #: Called, on the loop, with the id of a watched session and one thing its turn said (see ``STREAM_TYPES``).
146
+ self.on_stream: Callable[[str, dict[str, Any]], None] | None = None
147
+ self.on_conflict = on_conflict
148
+ self.reply_timeout = reply_timeout
149
+ self.request_timeout = request_timeout
150
+ self.backoff = backoff
151
+ self.incompatible_delay = incompatible_delay
152
+ #: What the broker said it accepts, once connected.
153
+ self.limits = Limits()
154
+ self._finder = Finder(
155
+ directory=directory, spawner=spawner, connect_timeout=connect_timeout, spawn_wait=spawn_wait
156
+ )
157
+ self._outage = _Outage(on_problem, problem_after)
158
+ self._session: SessionInfo | None = None
159
+ self._roster: tuple[PeerInfo, ...] = ()
160
+ self._groups: tuple[GroupInfo, ...] = ()
161
+ #: Called, on the loop, with each line an observer is told: ``{"kind": "message", "message": {...}}`` for a
162
+ #: message that passed, ``{"kind": "graph"}`` when who is here or which groups exist changed.
163
+ self.on_traffic: Callable[[dict[str, Any]], None] | None = None
164
+ self._observing = False
165
+ self._conflict: str | None = None
166
+ # The connection that is up, if any.
167
+ self._ws: Any = None
168
+ self._http: Any = None
169
+ self._connected = False
170
+ #: Whether anybody watches this session's turns: the broker says so, and until it does nothing is streamed.
171
+ self._watched = False
172
+ self._watching: set[str] = set()
173
+ self._outbox: deque[str] = deque(maxlen=512)
174
+ self._asks: deque[tuple[str, str]] = deque()
175
+ self._asker: asyncio.Task[None] | None = None
176
+ self._streamer: asyncio.Task[None] | None = None
177
+ self._goodbye = False
178
+ self._came_up = asyncio.Event()
179
+ # Asking the broker something over the WebSocket and waiting for its answer.
180
+ self._replies: dict[int, asyncio.Future[dict[str, Any]]] = {}
181
+ self._numbers = itertools.count(1)
182
+ # Telling the broker what this session is.
183
+ self._dirty = False
184
+ self._pusher: asyncio.Task[None] | None = None
185
+ self._supervisor: asyncio.Task[None] | None = None
186
+ self._closing = False
187
+
188
+ # -- what the shell asks ----------------------------------------------------------------------
189
+ @property
190
+ def connected(self) -> bool:
191
+ """Return whether this session is connected to a broker right now."""
192
+ return self._connected
193
+
194
+ @property
195
+ def on_problem(self) -> Callable[[str | None], None] | None:
196
+ """Told once when the broker has been out of reach for a while, and again when it is back."""
197
+ return self._outage.tell
198
+
199
+ @on_problem.setter
200
+ def on_problem(self, tell: Callable[[str | None], None] | None) -> None:
201
+ self._outage.tell = tell
202
+
203
+ @property
204
+ def last_error(self) -> str | None:
205
+ """Return the last reason a connection attempt failed, for a person trying to find out why."""
206
+ return self._finder.last_error
207
+
208
+ @property
209
+ def roster(self) -> tuple[PeerInfo, ...]:
210
+ """Return every registered session, this one included, as of the last update."""
211
+ return self._roster
212
+
213
+ @property
214
+ def groups(self) -> tuple[GroupInfo, ...]:
215
+ """Return the group chats this session may write to, as of the last update."""
216
+ return self._groups
217
+
218
+ @property
219
+ def session(self) -> SessionInfo | None:
220
+ """Return what this session has told the broker, or will when it connects."""
221
+ return self._session
222
+
223
+ @property
224
+ def conflict(self) -> str | None:
225
+ """Return why registration was refused, or ``None``."""
226
+ return self._conflict
227
+
228
+ def observe(self, callback: Callable[[dict[str, Any]], None] | None) -> None:
229
+ """Ask to be told of every message that passes through the broker (``None`` stops).
230
+
231
+ This is a view of the whole broker, not of this session's own mail, and is for a front end that draws who talks
232
+ to whom. Kept across a lost connection.
233
+ """
234
+ self.on_traffic = callback
235
+ wanted = callback is not None
236
+ if wanted != self._observing:
237
+ self._observing = wanted
238
+ self._say({"op": "observe" if wanted else "unobserve"})
239
+
240
+ def others(self) -> tuple[PeerInfo, ...]:
241
+ """Return the sessions that are not this one."""
242
+ mine = self._session.session_id if self._session is not None else None
243
+ return tuple(entry for entry in self._roster if entry.session_id != mine)
244
+
245
+ # -- watching a turn --------------------------------------------------------------------------
246
+ @property
247
+ def watched(self) -> bool:
248
+ """Return whether anybody is watching this session's turns right now."""
249
+ return self._watched and self._connected
250
+
251
+ def watch(self, session_id: str) -> None:
252
+ """Ask to be told what a session's turns say, as they happen. Kept across a lost connection."""
253
+ if session_id in self._watching:
254
+ return
255
+ self._watching.add(session_id)
256
+ self._ask("watch", session_id)
257
+
258
+ def unwatch(self, session_id: str) -> None:
259
+ """Stop watching a session."""
260
+ if session_id in self._watching:
261
+ self._watching.discard(session_id)
262
+ self._ask("unwatch", session_id)
263
+
264
+ def _ask(self, op: str, session_id: str) -> None:
265
+ """Ask the broker to ``watch`` or ``unwatch`` a session, one request after another in the order they were made.
266
+
267
+ A ``watch`` the broker refuses (it caps what a connection watches) is forgotten rather than kept, so what this
268
+ client believes it watches is what the broker does. Nothing is sent while disconnected: the connection that
269
+ follows asks for every session still wanted.
270
+ """
271
+ if not self._connected:
272
+ return
273
+ self._asks.append((op, session_id))
274
+ if self._asker is None or self._asker.done():
275
+ self._asker = asyncio.ensure_future(self._ask_all())
276
+
277
+ async def _ask_all(self) -> None:
278
+ while self._asks:
279
+ op, session_id = self._asks.popleft()
280
+ try:
281
+ await self._tell({"op": op, "session_id": session_id})
282
+ except Refused:
283
+ if op == "watch":
284
+ self._watching.discard(session_id)
285
+ except Exception: # noqa: BLE001 - a lost connection: what is still wanted is asked for on the next one
286
+ self._asks.clear()
287
+ return
288
+
289
+ def publish(self, event: dict[str, Any]) -> None:
290
+ """Say one thing about this session's turn to whoever watches it; nothing is sent when nobody does."""
291
+ if self.watched:
292
+ self._say({"op": "stream", "event": event})
293
+
294
+ def _say(self, line: dict[str, Any]) -> None:
295
+ """Send a line that expects no answer, in the order given. A connection that is down loses it."""
296
+ if not self._connected:
297
+ return
298
+ self._outbox.append(json.dumps(line))
299
+ if self._streamer is None or self._streamer.done():
300
+ self._streamer = asyncio.ensure_future(self._pump())
301
+
302
+ async def _pump(self) -> None:
303
+ """Send what is queued, one line after another, until there is nothing."""
304
+ while self._outbox and (ws := self._ws) is not None:
305
+ try:
306
+ await ws.send(self._outbox.popleft())
307
+ except Exception: # noqa: BLE001 - a lost connection: what was watched is asked for again on the next
308
+ return
309
+
310
+ def set_session(self, info: SessionInfo | None) -> None:
311
+ """Say what this session is now: a new id, a new title, a new status.
312
+
313
+ Safe to call at any time and from the loop only. While disconnected it is remembered and sent on
314
+ connecting, so the broker always hears the latest and never an old value.
315
+ """
316
+ self._session = info
317
+ self._schedule_push()
318
+
319
+ async def start(self) -> None:
320
+ """Begin connecting in the background; returns at once."""
321
+ if self._supervisor is None and not self._closing:
322
+ self._supervisor = asyncio.ensure_future(self._supervise())
323
+
324
+ async def wait_connected(self, timeout: float) -> bool:
325
+ """Wait until connected, for at most ``timeout`` seconds; used by tests and by callers that must know."""
326
+ if not self._connected:
327
+ with contextlib.suppress(asyncio.TimeoutError):
328
+ await asyncio.wait_for(self._came_up.wait(), timeout)
329
+ return self._connected
330
+
331
+ async def close(self) -> None:
332
+ """Disconnect and stop trying. Quick, idempotent, and never raises."""
333
+ self._closing = True
334
+ for task in (self._pusher, self._supervisor, self._asker):
335
+ if task is not None:
336
+ task.cancel()
337
+ await asyncio.gather(task, return_exceptions=True)
338
+ self._pusher = self._supervisor = self._asker = None
339
+ ws, http = self._ws, self._http
340
+ self._drop()
341
+ if http is not None:
342
+ with contextlib.suppress(Exception):
343
+ await http.aclose()
344
+ if ws is not None:
345
+ # Closing is waited for, briefly: it is what makes the broker see the connection end now and not
346
+ # whenever the loop next turns, which at exit is never.
347
+ with contextlib.suppress(Exception):
348
+ await asyncio.wait_for(ws.close(), 0.5)
349
+
350
+ # -- sending ----------------------------------------------------------------------------------
351
+ async def send(
352
+ self,
353
+ to: str,
354
+ text: str,
355
+ *,
356
+ hops: int = 0,
357
+ via: str = VIA_AGENT,
358
+ direct: bool = False,
359
+ mode: str = MODE_STEER,
360
+ images: Sequence[ImageData] = (),
361
+ ) -> Delivered:
362
+ """Send a message to another session and return once it has taken it.
363
+
364
+ Args:
365
+ to: The session's handle or id.
366
+ text: What to say; it may be empty when ``images`` is not.
367
+ hops: How many sessions it has passed through already.
368
+ via: How it came: the reader, the agent, or an external program.
369
+ direct: Ask a session that can to run it as its own reader's prompt and answer the sender.
370
+ mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the running request,
371
+ ``"followup"`` waits until that request ends.
372
+ images: Pictures to send with it. Only a ``direct`` message from a reader may carry them, and the broker
373
+ refuses them for any other.
374
+
375
+ Raises:
376
+ SendError: There is no broker, or the broker or the recipient refused, or nobody answered in time, or
377
+ the connection was lost with the message in flight.
378
+ """
379
+ if mode not in MODES:
380
+ raise SendError("invalid", "mode is 'steer' or 'followup'")
381
+ cleaned = self._checked(text, silent=bool(images))
382
+ body: dict[str, Any] = {"to": to, "text": cleaned, "via": via, "hops": hops, "mode": mode}
383
+ if direct:
384
+ body["direct"] = True
385
+ if images:
386
+ body["images"] = [picture.to_wire() for picture in images]
387
+ return self._delivered(await self._request("POST", "/messages", json=body, send=True))
388
+
389
+ async def send_group(
390
+ self, group: str, text: str, *, hops: int = 0, via: str = VIA_AGENT, mode: str = MODE_STEER
391
+ ) -> GroupDelivered:
392
+ """Write to a group chat: every other member that is connected is handed the message.
393
+
394
+ Raises:
395
+ SendError: As for :meth:`send`; ``denied`` when this session is not in the group, ``unknown`` when there
396
+ is no such group, ``gone`` when no other member is connected, ``rejected`` etc. when nobody took it.
397
+ """
398
+ if mode not in MODES:
399
+ raise SendError("invalid", "mode is 'steer' or 'followup'")
400
+ body = {"group": group, "text": self._checked(text), "via": via, "hops": hops, "mode": mode}
401
+ reply = await self._request("POST", "/messages", json=body, send=True)
402
+ name = reply.get("group")
403
+ delivered = []
404
+ for item in reply.get("delivered", []) if isinstance(reply.get("delivered"), list) else []:
405
+ peer = PeerInfo.from_wire(item.get("to")) if isinstance(item, dict) else None
406
+ if peer is not None:
407
+ disposition = item.get("disposition")
408
+ delivered.append((peer, disposition if isinstance(disposition, str) else ""))
409
+ failed = tuple(
410
+ (str(item.get("handle", "")), str(item.get("reason", "")))
411
+ for item in reply.get("failed", [])
412
+ if isinstance(item, dict)
413
+ )
414
+ return GroupDelivered(name if isinstance(name, str) else group, tuple(delivered), failed)
415
+
416
+ def _checked(self, text: str, *, silent: bool = False) -> str:
417
+ cleaned = clean_text(text)
418
+ if not cleaned.strip() and not silent:
419
+ raise SendError("empty", "a message needs some text")
420
+ if len(cleaned) > self.limits.max_text:
421
+ raise SendError("too_long", f"a message is limited to {self.limits.max_text} characters")
422
+ return cleaned
423
+
424
+ async def _request(
425
+ self,
426
+ method: str,
427
+ path: str,
428
+ *,
429
+ json: dict[str, Any] | None = None,
430
+ params: dict[str, Any] | None = None,
431
+ send: bool = False,
432
+ headers: dict[str, str] | None = None,
433
+ ) -> dict[str, Any]:
434
+ """Ask the broker over HTTP and return its JSON object; a refusal, or a connection lost, is a ``SendError``."""
435
+ import httpx
436
+
437
+ http = self._http if self._connected else None
438
+ if http is None:
439
+ raise SendError("offline", "not connected to the session broker")
440
+ await self._registered()
441
+ try:
442
+ response = await http.request(
443
+ method, path, json=json, params=params, headers=headers, timeout=self.reply_timeout
444
+ )
445
+ except httpx.TimeoutException:
446
+ raise SendError(
447
+ "timeout",
448
+ "the session broker did not answer in time"
449
+ + ("; the message may or may not have been delivered" if send else ""),
450
+ ) from None
451
+ except httpx.HTTPError:
452
+ raise SendError(
453
+ "lost",
454
+ "the connection to the session broker was lost"
455
+ + ("; the message may or may not have been delivered" if send else ""),
456
+ ) from None
457
+ try:
458
+ body = response.json()
459
+ except ValueError:
460
+ body = None
461
+ if response.status_code != 200 or not isinstance(body, dict):
462
+ refused = Refused.from_wire(body.get("error") if isinstance(body, dict) else None)
463
+ raise SendError(refused.reason, str(refused))
464
+ return body
465
+
466
+ async def manage_group(self, action: str, name: str, members: list[str] | None = None) -> GroupInfo | None:
467
+ """Create a group, add or remove members, leave it or delete it; returns the group after, or ``None`` if gone.
468
+
469
+ Raises:
470
+ SendError: ``denied`` for a group this session is not in, ``duplicate``, ``unknown``, ``invalid``, ``busy``.
471
+ """
472
+ body = await self._request("POST", "/groups", json={"action": action, "name": name, "members": members or []})
473
+ return GroupInfo.from_wire(body.get("group"))
474
+
475
+ async def join_group(self, name: str) -> GroupInfo | None:
476
+ """Put this session in a group, as the owner of the broker (whoever holds its token) may.
477
+
478
+ A session can only add to a group it is already in, which a person at a front end who wants to write to a
479
+ group that is not theirs cannot be; the owner can, and does it without a session key.
480
+
481
+ Raises:
482
+ SendError: ``unknown`` for a group that is not there, ``busy`` when it is full.
483
+ """
484
+ mine = self._session.session_id if self._session is not None else None
485
+ if mine is None:
486
+ raise SendError("not_registered", "this session has not said who it is")
487
+ body = await self._request(
488
+ "POST",
489
+ "/groups",
490
+ json={"action": "add", "name": name, "members": [mine]},
491
+ headers={"X-Session-Key": ""},
492
+ )
493
+ return GroupInfo.from_wire(body.get("group"))
494
+
495
+ async def history(
496
+ self, *, with_: str = "", group: str = "", limit: int = 20, before: int | None = None
497
+ ) -> tuple[list[dict[str, Any]], str]:
498
+ """Return stored messages, oldest first, and the id of the peer when one was asked for.
499
+
500
+ With ``with_`` it is the conversation between this session and that one, with ``group`` what was said in a
501
+ group this session is in, and with neither all of this session's own messages.
502
+
503
+ Raises:
504
+ SendError: ``unknown`` or ``ambiguous`` for a name, ``denied`` for a group this session is not in.
505
+ """
506
+ params: dict[str, Any] = {"limit": limit}
507
+ if with_:
508
+ params["with"] = with_
509
+ if group:
510
+ params["group"] = group
511
+ if before:
512
+ params["before"] = before
513
+ body = await self._request("GET", "/history", params=params)
514
+ messages = body.get("messages")
515
+ peer = body.get("peer")
516
+ return ([m for m in messages if isinstance(m, dict)] if isinstance(messages, list) else []), (
517
+ peer if isinstance(peer, str) else ""
518
+ )
519
+
520
+ async def graph(self) -> dict[str, Any]:
521
+ """Return the broker's whole picture: sessions, groups, how much each pair has said, the latest messages."""
522
+ return await self._request("GET", "/graph")
523
+
524
+ async def older(self, before: int, limit: int = 50, group: str = "") -> dict[str, Any]:
525
+ """Return the messages stored before the message with id ``before``, oldest first, and ``more``.
526
+
527
+ With ``group`` they are that group's messages, and ``before`` of ``0`` asks for its newest.
528
+ """
529
+ params: dict[str, Any] = {"before": before, "limit": limit}
530
+ if group:
531
+ params["group"] = group
532
+ return await self._request("GET", "/older", params=params)
533
+
534
+ async def timeline(self, since: float, until: float, limit: int = 500) -> dict[str, Any]:
535
+ """Return the messages stored between two times (epoch seconds), oldest first, to replay on screen."""
536
+ return await self._request("GET", "/timeline", params={"since": since, "until": until, "limit": limit})
537
+
538
+ @staticmethod
539
+ def _delivered(body: dict[str, Any]) -> Delivered:
540
+ """Read the broker's answer to a send: who took the message."""
541
+ recipient = PeerInfo.from_wire(body.get("to"))
542
+ if recipient is None:
543
+ raise SendError("internal", "the broker's answer did not say who took the message")
544
+ disposition = body.get("disposition")
545
+ return Delivered(recipient, disposition if isinstance(disposition, str) else "", body.get("direct") is True)
546
+
547
+ async def _registered(self) -> None:
548
+ """Wait, for a moment, until the broker has been told about this session.
549
+
550
+ A connection is usable the moment the broker has greeted it, which is before the session has been described
551
+ to it; a send in that gap would be refused for having no session. The wait is bounded and a failure of it is
552
+ not this call's to report: the send that follows gets the broker's own answer.
553
+ """
554
+ pusher = self._pusher
555
+ if pusher is not None and not pusher.done():
556
+ with contextlib.suppress(Exception): # the send that follows gets the broker's own answer
557
+ await asyncio.wait_for(asyncio.shield(pusher), self.request_timeout)
558
+
559
+ # -- staying connected ------------------------------------------------------------------------
560
+ async def _supervise(self) -> None:
561
+ """Stay connected: connect, serve until the connection ends, wait a little, again."""
562
+ failures = 0
563
+ while not self._closing:
564
+ self._goodbye = False
565
+ try:
566
+ served = await self._serve_once()
567
+ except asyncio.CancelledError:
568
+ raise
569
+ except Exception as error: # noqa: BLE001 - a broker problem must never become its session's
570
+ served = False
571
+ self._finder.last_error = (
572
+ str(error) if isinstance(error, ConnectionError) else f"{type(error).__name__}: {error}"
573
+ )
574
+ self._drop()
575
+ if self._closing:
576
+ return
577
+ failures = 0 if served else failures + 1
578
+ if not served:
579
+ self._outage.failed(
580
+ self._finder.last_error or "the session broker cannot be reached", at_once=self._finder.incompatible
581
+ )
582
+ await asyncio.sleep(self._delay(failures))
583
+
584
+ def _delay(self, failures: int) -> float:
585
+ """Return how long to wait before the next attempt."""
586
+ if self._goodbye:
587
+ return 0.05 # the broker said it was leaving: look for the next one at once
588
+ if self._finder.incompatible:
589
+ return self.incompatible_delay
590
+ return self.backoff[min(failures, len(self.backoff) - 1)] if failures else self.backoff[0]
591
+
592
+ def _drop(self) -> None:
593
+ """Forget the connection: nothing can be sent now, and whatever was waiting on it has failed."""
594
+ was, self._connected = self._connected, False
595
+ self._watched = False
596
+ self._groups = ()
597
+ self._outbox.clear()
598
+ self._asks.clear()
599
+ self._came_up.clear()
600
+ self._ws = self._http = None
601
+ for reply in self._replies.values():
602
+ if not reply.done():
603
+ reply.set_exception(ConnectionError("the connection ended"))
604
+ if was and self._roster:
605
+ self._roster = ()
606
+ self._notify_roster()
607
+
608
+ async def _serve_once(self) -> bool:
609
+ """Connect once and serve it to the end; return whether it ever got as far as being greeted."""
610
+ from websockets.exceptions import ConnectionClosed
611
+
612
+ ws, note = await self._finder.open(lambda: self._closing)
613
+ http = None
614
+ greeted = False
615
+ try:
616
+ hello = await self._greeting(ws, note.pid)
617
+ http = self._http_client(note.port, note.token, str(hello.get("key")))
618
+ greeted = True
619
+ self.limits = Limits.from_wire(hello.get("limits"))
620
+ self._ws, self._http, self._connected = ws, http, True
621
+ self._finder.incompatible = False
622
+ self._finder.last_error = None
623
+ self._outage.recovered()
624
+ self._came_up.set()
625
+ self._schedule_push()
626
+ for watching in list(self._watching):
627
+ self._ask("watch", watching)
628
+ if self._observing:
629
+ self._say({"op": "observe"})
630
+ try:
631
+ async for raw in ws:
632
+ await self._heard(ws, raw)
633
+ except ConnectionClosed:
634
+ pass
635
+ self._goodbye = ws.close_code == GOODBYE
636
+ finally:
637
+ if http is not None:
638
+ with contextlib.suppress(Exception):
639
+ await http.aclose()
640
+ with contextlib.suppress(Exception):
641
+ await ws.close()
642
+ return greeted
643
+
644
+ async def _greeting(self, ws: Any, pid: int) -> dict[str, Any]:
645
+ """Read the broker's greeting, turning what can go wrong into a sentence."""
646
+ from websockets.exceptions import ConnectionClosed
647
+
648
+ try:
649
+ hello = json.loads(await asyncio.wait_for(ws.recv(), self.request_timeout))
650
+ except asyncio.TimeoutError:
651
+ raise ConnectionError(f"the session broker (pid {pid}) is running but does not answer") from None
652
+ except (ConnectionClosed, ValueError):
653
+ raise ConnectionError("the session broker closed the connection while greeting") from None
654
+ if not isinstance(hello, dict) or hello.get("op") != "hello" or hello.get("protocol") != PROTOCOL_VERSION:
655
+ self._finder.incompatible = True
656
+ raise ConnectionError(f"the session broker (pid {pid}) speaks another protocol")
657
+ return hello
658
+
659
+ @staticmethod
660
+ def _http_client(port: int, token: str, key: str) -> Any:
661
+ import httpx
662
+
663
+ return httpx.AsyncClient(
664
+ base_url=f"http://127.0.0.1:{port}",
665
+ headers={"Authorization": f"Bearer {token}", "X-Session-Key": key},
666
+ trust_env=False, # the broker is on this machine; a proxy named in the environment is not for it
667
+ )
668
+
669
+ # -- what the broker says ---------------------------------------------------------------------
670
+ async def _heard(self, ws: Any, raw: str | bytes) -> None:
671
+ """Act on one line from the broker; one that makes no sense is ignored."""
672
+ try:
673
+ line = json.loads(raw)
674
+ except ValueError:
675
+ return
676
+ if not isinstance(line, dict):
677
+ return
678
+ match line.get("op"):
679
+ case "roster":
680
+ self._roster = roster_from_wire(line.get("sessions"))
681
+ self._notify_roster()
682
+ case "groups":
683
+ listed = line.get("groups")
684
+ groups = tuple(
685
+ g for g in (GroupInfo.from_wire(item) for item in (listed if isinstance(listed, list) else [])) if g
686
+ )
687
+ if groups != self._groups:
688
+ self._groups = groups
689
+ self._notify_roster()
690
+ case "traffic":
691
+ if self.on_traffic is not None:
692
+ with contextlib.suppress(Exception): # a listener's bug must not end the connection
693
+ self.on_traffic({k: v for k, v in line.items() if k != "op"})
694
+ case "watched":
695
+ count = line.get("count")
696
+ self._watched = isinstance(count, int) and not isinstance(count, bool) and count > 0
697
+ case "stream":
698
+ session_id, event = line.get("session_id"), line.get("event")
699
+ if self.on_stream is not None and isinstance(session_id, str) and isinstance(event, dict):
700
+ with contextlib.suppress(Exception): # a listener's bug must not end the connection
701
+ self.on_stream(session_id, event)
702
+ case "deliver":
703
+ await ws.send(json.dumps({"op": "ack", "id": line.get("id"), **self._deliver(line)}))
704
+ case "done" | "refused":
705
+ reply = self._replies.get(line["n"]) if isinstance(line.get("n"), int) else None
706
+ if reply is not None and not reply.done():
707
+ reply.set_result(line)
708
+
709
+ def _deliver(self, line: dict[str, Any]) -> dict[str, Any]:
710
+ """Hand a message to the session and return what the session said, as the fields of an ack."""
711
+ sender, text, hops, via = (
712
+ PeerInfo.from_wire(line.get("from")),
713
+ line.get("text"),
714
+ line.get("hops"),
715
+ line.get("via"),
716
+ )
717
+ if sender is None or not isinstance(text, str) or not isinstance(hops, int) or isinstance(hops, bool):
718
+ return _ack(Receipt(False, "the message was malformed"))
719
+ if self.on_message is None:
720
+ return _ack(Receipt(False, "this session is not taking messages"))
721
+ mode = line.get("mode")
722
+ if not isinstance(mode, str):
723
+ mode = MODE_STEER # a peer that does not say: the protocol's default
724
+ elif mode not in MODES:
725
+ mode = MODE_FOLLOWUP # one this version does not know: the safe half, not the disruptive one
726
+ try:
727
+ pictures = parse_images(line.get("images")) if line.get("direct") is True and via == VIA_USER else ()
728
+ except Refused:
729
+ return _ack(Receipt(False, "the message's pictures were malformed"))
730
+ incoming = Incoming(
731
+ sender,
732
+ clean_text(text),
733
+ hops,
734
+ via if via in VIAS else VIA_AGENT,
735
+ line.get("direct") is True and via == VIA_USER,
736
+ mode,
737
+ line["group"] if isinstance(line.get("group"), str) else "",
738
+ pictures,
739
+ )
740
+ try:
741
+ return _ack(self.on_message(incoming))
742
+ except Exception as error: # noqa: BLE001 - the sender is told, the session carries on
743
+ return _ack(Receipt(False, f"the session could not take it ({type(error).__name__})"))
744
+
745
+ def _notify_roster(self) -> None:
746
+ if self.on_roster is not None:
747
+ with contextlib.suppress(Exception): # a listener's bug must not end the connection
748
+ self.on_roster(self._roster)
749
+
750
+ # -- telling the broker what this session is --------------------------------------------------
751
+ def _schedule_push(self) -> None:
752
+ self._dirty = True
753
+ if self._connected and (self._pusher is None or self._pusher.done()):
754
+ self._pusher = asyncio.ensure_future(self._push())
755
+
756
+ async def _push(self) -> None:
757
+ """Send the session's latest description, and again if it changed while that was in flight."""
758
+ while self._dirty and self._connected:
759
+ self._dirty = False
760
+ info = self._session
761
+ try:
762
+ await self._tell({"op": "leave"} if info is None else _describe(info))
763
+ self._set_conflict(None)
764
+ except Refused as error:
765
+ if error.reason == "duplicate":
766
+ self._set_conflict(str(error))
767
+ except Exception: # noqa: BLE001 - a lost connection: the next one pushes the latest again
768
+ return
769
+
770
+ async def _tell(self, line: dict[str, Any]) -> dict[str, Any]:
771
+ """Say something to the broker and return its ``done``; its refusal is raised.
772
+
773
+ Raises:
774
+ Refused: The broker refused.
775
+ """
776
+ ws = self._ws
777
+ if ws is None:
778
+ raise ConnectionError("not connected")
779
+ number = next(self._numbers)
780
+ reply: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
781
+ self._replies[number] = reply
782
+ try:
783
+ await ws.send(json.dumps({**line, "n": number}))
784
+ answer = await asyncio.wait_for(reply, self.request_timeout)
785
+ finally:
786
+ self._replies.pop(number, None)
787
+ if answer.get("op") == "refused":
788
+ raise Refused.from_wire(answer.get("error"))
789
+ return answer
790
+
791
+ def _set_conflict(self, message: str | None) -> None:
792
+ if message == self._conflict:
793
+ return
794
+ self._conflict = message
795
+ if self.on_conflict is not None:
796
+ with contextlib.suppress(Exception):
797
+ self.on_conflict(message)
798
+
799
+
800
+ def _describe(info: SessionInfo) -> dict[str, Any]:
801
+ """Return a session as the line that tells the broker what it is."""
802
+ return {
803
+ "op": "session",
804
+ "session_id": info.session_id,
805
+ "title": info.title,
806
+ "workspace": info.workspace,
807
+ "status": info.status,
808
+ "kind": info.kind,
809
+ "direct": info.direct,
810
+ }
811
+
812
+
813
+ def _ack(receipt: Receipt) -> dict[str, Any]:
814
+ """Return a receipt as the fields of an ``ack``."""
815
+ answer: dict[str, Any] = {
816
+ "accepted": receipt.accepted,
817
+ "reason": receipt.reason,
818
+ "disposition": receipt.disposition,
819
+ }
820
+ if receipt.busy:
821
+ answer["busy"] = True
822
+ return answer
823
+
824
+
825
+ __all__ = [
826
+ "BACKOFF",
827
+ "BrokerClient",
828
+ "Delivered",
829
+ "INCOMPATIBLE_DELAY",
830
+ "Incoming",
831
+ "PROBLEM_AFTER",
832
+ "REQUEST_TIMEOUT",
833
+ "Receipt",
834
+ "RosterEntry",
835
+ "SendError",
836
+ "SessionInfo",
837
+ ]