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.
- ankicli.py +214 -0
- ankiweb_api-1.0.0.dist-info/METADATA +574 -0
- ankiweb_api-1.0.0.dist-info/RECORD +14 -0
- ankiweb_api-1.0.0.dist-info/WHEEL +4 -0
- ankiweb_api-1.0.0.dist-info/entry_points.txt +4 -0
- ankiweb_api-1.0.0.dist-info/licenses/LICENSE +21 -0
- ankiweb_auth.py +148 -0
- ankiweb_client.py +405 -0
- ankiweb_engine/index.js +44894 -0
- ankiweb_proto.py +372 -0
- ankiweb_schema.py +49 -0
- make_test_addon.py +66 -0
- terminal_ankiweb.py +342 -0
- upload.py +195 -0
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)
|