sillo-wire 0.1.0.dev1__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.
- _sillo_wire_bootstrap.py +104 -0
- sillo-stubs/py.typed +1 -0
- sillo-stubs/wire.pyi +48 -0
- sillo_wire/__init__.py +58 -0
- sillo_wire/backlog.py +140 -0
- sillo_wire/consumer.py +177 -0
- sillo_wire/envelope.py +92 -0
- sillo_wire/errors.py +30 -0
- sillo_wire/hub.py +315 -0
- sillo_wire/peer.py +231 -0
- sillo_wire/policy.py +42 -0
- sillo_wire/py.typed +0 -0
- sillo_wire/testing.py +86 -0
- sillo_wire-0.1.0.dev1.dist-info/METADATA +244 -0
- sillo_wire-0.1.0.dev1.dist-info/RECORD +18 -0
- sillo_wire-0.1.0.dev1.dist-info/WHEEL +4 -0
- sillo_wire-0.1.0.dev1.dist-info/licenses/LICENSE +27 -0
- sillo_wire.pth +1 -0
_sillo_wire_bootstrap.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Make ``sillo.wire`` resolve to this package, without touching the framework.
|
|
2
|
+
|
|
3
|
+
The code lives in the top-level ``sillo_wire`` package. This module registers a
|
|
4
|
+
meta-path finder that maps the name ``sillo.wire`` onto it, so both import
|
|
5
|
+
paths reach the same objects:
|
|
6
|
+
|
|
7
|
+
from sillo.wire import Hub # reads as part of the framework
|
|
8
|
+
from sillo_wire import Hub # where the code actually is
|
|
9
|
+
|
|
10
|
+
It is loaded by ``sillo_wire.pth`` at interpreter startup, which is the only
|
|
11
|
+
hook that runs *before* an ``import sillo.wire`` could fail. Nothing is
|
|
12
|
+
imported here — neither ``sillo`` nor ``sillo_wire`` — so the cost is one
|
|
13
|
+
object appended to ``sys.meta_path``.
|
|
14
|
+
|
|
15
|
+
Why not simply ship ``sillo/wire/`` into the framework's own package directory:
|
|
16
|
+
two distributions writing into one directory goes wrong in both directions.
|
|
17
|
+
Installing the framework from a checkout moves where ``sillo`` resolves and
|
|
18
|
+
orphans whatever the other package left in ``site-packages``; and removing or
|
|
19
|
+
replacing the framework leaves that directory standing with no ``__init__.py``
|
|
20
|
+
in it, which is an override rather than an addition. Nothing here writes into
|
|
21
|
+
``sillo/`` at all.
|
|
22
|
+
|
|
23
|
+
Static analysis does not run import hooks, so type checkers are served
|
|
24
|
+
separately, by the partial stubs in ``sillo-stubs/`` (PEP 561).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import sys
|
|
30
|
+
from importlib.abc import Loader, MetaPathFinder
|
|
31
|
+
from importlib.machinery import ModuleSpec
|
|
32
|
+
from importlib.util import find_spec
|
|
33
|
+
|
|
34
|
+
ALIAS = "sillo.wire"
|
|
35
|
+
REAL = "sillo_wire"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve(fullname: str) -> str | None:
|
|
39
|
+
"""The real module *fullname* stands for, or ``None`` if it is not ours."""
|
|
40
|
+
if fullname == ALIAS:
|
|
41
|
+
return REAL
|
|
42
|
+
if fullname.startswith(ALIAS + "."):
|
|
43
|
+
return REAL + fullname[len(ALIAS) :]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _AliasLoader(Loader):
|
|
48
|
+
"""Hands back the already-imported target, so both names are one object.
|
|
49
|
+
|
|
50
|
+
Loading the source a second time under the other name would give two
|
|
51
|
+
``Hub`` classes and two sets of rooms — a broadcast would reach half of
|
|
52
|
+
them, and ``isinstance`` would disagree with itself.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, target: str) -> None:
|
|
56
|
+
self.target = target
|
|
57
|
+
|
|
58
|
+
def create_module(self, spec: ModuleSpec):
|
|
59
|
+
import importlib
|
|
60
|
+
|
|
61
|
+
return importlib.import_module(self.target)
|
|
62
|
+
|
|
63
|
+
def exec_module(self, module) -> None:
|
|
64
|
+
"""Already executed under its own name; nothing to run again."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _AliasFinder(MetaPathFinder):
|
|
68
|
+
"""Answers for ``sillo.wire`` and anything beneath it."""
|
|
69
|
+
|
|
70
|
+
def find_spec(self, fullname: str, path=None, target=None):
|
|
71
|
+
real = _resolve(fullname)
|
|
72
|
+
if real is None:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
if find_spec(real) is None:
|
|
76
|
+
return None
|
|
77
|
+
except (ImportError, ValueError):
|
|
78
|
+
# The package is half-installed, or its parent is missing. Decline
|
|
79
|
+
# rather than raise: another finder may do better, and the import
|
|
80
|
+
# error a caller gets should be the ordinary one.
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
import importlib
|
|
84
|
+
|
|
85
|
+
module = importlib.import_module(real)
|
|
86
|
+
spec = ModuleSpec(fullname, _AliasLoader(real))
|
|
87
|
+
spec.submodule_search_locations = getattr(module, "__path__", None)
|
|
88
|
+
return spec
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def install() -> bool:
|
|
92
|
+
"""Register the finder. Returns whether it was newly added.
|
|
93
|
+
|
|
94
|
+
Idempotent, because a ``.pth`` is not the only thing that may import this
|
|
95
|
+
module — a test does too, and a second finder would be dead weight on every
|
|
96
|
+
import in the process.
|
|
97
|
+
"""
|
|
98
|
+
if any(isinstance(finder, _AliasFinder) for finder in sys.meta_path):
|
|
99
|
+
return False
|
|
100
|
+
sys.meta_path.insert(0, _AliasFinder())
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
install()
|
sillo-stubs/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
partial
|
sillo-stubs/wire.pyi
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Type stubs for ``sillo.wire``.
|
|
2
|
+
|
|
3
|
+
The runtime alias is installed by ``_sillo_wire_bootstrap`` via a ``.pth``, and
|
|
4
|
+
a type checker never runs import hooks — so without this file
|
|
5
|
+
``from sillo.wire import Hub`` type-checks as a missing module even though it
|
|
6
|
+
imports fine.
|
|
7
|
+
|
|
8
|
+
``py.typed`` next to this file contains the word ``partial`` (PEP 561), which
|
|
9
|
+
is what keeps these stubs additive: a checker uses them for ``sillo.wire`` and
|
|
10
|
+
falls back to the framework's own inline types for the rest of ``sillo``.
|
|
11
|
+
Without it, this directory would claim to describe all of ``sillo`` and hide
|
|
12
|
+
the types the framework ships.
|
|
13
|
+
|
|
14
|
+
Nothing is declared here. Everything is re-exported from ``sillo_wire``, whose
|
|
15
|
+
inline annotations are the single source of truth.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from sillo_wire import Backlog as Backlog
|
|
19
|
+
from sillo_wire import DeliveryReport as DeliveryReport
|
|
20
|
+
from sillo_wire import Encoding as Encoding
|
|
21
|
+
from sillo_wire import Envelope as Envelope
|
|
22
|
+
from sillo_wire import Hub as Hub
|
|
23
|
+
from sillo_wire import MemoryBacklog as MemoryBacklog
|
|
24
|
+
from sillo_wire import NullBacklog as NullBacklog
|
|
25
|
+
from sillo_wire import Overflow as Overflow
|
|
26
|
+
from sillo_wire import Peer as Peer
|
|
27
|
+
from sillo_wire import PeerGone as PeerGone
|
|
28
|
+
from sillo_wire import RoomConsumer as RoomConsumer
|
|
29
|
+
from sillo_wire import RoomNotFound as RoomNotFound
|
|
30
|
+
from sillo_wire import WireError as WireError
|
|
31
|
+
|
|
32
|
+
__version__: str
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Backlog",
|
|
36
|
+
"DeliveryReport",
|
|
37
|
+
"Encoding",
|
|
38
|
+
"Envelope",
|
|
39
|
+
"Hub",
|
|
40
|
+
"MemoryBacklog",
|
|
41
|
+
"NullBacklog",
|
|
42
|
+
"Overflow",
|
|
43
|
+
"Peer",
|
|
44
|
+
"PeerGone",
|
|
45
|
+
"RoomConsumer",
|
|
46
|
+
"RoomNotFound",
|
|
47
|
+
"WireError",
|
|
48
|
+
]
|
sillo_wire/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Rooms, presence and fan-out for Sillo WebSockets.
|
|
2
|
+
|
|
3
|
+
Install as ``sillo-wire``; import as ``sillo_wire``::
|
|
4
|
+
|
|
5
|
+
from sillo import SilloApp
|
|
6
|
+
from sillo_wire import Hub, Peer
|
|
7
|
+
|
|
8
|
+
app = SilloApp()
|
|
9
|
+
hub = Hub()
|
|
10
|
+
|
|
11
|
+
@app.ws_route("/ws/room/{name}")
|
|
12
|
+
async def room(socket, name: str):
|
|
13
|
+
await socket.accept()
|
|
14
|
+
peer = Peer(socket, identity=socket.query_params.get("user"))
|
|
15
|
+
await hub.join(peer, name)
|
|
16
|
+
try:
|
|
17
|
+
async for message in socket.iter_json():
|
|
18
|
+
await hub.broadcast(name, message)
|
|
19
|
+
finally:
|
|
20
|
+
await hub.disconnect(peer)
|
|
21
|
+
|
|
22
|
+
Three things differ from a naive implementation, and they are the reason this
|
|
23
|
+
package exists:
|
|
24
|
+
|
|
25
|
+
* **A broadcast never blocks.** Peers have bounded queues and a writer each, so
|
|
26
|
+
one client that has stopped reading cannot stall the room behind it.
|
|
27
|
+
* **Nothing is global.** A :class:`Hub` is an object. Two of them are two
|
|
28
|
+
independent worlds, which is what makes tests and multi-tenancy simple.
|
|
29
|
+
* **History is replayable.** Envelopes carry a monotonic sequence, so a client
|
|
30
|
+
that reconnects asks for what it missed rather than for everything.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from sillo_wire.backlog import Backlog, MemoryBacklog, NullBacklog
|
|
34
|
+
from sillo_wire.consumer import RoomConsumer
|
|
35
|
+
from sillo_wire.envelope import DeliveryReport, Encoding, Envelope
|
|
36
|
+
from sillo_wire.errors import PeerGone, RoomNotFound, WireError
|
|
37
|
+
from sillo_wire.hub import Hub
|
|
38
|
+
from sillo_wire.peer import Peer
|
|
39
|
+
from sillo_wire.policy import Overflow
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0.dev1"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Backlog",
|
|
45
|
+
"DeliveryReport",
|
|
46
|
+
"Encoding",
|
|
47
|
+
"Envelope",
|
|
48
|
+
"Hub",
|
|
49
|
+
"MemoryBacklog",
|
|
50
|
+
"NullBacklog",
|
|
51
|
+
"Overflow",
|
|
52
|
+
"Peer",
|
|
53
|
+
"PeerGone",
|
|
54
|
+
"RoomConsumer",
|
|
55
|
+
"RoomNotFound",
|
|
56
|
+
"WireError",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
sillo_wire/backlog.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Recent traffic per room, and where a reconnecting client left off.
|
|
2
|
+
|
|
3
|
+
A backlog is what turns a dropped connection into a gap a client can close. It
|
|
4
|
+
keeps the last N bytes of a room's envelopes and answers "everything after
|
|
5
|
+
sequence 42", which is the question a client actually has after reconnecting.
|
|
6
|
+
|
|
7
|
+
The memory implementation caps by *payload* bytes and evicts oldest-first.
|
|
8
|
+
Sizing a list of messages with :func:`sys.getsizeof` measures the list's
|
|
9
|
+
pointer array rather than the messages, so a "1 MB" cap set that way holds
|
|
10
|
+
whatever fits in 1 MB of pointers — a hundred times its stated limit — and
|
|
11
|
+
then, on tripping, discards the entire history rather than the oldest part of
|
|
12
|
+
it. Both of those are the bug this exists to not have.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import typing
|
|
18
|
+
from collections import deque
|
|
19
|
+
|
|
20
|
+
from sillo_wire.envelope import Envelope
|
|
21
|
+
|
|
22
|
+
__all__ = ["Backlog", "MemoryBacklog", "NullBacklog"]
|
|
23
|
+
|
|
24
|
+
#: One mebibyte per room, which is a few thousand chat messages and a few
|
|
25
|
+
#: hundred sizeable JSON documents.
|
|
26
|
+
DEFAULT_CAPACITY_BYTES = 1_048_576
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Backlog(typing.Protocol):
|
|
30
|
+
"""What a hub needs from a message store.
|
|
31
|
+
|
|
32
|
+
A protocol rather than a base class so a Redis or Postgres implementation
|
|
33
|
+
does not have to import anything from here to satisfy it.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def append(self, envelope: Envelope) -> None:
|
|
37
|
+
"""Record *envelope* against its room."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
async def since(self, room: str, seq: int) -> list[Envelope]:
|
|
41
|
+
"""Every retained envelope for *room* newer than *seq*, oldest first."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
async def latest(self, room: str, limit: int = 50) -> list[Envelope]:
|
|
45
|
+
"""The most recent *limit* envelopes for *room*, oldest first."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
async def clear(self, room: str | None = None) -> None:
|
|
49
|
+
"""Forget one room's history, or all of it."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MemoryBacklog:
|
|
54
|
+
"""Per-room history in memory, capped by payload bytes.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
capacity_bytes: How much payload to retain per room. Eviction is
|
|
58
|
+
oldest-first, so the cap trims the tail of the history rather than
|
|
59
|
+
emptying it.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_bytes", "_rooms", "capacity_bytes")
|
|
63
|
+
|
|
64
|
+
def __init__(self, capacity_bytes: int = DEFAULT_CAPACITY_BYTES) -> None:
|
|
65
|
+
if capacity_bytes < 1:
|
|
66
|
+
raise ValueError("capacity_bytes must be at least 1")
|
|
67
|
+
self.capacity_bytes = capacity_bytes
|
|
68
|
+
self._rooms: dict[str, deque[Envelope]] = {}
|
|
69
|
+
self._bytes: dict[str, int] = {}
|
|
70
|
+
|
|
71
|
+
async def append(self, envelope: Envelope) -> None:
|
|
72
|
+
"""Record *envelope*, evicting oldest until the room fits its cap."""
|
|
73
|
+
room = envelope.room
|
|
74
|
+
entries = self._rooms.setdefault(room, deque())
|
|
75
|
+
entries.append(envelope)
|
|
76
|
+
self._bytes[room] = self._bytes.get(room, 0) + envelope.size()
|
|
77
|
+
|
|
78
|
+
# A single envelope larger than the whole cap would loop forever if the
|
|
79
|
+
# room could be emptied; stopping at one entry keeps the most recent
|
|
80
|
+
# message retrievable, which is more useful than an empty room.
|
|
81
|
+
while len(entries) > 1 and self._bytes[room] > self.capacity_bytes:
|
|
82
|
+
self._bytes[room] -= entries.popleft().size()
|
|
83
|
+
|
|
84
|
+
async def since(self, room: str, seq: int) -> list[Envelope]:
|
|
85
|
+
"""Everything retained for *room* after *seq*."""
|
|
86
|
+
return [e for e in self._rooms.get(room, ()) if e.seq > seq]
|
|
87
|
+
|
|
88
|
+
async def latest(self, room: str, limit: int = 50) -> list[Envelope]:
|
|
89
|
+
"""The last *limit* envelopes for *room*."""
|
|
90
|
+
if limit <= 0:
|
|
91
|
+
return []
|
|
92
|
+
entries = self._rooms.get(room)
|
|
93
|
+
if not entries:
|
|
94
|
+
return []
|
|
95
|
+
return list(entries)[-limit:]
|
|
96
|
+
|
|
97
|
+
async def clear(self, room: str | None = None) -> None:
|
|
98
|
+
"""Forget *room*, or every room."""
|
|
99
|
+
if room is None:
|
|
100
|
+
self._rooms.clear()
|
|
101
|
+
self._bytes.clear()
|
|
102
|
+
return
|
|
103
|
+
self._rooms.pop(room, None)
|
|
104
|
+
self._bytes.pop(room, None)
|
|
105
|
+
|
|
106
|
+
def usage(self, room: str) -> int:
|
|
107
|
+
"""Bytes currently retained for *room*.
|
|
108
|
+
|
|
109
|
+
Exposed because a cap you cannot observe is a cap you cannot tune.
|
|
110
|
+
"""
|
|
111
|
+
return self._bytes.get(room, 0)
|
|
112
|
+
|
|
113
|
+
def rooms(self) -> list[str]:
|
|
114
|
+
"""Every room this backlog holds something for."""
|
|
115
|
+
return list(self._rooms)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NullBacklog:
|
|
119
|
+
"""Keeps nothing.
|
|
120
|
+
|
|
121
|
+
The right choice for traffic that is worthless a second later — cursors,
|
|
122
|
+
typing indicators, telemetry — where retaining it costs memory and buys
|
|
123
|
+
nobody anything.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
__slots__ = ()
|
|
127
|
+
|
|
128
|
+
async def append(self, envelope: Envelope) -> None:
|
|
129
|
+
"""Discard *envelope*."""
|
|
130
|
+
|
|
131
|
+
async def since(self, room: str, seq: int) -> list[Envelope]:
|
|
132
|
+
"""Always empty."""
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
async def latest(self, room: str, limit: int = 50) -> list[Envelope]:
|
|
136
|
+
"""Always empty."""
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
async def clear(self, room: str | None = None) -> None:
|
|
140
|
+
"""Nothing to clear."""
|
sillo_wire/consumer.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""A class-based endpoint with the room plumbing already wired.
|
|
2
|
+
|
|
3
|
+
:class:`RoomConsumer` is the ergonomic layer over :class:`~sillo_wire.hub.Hub`
|
|
4
|
+
and :class:`~sillo_wire.peer.Peer`: it accepts the socket, builds the peer,
|
|
5
|
+
joins the rooms, pumps messages into :meth:`~RoomConsumer.on_message`, and
|
|
6
|
+
guarantees the peer is removed from every room when the connection ends —
|
|
7
|
+
including when the handler raises.
|
|
8
|
+
|
|
9
|
+
Subclass it and override the hooks you need::
|
|
10
|
+
|
|
11
|
+
class Chat(RoomConsumer):
|
|
12
|
+
hub = my_hub
|
|
13
|
+
|
|
14
|
+
async def rooms(self, ctx):
|
|
15
|
+
return [ctx.path_params["room"]]
|
|
16
|
+
|
|
17
|
+
async def on_message(self, data):
|
|
18
|
+
await self.broadcast(data)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import typing
|
|
24
|
+
|
|
25
|
+
from sillo_wire.envelope import DeliveryReport, Encoding
|
|
26
|
+
from sillo_wire.hub import Hub
|
|
27
|
+
from sillo_wire.peer import Peer
|
|
28
|
+
from sillo_wire.policy import Overflow
|
|
29
|
+
|
|
30
|
+
__all__ = ["RoomConsumer"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RoomConsumer:
|
|
34
|
+
"""One connection's lifecycle, from accept to cleanup.
|
|
35
|
+
|
|
36
|
+
A fresh instance is created per connection, so ``self`` is a safe place to
|
|
37
|
+
keep per-connection state — unlike the hub, which is shared.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
#: The hub this consumer joins rooms on. Override per subclass, or pass one
|
|
41
|
+
#: to :meth:`as_handler`.
|
|
42
|
+
hub: typing.ClassVar[Hub | None] = None
|
|
43
|
+
|
|
44
|
+
#: How payloads are written, and which ``iter_*`` the read loop uses.
|
|
45
|
+
encoding: typing.ClassVar[Encoding] = Encoding.JSON
|
|
46
|
+
|
|
47
|
+
#: Outbound queue depth per connection.
|
|
48
|
+
capacity: typing.ClassVar[int] = 64
|
|
49
|
+
|
|
50
|
+
#: What happens when a connection cannot keep up.
|
|
51
|
+
overflow: typing.ClassVar[Overflow] = Overflow.DROP_OLDEST
|
|
52
|
+
|
|
53
|
+
def __init__(self, hub: Hub | None = None) -> None:
|
|
54
|
+
resolved = hub if hub is not None else self.hub
|
|
55
|
+
if resolved is None:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{type(self).__name__} has no hub: set `hub` on the class or "
|
|
58
|
+
f"pass one to as_handler()"
|
|
59
|
+
)
|
|
60
|
+
self._hub: Hub = resolved
|
|
61
|
+
self.peer: Peer | None = None
|
|
62
|
+
self.ctx: typing.Any = None
|
|
63
|
+
self.joined: list[str] = []
|
|
64
|
+
|
|
65
|
+
# ── registration ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def as_handler(cls, hub: Hub | None = None) -> typing.Callable[..., typing.Any]:
|
|
69
|
+
"""Build the coroutine to hand to ``@app.ws_route``.
|
|
70
|
+
|
|
71
|
+
Path parameters arrive as keyword arguments, exactly as on an HTTP
|
|
72
|
+
route, and are forwarded to :meth:`rooms` through the context rather
|
|
73
|
+
than to the hooks — the hooks have a fixed shape so subclasses do not
|
|
74
|
+
each have to declare them.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
async def handler(ctx: typing.Any, **params: typing.Any) -> None:
|
|
78
|
+
await cls(hub)(ctx)
|
|
79
|
+
|
|
80
|
+
return handler
|
|
81
|
+
|
|
82
|
+
# ── the loop ─────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async def __call__(self, ctx: typing.Any) -> None:
|
|
85
|
+
"""Run one connection to completion."""
|
|
86
|
+
self.ctx = ctx
|
|
87
|
+
await ctx.accept()
|
|
88
|
+
|
|
89
|
+
self.peer = Peer(
|
|
90
|
+
ctx,
|
|
91
|
+
encoding=self.encoding,
|
|
92
|
+
identity=await self.identify(ctx),
|
|
93
|
+
capacity=self.capacity,
|
|
94
|
+
overflow=self.overflow,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
for room in await self.rooms(ctx):
|
|
99
|
+
await self._hub.join(self.peer, room)
|
|
100
|
+
self.joined.append(room)
|
|
101
|
+
|
|
102
|
+
await self.on_connect()
|
|
103
|
+
await self._pump()
|
|
104
|
+
finally:
|
|
105
|
+
# Runs on a clean close, a client disconnect, and an exception in a
|
|
106
|
+
# hook alike. Leaving a peer subscribed after its socket is gone is
|
|
107
|
+
# the leak this exists to prevent.
|
|
108
|
+
await self.on_disconnect()
|
|
109
|
+
await self._hub.disconnect(self.peer)
|
|
110
|
+
|
|
111
|
+
async def _pump(self) -> None:
|
|
112
|
+
"""Read from the socket until it closes, dispatching each message."""
|
|
113
|
+
iterator = {
|
|
114
|
+
Encoding.JSON: "iter_json",
|
|
115
|
+
Encoding.TEXT: "iter_text",
|
|
116
|
+
Encoding.BYTES: "iter_bytes",
|
|
117
|
+
}[self.encoding]
|
|
118
|
+
|
|
119
|
+
async for message in getattr(self.ctx, iterator)():
|
|
120
|
+
await self.on_message(message)
|
|
121
|
+
|
|
122
|
+
# ── helpers ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async def broadcast(
|
|
125
|
+
self, payload: typing.Any, room: str | None = None
|
|
126
|
+
) -> DeliveryReport:
|
|
127
|
+
"""Send *payload* to *room*, defaulting to the first room joined."""
|
|
128
|
+
target = room if room is not None else (self.joined[0] if self.joined else None)
|
|
129
|
+
if target is None:
|
|
130
|
+
return DeliveryReport()
|
|
131
|
+
return await self._hub.broadcast(target, payload)
|
|
132
|
+
|
|
133
|
+
async def reply(self, payload: typing.Any) -> None:
|
|
134
|
+
"""Send *payload* to this connection alone."""
|
|
135
|
+
if self.peer is not None:
|
|
136
|
+
await self.peer.send(payload)
|
|
137
|
+
|
|
138
|
+
async def join(self, room: str) -> bool:
|
|
139
|
+
"""Subscribe this connection to another room."""
|
|
140
|
+
if self.peer is None: # pragma: no cover - unreachable inside __call__
|
|
141
|
+
return False
|
|
142
|
+
added = await self._hub.join(self.peer, room)
|
|
143
|
+
if added:
|
|
144
|
+
self.joined.append(room)
|
|
145
|
+
return added
|
|
146
|
+
|
|
147
|
+
async def leave(self, room: str) -> bool:
|
|
148
|
+
"""Unsubscribe this connection from *room*."""
|
|
149
|
+
if self.peer is None: # pragma: no cover - unreachable inside __call__
|
|
150
|
+
return False
|
|
151
|
+
removed = await self._hub.leave(self.peer, room)
|
|
152
|
+
if removed and room in self.joined:
|
|
153
|
+
self.joined.remove(room)
|
|
154
|
+
return removed
|
|
155
|
+
|
|
156
|
+
# ── hooks ────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async def identify(self, ctx: typing.Any) -> typing.Any:
|
|
159
|
+
"""Who this connection belongs to. ``None`` means anonymous.
|
|
160
|
+
|
|
161
|
+
Called once, before any room is joined, so the identity is already set
|
|
162
|
+
when presence listeners fire.
|
|
163
|
+
"""
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
async def rooms(self, ctx: typing.Any) -> list[str]:
|
|
167
|
+
"""Which rooms to join on connect. Defaults to none."""
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
async def on_connect(self) -> None:
|
|
171
|
+
"""Called once the peer is in its rooms."""
|
|
172
|
+
|
|
173
|
+
async def on_message(self, data: typing.Any) -> None:
|
|
174
|
+
"""Called for each message received. Override this."""
|
|
175
|
+
|
|
176
|
+
async def on_disconnect(self) -> None:
|
|
177
|
+
"""Called once as the connection ends, before the peer is removed."""
|
sillo_wire/envelope.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The unit of traffic, and the result of sending one.
|
|
2
|
+
|
|
3
|
+
An :class:`Envelope` is what a room stores and replays. It carries a monotonic
|
|
4
|
+
:attr:`~Envelope.seq` so a client that reconnects can say "everything after
|
|
5
|
+
42" rather than "everything" or "nothing" -- see :meth:`Backlog.since`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import enum
|
|
11
|
+
import itertools
|
|
12
|
+
import typing
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
__all__ = ["DeliveryReport", "Encoding", "Envelope"]
|
|
17
|
+
|
|
18
|
+
#: Process-wide sequence source. Monotonic rather than time-based because two
|
|
19
|
+
#: envelopes created in the same microsecond must still order, and a clock that
|
|
20
|
+
#: steps backwards must not make a replay cursor skip.
|
|
21
|
+
_counter = itertools.count(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Encoding(enum.Enum):
|
|
25
|
+
"""How a payload is written to the socket."""
|
|
26
|
+
|
|
27
|
+
JSON = "json"
|
|
28
|
+
TEXT = "text"
|
|
29
|
+
BYTES = "bytes"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Envelope:
|
|
34
|
+
"""One message, addressed to a room.
|
|
35
|
+
|
|
36
|
+
Frozen because an envelope handed to a fan-out is shared by every peer in
|
|
37
|
+
the room; a mutable one would let a slow peer observe an edit made after it
|
|
38
|
+
was queued.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
payload: typing.Any
|
|
42
|
+
room: str = ""
|
|
43
|
+
seq: int = field(default_factory=lambda: next(_counter))
|
|
44
|
+
#: Factories rather than plain defaults: a plain default is evaluated once,
|
|
45
|
+
#: at class definition, which would stamp every envelope in a backlog with
|
|
46
|
+
#: the same import-time timestamp.
|
|
47
|
+
sent_at: datetime = field(
|
|
48
|
+
default_factory=lambda: datetime.now(tz=timezone.utc)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def size(self) -> int:
|
|
52
|
+
"""Roughly what this costs to hold, in bytes.
|
|
53
|
+
|
|
54
|
+
Used by the backlog to enforce its cap. Exact for the encodings that
|
|
55
|
+
have a length and estimated otherwise, which is the right trade for a
|
|
56
|
+
limit whose purpose is to stop unbounded growth rather than to account
|
|
57
|
+
precisely.
|
|
58
|
+
"""
|
|
59
|
+
payload = self.payload
|
|
60
|
+
if isinstance(payload, (bytes, bytearray, memoryview)):
|
|
61
|
+
return len(payload)
|
|
62
|
+
if isinstance(payload, str):
|
|
63
|
+
return len(payload.encode("utf-8", "replace"))
|
|
64
|
+
return len(repr(payload).encode("utf-8", "replace"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class DeliveryReport:
|
|
69
|
+
"""What a fan-out actually did.
|
|
70
|
+
|
|
71
|
+
A broadcast that returns nothing is a broadcast you cannot operate: there
|
|
72
|
+
is no way to tell "nobody was listening" from "everybody was, and half of
|
|
73
|
+
them failed".
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
delivered: int = 0
|
|
77
|
+
"""Peers the message was written to."""
|
|
78
|
+
|
|
79
|
+
dropped: int = 0
|
|
80
|
+
"""Peers whose queue was full, resolved by their overflow policy."""
|
|
81
|
+
|
|
82
|
+
failed: int = 0
|
|
83
|
+
"""Peers whose socket raised while being written to."""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def attempted(self) -> int:
|
|
87
|
+
"""Every peer the room contained when the fan-out began."""
|
|
88
|
+
return self.delivered + self.dropped + self.failed
|
|
89
|
+
|
|
90
|
+
def __bool__(self) -> bool:
|
|
91
|
+
"""True when at least one peer received the message."""
|
|
92
|
+
return self.delivered > 0
|
sillo_wire/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exceptions raised by :mod:`sillo_wire`.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`WireError`, so an application that wants to
|
|
4
|
+
treat "the realtime layer failed" as one case can catch that and nothing else.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = ["PeerGone", "RoomNotFound", "WireError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WireError(Exception):
|
|
13
|
+
"""Base class for every error this package raises."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PeerGone(WireError):
|
|
17
|
+
"""A peer's socket is closed, or closed while being written to.
|
|
18
|
+
|
|
19
|
+
Raised only by the explicit single-peer sends. A broadcast never raises
|
|
20
|
+
this — one dead subscriber is an ordinary event in a fan-out, and is
|
|
21
|
+
reported through :class:`~sillo_wire.envelope.DeliveryReport` instead.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RoomNotFound(WireError):
|
|
26
|
+
"""A room was addressed by name and no such room exists.
|
|
27
|
+
|
|
28
|
+
Like :class:`PeerGone`, this is raised by direct operations rather than by
|
|
29
|
+
broadcasts, where an empty room is a normal outcome and not a failure.
|
|
30
|
+
"""
|