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_auth.py ADDED
@@ -0,0 +1,148 @@
1
+ """AnkiWeb session login for read endpoints that need auth (e.g. list-mine).
2
+
3
+ Uploads keep using the upstream Node engine (dist/index.js). This module only
4
+ adds the login step needed to call authenticated *read* endpoints from Python.
5
+
6
+ Wire format from upstream proto/ankiweb.proto:
7
+ POST /svc/account/login
8
+ LoginRequest{username=1, password=2} -> LoginResponse{status=1, token=2}
9
+ status: 0 UNKNOWN, 1 AUTHENTICATED, 2 INVALID_USER, 3 INVALID_PASS
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ import requests
17
+
18
+ BASE = "https://ankiweb.net"
19
+ LOGIN_URL = f"{BASE}/svc/account/login"
20
+
21
+ LOGIN_STATUS = {
22
+ 0: "UNKNOWN",
23
+ 1: "AUTHENTICATED",
24
+ 2: "INVALID_USER",
25
+ 3: "INVALID_PASS",
26
+ }
27
+
28
+
29
+ class AuthError(RuntimeError):
30
+ pass
31
+
32
+
33
+ def _varint(n: int) -> bytes:
34
+ out = bytearray()
35
+ while True:
36
+ b = n & 0x7F
37
+ n >>= 7
38
+ if n:
39
+ out.append(b | 0x80)
40
+ else:
41
+ out.append(b)
42
+ return bytes(out)
43
+
44
+
45
+ def _field_str(field: int, text: str) -> bytes:
46
+ raw = text.encode("utf-8")
47
+ return _varint((field << 3) | 2) + _varint(len(raw)) + raw
48
+
49
+
50
+ def _read_varint(buf: bytes, pos: int) -> tuple[int, int]:
51
+ shift = 0
52
+ result = 0
53
+ while True:
54
+ if pos >= len(buf):
55
+ raise ValueError("truncated varint")
56
+ b = buf[pos]
57
+ pos += 1
58
+ result |= (b & 0x7F) << shift
59
+ shift += 7
60
+ if not (b & 0x80):
61
+ return result, pos
62
+
63
+
64
+ def encode_login_request(username: str, password: str) -> bytes:
65
+ return _field_str(1, username) + _field_str(2, password)
66
+
67
+
68
+ def decode_login_response(buf: bytes) -> tuple[int, str]:
69
+ status, token = 0, ""
70
+ pos = 0
71
+ while pos < len(buf):
72
+ key, pos = _read_varint(buf, pos)
73
+ field, wire = key >> 3, key & 7
74
+ if wire == 0:
75
+ v, pos = _read_varint(buf, pos)
76
+ if field == 1:
77
+ status = v
78
+ elif wire == 2:
79
+ ln, pos = _read_varint(buf, pos)
80
+ if field == 2:
81
+ token = buf[pos : pos + ln].decode("utf-8", "replace")
82
+ pos += ln
83
+ else:
84
+ raise ValueError(f"unexpected wire type {wire}")
85
+ return status, token
86
+
87
+
88
+ def default_env_file():
89
+ """Best-guess `.env` location: current directory first, then this file's dir.
90
+
91
+ Running from a clone (`./ankicli.py`) wants the repo's `.env`; running an
92
+ installed console script wants `./.env` from wherever the user invoked it,
93
+ since site-packages is not a sane place to keep credentials.
94
+ """
95
+ import os as _os
96
+ from pathlib import Path as _Path
97
+
98
+ cwd_env = _Path.cwd() / ".env"
99
+ if cwd_env.is_file():
100
+ return cwd_env
101
+ return _Path(__file__).resolve().parent / ".env"
102
+
103
+
104
+ def load_dotenv(path) -> None:
105
+ """Minimal .env loader (no dependency). Existing env vars win."""
106
+ from pathlib import Path as _Path
107
+
108
+ p = _Path(path)
109
+ if not p.is_file():
110
+ return
111
+ for raw in p.read_text(encoding="utf-8").splitlines():
112
+ line = raw.strip()
113
+ if not line or line.startswith("#"):
114
+ continue
115
+ if line.startswith("export "):
116
+ line = line[7:].strip()
117
+ if "=" not in line:
118
+ continue
119
+ key, val = line.split("=", 1)
120
+ import os as _os
121
+
122
+ _os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'"))
123
+
124
+
125
+ def login(username: str, password: str, timeout: int = 30) -> str:
126
+ """Return the raw `ankiweb` session cookie value."""
127
+ r = requests.post(
128
+ LOGIN_URL,
129
+ data=encode_login_request(username, password),
130
+ headers={
131
+ "Accept": "*/*",
132
+ "Content-Type": "application/octet-stream",
133
+ "Referer": f"{BASE}/account/login",
134
+ },
135
+ timeout=timeout,
136
+ )
137
+ if r.status_code != 200:
138
+ raise AuthError(f"login HTTP {r.status_code}: {r.text[:300]}")
139
+ status, _token = decode_login_response(r.content)
140
+ if status != 1:
141
+ raise AuthError(f"login failed: {LOGIN_STATUS.get(status, status)}")
142
+ cookie = r.cookies.get("ankiweb")
143
+ if not cookie:
144
+ m = re.search(r"ankiweb=([^;]+)", r.headers.get("Set-Cookie", ""))
145
+ cookie = m.group(1) if m else None
146
+ if not cookie:
147
+ raise AuthError("login succeeded but no ankiweb cookie in response")
148
+ return cookie
ankiweb_client.py ADDED
@@ -0,0 +1,405 @@
1
+ """AnkiWeb full API client - typed methods for all 41 public /svc/* endpoints.
2
+
3
+ Decoded from AnkiWeb's own SvelteKit frontend bundle (protobuf-es definitions);
4
+ wire format matches the official web app exactly. No ad-hoc parsing.
5
+
6
+ client = AnkiWebClient() # auto-loads .env
7
+ client.login() # or AnkiWebClient(cookie=...)
8
+ client.shared_list_mine() # your shared add-ons & decks
9
+ client.shared_item_info(shared_id=805854302)
10
+
11
+ Endpoint reference: docs/API.md and docs/ENDPOINTS.md.
12
+ Regenerate the schema with: python3 tools/generate_schema.py
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import requests
23
+
24
+ from ankiweb_proto import decode, encode, ProtoError
25
+ from ankiweb_schema import (
26
+ ENDPOINTS,
27
+ SCHEMA,
28
+ has_message,
29
+ public_endpoints,
30
+ endpoint,
31
+ )
32
+ from ankiweb_auth import (
33
+ AuthError,
34
+ default_env_file,
35
+ load_dotenv as auth_load_dotenv,
36
+ login as auth_login,
37
+ )
38
+
39
+ BASE = "https://ankiweb.net"
40
+ SHARED = f"{BASE}/shared/"
41
+
42
+ SCHEMA_REVISION = "2026-10-10"
43
+
44
+
45
+ class AnkiWebError(RuntimeError):
46
+ """Network-level or HTTP error from AnkiWeb."""
47
+
48
+
49
+ class NotFoundError(AnkiWebError):
50
+ """Resource doesn't exist or isn't public."""
51
+
52
+
53
+ class ValidationError(ValueError):
54
+ """Invalid input for a known endpoint."""
55
+
56
+
57
+ _NO_AUTH_PATHS = frozenset({
58
+ "/svc/account/login",
59
+ "/svc/account/signup",
60
+ "/svc/account/reset-password",
61
+ "/svc/account/resend-verification",
62
+ "/svc/account/verify-email",
63
+ "/svc/account/get-account-status",
64
+ "/svc/shared/item-info",
65
+ "/svc/shared/list-addons",
66
+ "/svc/shared/list-decks",
67
+ })
68
+
69
+ _ALIASES = {
70
+ "login": "account_login",
71
+ "list_mine": "shared_list_mine",
72
+ "item_info": "shared_item_info",
73
+ }
74
+
75
+
76
+ def _method_name(path: str) -> str:
77
+ parts = [p for p in path.split("/") if p and p not in ("svc", "")]
78
+ if len(parts) == 1:
79
+ return parts[0]
80
+ return "_".join(parts[-2:]).replace("-", "_")
81
+
82
+
83
+ class AnkiWebClient:
84
+ """High-level AnkiWeb API client (41 public endpoints as typed methods)."""
85
+
86
+ def __init__(self, username=None, password=None, env_file=None,
87
+ cookie=None, timeout=30):
88
+ self._cookie = cookie
89
+ self._timeout = timeout
90
+ self._username = username
91
+ self._password = password
92
+ self._env_file = Path(env_file) if env_file else default_env_file()
93
+ auth_load_dotenv(self._env_file)
94
+
95
+ def login(self, username=None, password=None) -> str:
96
+ """Authenticate and cache session cookie. Returns the cookie."""
97
+ u = username or self._username or os.environ.get("ANKIWEB_USERNAME", "")
98
+ p = password or self._password or os.environ.get("ANKIWEB_PASSWORD", "")
99
+ if not u or not p:
100
+ raise AuthError("username/password required (ANKIWEB_USERNAME/PASSWORD or explicit)")
101
+ self._cookie = auth_login(u, p, timeout=self._timeout)
102
+ return self._cookie
103
+
104
+ def _ensure_auth(self, path: str):
105
+ if path in _NO_AUTH_PATHS:
106
+ return self._cookie
107
+ if not self._cookie:
108
+ self.login()
109
+ return self._cookie
110
+
111
+ def logout(self) -> None:
112
+ self._cookie = None
113
+
114
+ @property
115
+ def is_authenticated(self) -> bool:
116
+ return self._cookie is not None
117
+
118
+ def _request(self, method, path, request_obj=None, response_name=None, query=None) -> dict:
119
+ ep = endpoint(path)
120
+ if ep is None:
121
+ raise AnkiWebError(f"Unknown endpoint: {path}")
122
+ headers = {"Accept": "*/*", "Content-Type": "application/octet-stream"}
123
+ cookie = self._ensure_auth(path)
124
+ if cookie and path not in ("/svc/account/login", "/svc/account/signup",
125
+ "/svc/account/reset-password", "/svc/account/resend-verification"):
126
+ headers["Cookie"] = f"has_auth=1; ankiweb={cookie}"
127
+ headers["Referer"] = f"{BASE}/shared/mine"
128
+ body = b""
129
+ if request_obj is not None and ep["request"]:
130
+ if not has_message(ep["request"]):
131
+ raise ValidationError(f"Request type {ep['request']} not in schema")
132
+ body = encode(ep["request"], request_obj)
133
+ qs = ""
134
+ if query:
135
+ qs = "?" + "&".join(f"{k}={v}" for k, v in query.items())
136
+ url = f"{BASE}{path}{qs}"
137
+ try:
138
+ r = requests.request(method, url, data=body, headers=headers, timeout=self._timeout)
139
+ except requests.RequestException as e:
140
+ raise AnkiWebError(f"Network error: {e}") from e
141
+ if r.status_code in (401, 403):
142
+ self._cookie = None
143
+ raise AnkiWebError("Session expired or invalid credentials")
144
+ if r.status_code == 404:
145
+ raise NotFoundError(f"404 for {path}")
146
+ if r.status_code != 200:
147
+ raise AnkiWebError(f"HTTP {r.status_code}: {r.text[:300]}")
148
+ if response_name is None:
149
+ response_name = ep["response"]
150
+ if not response_name or not has_message(response_name):
151
+ return {"raw": r.content, "endpoint": path}
152
+ try:
153
+ return decode(response_name, r.content)
154
+ except ProtoError as e:
155
+ raise AnkiWebError(f"Decode failed: {e}") from e
156
+
157
+ def _call(self, path, request=None, query=None, response_as=None) -> dict:
158
+ ep = endpoint(path)
159
+ if ep is None:
160
+ raise AnkiWebError(f"Unknown endpoint: {path}")
161
+ req_name = ep["request"]
162
+ if req_name and request is None:
163
+ raise ValidationError(f"{path} requires a request object ({req_name})")
164
+ if req_name and not has_message(req_name):
165
+ raise ValidationError(f"Request type {req_name} not in schema")
166
+ return self._request(method=ep["method"], path=path, request_obj=request,
167
+ response_name=response_as or ep["response"], query=query)
168
+ # ================= account (12) =================
169
+
170
+ def account_login(self, username: str, password: str) -> dict:
171
+ """POST /svc/account/login (no session). -> {status, token}."""
172
+ return self._call("/svc/account/login", {"username": username, "password": password})
173
+
174
+ def account_check_terms(self) -> dict:
175
+ """POST /svc/account/check-terms -> {needs_to_confirm}."""
176
+ return self._call("/svc/account/check-terms", {})
177
+
178
+ def account_confirm_terms(self, agree_to_terms: bool = True) -> dict:
179
+ """POST /svc/account/confirm-terms (empty response)."""
180
+ return self._call("/svc/account/confirm-terms", {"agree_to_terms": agree_to_terms})
181
+
182
+ def account_get_account_status(self) -> dict:
183
+ """POST /svc/account/get-account-status -> {logged_in, redirect_to?}."""
184
+ return self._call("/svc/account/get-account-status", {})
185
+
186
+ def account_get_settings(self) -> dict:
187
+ """POST /svc/account/get-settings -> {current_email}."""
188
+ return self._call("/svc/account/get-settings", {})
189
+
190
+ def account_update_settings(self, current_password="", new_password="", new_email="") -> dict:
191
+ """POST /svc/account/update-settings -> {result enum}."""
192
+ return self._call("/svc/account/update-settings", {
193
+ "current_password": current_password, "new_password": new_password,
194
+ "new_email": new_email})
195
+
196
+ def account_signup(self, username: str, password: str) -> dict:
197
+ """POST /svc/account/signup (no session) -> {status, ankiuser_login_token?}."""
198
+ return self._call("/svc/account/signup", {"username": username, "password": password})
199
+
200
+ def account_verify_email(self, token="") -> dict:
201
+ """POST /svc/account/verify-email -> {token_verified, sending_status}."""
202
+ return self._call("/svc/account/verify-email", {"token": token} if token else {})
203
+
204
+ def account_resend_verification(self, hkey="") -> dict:
205
+ """POST /svc/account/resend-verification -> {throttled}."""
206
+ return self._call("/svc/account/resend-verification", {"hkey": hkey} if hkey else {})
207
+
208
+ def account_reset_password(self, email_to="", check_token="", new_password=None) -> dict:
209
+ """POST /svc/account/reset-password -> {send_email, token_ok, set_new_password}."""
210
+ req = {"email_to": email_to, "check_token": check_token}
211
+ if new_password is not None:
212
+ req["new_password"] = new_password
213
+ return self._call("/svc/account/reset-password", req)
214
+
215
+ def account_total_media(self) -> dict:
216
+ """POST /svc/account/total-media -> {total_bytes}."""
217
+ return self._call("/svc/account/total-media", {})
218
+
219
+ def account_remove_account(self, current_password: str) -> dict:
220
+ """POST /svc/account/remove-account - IRREVERSIBLE -> {status, media_removed}."""
221
+ return self._call("/svc/account/remove-account", {"current_password": current_password})
222
+
223
+ # ================= decks (8) =================
224
+
225
+ def decks_create_deck(self, name: str) -> dict:
226
+ """POST /svc/decks/create-deck (empty response)."""
227
+ return self._call("/svc/decks/create-deck", {"name": name})
228
+
229
+ def decks_deck_list_info(self, minutes_west_of_utc=None) -> dict:
230
+ """POST /svc/decks/deck-list-info -> {top_node, current_deck_id, sizes}."""
231
+ req = {} if minutes_west_of_utc is None else {"minutes_west_of_utc": minutes_west_of_utc}
232
+ return self._call("/svc/decks/deck-list-info", req)
233
+
234
+ def decks_deck_share_info(self, deck_id: int) -> dict:
235
+ """POST /svc/decks/deck-share-info -> {metadata, is_large_user, share_count}."""
236
+ return self._call("/svc/decks/deck-share-info", {"deck_id": deck_id})
237
+
238
+ def decks_deck_share(self, metadata: dict, confirm_copyright: bool = True) -> dict:
239
+ """POST /svc/decks/deck-share (empty response)."""
240
+ return self._call("/svc/decks/deck-share",
241
+ {"metadata": metadata, "confirm_copyright": confirm_copyright})
242
+
243
+ def decks_deck_share_state(self) -> dict:
244
+ """POST /svc/decks/deck-share-state -> {state, shared_id?}."""
245
+ return self._call("/svc/decks/deck-share-state", {})
246
+
247
+ def decks_rename_deck(self, deck_id: int, new_name: str) -> dict:
248
+ """POST /svc/decks/rename-deck -> {error}."""
249
+ return self._call("/svc/decks/rename-deck", {"deck_id": deck_id, "new_name": new_name})
250
+
251
+ def decks_remove_deck(self, deck_id: int) -> dict:
252
+ """POST /svc/decks/remove-deck (empty response)."""
253
+ return self._call("/svc/decks/remove-deck", {"deck_id": deck_id})
254
+
255
+ def decks_select_deck(self, deck_id: int) -> dict:
256
+ """POST /svc/decks/select-deck (empty response)."""
257
+ return self._call("/svc/decks/select-deck", {"deck_id": deck_id})
258
+
259
+ # ================= editor (3) + search (1) =================
260
+
261
+ def editor_get_info_for_adding(self) -> dict:
262
+ """POST /svc/editor/get-info-for-adding -> {notetypes[], decks[], fields[]...}."""
263
+ return self._call("/svc/editor/get-info-for-adding", {})
264
+
265
+ def editor_get_note_info(self, note_id: int) -> dict:
266
+ """POST /svc/editor/get-note-info -> {fields[], notetype_fields[], tags}."""
267
+ return self._call("/svc/editor/get-note-info", {"note_id": note_id})
268
+
269
+ def editor_get_notetype_fields(self, notetype_id: int) -> dict:
270
+ """POST /svc/editor/get-notetype-fields -> {fields[]}."""
271
+ return self._call("/svc/editor/get-notetype-fields", {"notetype_id": notetype_id})
272
+
273
+ def editor_add_or_update(self, request: dict) -> dict:
274
+ """POST /svc/editor/add-or-update. See SCHEMA['EditorAddOrUpdateRequest']."""
275
+ return self._call("/svc/editor/add-or-update", request)
276
+
277
+ def search_search(self, search: str) -> dict:
278
+ """POST /svc/search/search -> {notes[], error}."""
279
+ return self._call("/svc/search/search", {"search": search})
280
+ # ================= shared: public reads (5) =================
281
+
282
+ def shared_item_info(self, shared_id=None, query=None) -> dict:
283
+ """GET /svc/shared/item-info?sharedId=<id> (no auth) -> {available, missing, access_denied}."""
284
+ q = dict(query or {})
285
+ if shared_id is not None:
286
+ q.setdefault("sharedId", shared_id)
287
+ if "sharedId" not in q:
288
+ raise ValidationError("item-info requires query={'sharedId': <id>}")
289
+ return self._call("/svc/shared/item-info", query=q)
290
+
291
+ def item_info(self, addon_id: int) -> dict:
292
+ """Alias of shared_item_info(shared_id=addon_id)."""
293
+ return self.shared_item_info(shared_id=addon_id)
294
+
295
+ def shared_list_addons(self) -> dict:
296
+ """GET /svc/shared/list-addons (no auth) -> {rows[], user_upload_status}."""
297
+ return self._call("/svc/shared/list-addons")
298
+
299
+ def shared_list_decks(self) -> dict:
300
+ """GET /svc/shared/list-decks (no auth) -> {rows[]}."""
301
+ return self._call("/svc/shared/list-decks")
302
+
303
+ def shared_items_by_author(self, shared_id: int) -> dict:
304
+ """POST /svc/shared/items-by-author -> {items[{shared_id, title}]}."""
305
+ return self._call("/svc/shared/items-by-author", {"shared_id": shared_id})
306
+
307
+ def shared_get_addon_info(self, addon_id: int) -> dict:
308
+ """POST /svc/shared/get-addon-info -> {addon{...}}."""
309
+ return self._call("/svc/shared/get-addon-info", {"addon_id": addon_id})
310
+
311
+ # ================= shared: account reads/writes (7) =================
312
+
313
+ def shared_list_mine(self) -> dict:
314
+ """POST /svc/shared/list-mine (auth) -> {items[], reviews[], expired_decks[]}."""
315
+ return self._call("/svc/shared/list-mine", {})
316
+
317
+ def list_mine(self) -> dict:
318
+ """Alias of shared_list_mine()."""
319
+ return self.shared_list_mine()
320
+
321
+ def shared_remove_item(self, shared_id: int) -> dict:
322
+ """POST /svc/shared/remove-item (empty response)."""
323
+ return self._call("/svc/shared/remove-item", {"shared_id": shared_id})
324
+
325
+ def shared_get_own_review(self, shared_id: int) -> dict:
326
+ """POST /svc/shared/get-own-review -> {review, shared_item_title, support_page?}."""
327
+ return self._call("/svc/shared/get-own-review", {"shared_id": shared_id})
328
+
329
+ def shared_set_own_review(self, shared_id: int, review: dict) -> dict:
330
+ """POST /svc/shared/set-own-review (empty response). review={body, thumbs_up}."""
331
+ return self._call("/svc/shared/set-own-review",
332
+ {"shared_id": shared_id, "review": review})
333
+
334
+ def shared_reply_to_review(self, shared_id: int, hash: int, body: str) -> dict:
335
+ """POST /svc/shared/reply-to-review (empty response)."""
336
+ return self._call("/svc/shared/reply-to-review",
337
+ {"shared_Id": shared_id, "hash": hash, "body": body})
338
+
339
+ def shared_report_copyright(self, shared_id: int) -> dict:
340
+ """POST /svc/shared/report-copyright (empty response)."""
341
+ return self._call("/svc/shared/report-copyright", {"shared_id": shared_id})
342
+
343
+ def shared_upload_addon(self, info: dict, file: dict | None = None) -> dict:
344
+ """POST /svc/shared/upload-addon -> {addon_id}. Also see upload.py / Action."""
345
+ req = {"info": info}
346
+ if file is not None:
347
+ req["file"] = file
348
+ return self._call("/svc/shared/upload-addon", req)
349
+
350
+ # ================= study (4) =================
351
+
352
+ def study_get_deck_limits(self) -> dict:
353
+ """POST /svc/study/get-deck-limits -> {new, review, new_cards_ignore_review_limit, error}."""
354
+ return self._call("/svc/study/get-deck-limits", {})
355
+
356
+ def study_set_deck_limits(self, new: int, review: int,
357
+ new_cards_ignore_review_limit: bool = False) -> dict:
358
+ """POST /svc/study/set-deck-limits (empty response)."""
359
+ return self._call("/svc/study/set-deck-limits", {
360
+ "new": new, "review": review,
361
+ "new_cards_ignore_review_limit": new_cards_ignore_review_limit})
362
+
363
+ def study_study_cards(self, answer=None, next_card_id=None) -> dict:
364
+ """POST /svc/study/study-cards -> {sched_ver, cards[], new/learn/review_count}."""
365
+ req = {}
366
+ if answer is not None:
367
+ req["answer"] = answer
368
+ if next_card_id is not None:
369
+ req["next_card_id"] = next_card_id
370
+ return self._call("/svc/study/study-cards", req)
371
+
372
+ def study_upgrade_scheduler(self, v3: bool = True) -> dict:
373
+ """POST /svc/study/upgrade-scheduler (empty response)."""
374
+ return self._call("/svc/study/upgrade-scheduler", {"v3": v3})
375
+
376
+ def __getattr__(self, name: str):
377
+ if name in _ALIASES:
378
+ return getattr(self, _ALIASES[name])
379
+ for ep in public_endpoints():
380
+ if _method_name(ep["path"]) == name:
381
+ path = ep["path"]
382
+
383
+ def caller(request=None, *, query=None, response_as=None, _path=path):
384
+ return self._call(_path, request, query=query, response_as=response_as)
385
+
386
+ caller.__name__ = name
387
+ caller.__doc__ = f"{ep['method']} {ep['path']} req={ep['request']} resp={ep['response']}"
388
+ return caller
389
+ raise AttributeError(f"'AnkiWebClient' has no method '{name}'")
390
+
391
+
392
+ _client = None
393
+
394
+
395
+ def get_client(**kwargs) -> AnkiWebClient:
396
+ """Get or create the default client (uses .env if present)."""
397
+ global _client
398
+ if _client is None:
399
+ _client = AnkiWebClient(**kwargs)
400
+ return _client
401
+
402
+
403
+ __all__ = ["AnkiWebClient", "get_client", "AnkiWebError", "NotFoundError",
404
+ "AuthError", "ValidationError", "SCHEMA", "ENDPOINTS",
405
+ "SCHEMA_REVISION", "public_endpoints", "endpoint"]