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 ADDED
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Terminal CLI for AnkiWeb: info, download, mine, + full /svc/* access.
3
+
4
+ ./ankicli.py info 805854302
5
+ ./ankicli.py download 805854302 -o /tmp/test.ankiaddon
6
+ ./ankicli.py mine # your shared decks + add-ons (needs login)
7
+ ./ankicli.py call shared_item_info --query sharedId=805854302
8
+ ./ankicli.py call shared_list_addons
9
+ ./ankicli.py endpoints # list all 41 endpoints
10
+
11
+ Uploads go through ./upload.py (thin wrapper over the forked upstream action).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import datetime
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from ankiweb_auth import AuthError, default_env_file, load_dotenv, login
23
+ from terminal_ankiweb import (
24
+ AnkiWebError,
25
+ NotFoundError,
26
+ download_addon,
27
+ fetch_item_info,
28
+ fetch_list_mine,
29
+ )
30
+
31
+ HERE = Path(__file__).resolve().parent
32
+
33
+
34
+ def cmd_info(args) -> int:
35
+ try:
36
+ info = fetch_item_info(args.addon_id)
37
+ except NotFoundError as e:
38
+ print(f"error: {e}", file=sys.stderr)
39
+ return 1
40
+ except AnkiWebError as e:
41
+ print(f"error: {e}", file=sys.stderr)
42
+ return 1
43
+ print(f"id: {info['shared_id']}")
44
+ print(f"title: {info['title']}")
45
+ if info.get("tags"):
46
+ print(f"tags: {info['tags'].strip()}")
47
+ if info.get("support_url"):
48
+ print(f"support: {info['support_url']}")
49
+ print(f"size: {info['size']}")
50
+ if info["last_updated"]:
51
+ dt = datetime.datetime.fromtimestamp(info["last_updated"], datetime.timezone.utc)
52
+ print(f"last_updated: {info['last_updated']} ({dt.isoformat()})")
53
+ else:
54
+ print("last_updated: 0")
55
+ if info["versions"]:
56
+ print(f"versions: {', '.join(info['versions'])}")
57
+ print("description:")
58
+ print(info["description"] or "(empty)")
59
+ # AnkiWeb caches item-info for 10 minutes, so the page link needs the same
60
+ # `cb` cache-buster the CLI just read with, or the browser shows stale data.
61
+ cb = info.get("cache_bust")
62
+ if cb:
63
+ print(f"verify: https://ankiweb.net/shared/info/{info['shared_id']}?cb={cb}")
64
+ if args.verbose:
65
+ print(f"timestamps_seen: {info['timestamps_seen']}")
66
+ return 0
67
+
68
+
69
+ def cmd_download(args) -> int:
70
+ try:
71
+ meta = download_addon(args.addon_id, dest=args.output, point_version=args.point_version)
72
+ except AnkiWebError as e:
73
+ print(f"error: {e}", file=sys.stderr)
74
+ return 1
75
+ print(f"saved: {meta['path']}")
76
+ print(f"filename: {meta['filename']}")
77
+ print(f"size: {meta['size']}")
78
+ print(f"t={meta['t']} minpt={meta['minpt']} maxpt={meta['maxpt']} bidx={meta['bidx']}")
79
+ print(f"via: {meta['final_url']}")
80
+ return 0
81
+
82
+
83
+ def load_creds(env_file: str = "") -> tuple[str, str]:
84
+ env_path = Path(env_file).expanduser() if env_file else default_env_file()
85
+ load_dotenv(env_path)
86
+ u = os.environ.get("ANKIWEB_USERNAME", "")
87
+ p = os.environ.get("ANKIWEB_PASSWORD", "")
88
+ missing = [n for n, v in (("ANKIWEB_USERNAME", u), ("ANKIWEB_PASSWORD", p)) if not v]
89
+ if missing:
90
+ raise AuthError(
91
+ f"missing {' and '.join(missing)} — set them in {env_path} or the environment"
92
+ )
93
+ return u, p
94
+
95
+
96
+ def cmd_mine(args) -> int:
97
+ try:
98
+ user, password = load_creds(args.env_file)
99
+ cookie = login(user, password)
100
+ data = fetch_list_mine(cookie)
101
+ except (AuthError, AnkiWebError) as e:
102
+ print(f"error: {e}", file=sys.stderr)
103
+ return 1
104
+
105
+ items = data["items"]
106
+ if not items:
107
+ print("no shared items")
108
+ return 0
109
+
110
+ header = f"{'ID':>12} {'UPD':<10} {'DL':>6} {'+':>3} {'-':>3} TITLE"
111
+ print(f"{len(items)} shared item(s), {data.get('review_count', 0)} review(s):\n")
112
+ print(header)
113
+ print("-" * len(header))
114
+ for it in items:
115
+ mtime = (
116
+ datetime.datetime.fromtimestamp(it["mtime"], datetime.timezone.utc).strftime("%Y-%m-%d")
117
+ if it["mtime"] else "n/a"
118
+ )
119
+ print(
120
+ f"{it['id']:>12} {mtime:<10} {it['downloads']:>6} "
121
+ f"{it['thumbs_up']:>3} {it['thumbs_down']:>3} {it['title']}"
122
+ )
123
+ if args.json:
124
+ import json as _json
125
+
126
+ print("\n" + _json.dumps(items, indent=2))
127
+ return 0
128
+
129
+
130
+ def cmd_endpoints(args) -> int:
131
+ from ankiweb_schema import public_endpoints
132
+ for ep in public_endpoints():
133
+ print(f"{ep['method']:4} {ep['path']:40} req={ep['request']} resp={ep['response']}")
134
+ return 0
135
+
136
+
137
+ def cmd_call(args) -> int:
138
+ import json as _json
139
+ from ankiweb_client import AnkiWebClient, AnkiWebError, _method_name
140
+ from ankiweb_schema import endpoint as _endpoint, public_endpoints
141
+
142
+ # find endpoint by method name
143
+ target = None
144
+ for ep in public_endpoints():
145
+ if _method_name(ep["path"]) == args.method or ep["path"] == args.method:
146
+ target = ep
147
+ break
148
+ if target is None:
149
+ print(f"error: unknown method '{args.method}' (see: ankicli.py endpoints)",
150
+ file=sys.stderr)
151
+ return 1
152
+ query = {}
153
+ for kv in args.query:
154
+ if "=" not in kv:
155
+ print(f"error: --query must be K=V, got '{kv}'", file=sys.stderr)
156
+ return 1
157
+ k, v = kv.split("=", 1)
158
+ try:
159
+ v = int(v)
160
+ except ValueError:
161
+ pass
162
+ query[k] = v
163
+ request = _json.loads(args.request) if args.request else None
164
+ client = AnkiWebClient(cookie=args.cookie or None,
165
+ env_file=args.env_file or None)
166
+ try:
167
+ out = client._call(target["path"], request,
168
+ query=query or None)
169
+ except AnkiWebError as e:
170
+ print(f"error: {e}", file=sys.stderr)
171
+ return 1
172
+ print(_json.dumps(out, indent=2, default=str))
173
+ return 0
174
+
175
+
176
+ def main() -> int:
177
+ ap = argparse.ArgumentParser(description="AnkiWeb terminal: full /svc/* access")
178
+ sub = ap.add_subparsers(dest="cmd", required=True)
179
+
180
+ p = sub.add_parser("info", help="fetch public title/description/size via svc/shared/item-info")
181
+ p.add_argument("addon_id", type=int)
182
+ p.add_argument("-v", "--verbose", action="store_true")
183
+ p.set_defaults(fn=cmd_info)
184
+
185
+ p = sub.add_parser("download", help="download .ankiaddon via /shared/download/<id>")
186
+ p.add_argument("addon_id", type=int)
187
+ p.add_argument("-o", "--output", default=None)
188
+ p.add_argument("--point-version", type=int, default=250902,
189
+ help="Anki int_version, e.g. 250902 for 25.09.02 (default: 250902)")
190
+ p.set_defaults(fn=cmd_download)
191
+
192
+ p = sub.add_parser("mine", help="list items shared by your account (needs login)")
193
+ p.add_argument("--env-file", default="", help="default: ./.env")
194
+ p.add_argument("--json", action="store_true", help="also print raw JSON")
195
+ p.set_defaults(fn=cmd_mine)
196
+
197
+ p = sub.add_parser("endpoints", help="list all 41 public endpoints")
198
+ p.set_defaults(fn=cmd_endpoints)
199
+
200
+ p = sub.add_parser("call", help="call any public endpoint by method name")
201
+ p.add_argument("method", help="e.g. shared_item_info, shared_list_addons, decks_create_deck")
202
+ p.add_argument("--request", default="", help="request body as JSON (for POST)")
203
+ p.add_argument("--query", action="append", default=[], metavar="K=V",
204
+ help="query param (repeatable; for GET)")
205
+ p.add_argument("--env-file", default="", help="default: ./.env (only needed for auth endpoints)")
206
+ p.add_argument("--cookie", default="", help="session cookie (else login via .env for auth endpoints)")
207
+ p.set_defaults(fn=cmd_call)
208
+
209
+ args = ap.parse_args()
210
+ return args.fn(args)
211
+
212
+
213
+ if __name__ == "__main__":
214
+ raise SystemExit(main())