ffs-triumph 0.1.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.
- ffs_triumph/__init__.py +13 -0
- ffs_triumph/__main__.py +6 -0
- ffs_triumph/cli.py +388 -0
- ffs_triumph/client.py +203 -0
- ffs_triumph/config.py +120 -0
- ffs_triumph/document.py +146 -0
- ffs_triumph/pdf.py +61 -0
- ffs_triumph/render.py +243 -0
- ffs_triumph-0.1.0.dist-info/METADATA +137 -0
- ffs_triumph-0.1.0.dist-info/RECORD +13 -0
- ffs_triumph-0.1.0.dist-info/WHEEL +4 -0
- ffs_triumph-0.1.0.dist-info/entry_points.txt +2 -0
- ffs_triumph-0.1.0.dist-info/licenses/LICENSE +21 -0
ffs_triumph/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""FFS — Full-manual Fetcher & Stitcher.
|
|
2
|
+
|
|
3
|
+
Turn a Triumph Technical Information service manual (a JavaScript SPA) into a
|
|
4
|
+
single, self-contained, paginated PDF for offline viewing and printing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
from .client import LoginError, TriumphClient
|
|
10
|
+
from .config import ManualConfig
|
|
11
|
+
from .render import HtmlRenderer
|
|
12
|
+
|
|
13
|
+
__all__ = ["TriumphClient", "LoginError", "ManualConfig", "HtmlRenderer", "__version__"]
|
ffs_triumph/__main__.py
ADDED
ffs_triumph/cli.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""FFS command-line interface: discover, build, list, audit, install-browser."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .client import LoginError, TriumphClient
|
|
12
|
+
from .config import (
|
|
13
|
+
API_BASE,
|
|
14
|
+
DEFAULT_DOC_TYPE,
|
|
15
|
+
DEFAULT_LANGUAGE,
|
|
16
|
+
ManualConfig,
|
|
17
|
+
credentials_from_env_and_dotenv,
|
|
18
|
+
load_config_file,
|
|
19
|
+
save_config_file,
|
|
20
|
+
)
|
|
21
|
+
from .document import assemble_document
|
|
22
|
+
from .pdf import install_browser, render_pdf
|
|
23
|
+
from .render import HtmlRenderer
|
|
24
|
+
|
|
25
|
+
HTML_NAME = "manual.html"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- small interactive helpers ---------------------------------------------
|
|
29
|
+
|
|
30
|
+
def interactive() -> bool:
|
|
31
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def menu(prompt: str, labels: list[str]) -> int:
|
|
35
|
+
print(prompt)
|
|
36
|
+
for i, label in enumerate(labels, 1):
|
|
37
|
+
print(f" {i}. {label}")
|
|
38
|
+
while True:
|
|
39
|
+
raw = input(f"Enter 1-{len(labels)}: ").strip()
|
|
40
|
+
if raw.isdigit() and 1 <= int(raw) <= len(labels):
|
|
41
|
+
return int(raw) - 1
|
|
42
|
+
print("Invalid selection.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def confirm(prompt: str, default=True) -> bool:
|
|
46
|
+
if not interactive():
|
|
47
|
+
return default
|
|
48
|
+
suffix = " [Y/n] " if default else " [y/N] "
|
|
49
|
+
ans = input(prompt + suffix).strip().lower()
|
|
50
|
+
if not ans:
|
|
51
|
+
return default
|
|
52
|
+
return ans.startswith("y")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _systitle(doc: dict):
|
|
56
|
+
return ((doc.get("metadata") or {}).get("docType") or {}).get("systemTitle")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- resolution pipeline ----------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def resolve_credentials(args, cfg_file) -> tuple[str, str]:
|
|
62
|
+
email, password = args.email, args.password
|
|
63
|
+
if not (email and password):
|
|
64
|
+
env_email, env_pw = credentials_from_env_and_dotenv()
|
|
65
|
+
email = email or env_email
|
|
66
|
+
password = password or env_pw
|
|
67
|
+
email = email or cfg_file.get("email")
|
|
68
|
+
if not email:
|
|
69
|
+
if interactive():
|
|
70
|
+
email = input("Triumph email: ").strip()
|
|
71
|
+
else:
|
|
72
|
+
sys.exit("ERROR: no email. Set TTI_EMAIL, --email, or a config file.")
|
|
73
|
+
if not password:
|
|
74
|
+
if interactive():
|
|
75
|
+
password = getpass.getpass("Triumph password: ")
|
|
76
|
+
else:
|
|
77
|
+
sys.exit("ERROR: no password. Set TTI_PASSWORD or --password.")
|
|
78
|
+
return email, password
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def make_client(args, cfg_file) -> TriumphClient:
|
|
82
|
+
email, password = resolve_credentials(args, cfg_file)
|
|
83
|
+
try:
|
|
84
|
+
return TriumphClient(
|
|
85
|
+
email, password,
|
|
86
|
+
cache_dir=Path(args.cache_dir),
|
|
87
|
+
verbose=args.verbose,
|
|
88
|
+
use_cache=not args.no_cache,
|
|
89
|
+
api_base=args.api_base,
|
|
90
|
+
)
|
|
91
|
+
except LoginError as err:
|
|
92
|
+
sys.exit(f"ERROR: {err}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_vin(client: TriumphClient, args, cfg_file) -> str:
|
|
96
|
+
if args.vin:
|
|
97
|
+
return args.vin
|
|
98
|
+
if cfg_file.get("vin"):
|
|
99
|
+
return cfg_file["vin"]
|
|
100
|
+
vins = client.subscribed_vins()
|
|
101
|
+
if len(vins) == 1:
|
|
102
|
+
client.log(0, f"Auto-detected VIN from your subscription: {vins[0]}")
|
|
103
|
+
return vins[0]
|
|
104
|
+
if len(vins) > 1:
|
|
105
|
+
if not interactive():
|
|
106
|
+
sys.exit("ERROR: multiple VINs on this account; pass --vin.")
|
|
107
|
+
labels = []
|
|
108
|
+
for v in vins:
|
|
109
|
+
try:
|
|
110
|
+
p = client.product_search(v)
|
|
111
|
+
labels.append(f"{v} — {p.get('modelYear', '')} {p.get('modelName', '')}".strip())
|
|
112
|
+
except requests.HTTPError:
|
|
113
|
+
labels.append(v)
|
|
114
|
+
return vins[menu("Select your motorcycle:", labels)]
|
|
115
|
+
if interactive():
|
|
116
|
+
return input("Enter your VIN (frame/registration/key tag): ").strip()
|
|
117
|
+
sys.exit("ERROR: no VIN found on your account; pass --vin.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def select_document(client: TriumphClient, product_context, language, args):
|
|
121
|
+
"""Return (root_id, title). Defaults to the single Service Manual."""
|
|
122
|
+
if args.root_id:
|
|
123
|
+
return args.root_id, None
|
|
124
|
+
docs = client.list_documents(product_context)
|
|
125
|
+
in_lang = [d for d in docs if d.get("language") == language]
|
|
126
|
+
if not in_lang:
|
|
127
|
+
sys.exit(f"ERROR: no documents found for language {language!r}.")
|
|
128
|
+
|
|
129
|
+
if args.all_types:
|
|
130
|
+
candidates = in_lang
|
|
131
|
+
else:
|
|
132
|
+
candidates = [d for d in in_lang if _systitle(d) == args.doc_type]
|
|
133
|
+
if not candidates:
|
|
134
|
+
print(f"No {args.doc_type!r} document for language {language!r}; "
|
|
135
|
+
"showing all available documents.")
|
|
136
|
+
candidates = in_lang
|
|
137
|
+
|
|
138
|
+
if len(candidates) == 1:
|
|
139
|
+
d = candidates[0]
|
|
140
|
+
client.log(0, f"Selected document: {d.get('title')} [{d['_id']}]")
|
|
141
|
+
return d["_id"], d.get("title")
|
|
142
|
+
if not interactive():
|
|
143
|
+
sys.exit("ERROR: multiple documents match; pass --root-id "
|
|
144
|
+
"(run `ffs discover` to list them).")
|
|
145
|
+
labels = [f"{d.get('title')} [{_systitle(d)}]" for d in candidates]
|
|
146
|
+
d = candidates[menu("Select a document:", labels)]
|
|
147
|
+
return d["_id"], d.get("title")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_manual_config(client: TriumphClient, args, cfg_file) -> ManualConfig:
|
|
151
|
+
language = (args.language or cfg_file.get("language")
|
|
152
|
+
or client.default_language() or DEFAULT_LANGUAGE)
|
|
153
|
+
|
|
154
|
+
# Full power-user override: explicit root + product context from flags.
|
|
155
|
+
if args.root_id and args.model_code and args.serial:
|
|
156
|
+
pc = {
|
|
157
|
+
"modelCode": args.model_code,
|
|
158
|
+
"modelYear": args.model_year or "",
|
|
159
|
+
"serial": args.serial,
|
|
160
|
+
"engineNo": args.engine_no or "",
|
|
161
|
+
"market": args.market or "",
|
|
162
|
+
"state": "published",
|
|
163
|
+
"onlyValid": "true",
|
|
164
|
+
}
|
|
165
|
+
return ManualConfig(root_id=args.root_id, language=language,
|
|
166
|
+
api_base=args.api_base, product_context=pc)
|
|
167
|
+
|
|
168
|
+
vin = resolve_vin(client, args, cfg_file)
|
|
169
|
+
product = client.product_search(vin)
|
|
170
|
+
cfg = ManualConfig.from_product(product, language=language,
|
|
171
|
+
api_base=args.api_base,
|
|
172
|
+
root_id=args.root_id or cfg_file.get("root_id"))
|
|
173
|
+
# individual flag overrides of product-context fields
|
|
174
|
+
for flag, key in (("model_code", "modelCode"), ("model_year", "modelYear"),
|
|
175
|
+
("serial", "serial"), ("engine_no", "engineNo"),
|
|
176
|
+
("market", "market")):
|
|
177
|
+
val = getattr(args, flag)
|
|
178
|
+
if val:
|
|
179
|
+
cfg.product_context[key] = val
|
|
180
|
+
if not cfg.root_id:
|
|
181
|
+
cfg.root_id, _ = select_document(client, cfg.product_context, language, args)
|
|
182
|
+
cfg.vin = vin
|
|
183
|
+
return cfg
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def maybe_save(args, cfg_file, email, cfg: ManualConfig):
|
|
187
|
+
if args.no_save or not interactive():
|
|
188
|
+
return
|
|
189
|
+
proposed = {"email": email, "vin": cfg.vin, "root_id": cfg.root_id,
|
|
190
|
+
"language": cfg.language}
|
|
191
|
+
if all(cfg_file.get(k) == v for k, v in proposed.items()):
|
|
192
|
+
return # nothing new
|
|
193
|
+
if confirm("Save these settings for next time?"):
|
|
194
|
+
path = save_config_file(proposed)
|
|
195
|
+
print(f"Saved settings to {path} (password not stored).")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- subcommands ------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def cmd_build(args):
|
|
201
|
+
cfg_file = load_config_file()
|
|
202
|
+
client = make_client(args, cfg_file)
|
|
203
|
+
email, _ = resolve_credentials(args, cfg_file) # already validated; for save
|
|
204
|
+
cfg = build_manual_config(client, args, cfg_file)
|
|
205
|
+
client.config = cfg
|
|
206
|
+
maybe_save(args, cfg_file, email, cfg)
|
|
207
|
+
|
|
208
|
+
out_dir = Path(args.output_path)
|
|
209
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
renderer = HtmlRenderer(client, inline_images=args.inline_images,
|
|
211
|
+
image_out_dir=out_dir / "images")
|
|
212
|
+
document, leaf_count = assemble_document(
|
|
213
|
+
client, renderer, start_topic=args.topic, limit=args.limit)
|
|
214
|
+
|
|
215
|
+
html_path = out_dir / HTML_NAME
|
|
216
|
+
html_path.write_text(document, encoding="utf-8")
|
|
217
|
+
print(f"Wrote {html_path} ({leaf_count} topics, {len(document):,} bytes)")
|
|
218
|
+
|
|
219
|
+
if renderer.unhandled:
|
|
220
|
+
print("\nWARNING: unhandled node types fell through to the generic fallback:")
|
|
221
|
+
for nt, cnt in sorted(renderer.unhandled.items(), key=lambda kv: -kv[1]):
|
|
222
|
+
print(f" {cnt:5d} {nt}")
|
|
223
|
+
else:
|
|
224
|
+
print("Coverage: all node types handled.")
|
|
225
|
+
|
|
226
|
+
if args.html_only:
|
|
227
|
+
return
|
|
228
|
+
doc_title = client.get_root().get("title", "Service Manual")
|
|
229
|
+
pdf_path = out_dir / f"{cfg.slug()}.pdf"
|
|
230
|
+
render_pdf(html_path, pdf_path, doc_title)
|
|
231
|
+
print(f"Wrote {pdf_path}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_list(args):
|
|
235
|
+
cfg_file = load_config_file()
|
|
236
|
+
client = make_client(args, cfg_file)
|
|
237
|
+
client.config = build_manual_config(client, args, cfg_file)
|
|
238
|
+
for node, depth in client.iter_toc(args.topic):
|
|
239
|
+
children = node.get("children") or []
|
|
240
|
+
is_leaf = node.get("selectable") and not children
|
|
241
|
+
marker = "-" if is_leaf else "#"
|
|
242
|
+
print(f"{' ' * depth}{marker} [{node.get('id')}] {node.get('title') or ''}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_audit(args):
|
|
246
|
+
cfg_file = load_config_file()
|
|
247
|
+
client = make_client(args, cfg_file)
|
|
248
|
+
client.config = build_manual_config(client, args, cfg_file)
|
|
249
|
+
leaves = client.leaf_topics(args.topic, args.limit)
|
|
250
|
+
print(f"Selectable leaf topics in toc: {len(leaves)}")
|
|
251
|
+
errors, empty, fetched = [], [], 0
|
|
252
|
+
for nid, title, _ in leaves:
|
|
253
|
+
try:
|
|
254
|
+
topic = client.get_topic(nid)
|
|
255
|
+
except requests.HTTPError as err:
|
|
256
|
+
errors.append((nid, title, str(err)))
|
|
257
|
+
continue
|
|
258
|
+
fetched += 1
|
|
259
|
+
if not (topic.get("content") or []):
|
|
260
|
+
empty.append((nid, title))
|
|
261
|
+
if str(topic.get("id")) != nid:
|
|
262
|
+
errors.append((nid, title, f"returned id {topic.get('id')!r} != requested"))
|
|
263
|
+
print(f"Fetched OK: {fetched} / {len(leaves)}")
|
|
264
|
+
print(f"Empty content: {len(empty)}")
|
|
265
|
+
print(f"Errors/mismatches: {len(errors)}")
|
|
266
|
+
for nid, title, msg in errors[:20]:
|
|
267
|
+
print(f" ERROR {nid} {title!r}: {msg}")
|
|
268
|
+
for nid, title in empty[:20]:
|
|
269
|
+
print(f" EMPTY {nid} {title!r}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_discover(args):
|
|
273
|
+
cfg_file = load_config_file()
|
|
274
|
+
client = make_client(args, cfg_file)
|
|
275
|
+
language = (args.language or cfg_file.get("language")
|
|
276
|
+
or client.default_language() or DEFAULT_LANGUAGE)
|
|
277
|
+
vins = [args.vin] if args.vin else client.subscribed_vins()
|
|
278
|
+
if not vins:
|
|
279
|
+
print("No VINs found on this account. Pass --vin to inspect one.")
|
|
280
|
+
return
|
|
281
|
+
chosen_root = None
|
|
282
|
+
for vin in vins:
|
|
283
|
+
product = client.product_search(vin)
|
|
284
|
+
cfg = ManualConfig.from_product(product, language=language, api_base=args.api_base)
|
|
285
|
+
print(f"\nVIN {vin}: {product.get('modelYear', '')} {product.get('modelName', '')}")
|
|
286
|
+
print(f" product context: {cfg.product_context}")
|
|
287
|
+
docs = client.list_documents(cfg.product_context)
|
|
288
|
+
in_lang = [d for d in docs if d.get("language") == language]
|
|
289
|
+
print(f" documents ({language}): {len(in_lang)}")
|
|
290
|
+
for d in sorted(in_lang, key=lambda d: (_systitle(d) or "", d.get("title", ""))):
|
|
291
|
+
mark = "*" if _systitle(d) == DEFAULT_DOC_TYPE else " "
|
|
292
|
+
print(f" {mark} [{d['_id']}] {_systitle(d)!r:24} {d.get('title')}")
|
|
293
|
+
if _systitle(d) == DEFAULT_DOC_TYPE and chosen_root is None:
|
|
294
|
+
chosen_root = d["_id"]
|
|
295
|
+
if args.write_config:
|
|
296
|
+
path = save_config_file({"email": client.auth().get("user", {}).get("email"),
|
|
297
|
+
"vin": vins[0], "root_id": chosen_root,
|
|
298
|
+
"language": language})
|
|
299
|
+
print(f"\nWrote starter config to {path} (* = default Service Manual).")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_install_browser(args):
|
|
303
|
+
return install_browser()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- argument parser --------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def _add_common(sp):
|
|
309
|
+
sp.add_argument("--email", help="Triumph account email (else env TTI_EMAIL / prompt)")
|
|
310
|
+
sp.add_argument("--password", help="Triumph password (else env TTI_PASSWORD / prompt)")
|
|
311
|
+
sp.add_argument("--vin", help="Motorcycle VIN (else auto-detected from subscription)")
|
|
312
|
+
sp.add_argument("--root-id", help="Document id to use (skips document selection)")
|
|
313
|
+
sp.add_argument("--doc-type", default=DEFAULT_DOC_TYPE,
|
|
314
|
+
help=f"docType systemTitle to pick (default: {DEFAULT_DOC_TYPE!r})")
|
|
315
|
+
sp.add_argument("--all-types", action="store_true",
|
|
316
|
+
help="Don't filter by document type when selecting")
|
|
317
|
+
sp.add_argument("--language", help="Document language (default: account/en-gb)")
|
|
318
|
+
sp.add_argument("--model-code", help="Override product context modelCode")
|
|
319
|
+
sp.add_argument("--model-year", help="Override product context modelYear")
|
|
320
|
+
sp.add_argument("--serial", help="Override product context serial")
|
|
321
|
+
sp.add_argument("--engine-no", help="Override product context engineNo")
|
|
322
|
+
sp.add_argument("--market", help="Override product context market")
|
|
323
|
+
sp.add_argument("--api-base", default=API_BASE, help="API base URL")
|
|
324
|
+
sp.add_argument("--cache-dir", default=".cache",
|
|
325
|
+
help="Directory for cached topics/images (default: .cache)")
|
|
326
|
+
sp.add_argument("--no-cache", action="store_true",
|
|
327
|
+
help="Ignore cached topics/images (still writes cache)")
|
|
328
|
+
sp.add_argument("--no-save", action="store_true",
|
|
329
|
+
help="Don't offer to save settings to a config file")
|
|
330
|
+
sp.add_argument("--verbose", "-v", action="count", default=0)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def get_argparser():
|
|
334
|
+
p = argparse.ArgumentParser(
|
|
335
|
+
prog="ffs",
|
|
336
|
+
description="FFS — Full-manual Fetcher & Stitcher: turn a Triumph "
|
|
337
|
+
"Technical Information service manual into an offline PDF.")
|
|
338
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
339
|
+
sub = p.add_subparsers(dest="command")
|
|
340
|
+
|
|
341
|
+
b = sub.add_parser("build", help="Fetch a manual and render it to PDF")
|
|
342
|
+
b.add_argument("output_path", help="Directory for manual.html + the PDF")
|
|
343
|
+
b.add_argument("--topic", help="Only this topic id's subtree (dev/preview)")
|
|
344
|
+
b.add_argument("--limit", type=int, help="Render at most N leaf topics")
|
|
345
|
+
b.add_argument("--html-only", action="store_true", help="Skip PDF rendering")
|
|
346
|
+
b.add_argument("--inline-images", action="store_true",
|
|
347
|
+
help="Embed images as base64 in one large portable HTML file "
|
|
348
|
+
"(default: write to OUTPUT/images/; the PDF is self-contained "
|
|
349
|
+
"either way)")
|
|
350
|
+
_add_common(b)
|
|
351
|
+
b.set_defaults(func=cmd_build)
|
|
352
|
+
|
|
353
|
+
le = sub.add_parser("list", help="Print the table of contents with topic IDs")
|
|
354
|
+
le.add_argument("--topic", help="Only this topic id's subtree")
|
|
355
|
+
_add_common(le)
|
|
356
|
+
le.set_defaults(func=cmd_list)
|
|
357
|
+
|
|
358
|
+
a = sub.add_parser("audit", help="Verify every topic fetches with content")
|
|
359
|
+
a.add_argument("--topic", help="Only this topic id's subtree")
|
|
360
|
+
a.add_argument("--limit", type=int, help="Check at most N topics")
|
|
361
|
+
_add_common(a)
|
|
362
|
+
a.set_defaults(func=cmd_audit)
|
|
363
|
+
|
|
364
|
+
d = sub.add_parser("discover",
|
|
365
|
+
help="List your VIN(s), product context and available documents")
|
|
366
|
+
d.add_argument("--write-config", action="store_true",
|
|
367
|
+
help="Write a starter config file from the discovered values")
|
|
368
|
+
_add_common(d)
|
|
369
|
+
d.set_defaults(func=cmd_discover)
|
|
370
|
+
|
|
371
|
+
ib = sub.add_parser("install-browser",
|
|
372
|
+
help="Download the Chromium build needed for PDF rendering")
|
|
373
|
+
ib.set_defaults(func=cmd_install_browser)
|
|
374
|
+
|
|
375
|
+
return p
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main(argv=None):
|
|
379
|
+
parser = get_argparser()
|
|
380
|
+
args = parser.parse_args(argv)
|
|
381
|
+
if not getattr(args, "func", None):
|
|
382
|
+
parser.print_help()
|
|
383
|
+
return 2
|
|
384
|
+
return args.func(args)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
sys.exit(main())
|
ffs_triumph/client.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Authenticated client for the Triumph Technical Information documents API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .config import API_BASE, ManualConfig
|
|
12
|
+
|
|
13
|
+
REQUEST_DELAY = 0.15 # seconds, polite pause between network requests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoginError(RuntimeError):
|
|
17
|
+
"""Raised when authentication fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TriumphClient:
|
|
21
|
+
"""Logs in and fetches account, product, document, topic and image data.
|
|
22
|
+
|
|
23
|
+
Credentials are passed in explicitly (the CLI resolves them from env/.env/
|
|
24
|
+
prompt). A :class:`ManualConfig` supplies the manual's root id, language and
|
|
25
|
+
product context; it may be set after construction (e.g. after VIN discovery).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, email: str, password: str, *, config: ManualConfig | None = None,
|
|
29
|
+
cache_dir: Path = Path(".cache"), verbose: int = 0,
|
|
30
|
+
use_cache: bool = True, api_base: str = API_BASE):
|
|
31
|
+
self.verbose = verbose
|
|
32
|
+
self.use_cache = use_cache
|
|
33
|
+
self.api_base = api_base
|
|
34
|
+
self.config = config
|
|
35
|
+
self.cache_dir = Path(cache_dir)
|
|
36
|
+
self.topic_cache = self.cache_dir / "topics"
|
|
37
|
+
self.image_cache = self.cache_dir / "images"
|
|
38
|
+
self.topic_cache.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self.image_cache.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
self.session = requests.Session()
|
|
42
|
+
self._auth = None
|
|
43
|
+
self._root = None
|
|
44
|
+
self._image_mem = {} # href -> bytes
|
|
45
|
+
self._login(email, password)
|
|
46
|
+
|
|
47
|
+
def log(self, level, *args):
|
|
48
|
+
if self.verbose >= level:
|
|
49
|
+
print(*args, file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
def _login(self, email, password):
|
|
52
|
+
resp = self.session.post(
|
|
53
|
+
f"{self.api_base}/users/login",
|
|
54
|
+
data={"email": email, "password": password, "remember": True},
|
|
55
|
+
)
|
|
56
|
+
if resp.status_code != 200:
|
|
57
|
+
raise LoginError(f"Login failed ({resp.status_code}). Check email/password.")
|
|
58
|
+
self.log(1, "Logged in OK")
|
|
59
|
+
|
|
60
|
+
# -- account / product / document discovery ----------------------------
|
|
61
|
+
|
|
62
|
+
def auth(self) -> dict:
|
|
63
|
+
"""Fetch (and memoize) the /auth payload (account, subscriptions, prefs)."""
|
|
64
|
+
if self._auth is None:
|
|
65
|
+
r = self.session.get(f"{self.api_base}/auth")
|
|
66
|
+
r.raise_for_status()
|
|
67
|
+
self._auth = r.json()
|
|
68
|
+
return self._auth
|
|
69
|
+
|
|
70
|
+
def default_language(self) -> str | None:
|
|
71
|
+
return self.auth().get("user", {}).get("preferences", {}).get("documentLang")
|
|
72
|
+
|
|
73
|
+
def subscribed_vins(self) -> list[str]:
|
|
74
|
+
"""VIN(s) the account is entitled to, from subscription serial restrictions.
|
|
75
|
+
|
|
76
|
+
Active subscriptions are preferred; if none are active, fall back to all.
|
|
77
|
+
"""
|
|
78
|
+
subs = self.auth().get("organisationSubscriptions") or []
|
|
79
|
+
|
|
80
|
+
def serials(only_active):
|
|
81
|
+
out = []
|
|
82
|
+
for s in subs:
|
|
83
|
+
if only_active and s.get("state") != "active":
|
|
84
|
+
continue
|
|
85
|
+
for entry in (s.get("restrictions") or {}).get("serial") or []:
|
|
86
|
+
vin = entry.get("serial")
|
|
87
|
+
if vin and vin not in out:
|
|
88
|
+
out.append(vin)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
return serials(True) or serials(False)
|
|
92
|
+
|
|
93
|
+
def product_search(self, vin: str) -> dict:
|
|
94
|
+
"""Look up a VIN's product record (model code/year, serial, engine, market)."""
|
|
95
|
+
r = self.session.get(f"{self.api_base}/products/search/{vin}")
|
|
96
|
+
r.raise_for_status()
|
|
97
|
+
return r.json()
|
|
98
|
+
|
|
99
|
+
def list_documents(self, product_context: dict) -> list[dict]:
|
|
100
|
+
"""List documents available for a product context."""
|
|
101
|
+
r = self.session.get(f"{self.api_base}/documents?{urlencode(product_context)}")
|
|
102
|
+
r.raise_for_status()
|
|
103
|
+
return r.json()
|
|
104
|
+
|
|
105
|
+
# -- raw document/topic/image fetches ----------------------------------
|
|
106
|
+
|
|
107
|
+
def _ctx(self) -> ManualConfig:
|
|
108
|
+
if self.config is None or not self.config.root_id:
|
|
109
|
+
raise RuntimeError("No manual selected (ManualConfig.root_id is unset).")
|
|
110
|
+
return self.config
|
|
111
|
+
|
|
112
|
+
def get_root(self):
|
|
113
|
+
"""Fetch (and memoize) the root document, which carries the toc tree."""
|
|
114
|
+
if self._root is None:
|
|
115
|
+
cfg = self._ctx()
|
|
116
|
+
resp = self.session.get(f"{self.api_base}/documents/{cfg.root_id}")
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
self._root = resp.json()
|
|
119
|
+
if self._root.get("language") != cfg.language:
|
|
120
|
+
self.log(0, f"WARNING: root language is {self._root.get('language')!r}, "
|
|
121
|
+
f"expected {cfg.language!r}")
|
|
122
|
+
return self._root
|
|
123
|
+
|
|
124
|
+
def get_topic(self, topic_id):
|
|
125
|
+
"""Fetch one topic's full record (including its `content` AST)."""
|
|
126
|
+
cfg = self._ctx()
|
|
127
|
+
cache_file = self.topic_cache / f"{topic_id}.json"
|
|
128
|
+
if self.use_cache and cache_file.exists():
|
|
129
|
+
return json.loads(cache_file.read_text())
|
|
130
|
+
|
|
131
|
+
time.sleep(REQUEST_DELAY)
|
|
132
|
+
url = (f"{self.api_base}/documents/{cfg.root_id}/{topic_id}"
|
|
133
|
+
f"?{urlencode(cfg.product_context)}")
|
|
134
|
+
resp = self.session.get(url)
|
|
135
|
+
resp.raise_for_status()
|
|
136
|
+
data = resp.json()
|
|
137
|
+
cache_file.write_text(json.dumps(data))
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
def get_image(self, href):
|
|
141
|
+
"""Fetch one image asset's bytes."""
|
|
142
|
+
if href in self._image_mem:
|
|
143
|
+
return self._image_mem[href]
|
|
144
|
+
cache_file = self.image_cache / href
|
|
145
|
+
if self.use_cache and cache_file.exists():
|
|
146
|
+
data = cache_file.read_bytes()
|
|
147
|
+
self._image_mem[href] = data
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
cfg = self._ctx()
|
|
151
|
+
time.sleep(REQUEST_DELAY)
|
|
152
|
+
resp = self.session.get(f"{self.api_base}/documents/{cfg.root_id}/images/{href}")
|
|
153
|
+
resp.raise_for_status()
|
|
154
|
+
data = resp.content
|
|
155
|
+
cache_file.write_bytes(data)
|
|
156
|
+
self._image_mem[href] = data
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
# -- toc traversal -----------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def iter_toc(self, start_topic=None):
|
|
162
|
+
"""Yield (node, depth) for every toc entry, depth-first.
|
|
163
|
+
|
|
164
|
+
If start_topic is given, only that node's subtree is yielded.
|
|
165
|
+
"""
|
|
166
|
+
root = self.get_root()
|
|
167
|
+
toc = root.get("toc", [])
|
|
168
|
+
|
|
169
|
+
def walk(nodes, depth):
|
|
170
|
+
for node in nodes:
|
|
171
|
+
yield node, depth
|
|
172
|
+
yield from walk(node.get("children") or [], depth + 1)
|
|
173
|
+
|
|
174
|
+
if start_topic is None:
|
|
175
|
+
yield from walk(toc, 0)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
def find(nodes, depth):
|
|
179
|
+
for node in nodes:
|
|
180
|
+
if str(node.get("id")) == str(start_topic):
|
|
181
|
+
return node, depth
|
|
182
|
+
hit = find(node.get("children") or [], depth + 1)
|
|
183
|
+
if hit:
|
|
184
|
+
return hit
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
hit = find(toc, 0)
|
|
188
|
+
if not hit:
|
|
189
|
+
sys.exit(f"ERROR: topic id {start_topic} not found in table of contents")
|
|
190
|
+
node, depth = hit
|
|
191
|
+
yield node, depth
|
|
192
|
+
yield from walk(node.get("children") or [], depth + 1)
|
|
193
|
+
|
|
194
|
+
def leaf_topics(self, start_topic=None, limit=None):
|
|
195
|
+
"""Return ordered list of (id, title, depth) for selectable leaf topics."""
|
|
196
|
+
leaves = []
|
|
197
|
+
for node, depth in self.iter_toc(start_topic):
|
|
198
|
+
children = node.get("children") or []
|
|
199
|
+
if node.get("selectable") and not children:
|
|
200
|
+
leaves.append((str(node["id"]), node.get("title", ""), depth))
|
|
201
|
+
if limit and len(leaves) >= limit:
|
|
202
|
+
break
|
|
203
|
+
return leaves
|