shorthand-client 0.1.0__tar.gz

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,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: shorthand-client
3
+ Version: 0.1.0
4
+ Summary: Async client for the Shorthand server, used by the Home Assistant integration
5
+ License-Expression: MIT
6
+ Classifier: Framework :: AsyncIO
7
+ Classifier: Typing :: Typed
8
+ Requires-Dist: aiohttp>=3.14
9
+ Requires-Python: >=3.14.2
10
+ Project-URL: Source, https://github.com/JoshuaLoran/ha-shorthand
11
+ Description-Content-Type: text/markdown
12
+
13
+ # shorthand-client
14
+
15
+ The async Python client for the [Shorthand](https://github.com/JoshuaLoran/ha-shorthand) server. The Home Assistant integration uses it to talk to the server. You don't need to install it yourself.
@@ -0,0 +1,3 @@
1
+ # shorthand-client
2
+
3
+ The async Python client for the [Shorthand](https://github.com/JoshuaLoran/ha-shorthand) server. The Home Assistant integration uses it to talk to the server. You don't need to install it yourself.
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "shorthand-client"
3
+ version = "0.1.0"
4
+ description = "Async client for the Shorthand server, used by the Home Assistant integration"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.14.2"
8
+ dependencies = ["aiohttp>=3.14"]
9
+ classifiers = [
10
+ "Framework :: AsyncIO",
11
+ "Typing :: Typed",
12
+ ]
13
+
14
+ [project.urls]
15
+ Source = "https://github.com/JoshuaLoran/ha-shorthand"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12,<0.13"]
19
+ build-backend = "uv_build"
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "shorthand-client"
3
+ version = "0.1.0"
4
+ description = "Async client for the Shorthand server, used by the Home Assistant integration"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.14.2"
8
+ dependencies = ["aiohttp>=3.14"]
9
+ classifiers = ["Framework :: AsyncIO", "Typing :: Typed"]
10
+
11
+ [project.urls]
12
+ Source = "https://github.com/JoshuaLoran/ha-shorthand"
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.12,<0.13"]
16
+ build-backend = "uv_build"
@@ -0,0 +1,7 @@
1
+ """Async client for the Shorthand server (01 §9, D19)."""
2
+
3
+ from . import models
4
+ from .client import ShorthandClient
5
+ from .models import VERSION as PROTOCOL_VERSION
6
+
7
+ __all__ = ["PROTOCOL_VERSION", "ShorthandClient", "models"]
@@ -0,0 +1,216 @@
1
+ """The client (01 §9, D19): one WebSocket to the server.
2
+
3
+ The integration opens it. Requests are matched to replies by id; anything
4
+ the server starts (`learn_request`, `sync_request`) goes to `on_message`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import itertools
12
+ import logging
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ import aiohttp
16
+
17
+ from . import models
18
+ from .errors import (
19
+ AuthenticationError,
20
+ CannotConnectError,
21
+ ProtocolError,
22
+ RequestTimeoutError,
23
+ ServerError,
24
+ ShorthandError,
25
+ )
26
+
27
+ _LOGGER = logging.getLogger(__name__)
28
+
29
+ type MessageHandler = Callable[[models.Envelope], Awaitable[None]]
30
+ type ConnectHandler = Callable[[models.Welcome], Awaitable[None]]
31
+
32
+
33
+ class ShorthandClient:
34
+ """A connection to one Shorthand server.
35
+
36
+ The aiohttp session is the caller's (Home Assistant's) and is never
37
+ closed here.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ url: str,
43
+ session: aiohttp.ClientSession,
44
+ *,
45
+ client_name: str,
46
+ ha_version: str,
47
+ on_message: MessageHandler | None = None,
48
+ request_timeout: float = 10.0,
49
+ backoff: tuple[float, float] = (1.0, 30.0),
50
+ ) -> None:
51
+ """Set up a client for the server at `url` (its `/ws` endpoint)."""
52
+ self._url = url
53
+ self._session = session
54
+ self._client_name = client_name
55
+ self._ha_version = ha_version
56
+ self._on_message = on_message
57
+ self._timeout = request_timeout
58
+ self._backoff = backoff
59
+ self._ws: aiohttp.ClientWebSocketResponse | None = None
60
+ self._reader: asyncio.Task[None] | None = None
61
+ self._pending: dict[str, asyncio.Future[models.Envelope]] = {}
62
+ self._ids = itertools.count(1)
63
+ self._closing = False
64
+
65
+ @property
66
+ def connected(self) -> bool:
67
+ """Whether the connection is open and welcomed."""
68
+ return self._ws is not None and not self._ws.closed
69
+
70
+ async def connect(self, auth: models.Auth) -> models.Welcome:
71
+ """Open the connection and say hello; the welcome says who answered."""
72
+ try:
73
+ ws = await self._session.ws_connect(self._url, heartbeat=30)
74
+ except (aiohttp.ClientError, OSError) as err:
75
+ raise CannotConnectError(str(err)) from err
76
+ hello = models.Envelope(
77
+ id=self._next_id(),
78
+ body=models.Hello(
79
+ client=self._client_name, ha_version=self._ha_version, auth=auth
80
+ ),
81
+ )
82
+ try:
83
+ await ws.send_str(hello.to_json())
84
+ msg = await asyncio.wait_for(ws.receive(), self._timeout)
85
+ except TimeoutError as err:
86
+ await ws.close()
87
+ raise RequestTimeoutError("no answer to hello") from err
88
+ if msg.type is not aiohttp.WSMsgType.TEXT:
89
+ await ws.close()
90
+ raise CannotConnectError("the server closed the connection")
91
+ reply = models.parse(msg.data)
92
+ if isinstance(reply.body, models.ErrorReply):
93
+ await ws.close()
94
+ raise AuthenticationError(reply.body.message)
95
+ if not isinstance(reply.body, models.Welcome):
96
+ await ws.close()
97
+ raise CannotConnectError(f"expected welcome, got {reply.body.TYPE}")
98
+ self._ws = ws
99
+ self._reader = asyncio.create_task(self._read(ws))
100
+ return reply.body
101
+
102
+ async def request(self, body: models.Message) -> models.Message:
103
+ """Send a request and wait for the reply with its id."""
104
+ ws = self._require()
105
+ env = models.Envelope(id=self._next_id(), body=body)
106
+ future: asyncio.Future[models.Envelope] = (
107
+ asyncio.get_running_loop().create_future()
108
+ )
109
+ self._pending[env.id] = future
110
+ try:
111
+ await ws.send_str(env.to_json())
112
+ reply = await asyncio.wait_for(future, self._timeout)
113
+ except TimeoutError as err:
114
+ raise RequestTimeoutError(f"no answer to {body.TYPE}") from err
115
+ finally:
116
+ self._pending.pop(env.id, None)
117
+ if isinstance(reply.body, models.ErrorReply):
118
+ raise ServerError(reply.body.code, reply.body.message)
119
+ return reply.body
120
+
121
+ async def send(self, body: models.Message, *, reply_to: str | None = None) -> None:
122
+ """Send a message that needs no reply (a push, or an answer)."""
123
+ ws = self._require()
124
+ env = models.Envelope(id=reply_to or self._next_id(), body=body)
125
+ await ws.send_str(env.to_json())
126
+
127
+ async def ping(self) -> None:
128
+ """Check the server answers."""
129
+ reply = await self.request(models.Ping())
130
+ if not isinstance(reply, models.Pong):
131
+ raise CannotConnectError(f"expected pong, got {reply.TYPE}")
132
+
133
+ async def decide(self, decide: models.Decide) -> models.Decision:
134
+ """Ask what to do with these words."""
135
+ reply = await self.request(decide)
136
+ if not isinstance(reply, models.Decision):
137
+ raise CannotConnectError(f"expected decision, got {reply.TYPE}")
138
+ return reply
139
+
140
+ async def run_forever(
141
+ self,
142
+ auth: models.Auth,
143
+ *,
144
+ on_connect: ConnectHandler | None = None,
145
+ on_disconnect: Callable[[], Awaitable[None]] | None = None,
146
+ ) -> None:
147
+ """Stay connected until `close()`, reconnecting with backoff."""
148
+ first, most = self._backoff
149
+ delay = first
150
+ while not self._stopping():
151
+ try:
152
+ welcome = await self.connect(auth)
153
+ except AuthenticationError:
154
+ raise
155
+ except ShorthandError as err:
156
+ _LOGGER.debug("Can't reach the Shorthand server: %s", err)
157
+ else:
158
+ delay = first
159
+ if on_connect is not None:
160
+ await on_connect(welcome)
161
+ if self._reader is not None:
162
+ with contextlib.suppress(asyncio.CancelledError):
163
+ await self._reader
164
+ if on_disconnect is not None and not self._stopping():
165
+ await on_disconnect()
166
+ if self._stopping():
167
+ break
168
+ await asyncio.sleep(delay)
169
+ delay = min(delay * 2, most)
170
+
171
+ async def close(self) -> None:
172
+ """Close the connection; `run_forever` returns."""
173
+ self._closing = True
174
+ if self._ws is not None:
175
+ await self._ws.close()
176
+ if self._reader is not None:
177
+ self._reader.cancel()
178
+ with contextlib.suppress(asyncio.CancelledError):
179
+ await self._reader
180
+
181
+ async def _read(self, ws: aiohttp.ClientWebSocketResponse) -> None:
182
+ try:
183
+ async for msg in ws:
184
+ if msg.type is not aiohttp.WSMsgType.TEXT:
185
+ continue
186
+ try:
187
+ env = models.parse(msg.data)
188
+ except ProtocolError as err:
189
+ _LOGGER.warning("Ignoring a message from the server: %s", err)
190
+ continue
191
+ future = self._pending.get(env.id)
192
+ if future is not None and not future.done():
193
+ future.set_result(env)
194
+ elif self._on_message is not None:
195
+ await self._on_message(env)
196
+ finally:
197
+ self._ws = None
198
+ lost = CannotConnectError("the connection to the server was lost")
199
+ for future in self._pending.values():
200
+ if not future.done():
201
+ future.set_exception(lost)
202
+
203
+ def _stopping(self) -> bool:
204
+ # A method, so type checkers don't assume it can't change across an await.
205
+ return self._closing
206
+
207
+ def _require(self) -> aiohttp.ClientWebSocketResponse:
208
+ if self._ws is None or self._ws.closed:
209
+ raise CannotConnectError("not connected")
210
+ return self._ws
211
+
212
+ def _next_id(self) -> str:
213
+ return str(next(self._ids))
214
+
215
+
216
+ __all__ = ["ShorthandClient"]
@@ -0,0 +1,55 @@
1
+ """What can go wrong talking to the Shorthand server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .models import ErrorCode
9
+
10
+
11
+ class ShorthandError(Exception):
12
+ """Anything this library raises."""
13
+
14
+
15
+ class ProtocolError(ShorthandError):
16
+ """Text that isn't a message this version can use."""
17
+
18
+
19
+ class VersionMismatchError(ProtocolError):
20
+ """The peer speaks another protocol version."""
21
+
22
+ def __init__(self, got: int) -> None:
23
+ """Record the version the peer spoke."""
24
+ super().__init__(f"protocol version {got} isn't supported")
25
+ self.got = got
26
+
27
+
28
+ class UnknownMessageTypeError(ProtocolError):
29
+ """A message type this version doesn't know."""
30
+
31
+
32
+ class MalformedMessageError(ProtocolError):
33
+ """Not JSON, not an object, or a field that's missing or wrong."""
34
+
35
+
36
+ class CannotConnectError(ShorthandError):
37
+ """The server couldn't be reached, or the connection was lost."""
38
+
39
+
40
+ class AuthenticationError(ShorthandError):
41
+ """The server refused the pairing code or key."""
42
+
43
+
44
+ class RequestTimeoutError(ShorthandError):
45
+ """The server didn't answer a request in time."""
46
+
47
+
48
+ class ServerError(ShorthandError):
49
+ """The server answered a request with an error."""
50
+
51
+ def __init__(self, code: ErrorCode, message: str) -> None:
52
+ """Record the server's error code and message."""
53
+ super().__init__(f"{code}: {message}")
54
+ self.code = code
55
+ self.message = message
@@ -0,0 +1,454 @@
1
+ """The protocol's messages (07 §2), mirroring `shorthand-protocol` in Rust.
2
+
3
+ The Rust crate's fixtures are the contract: every one must parse into these
4
+ types and serialize back to the same JSON (`tests/test_contract.py`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ import json
11
+ import types
12
+ from dataclasses import MISSING, dataclass, field, fields, is_dataclass
13
+ from typing import Any, ClassVar, Union, get_args, get_origin, get_type_hints
14
+
15
+ from .errors import (
16
+ MalformedMessageError,
17
+ UnknownMessageTypeError,
18
+ VersionMismatchError,
19
+ )
20
+
21
+ VERSION = 1
22
+ """The protocol version this library speaks."""
23
+
24
+
25
+ class CheckResult(enum.StrEnum):
26
+ """Whether the house showed what a call should have done (03 §5)."""
27
+
28
+ VERIFIED = "verified"
29
+ MISMATCH = "mismatch"
30
+ UNCONFIRMED = "unconfirmed"
31
+ DEVICE_TROUBLE = "device_trouble"
32
+
33
+
34
+ class ErrorCode(enum.StrEnum):
35
+ """Why a request couldn't be done."""
36
+
37
+ UNAUTHORIZED = "unauthorized"
38
+ BAD_MESSAGE = "bad_message"
39
+ INTERNAL = "internal"
40
+
41
+
42
+ # Tagged variants: `TAG_KEY` names the field that says which one.
43
+
44
+
45
+ @dataclass(frozen=True, slots=True, kw_only=True)
46
+ class Pair:
47
+ """The first time: the code the server shows."""
48
+
49
+ TAG_KEY: ClassVar[str] = "method"
50
+ TAG: ClassVar[str] = "pair"
51
+ code: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True, kw_only=True)
55
+ class Key:
56
+ """Every time after: the key the server gave back."""
57
+
58
+ TAG_KEY: ClassVar[str] = "method"
59
+ TAG: ClassVar[str] = "key"
60
+ key: str
61
+
62
+
63
+ type Auth = Pair | Key
64
+
65
+
66
+ @dataclass(frozen=True, slots=True, kw_only=True)
67
+ class ServiceCall:
68
+ """One service call, as captured or to run (01 §5)."""
69
+
70
+ service: str
71
+ entity_ids: list[str] = field(default_factory=list)
72
+ data: Any = None
73
+
74
+
75
+ @dataclass(frozen=True, slots=True, kw_only=True)
76
+ class Run:
77
+ """Run a learned command."""
78
+
79
+ TAG_KEY: ClassVar[str] = "decision"
80
+ TAG: ClassVar[str] = "run"
81
+ command: int
82
+ calls: list[ServiceCall]
83
+ why: str
84
+
85
+
86
+ @dataclass(frozen=True, slots=True, kw_only=True)
87
+ class Pass:
88
+ """Pass the words to the brain."""
89
+
90
+ TAG_KEY: ClassVar[str] = "decision"
91
+ TAG: ClassVar[str] = "pass"
92
+ why: str
93
+
94
+
95
+ type Verdict = Run | Pass
96
+
97
+
98
+ @dataclass(frozen=True, slots=True, kw_only=True)
99
+ class Floor:
100
+ """A floor, with its aliases."""
101
+
102
+ id: str
103
+ name: str
104
+ aliases: list[str] = field(default_factory=list)
105
+
106
+
107
+ @dataclass(frozen=True, slots=True, kw_only=True)
108
+ class Area:
109
+ """An area, with its aliases and floor."""
110
+
111
+ id: str
112
+ name: str
113
+ aliases: list[str] = field(default_factory=list)
114
+ floor: str | None = None
115
+
116
+
117
+ @dataclass(frozen=True, slots=True, kw_only=True)
118
+ class Entity:
119
+ """An entity exposed to Assist."""
120
+
121
+ id: str
122
+ name: str
123
+ aliases: list[str] = field(default_factory=list)
124
+ domain: str
125
+ device_class: str | None = None
126
+ area: str | None = None
127
+ sensitive: bool = False
128
+ state: str
129
+ attributes: Any = None
130
+
131
+
132
+ @dataclass(frozen=True, slots=True, kw_only=True)
133
+ class Media:
134
+ """Something to play, from Music Assistant's library."""
135
+
136
+ uri: str
137
+ name: str
138
+ kind: str
139
+
140
+
141
+ @dataclass(frozen=True, slots=True, kw_only=True)
142
+ class Home:
143
+ """The house as Shorthand sees it (04 §1)."""
144
+
145
+ floors: list[Floor] = field(default_factory=list)
146
+ areas: list[Area] = field(default_factory=list)
147
+ entities: list[Entity] = field(default_factory=list)
148
+ media: list[Media] = field(default_factory=list)
149
+
150
+
151
+ @dataclass(frozen=True, slots=True, kw_only=True)
152
+ class State:
153
+ """One entity's state, as it is now or was then."""
154
+
155
+ entity_id: str
156
+ state: str
157
+ attributes: Any = None
158
+
159
+
160
+ @dataclass(frozen=True, slots=True, kw_only=True)
161
+ class Check:
162
+ """One call's check (03 §5)."""
163
+
164
+ call: int
165
+ result: CheckResult
166
+ detail: str | None = None
167
+
168
+
169
+ @dataclass(frozen=True, slots=True, kw_only=True)
170
+ class TurnRecord:
171
+ """What the brain did in one turn (03 §2)."""
172
+
173
+ words: str
174
+ conversation_id: str | None = None
175
+ here: str | None = None
176
+ focus_before: list[str] = field(default_factory=list)
177
+ calls: list[ServiceCall] = field(default_factory=list)
178
+ before: list[State] = field(default_factory=list)
179
+ checks: list[Check] = field(default_factory=list)
180
+ tools: Any = None
181
+ reply: str
182
+
183
+
184
+ # Messages: `TYPE` is the envelope's "type".
185
+
186
+
187
+ @dataclass(frozen=True, slots=True, kw_only=True)
188
+ class Hello:
189
+ """Integration → server: who's connecting, and how it proves it."""
190
+
191
+ TYPE: ClassVar[str] = "hello"
192
+ client: str
193
+ ha_version: str
194
+ auth: Auth
195
+
196
+
197
+ @dataclass(frozen=True, slots=True, kw_only=True)
198
+ class Welcome:
199
+ """Server → integration: accepted; the key only when pairing."""
200
+
201
+ TYPE: ClassVar[str] = "welcome"
202
+ server: str
203
+ server_id: str
204
+ key: str | None = None
205
+
206
+
207
+ @dataclass(frozen=True, slots=True, kw_only=True)
208
+ class Ping:
209
+ """Are you there?"""
210
+
211
+ TYPE: ClassVar[str] = "ping"
212
+
213
+
214
+ @dataclass(frozen=True, slots=True, kw_only=True)
215
+ class Pong:
216
+ """Yes."""
217
+
218
+ TYPE: ClassVar[str] = "pong"
219
+
220
+
221
+ @dataclass(frozen=True, slots=True, kw_only=True)
222
+ class Snapshot:
223
+ """The whole house, on connecting and when asked."""
224
+
225
+ TYPE: ClassVar[str] = "snapshot"
226
+ home: Home
227
+
228
+
229
+ @dataclass(frozen=True, slots=True, kw_only=True)
230
+ class States:
231
+ """State changes of exposed entities."""
232
+
233
+ TYPE: ClassVar[str] = "states"
234
+ changes: list[State]
235
+
236
+
237
+ @dataclass(frozen=True, slots=True, kw_only=True)
238
+ class Decide:
239
+ """What to do with these words."""
240
+
241
+ TYPE: ClassVar[str] = "decide"
242
+ words: str
243
+ conversation_id: str | None = None
244
+ here: str | None = None
245
+ focus: list[str] = field(default_factory=list)
246
+
247
+
248
+ @dataclass(frozen=True, slots=True, kw_only=True)
249
+ class Decision:
250
+ """The answer to a decide."""
251
+
252
+ TYPE: ClassVar[str] = "decision"
253
+ verdict: Verdict
254
+
255
+
256
+ @dataclass(frozen=True, slots=True, kw_only=True)
257
+ class Turn:
258
+ """What the brain did in a turn."""
259
+
260
+ TYPE: ClassVar[str] = "turn"
261
+ record: TurnRecord
262
+
263
+
264
+ @dataclass(frozen=True, slots=True, kw_only=True)
265
+ class Outcome:
266
+ """How a learned command's run went."""
267
+
268
+ TYPE: ClassVar[str] = "outcome"
269
+ command: int
270
+ checks: list[Check] = field(default_factory=list)
271
+
272
+
273
+ @dataclass(frozen=True, slots=True, kw_only=True)
274
+ class LearnRequest:
275
+ """Server → integration: run this AI Task."""
276
+
277
+ TYPE: ClassVar[str] = "learn_request"
278
+ task: str
279
+ instructions: str
280
+
281
+
282
+ @dataclass(frozen=True, slots=True, kw_only=True)
283
+ class LearnResult:
284
+ """Integration → server: the AI Task's result."""
285
+
286
+ TYPE: ClassVar[str] = "learn_result"
287
+ data: Any = None
288
+ error: str | None = None
289
+
290
+
291
+ @dataclass(frozen=True, slots=True, kw_only=True)
292
+ class SyncRequest:
293
+ """Server → integration: send a full snapshot."""
294
+
295
+ TYPE: ClassVar[str] = "sync_request"
296
+
297
+
298
+ @dataclass(frozen=True, slots=True, kw_only=True)
299
+ class ErrorReply:
300
+ """A request couldn't be done."""
301
+
302
+ TYPE: ClassVar[str] = "error"
303
+ code: ErrorCode
304
+ message: str
305
+
306
+
307
+ type Message = (
308
+ Hello
309
+ | Welcome
310
+ | Ping
311
+ | Pong
312
+ | Snapshot
313
+ | States
314
+ | Decide
315
+ | Decision
316
+ | Turn
317
+ | Outcome
318
+ | LearnRequest
319
+ | LearnResult
320
+ | SyncRequest
321
+ | ErrorReply
322
+ )
323
+
324
+ MESSAGE_TYPES: dict[str, type[Any]] = {
325
+ cls.TYPE: cls
326
+ for cls in (
327
+ Hello,
328
+ Welcome,
329
+ Ping,
330
+ Pong,
331
+ Snapshot,
332
+ States,
333
+ Decide,
334
+ Decision,
335
+ Turn,
336
+ Outcome,
337
+ LearnRequest,
338
+ LearnResult,
339
+ SyncRequest,
340
+ ErrorReply,
341
+ )
342
+ }
343
+
344
+
345
+ @dataclass(frozen=True, slots=True)
346
+ class Envelope:
347
+ """A message with its version and id."""
348
+
349
+ id: str
350
+ body: Message
351
+ v: int = VERSION
352
+
353
+ def to_json(self) -> str:
354
+ """The message as one JSON object, as the server expects it."""
355
+ return json.dumps(
356
+ {"v": self.v, "id": self.id, "type": self.body.TYPE, **_to_dict(self.body)}
357
+ )
358
+
359
+
360
+ def parse(text: str) -> Envelope:
361
+ """Read a message: the version first, then the type, then its fields."""
362
+ try:
363
+ raw = json.loads(text)
364
+ except ValueError as err:
365
+ raise MalformedMessageError(str(err)) from err
366
+ if not isinstance(raw, dict):
367
+ raise MalformedMessageError("not an object")
368
+ v = raw.pop("v", None)
369
+ if not isinstance(v, int) or isinstance(v, bool):
370
+ raise MalformedMessageError("no version")
371
+ if v != VERSION:
372
+ raise VersionMismatchError(v)
373
+ msg_id = raw.pop("id", None)
374
+ if not isinstance(msg_id, str):
375
+ raise MalformedMessageError("no id")
376
+ kind = raw.pop("type", None)
377
+ if not isinstance(kind, str):
378
+ raise MalformedMessageError("no type")
379
+ cls = MESSAGE_TYPES.get(kind)
380
+ if cls is None:
381
+ raise UnknownMessageTypeError(f"unknown message type {kind!r}")
382
+ return Envelope(id=msg_id, body=_from_dict(cls, raw, kind))
383
+
384
+
385
+ # A small typed converter between these dataclasses and JSON values.
386
+
387
+
388
+ def _to_dict(obj: Any) -> dict[str, Any]:
389
+ out = {f.name: _to_json(getattr(obj, f.name)) for f in fields(obj)}
390
+ if tag_key := getattr(type(obj), "TAG_KEY", None):
391
+ out[tag_key] = type(obj).TAG
392
+ return out
393
+
394
+
395
+ def _to_json(value: Any) -> Any:
396
+ if is_dataclass(value) and not isinstance(value, type):
397
+ return _to_dict(value)
398
+ if isinstance(value, list):
399
+ return [_to_json(v) for v in value]
400
+ if isinstance(value, enum.Enum):
401
+ return value.value
402
+ return value
403
+
404
+
405
+ def _from_dict(cls: type[Any], raw: Any, where: str) -> Any:
406
+ if not isinstance(raw, dict):
407
+ raise MalformedMessageError(f"{where}: expected an object")
408
+ hints = get_type_hints(cls)
409
+ kwargs: dict[str, Any] = {}
410
+ for f in fields(cls):
411
+ if f.name in raw:
412
+ kwargs[f.name] = _convert(hints[f.name], raw[f.name], f"{where}.{f.name}")
413
+ elif f.default is MISSING and f.default_factory is MISSING:
414
+ raise MalformedMessageError(f"{where}: missing {f.name}")
415
+ return cls(**kwargs)
416
+
417
+
418
+ def _convert(tp: Any, value: Any, where: str) -> Any: # noqa: PLR0911, PLR0912
419
+ # One branch per kind of type; splitting it would hide the whole picture.
420
+ tp = getattr(tp, "__value__", tp) # a `type X = …` alias
421
+ origin = get_origin(tp)
422
+ if tp is Any:
423
+ return value
424
+ if origin in (Union, types.UnionType):
425
+ options = [a for a in get_args(tp) if a is not type(None)]
426
+ if value is None and len(options) < len(get_args(tp)):
427
+ return None
428
+ tagged = [o for o in options if hasattr(o, "TAG_KEY")]
429
+ if tagged:
430
+ tag = value.get(tagged[0].TAG_KEY) if isinstance(value, dict) else None
431
+ for o in tagged:
432
+ if tag == o.TAG:
433
+ return _from_dict(o, value, where)
434
+ raise MalformedMessageError(f"{where}: unknown variant {tag!r}")
435
+ return _convert(options[0], value, where)
436
+ if origin is list:
437
+ if not isinstance(value, list):
438
+ raise MalformedMessageError(f"{where}: expected a list")
439
+ (item,) = get_args(tp)
440
+ return [_convert(item, v, f"{where}[{i}]") for i, v in enumerate(value)]
441
+ if isinstance(tp, type) and is_dataclass(tp):
442
+ return _from_dict(tp, value, where)
443
+ if isinstance(tp, type) and issubclass(tp, enum.Enum):
444
+ try:
445
+ return tp(value)
446
+ except ValueError as err:
447
+ raise MalformedMessageError(
448
+ f"{where}: {value!r} isn't a {tp.__name__}"
449
+ ) from err
450
+ if tp is int and (not isinstance(value, int) or isinstance(value, bool)):
451
+ raise MalformedMessageError(f"{where}: expected an integer")
452
+ if tp in (str, bool) and not isinstance(value, tp):
453
+ raise MalformedMessageError(f"{where}: expected {tp.__name__}")
454
+ return value
File without changes