muxws 0.2.0__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.
muxws/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """muxws - multiplexed, cancellable, bidirectional streams over one WebSocket.
2
+
3
+ The three backoff helpers are exported together or not at all: `backoff_delay`, `unjittered_delay`
4
+ and `should_retry` are one schedule seen from three sides, and an operator computing "when will it
5
+ try again" needs all three. Exporting one of them was arbitrary.
6
+ """
7
+
8
+ from muxws.api import accept, connect, resolve_codec, select_subprotocol, serve
9
+ from muxws.codecs import Codec, get_codec, register_codec, registered_codecs
10
+ from muxws.codecs.json_ import JsonCodec
11
+ from muxws.conf import Settings, settings
12
+ from muxws.errors import (
13
+ CodecError,
14
+ CodecMismatch,
15
+ CodecNotRegistered,
16
+ ConnectionClosed,
17
+ ConnectionGoingAway,
18
+ ConnectionLost,
19
+ MuxwsError,
20
+ ProtocolError,
21
+ RemoteError,
22
+ ResetCode,
23
+ StreamAlreadyConsumed,
24
+ StreamClosed,
25
+ StreamRefused,
26
+ StreamReset,
27
+ StreamTimeout,
28
+ )
29
+ from muxws.fragment import Assembler, encoded_length, MAX_FRAME_BYTES, split_frame
30
+ from muxws.frames import ABSENT, Frame, from_mapping, to_mapping
31
+ from muxws.observability import CloseReason
32
+ from muxws.peer import default_error_serializer, ErrorSerializer, Peer, StreamHandler
33
+ from muxws.reconnect import backoff_delay, Hello, Reconnect, should_retry, unjittered_delay
34
+ from muxws.registry import PeerRegistry
35
+ from muxws.stream import Stream, StreamState
36
+ from muxws.transports import SocketAdapter
37
+
38
+ __version__ = "0.2.0"
39
+
40
+ # The library registers JSON itself (WSM-CDC-004); the codec module must not (WSM-CDC-014).
41
+ register_codec("json", JsonCodec())
42
+
43
+ __all__ = [
44
+ "ABSENT",
45
+ "Assembler",
46
+ "CloseReason",
47
+ "Codec",
48
+ "CodecError",
49
+ "CodecMismatch",
50
+ "CodecNotRegistered",
51
+ "ConnectionClosed",
52
+ "ConnectionGoingAway",
53
+ "ConnectionLost",
54
+ "ErrorSerializer",
55
+ "Frame",
56
+ "Hello",
57
+ "JsonCodec",
58
+ "MAX_FRAME_BYTES",
59
+ "MuxwsError",
60
+ "Peer",
61
+ "PeerRegistry",
62
+ "ProtocolError",
63
+ "Reconnect",
64
+ "RemoteError",
65
+ "ResetCode",
66
+ "Settings",
67
+ "SocketAdapter",
68
+ "Stream",
69
+ "StreamAlreadyConsumed",
70
+ "StreamClosed",
71
+ "StreamHandler",
72
+ "StreamRefused",
73
+ "StreamReset",
74
+ "StreamState",
75
+ "StreamTimeout",
76
+ "__version__",
77
+ "accept",
78
+ "backoff_delay",
79
+ "connect",
80
+ "default_error_serializer",
81
+ "encoded_length",
82
+ "from_mapping",
83
+ "get_codec",
84
+ "register_codec",
85
+ "registered_codecs",
86
+ "resolve_codec",
87
+ "select_subprotocol",
88
+ "serve",
89
+ "settings",
90
+ "should_retry",
91
+ "split_frame",
92
+ "to_mapping",
93
+ "unjittered_delay",
94
+ ]
muxws/api.py ADDED
@@ -0,0 +1,267 @@
1
+ """`connect`, `accept` and `serve` - the module-level factories (§9.3).
2
+
3
+ The codec is resolved **before** any socket is touched. Putting the lookup after the upgrade is the
4
+ single most likely wrong implementation of WSM-CDC-016, and it is what makes a misconfigured
5
+ deployment fail as a puzzling decode error on the tenth frame rather than as a named startup error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any
12
+
13
+ from muxws.codecs import Codec, get_codec
14
+ from muxws.conf import settings
15
+ from muxws.errors import CodecMismatch, ConnectionClosed, ProtocolError
16
+ from muxws.fragment import MAX_FRAME_BYTES
17
+ from muxws.peer import ErrorSerializer, Peer, StreamHandler
18
+ from muxws.reconnect import ConnectionLoop, Hello, Reconnect
19
+ from muxws.subprotocol import mismatch_error, offer, PREFIX
20
+ from muxws.transports import SocketAdapter
21
+
22
+
23
+ def resolve_codec(override: Codec | None = None) -> Codec:
24
+ """An explicit `codec=` wins (WSM-CDC-012); otherwise the configured name (WSM-CDC-010/016).
25
+
26
+ Never falls back to JSON. A deployment that believes it is running msgpack, is not, and finds
27
+ out from neither end is the failure WSM-INV-015 names.
28
+ """
29
+ if override is not None:
30
+ return override
31
+ return get_codec(settings.codec)
32
+
33
+
34
+ async def connect(
35
+ url: str,
36
+ *,
37
+ headers: dict[str, str] | None = None,
38
+ subprotocols: list[str] | None = None,
39
+ hello: Any = None,
40
+ hello_headers: dict[str, Any] | None = None,
41
+ reconnect: Reconnect | None = None,
42
+ ping_interval: float = 20.0,
43
+ ping_timeout: float = 10.0,
44
+ hello_timeout: float = 10.0,
45
+ max_payload_bytes: int = 67_108_864,
46
+ max_concurrent_streams: int = 100,
47
+ error_serializer: ErrorSerializer | None = None,
48
+ codec: Codec | None = None,
49
+ max_frame_bytes: int = MAX_FRAME_BYTES,
50
+ on_stream: StreamHandler | None = None,
51
+ on_close: Callable[[Any], None] | None = None,
52
+ on_reconnect: Callable[[int, Any], None] | None = None,
53
+ ) -> Peer:
54
+ """Dial `url` and return a serving peer.
55
+
56
+ Raises if the **first** attempt fails, with the underlying error, whatever `reconnect` says
57
+ (WSM-RCN-006). Reconnection applies to connections that were established and then lost; a peer
58
+ that retried its first dial forever would turn a typo in the URL into silence. There is
59
+ deliberately no option that changes this (WSM-INV-018): a caller who wants the first dial retried
60
+ writes that loop itself, where it can decide what a permanent failure looks like.
61
+
62
+ Everything after that first dial belongs to the reconnect helper: the backoff schedule, the
63
+ heartbeat, and the hello replayed verbatim on every connection this peer ever makes.
64
+
65
+ `on_stream`, `on_close` and `on_reconnect` are the same handlers `peer.on_stream(...)` and
66
+ friends register, and they are registered **before** the hello goes out. That is the whole reason
67
+ they are parameters rather than a line the caller writes afterwards: the acceptor may push a
68
+ stream the instant it sees the hello, and a peer whose handler is registered one await later
69
+ answers that push `reset(REFUSED, "no on_stream handler")` (WSM-STM-033).
70
+ """
71
+ resolved = resolve_codec(codec)
72
+ dial = _websocket_dialer(url, resolved, headers=headers, subprotocols=subprotocols)
73
+
74
+ socket = await dial()
75
+ peer = Peer(
76
+ socket,
77
+ codec=resolved,
78
+ is_dialer=True,
79
+ error_serializer=error_serializer,
80
+ max_frame_bytes=max_frame_bytes,
81
+ max_payload_bytes=max_payload_bytes,
82
+ max_concurrent_streams=max_concurrent_streams,
83
+ )
84
+ if on_stream is not None:
85
+ peer.on_stream(on_stream)
86
+ if on_close is not None:
87
+ peer.on_close(on_close)
88
+ if on_reconnect is not None:
89
+ peer.on_reconnect(on_reconnect)
90
+ loop = ConnectionLoop(
91
+ peer,
92
+ dial,
93
+ options=reconnect or Reconnect(),
94
+ # Captured **here**, once, by value: replayed verbatim on every later connection and never
95
+ # re-read from the application's object (WSM-RCN-020).
96
+ hello=Hello(payload=hello, headers=hello_headers, timeout=hello_timeout),
97
+ ping_interval=ping_interval,
98
+ ping_timeout=ping_timeout,
99
+ )
100
+ await loop.establish()
101
+ loop.start()
102
+ # Held so the garbage collector cannot collect the supervisor out from under the peer.
103
+ peer._connection_loop = loop
104
+ return peer
105
+
106
+
107
+ def _websocket_dialer(
108
+ url: str,
109
+ resolved: Codec,
110
+ *,
111
+ headers: dict[str, str] | None,
112
+ subprotocols: list[str] | None,
113
+ ) -> Callable[[], Awaitable[SocketAdapter]]:
114
+ """One closure that dials, verifies the subprotocol, and hands back a `SocketAdapter`.
115
+
116
+ The helper is given this rather than a URL because it knows nothing about transports: every
117
+ reconnect is the same dial as the first one, down to the offered subprotocol list, so there is
118
+ exactly one place where a connection is made and no way for a reconnect to negotiate something
119
+ the first connection did not (WSM-CDC-016/020).
120
+ """
121
+ offered = offer(resolved.name, subprotocols)
122
+
123
+ async def dial() -> SocketAdapter:
124
+ import websockets
125
+
126
+ from muxws.transports.websockets_ import verify_negotiated, WebsocketsSocket
127
+
128
+ try:
129
+ connection = await websockets.connect(
130
+ url,
131
+ subprotocols=offered, # type: ignore[arg-type]
132
+ additional_headers=headers,
133
+ )
134
+ except Exception as exc:
135
+ if _looks_like_a_refused_handshake(exc):
136
+ raise mismatch_error(resolved.name) from exc
137
+ raise
138
+
139
+ try:
140
+ verify_negotiated(getattr(connection, "subprotocol", None), resolved.name)
141
+ except Exception:
142
+ from muxws.transports.websockets_ import POLICY_VIOLATION
143
+
144
+ await connection.close(code=POLICY_VIOLATION, reason="codec mismatch")
145
+ raise
146
+
147
+ return WebsocketsSocket(connection)
148
+
149
+ return dial
150
+
151
+
152
+ #: What an acceptor answers a codec it does not speak (WSM-CDC-022), and the only status this reads
153
+ #: as a refusal. Anything else is a server that failed for its own reasons and must surface as itself.
154
+ _REFUSED = 400
155
+
156
+
157
+ def _looks_like_a_refused_handshake(exc: BaseException) -> bool:
158
+ """A 400 on the upgrade is a refused muxws handshake, to be reported as `CodecMismatch`.
159
+
160
+ The status comes from `InvalidStatus.response`, never from the message. `"400" in str(exc)` also
161
+ matches the `OSError` for a connection refused on **port** 400, and matching a status code out of
162
+ prose is what let a cross-language dial - where the message wording differs - miss a real refusal
163
+ entirely (WSM-CDC-024). The string branch survives only as a fallback for a `websockets` release
164
+ that reports the status without attaching the response, and is narrowed to the phrase it uses.
165
+ """
166
+ from websockets.exceptions import InvalidStatus
167
+
168
+ if isinstance(exc, InvalidStatus):
169
+ return exc.response.status_code == _REFUSED
170
+ return f"HTTP {_REFUSED}" in str(exc)
171
+
172
+
173
+ async def accept(
174
+ socket: SocketAdapter | Any,
175
+ *,
176
+ max_payload_bytes: int = 67_108_864,
177
+ max_concurrent_streams: int = 100,
178
+ error_serializer: ErrorSerializer | None = None,
179
+ codec: Codec | None = None,
180
+ max_frame_bytes: int = MAX_FRAME_BYTES,
181
+ ) -> Peer:
182
+ """Accept an inbound connection and return a peer that is not yet serving.
183
+
184
+ A Starlette `WebSocket` is upgraded here, because `accept()` is the only party that knows which
185
+ subprotocol to select (WSM-CDC-026). Anything already implementing `SocketAdapter` is taken as
186
+ it is.
187
+ """
188
+ resolved = resolve_codec(codec)
189
+ adapter = await _adapt(socket, resolved.name)
190
+ peer = Peer(
191
+ adapter,
192
+ codec=resolved,
193
+ is_dialer=False,
194
+ error_serializer=error_serializer,
195
+ max_frame_bytes=max_frame_bytes,
196
+ max_payload_bytes=max_payload_bytes,
197
+ max_concurrent_streams=max_concurrent_streams,
198
+ )
199
+ return peer
200
+
201
+
202
+ async def _adapt(socket: Any, codec_name: str) -> SocketAdapter:
203
+ """Wrap whatever the application handed us, upgrading it first when that is still to be done.
204
+
205
+ The framework checks come **before** the `SocketAdapter` one. `SocketAdapter` is a
206
+ `runtime_checkable` Protocol, and `isinstance` against one of those tests only that the four
207
+ method *names* exist - which Starlette's `WebSocket` happens to satisfy. Checking it first meant
208
+ a Starlette socket was taken as an already-adapted one and the upgrade never happened, which
209
+ surfaces only against a real ASGI server.
210
+ """
211
+ if _is_starlette_websocket(socket):
212
+ from muxws.transports.starlette import perform_upgrade
213
+
214
+ return await perform_upgrade(socket, codec_name)
215
+
216
+ if _is_websockets_connection(socket):
217
+ from muxws.transports.websockets_ import verify_negotiated, WebsocketsSocket
218
+
219
+ verify_negotiated(getattr(socket, "subprotocol", None), codec_name)
220
+ return WebsocketsSocket(socket)
221
+
222
+ if isinstance(socket, SocketAdapter):
223
+ return socket
224
+
225
+ raise ProtocolError(
226
+ f"{type(socket).__name__} is neither a SocketAdapter nor a socket muxws knows how to "
227
+ f"upgrade; wrap it in an adapter (WSM-API-021)"
228
+ )
229
+
230
+
231
+ def _is_starlette_websocket(socket: Any) -> bool:
232
+ return hasattr(socket, "scope") and hasattr(socket, "client_state")
233
+
234
+
235
+ def _is_websockets_connection(socket: Any) -> bool:
236
+ """A `websockets` connection, which has already handshaken by the time we see it."""
237
+ return hasattr(socket, "recv") and hasattr(socket, "send") and hasattr(socket, "subprotocol")
238
+
239
+
240
+ async def serve(socket: SocketAdapter | Any, *, handler: StreamHandler, **peer_options: Any) -> None:
241
+ """Accept, register `handler`, and run the read loop until the socket closes."""
242
+ try:
243
+ peer = await accept(socket, **peer_options)
244
+ except CodecMismatch:
245
+ # The upgrade was refused and the 400 has already been sent (WSM-CDC-022). There is no peer
246
+ # and nothing further to do; raising here would turn an ordinary misconfiguration into a
247
+ # traceback out of the application's endpoint.
248
+ return
249
+ peer.on_stream(handler)
250
+ try:
251
+ await peer.serve()
252
+ except ConnectionClosed:
253
+ return
254
+
255
+
256
+ def select_subprotocol(connection: Any, subprotocols: list[str]) -> str:
257
+ """Re-exported handshake hook for the `websockets` library (WSM-CDC-027).
258
+
259
+ Raises `NegotiationError` rather than returning None on a mismatch; see the hook itself for why
260
+ that is the only shape `websockets` turns into the 400 WSM-CDC-022 requires.
261
+ """
262
+ from muxws.transports.websockets_ import select_subprotocol as hook
263
+
264
+ return hook(connection, subprotocols)
265
+
266
+
267
+ __all__ = ["PREFIX", "accept", "connect", "resolve_codec", "select_subprotocol", "serve"]
@@ -0,0 +1,99 @@
1
+ """The codec seam: the port, and the explicit registry behind it (§2.1, WSM-CDC-001..016)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+ from muxws.errors import CodecNotRegistered
8
+ from muxws.frames import Frame
9
+
10
+
11
+ @runtime_checkable
12
+ class Codec(Protocol):
13
+ """Turns a logical frame into a WebSocket message and back.
14
+
15
+ `binary` is **declared, not inferred** (WSM-CDC-002): the peer reads it to choose the socket's
16
+ text or binary send method and the inbound message type it expects. A peer never sniffs a message
17
+ to decide which branch to take.
18
+
19
+ `encode_payload` / `decode_payload` are the payload-level half of the port. WSM-CDC-001 names
20
+ only the frame-level pair, but fragmentation is defined in terms of the encoded form of a
21
+ *logical payload* (WSM-FRG-011) which the receiver hands back to the codec once reassembled
22
+ (WSM-FRG-030), and neither operation can be expressed through `encode`/`decode` alone.
23
+ See GAPS.md.
24
+ """
25
+
26
+ name: str
27
+ binary: bool
28
+
29
+ def encode(self, frame: Frame) -> str | bytes:
30
+ """One whole frame - envelope and payload - as it goes on the wire.
31
+
32
+ `str` under a text codec, `bytes` under a binary one; the peer picks the socket method from
33
+ `binary` and never from this return type. An absent field MUST NOT be emitted at all: an
34
+ omitted `payload` key and `"payload": null` are different frames.
35
+ """
36
+ ...
37
+
38
+ def decode(self, message: str | bytes) -> Frame:
39
+ """The inverse. Unknown envelope fields MUST be dropped rather than rejected (WSM-FRM-001).
40
+
41
+ Raises `ProtocolError` when the message is not something this codec can read - that is a
42
+ connection-level failure, and the peer answers it with `goaway`.
43
+ """
44
+ ...
45
+
46
+ def encode_payload(self, payload: Any) -> str | bytes:
47
+ """A payload on its own, with no envelope around it.
48
+
49
+ This is what fragmentation slices (WSM-FRG-011), so its output has to be exactly the bytes
50
+ that would appear inside a frame's payload position - a codec whose two encoders disagree
51
+ cuts fragments at boundaries the receiver cannot rejoin.
52
+ """
53
+ ...
54
+
55
+ def decode_payload(self, data: str | bytes) -> Any:
56
+ """What the receiver hands back once the fragments are concatenated (WSM-FRG-030).
57
+
58
+ The inverse of `encode_payload`, and the reason the port carries four members rather than
59
+ the two WSM-CDC-001 names.
60
+ """
61
+ ...
62
+
63
+
64
+ _REGISTRY: dict[str, Codec] = {}
65
+
66
+
67
+ def register_codec(name: str, codec: Codec) -> None:
68
+ """Register `codec` under `name`.
69
+
70
+ Registration is explicit and eager (WSM-CDC-013): there is no dynamic import, no lazy
71
+ auto-registration, no entry-point scan, and no probing of whether a module happens to be
72
+ installed. A codec module never registers itself at import time (WSM-CDC-014).
73
+ """
74
+ _REGISTRY[name] = codec
75
+
76
+
77
+ def get_codec(name: str) -> Codec:
78
+ """Return the codec registered under `name`.
79
+
80
+ Raises `CodecNotRegistered` naming the environment variable, the value found and the registered
81
+ set (WSM-CDC-016). There is no fallback to JSON, ever - a deployment that believes it is running
82
+ msgpack and silently is not may never find out (WSM-INV-015).
83
+ """
84
+ try:
85
+ return _REGISTRY[name]
86
+ except KeyError:
87
+ available = registered_codecs()
88
+ raise CodecNotRegistered(
89
+ f"codec {name!r} is not registered (MUXWS_CODEC={name!r}); "
90
+ f"registered codecs are {available!r}. Call register_codec({name!r}, ...) during "
91
+ f"bootstrap, before connecting.",
92
+ configured=name,
93
+ available=available,
94
+ ) from None
95
+
96
+
97
+ def registered_codecs() -> list[str]:
98
+ """Every registered codec name, sorted."""
99
+ return sorted(_REGISTRY)
muxws/codecs/json_.py ADDED
@@ -0,0 +1,71 @@
1
+ """The JSON codec - the interoperability baseline (WSM-CDC-004..008).
2
+
3
+ The module name carries a trailing underscore: a top-level `json.py` inside the package would shadow
4
+ the stdlib module this very file imports.
5
+
6
+ This module does **not** register itself (WSM-CDC-014). `muxws/__init__.py` does, because the library
7
+ is required to ship JSON registered (WSM-CDC-004) while a side-effecting import can never be
8
+ tree-shaken out.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ from typing import Any
16
+
17
+ from muxws.errors import ProtocolError
18
+ from muxws.frames import Frame, from_mapping, to_mapping
19
+
20
+ #: Compact separators and no ASCII escaping, so that `json.dumps` here and `JSON.stringify` in the
21
+ #: TypeScript port emit the same bytes for the same value - for strings, containers, booleans, null
22
+ #: and integers up to 2**53. They do **not** agree on floats or on larger integers: Python writes
23
+ #: `1.0`, `-0.0`, `1e+16`, `1e-07` where JavaScript writes `1`, `0`, `10000000000000000`, `1e-7`,
24
+ #: and JavaScript rounds an integer beyond 2**53 to the nearest double. Fragment boundaries are cut
25
+ #: over this encoded form, so a payload carrying a float is cut differently by the two ports - see
26
+ #: GAPS.md. It costs nothing on the wire (the sender chooses the cuts and the receiver only
27
+ #: concatenates), but it does bound what the shared boundary corpus may contain.
28
+ _DUMP_KWARGS: dict[str, Any] = {"separators": (",", ":"), "ensure_ascii": False, "allow_nan": False}
29
+
30
+
31
+ def _reject(value: Any) -> Any:
32
+ """`json.dumps` default hook: refuse rather than invent an encoding.
33
+
34
+ Bytes are a first-class payload type under a binary codec and are *not* one under JSON, and muxws
35
+ MUST NOT base64-encode them on the application's behalf (WSM-CDC-008).
36
+ """
37
+ if isinstance(value, (bytes, bytearray, memoryview)):
38
+ raise TypeError(
39
+ f"the json codec cannot carry {type(value).__name__}: bytes are a payload type only under "
40
+ f"a binary codec, and muxws does not base64-encode them for you (WSM-CDC-008). Either "
41
+ f"encode them in the application or configure the msgpack codec."
42
+ )
43
+ raise TypeError(f"the json codec cannot encode {type(value).__name__}")
44
+
45
+
46
+ class JsonCodec:
47
+ """`Codec` implementation over the standard library's JSON."""
48
+
49
+ name = "json"
50
+ binary = False
51
+
52
+ def encode(self, frame: Frame) -> str:
53
+ return json.dumps(to_mapping(frame), default=_reject, **_DUMP_KWARGS)
54
+
55
+ def decode(self, message: str | bytes) -> Frame:
56
+ try:
57
+ mapping = json.loads(message)
58
+ except (ValueError, TypeError) as exc:
59
+ raise ProtocolError(f"the json codec could not decode the message: {exc} (WSM-FRM-005)") from exc
60
+ if not isinstance(mapping, dict):
61
+ raise ProtocolError(f"a frame must decode to an object, got {type(mapping).__name__} (WSM-FRM-005)")
62
+ return from_mapping(mapping)
63
+
64
+ def encode_payload(self, payload: Any) -> str:
65
+ return json.dumps(payload, default=_reject, **_DUMP_KWARGS)
66
+
67
+ def decode_payload(self, data: str | bytes) -> Any:
68
+ try:
69
+ return json.loads(data)
70
+ except (ValueError, TypeError) as exc:
71
+ raise ProtocolError(f"the json codec could not decode a reassembled payload: {exc}") from exc
@@ -0,0 +1,91 @@
1
+ """The msgpack codec - the honest proof that the codec seam is a seam (WSM-CDC-006/007/008).
2
+
3
+ The module name carries a trailing underscore for the same reason `json_.py` does: a top-level
4
+ `msgpack.py` inside the package would shadow the third-party module this very file imports.
5
+
6
+ `msgpack` is an **optional extra** (WSM-PKG-002). Importing this module is the whole of the
7
+ selection: nothing inside muxws imports it, so a deployment that never asks for msgpack never pays
8
+ for it. And this module does **not** register itself (WSM-CDC-014) - the application calls
9
+ `register_codec("msgpack", MsgpackCodec())` during bootstrap. There is no entry-point scan and no
10
+ "is it installed" probe anywhere (WSM-CDC-013).
11
+
12
+ Nothing here may be pinned as bytes in a fixture (WSM-CDC-006): this library and
13
+ `@msgpack/msgpack` make different but equally valid choices about integer width and map format, and
14
+ a pinned-bytes fixture would make a legal encoder fail. Round-trip is the only assertion.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Final
20
+
21
+ import msgpack
22
+
23
+ from muxws.errors import ProtocolError
24
+ from muxws.frames import Frame, from_mapping, to_mapping
25
+
26
+ #: `use_bin_type=True` is what keeps `bytes` and `str` distinguishable on the wire, and bytes are a
27
+ #: first-class payload type under a binary codec (WSM-CDC-008). It has been the default since
28
+ #: msgpack 1.0; it is written out because a codec whose byte type collapsed into its string type
29
+ #: would still round-trip within one language and fail only against the other port.
30
+ _PACK_KWARGS: Final[dict[str, Any]] = {"use_bin_type": True}
31
+
32
+ #: - `raw=False` decodes msgpack `str` to `str` rather than to `bytes`. Without it every envelope key
33
+ #: and every `type` value would come back as `bytes`, `from_mapping` would see no `"type"` key, and
34
+ #: every inbound frame would be a protocol error. It is the 1.x default, and it is stated because
35
+ #: it was *not* the default in 0.x and a codec that silently changed shape on a downgrade is worse
36
+ #: than one that fails.
37
+ #: - `strict_map_key=False` lets a map key be something other than `str`/`bytes`. The default (True)
38
+ #: raises `ValueError` on an integer key, which would turn an application's choice of payload shape
39
+ #: into a decode failure that kills the connection - muxws does not police what a payload contains.
40
+ #: `@msgpack/msgpack` accepts integer keys by default, so this is also what makes the two ports
41
+ #: agree about which payloads are decodable at all. They do *not* agree on the result: JavaScript
42
+ #: object keys are strings, so `{1: "a"}` comes back as `{1: "a"}` here and as `{"1": "a"}` there.
43
+ #: That divergence is reported in GAPS.md and is why no such payload is in the shared corpus.
44
+ #: - `use_list=True` decodes a msgpack array to a `list`, not a `tuple`. It is the default, and it is
45
+ #: load-bearing: with `use_list=False`, `decode(encode(frame)) == frame` fails for every payload
46
+ #: containing an array, which is exactly the assertion WSM-CDC-006 rests on.
47
+ _UNPACK_KWARGS: Final[dict[str, Any]] = {"raw": False, "strict_map_key": False, "use_list": True}
48
+
49
+
50
+ def _unpack(data: str | bytes, what: str) -> Any:
51
+ """Decode one msgpack value, turning every library failure into a `ProtocolError`.
52
+
53
+ A `str` never reaches a binary codec from a correct peer: `binary` is declared, and a peer picks
54
+ `send_bytes` from it rather than sniffing the encoded value (WSM-CDC-002). A text message
55
+ arriving here therefore means the remote sent one on a binary connection, and there is no
56
+ recoding that could recover the bytes - the transport has already lost them. Saying so is the
57
+ only honest answer (WSM-FRM-005).
58
+ """
59
+ if isinstance(data, str):
60
+ raise ProtocolError(
61
+ f"the msgpack codec was handed a text {what}: msgpack is a binary codec and a text "
62
+ f"WebSocket message cannot carry it (WSM-CDC-002, WSM-FRM-005)"
63
+ )
64
+ try:
65
+ return msgpack.unpackb(data, **_UNPACK_KWARGS)
66
+ except (msgpack.UnpackException, ValueError, TypeError) as exc:
67
+ raise ProtocolError(f"the msgpack codec could not decode the {what}: {exc} (WSM-FRM-005)") from exc
68
+
69
+
70
+ class MsgpackCodec:
71
+ """`Codec` implementation over the `msgpack` package."""
72
+
73
+ name = "msgpack"
74
+ #: Declared, never inferred (WSM-CDC-002). The peer reads this to choose `send_bytes`; it does
75
+ #: not look at what `encode` returned.
76
+ binary = True
77
+
78
+ def encode(self, frame: Frame) -> bytes:
79
+ return msgpack.packb(to_mapping(frame), **_PACK_KWARGS)
80
+
81
+ def decode(self, message: str | bytes) -> Frame:
82
+ mapping = _unpack(message, "message")
83
+ if not isinstance(mapping, dict):
84
+ raise ProtocolError(f"a frame must decode to a map, got {type(mapping).__name__} (WSM-FRM-005)")
85
+ return from_mapping(mapping)
86
+
87
+ def encode_payload(self, payload: Any) -> bytes:
88
+ return msgpack.packb(payload, **_PACK_KWARGS)
89
+
90
+ def decode_payload(self, data: str | bytes) -> Any:
91
+ return _unpack(data, "reassembled payload")
muxws/conf.py ADDED
@@ -0,0 +1,28 @@
1
+ """Deployment configuration (§2.2, WSM-CDC-010/011).
2
+
3
+ The codec name is read from the environment, not from a call argument, because both ends of a
4
+ connection must agree on it and an argument is decided per call site rather than per deployment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+
12
+ class Settings:
13
+ """The one settings singleton. Writable, so an application may set it during bootstrap."""
14
+
15
+ def __init__(self) -> None:
16
+ self.codec: str = os.environ.get("MUXWS_CODEC", "json")
17
+
18
+ def reload(self) -> None:
19
+ """Re-read the environment. For tests; an application sets `codec` directly."""
20
+ self.codec = os.environ.get("MUXWS_CODEC", "json")
21
+
22
+ def __repr__(self) -> str:
23
+ return f"Settings(codec={self.codec!r})"
24
+
25
+
26
+ #: Read at connection time, never at import time - or WSM-CDC-011's "an application may set it
27
+ #: during bootstrap" stops being true for anything that imports muxws early.
28
+ settings = Settings()