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.
@@ -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
+ """