rope-protocol 0.1.1__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,17 @@
1
+ """ROPE - Resumable Offline Protocol for Emergencies.
2
+
3
+ Specification: docs/spec.md. Implemented so far: the frame codec of the wire format (``rope_protocol.frames``), the
4
+ link simulator for tests (``rope_protocol.linksim``) and streams from sources via a hub to consumers
5
+ (``rope_protocol.source``, ``rope_protocol.hub``, ``rope_protocol.consumer``; shared parts in ``sender``,
6
+ ``receiver``, ``participant``, ``store``), messages with custody (``rope_protocol.messages``,
7
+ ``rope_protocol.recipient``), offline media as a second path (``rope_protocol.media``) and the participant list
8
+ with names and a stream selection per consumer (``rope_protocol.roster``).
9
+ """
10
+
11
+ __version__ = "0.1.1"
12
+
13
+ #: Protocol version announced in the greeting (spec 14.5). Increase on incompatible changes.
14
+ PROTOCOL_VERSION = 1
15
+
16
+ #: Default TCP port of the hub (spec 14.1). Applications must allow overriding it.
17
+ DEFAULT_PORT = 47310
@@ -0,0 +1,146 @@
1
+ """Frames over an asyncio stream connection, with timeouts on every network call (spec section 10)."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections import deque
6
+
7
+ from . import frames as F
8
+
9
+ READ_SIZE = 64 * 1024
10
+
11
+
12
+ def now_ms():
13
+ return int(time.time() * 1000)
14
+
15
+
16
+ async def reap(tasks, timeout_s=2.0):
17
+ """Cancel helper tasks and collect their results, waiting at most ``timeout_s``.
18
+
19
+ Bounded on purpose: on Python 3.11 ``asyncio.wait_for`` can lose a cancellation, so a task might not end at once;
20
+ the caller must never hang on it. Collecting the results keeps late failures out of the "never retrieved" log.
21
+ """
22
+ tasks = [t for t in tasks if t is not None]
23
+ for t in tasks:
24
+ t.add_done_callback(_retrieve) # also if this cleanup itself is cancelled while waiting
25
+ t.cancel()
26
+ if tasks:
27
+ await asyncio.wait(tasks, timeout=timeout_s)
28
+
29
+
30
+ def _retrieve(task):
31
+ if not task.cancelled():
32
+ task.exception()
33
+
34
+
35
+ async def serve_frames(ch, timeout_s, handle, peer):
36
+ """Receive frames until the connection ends. ``handle(frame)`` (async) returns False for frames it does not
37
+ take; heartbeats and unknown extensions are skipped, anything else ends the session."""
38
+ while True:
39
+ f = await ch.recv(timeout_s)
40
+ if await handle(f):
41
+ continue
42
+ if f.type == F.FrameType.HEARTBEAT or f.is_extension:
43
+ continue
44
+ if f.type == F.FrameType.BYE:
45
+ raise ConnectionError("the %s closed the session: %s" % (peer, f.meta["reason"]))
46
+ if f.type == F.FrameType.ERROR:
47
+ raise ConnectionError("the %s reported %s: %s" % (peer, f.meta["code"], f.meta["text"]))
48
+ raise F.FrameError("bad_frame", "unexpected %s from the %s" % (F.FrameType(f.type).name, peer))
49
+
50
+
51
+ class Wakeup:
52
+ """Like ``asyncio.Event`` with a timeout on ``wait``, for a single waiter, built without ``asyncio.wait_for``
53
+ (see ``reap``): a cancellation always ends the wait."""
54
+
55
+ def __init__(self):
56
+ self._flag = False
57
+ self._fut = None
58
+
59
+ def set(self):
60
+ self._flag = True
61
+ if self._fut is not None and not self._fut.done():
62
+ self._fut.set_result(None)
63
+
64
+ def clear(self):
65
+ self._flag = False
66
+
67
+ async def wait(self, timeout_s):
68
+ if self._flag:
69
+ return
70
+ loop = asyncio.get_running_loop()
71
+ fut = self._fut = loop.create_future()
72
+ timer = loop.call_later(timeout_s, lambda: fut.done() or fut.set_result(None))
73
+ try:
74
+ await fut
75
+ finally:
76
+ timer.cancel()
77
+ self._fut = None
78
+
79
+
80
+ class Channel:
81
+ """Send and receive validated frames. Any failure raises ConnectionError (or FrameError for bad frames)."""
82
+
83
+ def __init__(self, reader, writer, send_timeout_s=10.0):
84
+ self.reader = reader
85
+ self.writer = writer
86
+ self.send_timeout_s = send_timeout_s
87
+ self.version = F.VERSION
88
+ self._decoder = F.FrameDecoder()
89
+ self._pending = deque()
90
+ self._send_lock = asyncio.Lock()
91
+ self.last_sent = time.monotonic()
92
+ self.last_received = time.monotonic()
93
+
94
+ async def send(self, frame):
95
+ frame.version = self.version
96
+ raw = F.encode(frame)
97
+ async with self._send_lock:
98
+ if self.writer.is_closing():
99
+ raise ConnectionError("connection closed")
100
+ self.writer.write(raw)
101
+ try:
102
+ await asyncio.wait_for(self.writer.drain(), self.send_timeout_s)
103
+ except asyncio.TimeoutError:
104
+ raise ConnectionError("send timed out after %.1f s" % self.send_timeout_s) from None
105
+ self.last_sent = time.monotonic()
106
+
107
+ @property
108
+ def closed(self):
109
+ return self.writer.is_closing()
110
+
111
+ async def recv(self, timeout_s):
112
+ """Next frame; ConnectionError if nothing at all arrives within ``timeout_s``."""
113
+ while not self._pending:
114
+ try:
115
+ data = await asyncio.wait_for(self.reader.read(READ_SIZE), timeout_s)
116
+ except asyncio.TimeoutError:
117
+ raise ConnectionError("no sign of life for %.1f s" % timeout_s) from None
118
+ if not data:
119
+ raise ConnectionError("connection closed by peer")
120
+ self.last_received = time.monotonic()
121
+ self._pending.extend(self._decoder.feed(data))
122
+ frame = self._pending.popleft()
123
+ F.validate(frame)
124
+ return frame
125
+
126
+ async def heartbeat(self, interval_s):
127
+ """Send HEARTBEAT whenever nothing else was sent for ``interval_s`` (runs until cancelled or closed)."""
128
+ while True:
129
+ wait = self.last_sent + interval_s - time.monotonic()
130
+ if wait > 0:
131
+ await asyncio.sleep(wait)
132
+ continue
133
+ await self.send(F.Frame(F.FrameType.HEARTBEAT, {"t": now_ms()}))
134
+
135
+ async def error_and_close(self, code, text):
136
+ try:
137
+ await asyncio.wait_for(self.send(F.Frame(F.FrameType.ERROR, {"code": code, "text": text})), 1.0)
138
+ except Exception:
139
+ pass
140
+ self.abort()
141
+
142
+ def abort(self):
143
+ try:
144
+ self.writer.transport.abort()
145
+ except Exception:
146
+ pass
@@ -0,0 +1,97 @@
1
+ """Consumer participant: receives the streams of all sources from the hub, stores them durably and checks them
2
+ (spec 7, 8). Streams are named ``<source>/<stream>``.
3
+
4
+ The application reads the received bytes from ``path`` up to ``committed`` while a stream is still growing;
5
+ ``on_written`` and ``on_checked`` tell it when there is more and when a stream is complete and verified.
6
+ """
7
+
8
+ import asyncio
9
+ import os
10
+
11
+ from . import DEFAULT_PORT
12
+ from . import frames as F
13
+ from . import media
14
+ from .participant import Participant
15
+ from .receiver import BLOCK_SIZE, StreamReceiver
16
+ from .store import StreamStore
17
+
18
+ #: Key under which a consumer stores what it receives (it has exactly one sender: the hub).
19
+ FROM_HUB = "hub"
20
+
21
+
22
+ class Consumer(Participant):
23
+ """``root``: folder for the received streams and the mailbox. ``on_written(stream, gen, committed)`` after every
24
+ durable write, ``on_checked(stream, gen, result, path)`` after every final check, ``on_message(meta, body)`` once
25
+ per new message - all called in the event loop."""
26
+
27
+ role = "consumer"
28
+
29
+ def __init__(self, participant, operation, hub_host, root, hub_port=DEFAULT_PORT, timeout_s=10.0,
30
+ backoff=(1.0, 10.0), block_size=BLOCK_SIZE, on_written=None, on_checked=None, on_message=None,
31
+ on_delivery=None):
32
+ super().__init__(participant, operation, hub_host, hub_port, timeout_s, backoff,
33
+ mailbox_root=os.path.join(root, "messages"), on_message=on_message,
34
+ state_dir=root, on_delivery=on_delivery)
35
+ self.store = StreamStore(root)
36
+ self.on_written = on_written
37
+ self.on_checked = on_checked
38
+ self.receiver = StreamReceiver(self.store, FROM_HUB, block_size, on_written=self._written,
39
+ on_checked=self._checked, me="consumer")
40
+
41
+ # --- application interface -------------------------------------------------------------------------------
42
+ def streams(self):
43
+ """Everything received so far: [{stream, gen, committed, complete, verified, path}]."""
44
+ return [dict(stream=e["stream"], gen=e["gen"], committed=e["committed"], complete=e["complete"],
45
+ verified=e["verified"], path=e["path"])
46
+ for e in sorted(self.store.entries(), key=lambda e: (e["stream"], e["gen"]))
47
+ if e["participant"] == FROM_HUB]
48
+
49
+ def received(self, stream, gen=1):
50
+ """The stored stream (``path``, ``committed``, ``verified``)."""
51
+ return self.store.get(FROM_HUB, stream, gen)
52
+
53
+ def _activities(self):
54
+ return super()._activities() + [self.store.activity]
55
+
56
+ # --- session ---------------------------------------------------------------------------------------------------
57
+ async def _have(self):
58
+ return await asyncio.to_thread(self.store.have, FROM_HUB)
59
+
60
+ async def _active(self, ch, welcome):
61
+ async def handle(f):
62
+ return await self.receiver.handle(ch, f)
63
+ return [self._serve(ch, handle)]
64
+
65
+ # --- offline media (spec 8.6) ------------------------------------------------------------------------------------
66
+ async def _media_acks(self):
67
+ """What this consumer holds: the hub's next bundle for it starts there."""
68
+ return [(h["stream"], h["gen"], h["committed"]) for h in await self._have()]
69
+
70
+ async def _media_checks(self):
71
+ def failed():
72
+ return [(e["stream"], e["gen"], self.store.get(FROM_HUB, e["stream"], e["gen"]), self.receiver.block_size)
73
+ for e in self.store.entries() if e["participant"] == FROM_HUB and e["verified"] == "mismatch"]
74
+ return await asyncio.to_thread(failed)
75
+
76
+ def _media_frame(self, f, report, skipped):
77
+ if f.type == F.FrameType.SEGMENT:
78
+ media.import_segment(self.store, FROM_HUB, f, report, skipped)
79
+ elif f.type == F.FrameType.STREAM_END:
80
+ media.import_stream_end(self.store, FROM_HUB, f, report, skipped)
81
+ else:
82
+ return False
83
+ return True
84
+
85
+ async def _media_imported(self, report):
86
+ for owner, stream, gen, committed in report.written:
87
+ self._written(owner, stream, gen, committed)
88
+ for owner, stream, gen, result, path in report.checked:
89
+ self._checked(owner, stream, gen, result, path)
90
+
91
+ def _written(self, owner, stream, gen, committed):
92
+ if self.on_written:
93
+ self.on_written(stream, gen, committed)
94
+
95
+ def _checked(self, owner, stream, gen, result, path):
96
+ if self.on_checked:
97
+ self.on_checked(stream, gen, result, path)
@@ -0,0 +1,340 @@
1
+ """Frame codec of the ROPE wire format (spec section 14, draft 1).
2
+
3
+ A frame is a fixed 12-byte header, a JSON object ("meta"), raw bytes ("blob") and a CRC-32 over everything before it.
4
+ This module only encodes and decodes frames; it does no I/O. ``FrameDecoder`` is incremental: feed it whatever the
5
+ socket returned and it yields complete frames. It checks the length limits before buffering a frame, so a damaged or
6
+ hostile length field cannot make it allocate more than the limits allow.
7
+ """
8
+
9
+ import enum
10
+ import json
11
+ import re
12
+ import struct
13
+ import zlib
14
+ from dataclasses import dataclass, field
15
+
16
+ from . import roster
17
+
18
+ MAGIC = b"RP"
19
+ VERSION = 1
20
+ HEADER = struct.Struct(">2sBBII")
21
+ TRAILER = struct.Struct(">I")
22
+ MAX_META = 64 * 1024
23
+ MAX_BLOB = 1024 * 1024
24
+ EXTENSION_FIRST = 0x80
25
+
26
+ _IDENTIFIER = re.compile(r"[A-Za-z0-9._-]{1,64}\Z")
27
+ _HEX32 = re.compile(r"[0-9a-f]{32}\Z")
28
+ _HEX64 = re.compile(r"[0-9a-f]{64}\Z")
29
+
30
+
31
+ class FrameType(enum.IntEnum):
32
+ HELLO = 0x01
33
+ WELCOME = 0x02
34
+ REJECT = 0x03
35
+ HEARTBEAT = 0x04
36
+ BYE = 0x05
37
+ ERROR = 0x06
38
+ SEGMENT = 0x10
39
+ ACK = 0x11
40
+ RESEND = 0x12
41
+ STREAM_END = 0x13
42
+ STREAM_CHECK = 0x14
43
+ BLOCK_SUMS = 0x15
44
+ MESSAGE = 0x20
45
+ MESSAGE_ACK = 0x21
46
+ DELIVERY = 0x22
47
+ DELIVERY_ACK = 0x23
48
+
49
+
50
+ #: Required meta keys per core frame type (spec 14.3). Unknown keys are allowed and ignored.
51
+ REQUIRED = {
52
+ FrameType.HELLO: ("participant", "operation", "role", "versions", "t", "have"),
53
+ FrameType.WELCOME: ("version", "hub", "t", "t_hello", "heartbeat_s", "timeout_s", "have"),
54
+ FrameType.REJECT: ("code", "text"),
55
+ FrameType.HEARTBEAT: ("t",),
56
+ FrameType.BYE: ("reason",),
57
+ FrameType.ERROR: ("code", "text"),
58
+ FrameType.SEGMENT: ("stream", "gen", "off", "len", "crc", "t", "enc"),
59
+ FrameType.ACK: ("stream", "gen", "committed"),
60
+ FrameType.RESEND: ("stream", "gen", "off", "len"),
61
+ FrameType.STREAM_END: ("stream", "gen", "size", "sha256"),
62
+ FrameType.STREAM_CHECK: ("stream", "gen", "result", "size"),
63
+ FrameType.BLOCK_SUMS: ("stream", "gen", "block_size", "first", "sums"),
64
+ FrameType.MESSAGE: ("id", "from", "to", "t", "kind", "len", "crc", "enc"),
65
+ FrameType.MESSAGE_ACK: ("id",),
66
+ FrameType.DELIVERY: ("id", "delivered", "pending", "unknown"),
67
+ FrameType.DELIVERY_ACK: ("id", "delivered"),
68
+ }
69
+
70
+ ROLES = ("source", "consumer", "recipient")
71
+ ENCODINGS = ("none", "zlib")
72
+ CODES = ("unknown_participant", "wrong_operation", "version", "role", "busy", "bad_frame", "too_large", "unknown_type",
73
+ "storage", "internal")
74
+
75
+
76
+ class FrameError(Exception):
77
+ """A frame violates the wire format. ``code`` is the error code to send (spec 14.4) before closing."""
78
+
79
+ def __init__(self, code, text):
80
+ super().__init__(text)
81
+ self.code = code
82
+ self.text = text
83
+
84
+
85
+ @dataclass
86
+ class Frame:
87
+ type: int
88
+ meta: dict = field(default_factory=dict)
89
+ blob: bytes = b""
90
+ version: int = VERSION
91
+
92
+ @property
93
+ def is_extension(self):
94
+ return self.type >= EXTENSION_FIRST
95
+
96
+
97
+ def encode_meta(meta):
98
+ """Compact, key-sorted UTF-8 JSON (spec 14.2) - reproducible, so test vectors are exact."""
99
+ return json.dumps(meta, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode("utf-8")
100
+
101
+
102
+ def encode(frame):
103
+ """Frame -> bytes. Raises FrameError if a limit is exceeded or the type is out of range."""
104
+ if not 0 <= frame.type <= 0xFF or not 0 <= frame.version <= 0xFF:
105
+ raise FrameError("bad_frame", "frame type or version out of range")
106
+ meta = encode_meta(frame.meta)
107
+ blob = bytes(frame.blob)
108
+ if len(meta) > MAX_META:
109
+ raise FrameError("too_large", "meta is %d bytes, limit %d" % (len(meta), MAX_META))
110
+ if len(blob) > MAX_BLOB:
111
+ raise FrameError("too_large", "blob is %d bytes, limit %d" % (len(blob), MAX_BLOB))
112
+ body = HEADER.pack(MAGIC, frame.version, frame.type, len(meta), len(blob)) + meta + blob
113
+ return body + TRAILER.pack(zlib.crc32(body))
114
+
115
+
116
+ class FrameDecoder:
117
+ """Incremental decoder. ``feed(data)`` returns the frames completed by ``data``.
118
+
119
+ After a FrameError the stream position is lost; the caller must send ERROR and close the connection (spec 14.2).
120
+ Further calls then raise the same error again.
121
+ """
122
+
123
+ def __init__(self, max_meta=MAX_META, max_blob=MAX_BLOB):
124
+ self.max_meta = max_meta
125
+ self.max_blob = max_blob
126
+ self._buf = bytearray()
127
+ self._need = None # total frame length once the header is known
128
+ self._error = None
129
+
130
+ @property
131
+ def buffered(self):
132
+ return len(self._buf)
133
+
134
+ def feed(self, data):
135
+ if self._error:
136
+ raise self._error
137
+ self._buf += data
138
+ out = []
139
+ try:
140
+ while True:
141
+ frame = self._next()
142
+ if frame is None:
143
+ return out
144
+ out.append(frame)
145
+ except FrameError as e:
146
+ self._error = e
147
+ self._buf.clear()
148
+ raise
149
+
150
+ def _next(self):
151
+ if self._need is None:
152
+ if len(self._buf) < HEADER.size:
153
+ # reject a wrong magic as early as possible
154
+ if self._buf[:2] and not MAGIC.startswith(bytes(self._buf[:2])):
155
+ raise FrameError("bad_frame", "wrong magic")
156
+ return None
157
+ magic, _version, _type, m, b = HEADER.unpack_from(self._buf)
158
+ if magic != MAGIC:
159
+ raise FrameError("bad_frame", "wrong magic")
160
+ if m > self.max_meta or b > self.max_blob:
161
+ raise FrameError("too_large", "frame announces meta %d / blob %d bytes" % (m, b))
162
+ self._need = HEADER.size + m + b + TRAILER.size
163
+ if len(self._buf) < self._need:
164
+ return None
165
+ raw = bytes(self._buf[:self._need])
166
+ del self._buf[:self._need]
167
+ self._need = None
168
+ return decode(raw)
169
+
170
+
171
+ def decode(raw):
172
+ """Exactly one complete frame (bytes) -> Frame. Checks magic, lengths, CRC and that meta is a JSON object."""
173
+ if len(raw) < HEADER.size + TRAILER.size:
174
+ raise FrameError("bad_frame", "frame shorter than header and CRC")
175
+ magic, version, ftype, m, b = HEADER.unpack_from(raw)
176
+ if magic != MAGIC:
177
+ raise FrameError("bad_frame", "wrong magic")
178
+ if len(raw) != HEADER.size + m + b + TRAILER.size:
179
+ raise FrameError("bad_frame", "frame length does not match header")
180
+ (crc,) = TRAILER.unpack_from(raw, len(raw) - TRAILER.size)
181
+ if zlib.crc32(raw[:-TRAILER.size]) != crc:
182
+ raise FrameError("bad_frame", "CRC mismatch")
183
+ try:
184
+ meta = json.loads(raw[HEADER.size:HEADER.size + m].decode("utf-8"))
185
+ except (UnicodeDecodeError, ValueError):
186
+ raise FrameError("bad_frame", "meta is not valid UTF-8 JSON") from None
187
+ if not isinstance(meta, dict):
188
+ raise FrameError("bad_frame", "meta is not a JSON object")
189
+ return Frame(ftype, meta, raw[HEADER.size + m:HEADER.size + m + b], version)
190
+
191
+
192
+ def _is_int(v, minimum=0):
193
+ return isinstance(v, int) and not isinstance(v, bool) and v >= minimum
194
+
195
+
196
+ def _check_stream(meta):
197
+ s = meta["stream"]
198
+ if not isinstance(s, str) or not 1 <= len(s.encode("utf-8")) <= 255:
199
+ raise FrameError("bad_frame", "stream name must be 1-255 bytes UTF-8")
200
+ if not _is_int(meta["gen"], 1):
201
+ raise FrameError("bad_frame", "generation must be an integer >= 1")
202
+
203
+
204
+ def validate(frame):
205
+ """Check a decoded core frame against spec 14.3/14.4. Extensions pass unchecked; an unknown core type raises
206
+ FrameError('unknown_type'). Returns the FrameType."""
207
+ if frame.is_extension:
208
+ return None
209
+ try:
210
+ ftype = FrameType(frame.type)
211
+ except ValueError:
212
+ raise FrameError("unknown_type", "unknown core frame type 0x%02x" % frame.type) from None
213
+ meta = frame.meta
214
+ missing = [k for k in REQUIRED[ftype] if k not in meta]
215
+ if missing:
216
+ raise FrameError("bad_frame", "%s without %s" % (ftype.name, ", ".join(missing)))
217
+ for k in ("t", "t_hello"):
218
+ if k in REQUIRED[ftype] and not _is_int(meta[k]):
219
+ raise FrameError("bad_frame", "%s must be milliseconds since 1970 UTC" % k)
220
+ for k in ("participant", "hub", "operation", "from"):
221
+ if k in REQUIRED[ftype] and not (isinstance(meta[k], str) and _IDENTIFIER.match(meta[k])):
222
+ raise FrameError("bad_frame", "%s is not a valid identifier" % k)
223
+ if "stream" in REQUIRED[ftype]:
224
+ _check_stream(meta)
225
+ for k in ("off", "len", "size", "committed", "first", "crc"):
226
+ if k in REQUIRED[ftype] and not _is_int(meta[k]):
227
+ raise FrameError("bad_frame", "%s must be a non-negative integer" % k)
228
+ if ftype in (FrameType.REJECT, FrameType.ERROR) and not isinstance(meta["text"], str):
229
+ raise FrameError("bad_frame", "text must be a string")
230
+ if ftype is FrameType.HELLO:
231
+ if meta["role"] not in ROLES:
232
+ raise FrameError("bad_frame", "unknown role %r" % (meta["role"],))
233
+ if not (isinstance(meta["versions"], list) and meta["versions"] and all(_is_int(v, 1) for v in meta["versions"])):
234
+ raise FrameError("bad_frame", "versions must be a non-empty list of integers >= 1")
235
+ if ftype in (FrameType.HELLO, FrameType.WELCOME):
236
+ if not isinstance(meta["have"], list):
237
+ raise FrameError("bad_frame", "have must be a list")
238
+ for h in meta["have"]:
239
+ if not (isinstance(h, dict) and "stream" in h and "gen" in h and _is_int(h.get("committed"))):
240
+ raise FrameError("bad_frame", "have entries need stream, gen, committed")
241
+ _check_stream(h)
242
+ if ftype is FrameType.WELCOME:
243
+ if not _is_int(meta["version"], 1):
244
+ raise FrameError("bad_frame", "version must be an integer >= 1")
245
+ for k in ("heartbeat_s", "timeout_s"):
246
+ if isinstance(meta[k], bool) or not isinstance(meta[k], (int, float)) or meta[k] <= 0:
247
+ raise FrameError("bad_frame", "%s must be a positive number" % k)
248
+ if "participants" in meta:
249
+ reason = roster.check_public(meta["participants"])
250
+ if reason:
251
+ raise FrameError("bad_frame", reason)
252
+ if ftype in (FrameType.SEGMENT, FrameType.MESSAGE) and meta["enc"] not in ENCODINGS:
253
+ raise FrameError("bad_frame", "unknown encoding %r" % (meta["enc"],))
254
+ if ftype is FrameType.SEGMENT and not isinstance(meta.get("repair", False), bool):
255
+ raise FrameError("bad_frame", "repair must be true or false")
256
+ if ftype is FrameType.STREAM_END and not (isinstance(meta["sha256"], str) and _HEX64.match(meta["sha256"])):
257
+ raise FrameError("bad_frame", "sha256 must be 64 lower-case hex characters")
258
+ if ftype is FrameType.STREAM_CHECK and meta["result"] not in ("ok", "mismatch"):
259
+ raise FrameError("bad_frame", "result must be ok or mismatch")
260
+ if ftype is FrameType.BLOCK_SUMS:
261
+ if not _is_int(meta["block_size"], 1):
262
+ raise FrameError("bad_frame", "block_size must be an integer >= 1")
263
+ if not (isinstance(meta["sums"], list) and all(isinstance(s, str) and _HEX64.match(s) for s in meta["sums"])):
264
+ raise FrameError("bad_frame", "sums must be SHA-256 hex digests")
265
+ if ftype in (FrameType.MESSAGE, FrameType.MESSAGE_ACK, FrameType.DELIVERY, FrameType.DELIVERY_ACK) and not (isinstance(meta["id"], str) and _HEX32.match(meta["id"])):
266
+ raise FrameError("bad_frame", "message id must be 32 lower-case hex characters")
267
+ if ftype is FrameType.MESSAGE:
268
+ if not (isinstance(meta["to"], list) and meta["to"] and all(isinstance(t, str) and _IDENTIFIER.match(t) for t in meta["to"])):
269
+ raise FrameError("bad_frame", "to must be a non-empty list of identifiers")
270
+ if not (isinstance(meta["kind"], str) and _IDENTIFIER.match(meta["kind"])):
271
+ raise FrameError("bad_frame", "kind is not a valid identifier")
272
+ if ftype is FrameType.DELIVERY:
273
+ for k in ("delivered", "pending", "unknown"):
274
+ if not (isinstance(meta[k], list) and all(isinstance(t, str) and _IDENTIFIER.match(t) for t in meta[k])):
275
+ raise FrameError("bad_frame", "%s must be a list of identifiers" % k)
276
+ if ftype is FrameType.DELIVERY_ACK and not _is_int(meta["delivered"]):
277
+ raise FrameError("bad_frame", "delivered must be a non-negative integer")
278
+ if ftype in (FrameType.REJECT, FrameType.ERROR) and meta["code"] not in CODES:
279
+ # unknown codes from newer versions are allowed but must be strings
280
+ if not isinstance(meta["code"], str):
281
+ raise FrameError("bad_frame", "code must be a string")
282
+ return ftype
283
+
284
+
285
+ def encode_payload(data, level=1):
286
+ """Raw bytes -> (enc, blob): zlib only when it makes the blob smaller (spec 14.4)."""
287
+ packed = zlib.compress(data, level)
288
+ return ("zlib", packed) if len(packed) < len(data) else ("none", bytes(data))
289
+
290
+
291
+ def decode_payload(enc, blob, length):
292
+ """(enc, blob) -> raw bytes of exactly ``length``; FrameError on anything else (never more than ``length``)."""
293
+ if enc == "none":
294
+ data = bytes(blob)
295
+ elif enc == "zlib":
296
+ d = zlib.decompressobj()
297
+ try:
298
+ data = d.decompress(blob, length + 1)
299
+ except zlib.error:
300
+ raise FrameError("bad_frame", "zlib data damaged") from None
301
+ if not d.eof or d.unused_data:
302
+ raise FrameError("bad_frame", "zlib data incomplete or followed by extra bytes")
303
+ else:
304
+ raise FrameError("bad_frame", "unknown encoding %r" % (enc,))
305
+ if len(data) != length:
306
+ raise FrameError("bad_frame", "decoded length %d, expected %d" % (len(data), length))
307
+ return data
308
+
309
+
310
+ def segment(stream, gen, off, data, t, level=1, **extra):
311
+ """Build a SEGMENT frame for raw ``data`` at offset ``off``."""
312
+ enc, blob = encode_payload(data, level)
313
+ meta = dict(extra, stream=stream, gen=gen, off=off, len=len(data), crc=zlib.crc32(data), t=t, enc=enc)
314
+ return Frame(FrameType.SEGMENT, meta, blob)
315
+
316
+
317
+ def message(msg_id, sender, to, t, kind, body, level=1):
318
+ """Build a MESSAGE frame with the raw ``body`` (spec 8.5, 14.3)."""
319
+ enc, blob = encode_payload(body, level)
320
+ meta = dict(id=msg_id, to=list(to), t=t, kind=kind, len=len(body), crc=zlib.crc32(body), enc=enc)
321
+ meta["from"] = sender
322
+ return Frame(FrameType.MESSAGE, meta, blob)
323
+
324
+
325
+ def message_body(frame):
326
+ """Decoded, CRC-checked body of a MESSAGE frame."""
327
+ m = frame.meta
328
+ body = decode_payload(m["enc"], frame.blob, m["len"])
329
+ if zlib.crc32(body) != m["crc"]:
330
+ raise FrameError("bad_frame", "message %s damaged (CRC mismatch)" % m["id"])
331
+ return body
332
+
333
+
334
+ def segment_data(frame):
335
+ """Decoded, CRC-checked raw data of a SEGMENT frame."""
336
+ m = frame.meta
337
+ data = decode_payload(m["enc"], frame.blob, m["len"])
338
+ if zlib.crc32(data) != m["crc"]:
339
+ raise FrameError("bad_frame", "segment CRC mismatch")
340
+ return data