ankiweb-api 1.0.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.
ankiweb_proto.py ADDED
@@ -0,0 +1,372 @@
1
+ """Schema-driven protobuf codec for the AnkiWeb `/svc/*` API.
2
+
3
+ Pure stdlib. Wire format only - no .proto compilation, no protobuf dependency.
4
+ Messages are described by `ankiweb_schema.SCHEMA` (field numbers + types
5
+ recovered from AnkiWeb's own frontend bundle).
6
+
7
+ Supported field types
8
+ ---------------------
9
+ scalar : int32 int64 uint32 uint64 sint32 sint64 bool sfixed32 sfixed64
10
+ fixed32 fixed64 float double string bytes enum
11
+ message : nested message (named in SCHEMA)
12
+
13
+ Supported modifiers
14
+ -------------------
15
+ repeated - packed (numeric scalars) or unpacked (everything else)
16
+ optional - proto3 `optional`, i.e. presence tracked
17
+ map - decoded as repeated key/value submessages
18
+
19
+ Unknown fields are preserved on decode and re-emitted on encode, so a round-trip
20
+ through this codec does not silently drop data from a newer server.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import struct as _struct
26
+
27
+ from ankiweb_schema import SCHEMA
28
+
29
+ # ---------------------------------------------------------------- wire helpers
30
+
31
+
32
+ def _varint(n: int) -> bytes:
33
+ if n < 0:
34
+ n &= (1 << 64) - 1
35
+ out = bytearray()
36
+ while True:
37
+ b = n & 0x7F
38
+ n >>= 7
39
+ if n:
40
+ out.append(b | 0x80)
41
+ else:
42
+ out.append(b)
43
+ return bytes(out)
44
+
45
+
46
+ def _read_varint(buf: bytes, pos: int) -> tuple[int, int]:
47
+ shift = 0
48
+ result = 0
49
+ while True:
50
+ if pos >= len(buf):
51
+ raise ValueError("truncated varint")
52
+ b = buf[pos]
53
+ pos += 1
54
+ result |= (b & 0x7F) << shift
55
+ shift += 7
56
+ if not (b & 0x80):
57
+ return result, pos
58
+ if shift >= 70:
59
+ raise ValueError("varint too long")
60
+
61
+
62
+ def _zigzag_decode(n: int) -> int:
63
+ return (n >> 1) ^ -(n & 1)
64
+
65
+
66
+ def _zigzag_encode(n: int, bits: int = 64) -> int:
67
+ return (n << 1) ^ (n >> (bits - 1))
68
+
69
+
70
+ def _to_signed(n: int, bits: int) -> int:
71
+ n &= (1 << bits) - 1
72
+ return n - (1 << bits) if n & (1 << (bits - 1)) else n
73
+
74
+
75
+ # numeric types that can use packed encoding
76
+ _PACKABLE = {
77
+ "int32", "int64", "uint32", "uint64", "sint32", "sint64",
78
+ "bool", "sfixed32", "sfixed64", "fixed32", "fixed64", "float", "double", "enum",
79
+ }
80
+
81
+
82
+ def _is_numeric(t: str) -> bool:
83
+ return t in _PACKABLE
84
+
85
+
86
+ def _decode_scalar(t: str, raw: bytes, wire: int):
87
+ """Decode one scalar value given its wire type."""
88
+ if t == "string":
89
+ return raw.decode("utf-8", "replace")
90
+ if t == "bytes":
91
+ return raw
92
+ if t == "bool":
93
+ return bool(raw if wire == 0 else raw[0])
94
+ if t in ("float",):
95
+ return _struct.unpack("<f", raw)[0]
96
+ if t in ("double",):
97
+ return _struct.unpack("<d", raw)[0]
98
+ if t == "fixed32":
99
+ return _struct.unpack("<I", raw)[0]
100
+ if t == "sfixed32":
101
+ return _struct.unpack("<i", raw)[0]
102
+ if t == "fixed64":
103
+ return _struct.unpack("<Q", raw)[0]
104
+ if t == "sfixed64":
105
+ return _struct.unpack("<q", raw)[0]
106
+ if t in ("int32", "int64"):
107
+ return _to_signed(raw, 64) if t == "int64" else _to_signed(raw, 32)
108
+ if t in ("uint32", "uint64"):
109
+ return raw & 0xFFFFFFFF if t == "uint32" else raw
110
+ if t in ("sint32", "sint64"):
111
+ return _to_signed(_zigzag_decode(raw), 32 if t == "sint32" else 64)
112
+ if t == "enum":
113
+ return _to_signed(raw, 32)
114
+ raise ValueError(f"unsupported scalar type {t}")
115
+
116
+
117
+ def _encode_scalar(t: str, value) -> tuple[int, bytes]:
118
+ """Return (wire_type, payload_bytes) for one scalar."""
119
+ if t == "string":
120
+ return 2, value.encode("utf-8") if isinstance(value, str) else bytes(value)
121
+ if t == "bytes":
122
+ return 2, bytes(value)
123
+ if t == "bool":
124
+ return 0, _varint(1 if value else 0)
125
+ if t == "float":
126
+ return 5, _struct.pack("<f", value)
127
+ if t == "double":
128
+ return 1, _struct.pack("<d", value)
129
+ if t == "fixed32":
130
+ return 5, _struct.pack("<I", value)
131
+ if t == "sfixed32":
132
+ return 5, _struct.pack("<i", value)
133
+ if t == "fixed64":
134
+ return 1, _struct.pack("<Q", value)
135
+ if t == "sfixed64":
136
+ return 1, _struct.pack("<q", value)
137
+ if t == "sint32":
138
+ return 0, _varint(_zigzag_encode(value, 32))
139
+ if t == "sint64":
140
+ return 0, _varint(_zigzag_encode(value, 64))
141
+ if t == "int32":
142
+ return 0, _varint(value & 0xFFFFFFFFFFFFFFFF if value >= 0 else value)
143
+ if t == "int64":
144
+ return 0, _varint(value)
145
+ if t in ("uint32", "uint64", "enum"):
146
+ return 0, _varint(value)
147
+ raise ValueError(f"unsupported scalar type {t}")
148
+
149
+
150
+ def _wire_width(t: str) -> int:
151
+ return {
152
+ "float": 4, "fixed32": 4, "sfixed32": 4, "double": 8,
153
+ "fixed64": 8, "sfixed64": 8,
154
+ }.get(t, 0)
155
+
156
+
157
+ # ---------------------------------------------------------------- public API
158
+
159
+
160
+ class ProtoError(ValueError):
161
+ pass
162
+
163
+
164
+ def _fields_of(msg_name: str) -> list[dict]:
165
+ try:
166
+ return SCHEMA[msg_name]["fields"]
167
+ except KeyError:
168
+ raise ProtoError(f"unknown message {msg_name!r}") from None
169
+
170
+
171
+ def decode(msg_name: str, buf: bytes) -> dict:
172
+ """Decode protobuf bytes into a dict keyed by field name.
173
+
174
+ Returns a plain dict: scalars as Python values, nested messages as dicts,
175
+ repeated fields as lists, and unknown fields under `_unknown` as raw bytes.
176
+ """
177
+ fields = _fields_of(msg_name)
178
+ by_no = {f["no"]: f for f in fields}
179
+ out: dict = {}
180
+ unknown: dict[int, list] = {}
181
+ pos = 0
182
+ while pos < len(buf):
183
+ try:
184
+ key, pos = _read_varint(buf, pos)
185
+ no, wire = key >> 3, key & 7
186
+ except ValueError as exc:
187
+ raise ProtoError(f"invalid protobuf key at byte {pos}: {exc}") from exc
188
+ f = by_no.get(no)
189
+ if wire == 0:
190
+ raw, pos = _read_varint(buf, pos)
191
+ elif wire == 1:
192
+ if pos + 8 > len(buf):
193
+ raise ProtoError(f"truncated fixed64 field at byte {pos}")
194
+ raw, pos = buf[pos:pos + 8], pos + 8
195
+ elif wire == 5:
196
+ if pos + 4 > len(buf):
197
+ raise ProtoError(f"truncated fixed32 field at byte {pos}")
198
+ raw, pos = buf[pos:pos + 4], pos + 4
199
+ elif wire == 2:
200
+ ln, pos = _read_varint(buf, pos)
201
+ if pos + ln > len(buf):
202
+ raise ProtoError(f"truncated length-delimited field at byte {pos}")
203
+ raw, pos = buf[pos:pos + ln], pos + ln
204
+ elif wire in (3, 4):
205
+ # deprecated groups: skip to matching end-group
206
+ depth = 1
207
+ while pos < len(buf) and depth:
208
+ k2, pos = _read_varint(buf, pos)
209
+ w2 = k2 & 7
210
+ if w2 == 3:
211
+ depth += 1
212
+ elif w2 == 4:
213
+ depth -= 1
214
+ elif w2 == 0:
215
+ _, pos = _read_varint(buf, pos)
216
+ elif w2 == 2:
217
+ l2, pos = _read_varint(buf, pos)
218
+ pos += l2
219
+ elif w2 == 1:
220
+ pos += 8
221
+ elif w2 == 5:
222
+ pos += 4
223
+ raw = None
224
+ else:
225
+ break
226
+
227
+ if f is None:
228
+ unknown.setdefault(no, []).append((wire, raw))
229
+ continue
230
+
231
+ t = f["type"]
232
+ name = f["name"]
233
+ expected_wire = f["wire"]
234
+ packed_numeric = f["repeated"] and _is_numeric(t) and expected_wire == 0
235
+ if wire != expected_wire and not (packed_numeric and wire == 2):
236
+ # A reused/incorrect schema must not silently reinterpret a
237
+ # length-delimited payload as an integer (or vice versa).
238
+ unknown.setdefault(no, []).append((wire, raw))
239
+ continue
240
+ if f["repeated"]:
241
+ # packed numeric run
242
+ if wire == 2 and _is_numeric(t) and f["wire"] == 0:
243
+ vals = []
244
+ p2 = 0
245
+ while p2 < len(raw):
246
+ v, p2 = _read_varint(raw, p2)
247
+ vals.append(_decode_scalar(t, v, 0))
248
+ out.setdefault(name, []).extend(vals)
249
+ elif wire == 2 and _is_numeric(t) and f["wire"] in (1, 5):
250
+ width = _wire_width(t)
251
+ if len(raw) % width:
252
+ raise ProtoError(f"malformed packed {t} field {name!r}")
253
+ vals = [_decode_scalar(t, raw[i:i + width], f["wire"])
254
+ for i in range(0, len(raw), width)]
255
+ out.setdefault(name, []).extend(vals)
256
+ elif t == "message":
257
+ out.setdefault(name, []).append(decode(f["T"], raw))
258
+ elif t == "string":
259
+ out.setdefault(name, []).append(raw.decode("utf-8", "replace"))
260
+ elif t == "bytes":
261
+ out.setdefault(name, []).append(raw)
262
+ else:
263
+ out.setdefault(name, []).append(_decode_scalar(t, raw, wire))
264
+ else:
265
+ if t == "message":
266
+ out[name] = decode(f["T"], raw)
267
+ elif t == "string":
268
+ out[name] = raw.decode("utf-8", "replace")
269
+ elif t == "bytes":
270
+ out[name] = raw
271
+ else:
272
+ out[name] = _decode_scalar(t, raw, wire)
273
+ if unknown:
274
+ out["_unknown"] = unknown
275
+ return out
276
+
277
+
278
+ def encode(msg_name: str, _fields: dict | None = None, **kwargs) -> bytes:
279
+ """Encode a dict (or kwargs) into protobuf bytes using the schema.
280
+
281
+ >>> encode("LoginRequest", username="me", password="pw")
282
+ """
283
+ data = dict(_fields or {})
284
+ data.update(kwargs)
285
+ fields = _fields_of(msg_name)
286
+ by_name = {f["name"]: f for f in fields}
287
+ out = bytearray()
288
+
289
+ for f in fields:
290
+ name = f["name"]
291
+ if name not in data:
292
+ continue
293
+ value = data[name]
294
+ if value is None:
295
+ continue
296
+ no, t = f["no"], f["type"]
297
+
298
+ if f["repeated"]:
299
+ if value is None:
300
+ continue
301
+ vals = value if isinstance(value, (list, tuple)) else [value]
302
+ if not vals:
303
+ continue
304
+ if _is_numeric(t):
305
+ packed = bytearray()
306
+ for v in vals:
307
+ w, payload = _encode_scalar(t, v)
308
+ if w == 0:
309
+ packed += payload
310
+ elif w in (1, 5):
311
+ packed += payload
312
+ out += _varint((no << 3) | 2) + _varint(len(packed)) + bytes(packed)
313
+ else:
314
+ for v in vals:
315
+ if t == "message":
316
+ payload = encode(f["T"], v)
317
+ out += _varint((no << 3) | 2) + _varint(len(payload)) + payload
318
+ elif t == "string":
319
+ payload = v.encode("utf-8") if isinstance(v, str) else bytes(v)
320
+ out += _varint((no << 3) | 2) + _varint(len(payload)) + payload
321
+ elif t == "bytes":
322
+ payload = bytes(v)
323
+ out += _varint((no << 3) | 2) + _varint(len(payload)) + payload
324
+ else:
325
+ w, payload = _encode_scalar(t, v)
326
+ out += _varint((no << 3) | w) + payload
327
+ else:
328
+ if t == "message":
329
+ payload = encode(f["T"], value)
330
+ out += _varint((no << 3) | 2) + _varint(len(payload)) + payload
331
+ elif t == "string":
332
+ payload = value.encode("utf-8") if isinstance(value, str) else bytes(value)
333
+ out += _varint((no << 3) | 2) + _varint(len(payload)) + payload
334
+ elif t == "bytes":
335
+ payload = bytes(value)
336
+ out += _varint((no << 3) | 2) + _varint(len(payload)) + payload
337
+ else:
338
+ w, payload = _encode_scalar(t, value)
339
+ out += _varint((no << 3) | w) + payload
340
+
341
+ # re-emit unknown fields the caller passed through
342
+ for no, entries in (data.get("_unknown") or {}).items():
343
+ for wire, raw in entries:
344
+ if raw is None:
345
+ continue
346
+ out += _varint((no << 3) | wire)
347
+ if wire == 0:
348
+ out += _varint(raw)
349
+ elif wire in (1, 5):
350
+ out += raw
351
+ elif wire == 2:
352
+ out += _varint(len(raw)) + raw
353
+ return bytes(out)
354
+
355
+
356
+ def _varint_raw(n: int) -> bytes:
357
+ return _varint(n)
358
+
359
+
360
+ def describe(msg_name: str) -> str:
361
+ """Human-readable field list, for docs and debugging."""
362
+ lines = [f"{msg_name}:"]
363
+ for f in _fields_of(msg_name):
364
+ flags = []
365
+ if f["repeated"]:
366
+ flags.append("repeated")
367
+ if f["optional"]:
368
+ flags.append("optional")
369
+ fl = (" [" + ", ".join(flags) + "]") if flags else ""
370
+ t = f["T"] if f["type"] == "message" else f["type"]
371
+ lines.append(f" {f['no']:>3} {f['name']:<28} {t}{fl}")
372
+ return "\n".join(lines)