fxcss 0.6.1__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.
fxcss/cli.py ADDED
@@ -0,0 +1,558 @@
1
+ #!/usr/bin/env python3
2
+ """fxcss - a testing toolkit for Firefox userChrome.css themes.
3
+
4
+ fxcss try download a theme from GitHub and test-drive it
5
+ fxcss watch edit CSS and see it live, no restart
6
+ fxcss pick click any part of the UI to get its CSS selector
7
+ fxcss inspect look up a selector you already have
8
+ fxcss audit find selectors that no longer match, and suggest fixes
9
+ fxcss changelog diff two Firefox builds to see what chrome changed
10
+ fxcss snapshot record this Firefox's chrome names, to diff against later
11
+ fxcss catalogue build a directory of themeable UI parts
12
+ fxcss shot capture a set of screenshots
13
+ fxcss compare diff two sets into before/after/diff images
14
+ fxcss doctor report what this Firefox supports
15
+
16
+ Run `fxcss <command> --help` for the options of each.
17
+ """
18
+
19
+ import argparse
20
+ import signal
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ from . import core
27
+
28
+ TOOLBOX_KEY = {
29
+ "darwin": "Cmd+Opt+Shift+I",
30
+ }.get(sys.platform, "Ctrl+Alt+Shift+I")
31
+
32
+
33
+ def _watched_files(theme: Path):
34
+ for root in (theme / "chrome", theme / "custom"):
35
+ if root.exists():
36
+ for p in root.rglob("*"):
37
+ if p.is_file() and p.suffix in (".css", ".svg"):
38
+ yield p
39
+
40
+
41
+ def _fingerprint(theme: Path):
42
+ return {p: p.stat().st_mtime_ns for p in _watched_files(theme)}
43
+
44
+
45
+ def _install_signal_handlers():
46
+ """Make a terminate signal exit like Ctrl-C, so the browser is shut down."""
47
+ def _bail(signum, frame):
48
+ raise KeyboardInterrupt
49
+ for sig in (getattr(signal, "SIGTERM", None), getattr(signal, "SIGHUP", None)):
50
+ if sig is None:
51
+ continue
52
+ try:
53
+ signal.signal(sig, _bail)
54
+ except (ValueError, OSError):
55
+ pass
56
+
57
+
58
+ def _references(theme, selector):
59
+ from .catalogue import css_references
60
+ return css_references(theme, selector)
61
+
62
+
63
+ def cmd_try(args):
64
+ import shutil
65
+ import tempfile
66
+ from . import fetch
67
+
68
+ _install_signal_handlers()
69
+ owner, name = fetch.parse_repo(args.repo)
70
+
71
+ if args.ref:
72
+ ref, why = args.ref, f"ref {args.ref}"
73
+ info = None
74
+ else:
75
+ info = fetch.resolve(owner, name)
76
+ print()
77
+ for line in fetch.humanise(info):
78
+ print(" " + line)
79
+ prefer = "commit" if args.commit else "release"
80
+ ref, why = fetch.choose_ref(info, prefer)
81
+
82
+ workdir = Path(tempfile.mkdtemp(prefix="fxcss-try-"))
83
+ keep = args.keep.resolve() if args.keep else None
84
+ try:
85
+ print(f"\n fetching {why} …")
86
+ repo_root = fetch.download(owner, name, ref, workdir / "src")
87
+ theme_root = fetch.find_theme_root(repo_root)
88
+ if theme_root is None:
89
+ print("\n No chrome/userChrome.css anywhere in this repository, so "
90
+ "there is\n nothing for Firefox to load. Is it a userChrome theme?")
91
+ return 2
92
+
93
+ facts = fetch.describe(repo_root, theme_root)
94
+ where = theme_root.relative_to(repo_root)
95
+ print(f" theme found at {where if str(where) != '.' else 'the repository root'}"
96
+ f" ({facts['stylesheets']} stylesheets, {facts['bytes'] // 1024} KB)")
97
+
98
+ if facts["scripts"]:
99
+ print(f"\n This theme ships {', '.join(facts['scripts'])}. fxcss does not run "
100
+ f"it —\n it installs the files itself, which is all those scripts do.")
101
+ if facts["flags"]:
102
+ print("\n Options its README documents:")
103
+ for entry in facts["flags"]:
104
+ print(f" {entry['flag']:<6} {entry['text']}")
105
+ if facts["variants"]:
106
+ names = [v.stem for v in facts["variants"]]
107
+ print(f"\n Optional stylesheets you can layer on with --with:")
108
+ print(" " + ", ".join(names))
109
+
110
+ chosen = []
111
+ if args.with_sheets:
112
+ wanted = {w.strip().lower() for w in args.with_sheets.split(",") if w.strip()}
113
+ by_name = {v.stem.lower(): v for v in facts["variants"]}
114
+ missing = wanted - set(by_name)
115
+ if missing:
116
+ print(f"\n error: no optional sheet named {', '.join(sorted(missing))}",
117
+ file=sys.stderr)
118
+ return 2
119
+ chosen = [by_name[w] for w in sorted(wanted)]
120
+
121
+ if args.info:
122
+ return 0
123
+
124
+ firefox = core.find_firefox(args.firefox)
125
+ session = core.Session(theme_root, firefox, dark=args.dark,
126
+ native_menus=args.native_menus,
127
+ devtools=not args.no_devtools)
128
+ if chosen:
129
+ fetch.apply_variants(session.profile, chosen)
130
+ print(f" layering on: {', '.join(c.stem for c in chosen)}")
131
+
132
+ with session:
133
+ session.setup_window()
134
+ version = session.info()["version"]
135
+ print(f"\n Firefox {version} — this is a throwaway profile; your own "
136
+ f"Firefox\n profile has not been touched.")
137
+ if args.shot:
138
+ core.capture_views(session, args.shot.resolve())
139
+ print(f"\n wrote screenshots to {args.shot}")
140
+ return 0
141
+ print(" Ctrl-C here when you are done.\n")
142
+ try:
143
+ while True:
144
+ time.sleep(0.5)
145
+ except KeyboardInterrupt:
146
+ print("\n closing.")
147
+ return 0
148
+ finally:
149
+ if keep:
150
+ keep.parent.mkdir(parents=True, exist_ok=True)
151
+ if keep.exists():
152
+ shutil.rmtree(keep, ignore_errors=True)
153
+ shutil.copytree(workdir / "src", keep)
154
+ print(f" kept the download at {keep}")
155
+ shutil.rmtree(workdir, ignore_errors=True)
156
+
157
+
158
+ def cmd_watch(args):
159
+ theme = args.theme.resolve()
160
+ firefox = core.find_firefox(args.firefox)
161
+ _install_signal_handlers()
162
+
163
+ print(f"fxcss watch\n theme: {theme}\n firefox: {firefox}")
164
+ if args.native_menus is False:
165
+ print(" menus: XUL (themeable) — right-click to inspect them live")
166
+
167
+ # An empty userChrome.css hands the stylesheet entirely to us, so replacing
168
+ # it reflects deletions as well as additions: a rule you remove really
169
+ # disappears instead of lingering from the copy loaded at startup.
170
+ session = core.Session(theme, firefox, dark=args.dark,
171
+ native_menus=args.native_menus,
172
+ empty_user_chrome=True, devtools=not args.no_devtools)
173
+ with session:
174
+ session.setup_window()
175
+ session.reload_theme()
176
+ print("\n theme loaded. Saving any file under chrome/ reloads it.")
177
+ if not args.no_devtools:
178
+ print(f" Browser Toolbox: press {TOOLBOX_KEY} in the browser window")
179
+ print(" to inspect the UI itself (not page content).")
180
+ print(" Ctrl-C to stop.\n")
181
+
182
+ state = _fingerprint(theme)
183
+ reloads = 0
184
+ try:
185
+ while True:
186
+ time.sleep(args.interval)
187
+ current = _fingerprint(theme)
188
+ if current == state:
189
+ continue
190
+ changed = sorted({p for p in set(current) | set(state)
191
+ if current.get(p) != state.get(p)})
192
+ state = current
193
+ t0 = time.perf_counter()
194
+ try:
195
+ session.reload_theme()
196
+ except Exception as exc: # a half-saved file must not kill the loop
197
+ print(f" ! reload failed: {exc}")
198
+ continue
199
+ reloads += 1
200
+ names = ", ".join(p.name for p in changed[:3])
201
+ if len(changed) > 3:
202
+ names += f" +{len(changed) - 3} more"
203
+ print(f" [{reloads:>3}] {names} -> reloaded in "
204
+ f"{(time.perf_counter() - t0) * 1000:.0f} ms")
205
+ if args.shot:
206
+ args.shot.parent.mkdir(parents=True, exist_ok=True)
207
+ args.shot.write_bytes(session.m.screenshot())
208
+ print(f" wrote {args.shot}")
209
+ except KeyboardInterrupt:
210
+ print("\n stopping.")
211
+ return 0
212
+
213
+
214
+ def cmd_pick(args):
215
+ from . import probe
216
+ theme = args.theme.resolve()
217
+ firefox = core.find_firefox(args.firefox)
218
+ _install_signal_handlers()
219
+ print(f"fxcss pick\n theme: {theme}\n firefox: {firefox}")
220
+ with core.Session(theme, firefox, dark=args.dark,
221
+ native_menus=args.native_menus, devtools=True) as session:
222
+ session.setup_window()
223
+ return probe.pick(session, theme, _references)
224
+
225
+
226
+ def cmd_inspect(args):
227
+ from . import probe
228
+ theme = args.theme.resolve()
229
+ firefox = core.find_firefox(args.firefox)
230
+ with core.Session(theme, firefox, dark=args.dark, devtools=True) as session:
231
+ session.setup_window()
232
+ return probe.inspect_selector(session, args.selector, theme, _references)
233
+
234
+
235
+ def cmd_audit(args):
236
+ from . import audit
237
+ theme = args.theme.resolve()
238
+ firefox = core.find_firefox(args.firefox)
239
+ print(f"fxcss audit\n theme: {theme}\n firefox: {firefox}\n")
240
+ static = None if args.no_unused else audit.collect_unused(theme)
241
+ with core.Session(theme, firefox) as session:
242
+ result = audit.audit(session, theme)
243
+
244
+ unused = None
245
+ if static:
246
+ # Ask an *unthemed* Firefox which custom properties it knows about.
247
+ # Against the themed browser every name resolves, because the theme
248
+ # itself set them, and nothing could be told apart.
249
+ candidates = (set(static["used"]) - set(static["defined"])) | (
250
+ set(static["defined"]) - set(static["used"]))
251
+ known = set()
252
+ if candidates:
253
+ with core.Session(theme, firefox, empty_user_chrome=True) as vanilla:
254
+ # Sweep the same states first: panel-scoped properties like
255
+ # --arrowpanel-background do not exist until the panel has been
256
+ # built, and would otherwise look like names Firefox never had.
257
+ audit.collect_dom(vanilla, verbose=False)
258
+ known = audit.probe_properties(vanilla, candidates)
259
+ unused = audit.classify_unused(static, known)
260
+ audit.report(result, show_all=args.all, colour=not args.no_colour)
261
+ if unused:
262
+ audit.report_unused(unused, colour=not args.no_colour, show_all=args.all)
263
+ if args.patch:
264
+ files = audit.write_patch(result, theme, args.patch.resolve())
265
+ if files:
266
+ print(f" wrote {args.patch} ({files} file(s)); review it, then:")
267
+ print(f" git apply {args.patch}\n")
268
+ else:
269
+ print(" nothing confident enough to patch\n")
270
+ actionable = [f for f in result["findings"] if f["confidence"] != "unresolved"]
271
+ if args.strict and actionable:
272
+ print(f" --strict: failing because {len(actionable)} selector(s) need attention\n")
273
+ return 1
274
+ return 0
275
+
276
+
277
+ def cmd_snapshot(args):
278
+ import json
279
+ from . import audit
280
+ theme = args.theme.resolve()
281
+ firefox = core.find_firefox(args.firefox)
282
+ with core.Session(theme, firefox) as session:
283
+ snap = audit.make_snapshot(session, verbose=True)
284
+ args.out.parent.mkdir(parents=True, exist_ok=True)
285
+ args.out.write_text(json.dumps(snap, indent=1) + "\n", encoding="utf-8")
286
+ print(f"\n Firefox {snap['version']}: {len(snap['ids'])} ids, "
287
+ f"{len(snap['classes'])} classes")
288
+ print(f" wrote {args.out}")
289
+ return 0
290
+
291
+
292
+ def cmd_changelog(args):
293
+ from . import audit
294
+ theme = args.theme.resolve()
295
+ if not args.baseline and not args.against:
296
+ print("error: pass --against <firefox> or --baseline <snapshot.json>",
297
+ file=sys.stderr)
298
+ return 2
299
+ after_bin = core.find_firefox(args.firefox)
300
+
301
+ snapshots = {}
302
+ if args.baseline:
303
+ data, dom = audit.load_snapshot(args.baseline)
304
+ snapshots["before"], snapshots["before_version"] = dom, data["version"]
305
+ print(f"fxcss changelog\n before: {args.baseline} (Firefox {data['version']})")
306
+ print(f" after: {after_bin}\n")
307
+ with core.Session(theme, after_bin) as session:
308
+ snapshots["after_version"] = session.info()["version"]
309
+ print(f" after — Firefox {snapshots['after_version']}")
310
+ snapshots["after"] = audit.collect_dom(session)
311
+ else:
312
+ before_bin = core.find_firefox(args.against)
313
+ print(f"fxcss changelog\n before: {before_bin}\n after: {after_bin}\n")
314
+ for label, binary in (("before", before_bin), ("after", after_bin)):
315
+ with core.Session(theme, binary) as session:
316
+ version = session.info()["version"]
317
+ print(f" {label} — Firefox {version}")
318
+ snapshots[label] = audit.collect_dom(session)
319
+ snapshots[label + "_version"] = version
320
+
321
+ tokens = audit.extract_tokens(theme)
322
+ delta = audit.changelog(snapshots["before"], snapshots["after"], tokens)
323
+
324
+ print(f"\n Firefox {snapshots['before_version']} → {snapshots['after_version']}")
325
+ print(f" {len(delta['removed'])} chrome names gone, {len(delta['added'])} new")
326
+
327
+ affected = delta.get("affects_theme") or []
328
+ if affected:
329
+ print(f"\n {len(affected)} of them are used by this theme:")
330
+ for token in affected:
331
+ first = tokens[token][0]
332
+ print(f" {token:<44} {first['file']}:{first['line']}")
333
+ print("\n Run `fxcss audit` for suggested replacements.")
334
+ else:
335
+ print("\n None of them are used by this theme.")
336
+
337
+ if args.show_all:
338
+ print("\n gone:")
339
+ for token in delta["removed"]:
340
+ print(f" - {token}")
341
+ print("\n new:")
342
+ for token in delta["added"]:
343
+ print(f" + {token}")
344
+ print()
345
+ return 0
346
+
347
+
348
+ def cmd_shot(args):
349
+ theme = args.theme.resolve()
350
+ if not (theme / "chrome" / "userChrome.css").exists():
351
+ print(f"error: no chrome/userChrome.css under {theme}", file=sys.stderr)
352
+ return 2
353
+ firefox = core.find_firefox(args.firefox)
354
+ urls = getattr(args, "url", [])
355
+ with core.Session(theme, firefox) as session:
356
+ if not getattr(args, "only_live", False):
357
+ core.capture_views(session, args.out.resolve())
358
+ if urls:
359
+ print("\n live sites (captured, never compared):", flush=True)
360
+ core.capture_live(session, args.out.resolve(), urls)
361
+ print(f"\nwrote screenshots to {args.out}")
362
+ return 0
363
+
364
+
365
+ def cmd_compare(args):
366
+ from . import compare
367
+ return compare.run(args.base.resolve(), args.head.resolve(),
368
+ args.out.resolve(), args.platform)
369
+
370
+
371
+ def cmd_catalogue(args):
372
+ from . import catalogue
373
+ theme = args.theme.resolve()
374
+ firefox = core.find_firefox(args.firefox)
375
+ out = args.out.resolve()
376
+ with core.Session(theme, firefox, native_menus=args.native_menus) as session:
377
+ result = catalogue.build(session, theme, out,
378
+ self_contained=args.self_contained)
379
+ found = sum(1 for e in result["entries"]
380
+ if any(m["found"] for m in e["modes"].values()))
381
+ print(f"\n{found}/{len(result['entries'])} landmarks resolved")
382
+ print(f"open {out / 'index.html'}")
383
+ if args.open:
384
+ opener = {"darwin": "open", "win32": "start"}.get(sys.platform, "xdg-open")
385
+ subprocess.run([opener, str(out / "index.html")], check=False)
386
+ return 0
387
+
388
+
389
+ def cmd_doctor(args):
390
+ theme = args.theme.resolve()
391
+ firefox = core.find_firefox(args.firefox)
392
+ print(f"theme: {theme}")
393
+ print(f"firefox: {firefox}")
394
+ with core.Session(theme, firefox, devtools=True) as session:
395
+ info = session.info()
396
+ print(f"\nversion: {info['version']} (build {info['buildID']}) on {info['os']}")
397
+ print(f"window: {info['outer']} at dpr {info['dpr']}")
398
+ print(f"userChrome.css enabled: {info['legacyStylesheets']}")
399
+ print(f"Browser Toolbox: enabled in the temp profile ({TOOLBOX_KEY})")
400
+
401
+ native = info["nativeContextMenus"]
402
+ print("\ncontext menus:")
403
+ for platform, value in native.items():
404
+ if value is None:
405
+ continue
406
+ state = "native (CSS cannot style them)" if value else "XUL (themeable)"
407
+ print(f" {platform:<8} native-context-menus={value} -> {state}")
408
+ if native.get("macos"):
409
+ print(" note: on macOS this defaults to true, so menupopup rules have no\n"
410
+ " effect on right-click menus there. Use --native-menus=false\n"
411
+ " to work on them.")
412
+
413
+ sheets = sorted((theme / "chrome").rglob("*.css")) if (theme / "chrome").exists() else []
414
+ total = sum(p.stat().st_size for p in sheets)
415
+ print(f"\ntheme: {len(sheets)} stylesheets, {total:,} bytes")
416
+ missing = [p for p in ("chrome/userChrome.css",) if not (theme / p).exists()]
417
+ print("missing expected files: " + (", ".join(missing) if missing else "none"))
418
+ return 0
419
+
420
+
421
+ def _bool(v):
422
+ return v.lower() not in ("false", "0", "no")
423
+
424
+
425
+ def _common(p, theme=True):
426
+ if theme:
427
+ p.add_argument("--theme", type=Path, default=Path.cwd(),
428
+ help="theme root, the folder containing chrome/ (default: cwd)")
429
+ p.add_argument("--firefox", default=None,
430
+ help="path to the firefox binary (default: autodetect, or $FIREFOX_BIN)")
431
+
432
+
433
+ def _menus(p):
434
+ p.add_argument("--native-menus", dest="native_menus", default=None, type=_bool,
435
+ metavar="BOOL",
436
+ help="false makes right-click menus XUL, so a theme can style them")
437
+
438
+
439
+ def main(argv=None):
440
+ ap = argparse.ArgumentParser(
441
+ prog="fxcss", description=__doc__,
442
+ formatter_class=argparse.RawDescriptionHelpFormatter)
443
+ sub = ap.add_subparsers(dest="cmd", required=True)
444
+
445
+ for verb, helptext in (("try", "download a theme from GitHub and test-drive it"),
446
+ ("install", "alias for try")):
447
+ tr = sub.add_parser(verb, help=helptext)
448
+ tr.add_argument("repo", help="owner/name, or a github.com URL")
449
+ tr.add_argument("--firefox", default=None,
450
+ help="path to the firefox binary (default: autodetect)")
451
+ tr.add_argument("--ref", default=None, help="tag, branch or commit to fetch")
452
+ tr.add_argument("--commit", action="store_true",
453
+ help="use the latest commit rather than the latest release")
454
+ tr.add_argument("--with", dest="with_sheets", default=None, metavar="NAME[,NAME]",
455
+ help="also load named optional stylesheets")
456
+ tr.add_argument("--dark", action="store_true", help="start in dark mode")
457
+ tr.add_argument("--shot", type=Path, default=None,
458
+ help="capture screenshots instead of opening interactively")
459
+ tr.add_argument("--keep", type=Path, default=None,
460
+ help="keep the downloaded theme at this path")
461
+ tr.add_argument("--info", action="store_true",
462
+ help="report what was found and stop, without launching")
463
+ tr.add_argument("--no-devtools", action="store_true")
464
+ _menus(tr)
465
+ tr.set_defaults(func=cmd_try)
466
+
467
+ w = sub.add_parser("watch", help="live-reload the theme as you edit")
468
+ _common(w); _menus(w)
469
+ w.add_argument("--dark", action="store_true", help="start in dark mode")
470
+ w.add_argument("--interval", type=float, default=0.4, help="poll seconds")
471
+ w.add_argument("--shot", type=Path, default=None,
472
+ help="also write a screenshot here after every reload")
473
+ w.add_argument("--no-devtools", action="store_true",
474
+ help="do not enable the Browser Toolbox in the temp profile")
475
+ w.set_defaults(func=cmd_watch)
476
+
477
+ k = sub.add_parser("pick", help="click any part of the UI to get its selector")
478
+ _common(k); _menus(k)
479
+ k.add_argument("--dark", action="store_true", help="start in dark mode")
480
+ k.set_defaults(func=cmd_pick)
481
+
482
+ i = sub.add_parser("inspect", help="look up a selector you already have")
483
+ _common(i)
484
+ i.add_argument("selector", help="a CSS selector, e.g. '#urlbar' or '.tab-close-button'")
485
+ i.add_argument("--dark", action="store_true", help="report dark-mode styles")
486
+ i.set_defaults(func=cmd_inspect)
487
+
488
+ a = sub.add_parser("audit", help="find selectors that no longer match anything")
489
+ _common(a)
490
+ a.add_argument("--patch", type=Path, default=None,
491
+ help="write the confident replacements as a unified diff")
492
+ a.add_argument("--all", action="store_true",
493
+ help="also list tokens with no suggestion")
494
+ a.add_argument("--no-colour", action="store_true", help="plain output")
495
+ a.add_argument("--strict", action="store_true",
496
+ help="exit non-zero if any selector needs attention (for CI)")
497
+ a.add_argument("--no-unused", action="store_true",
498
+ help="skip the unused/unreachable section")
499
+ a.set_defaults(func=cmd_audit)
500
+
501
+ cl = sub.add_parser("changelog",
502
+ help="diff two Firefox builds to see what chrome changed")
503
+ _common(cl)
504
+ cl.add_argument("--against", default=None,
505
+ help="path to the other firefox binary to compare with")
506
+ cl.add_argument("--baseline", type=Path, default=None,
507
+ help="compare against a saved snapshot instead of a second browser")
508
+ cl.add_argument("--show-all", action="store_true",
509
+ help="list every name that changed, not just ones the theme uses")
510
+ cl.set_defaults(func=cmd_changelog)
511
+
512
+ s = sub.add_parser("shot", help="capture the standard screenshot set")
513
+ _common(s)
514
+ s.add_argument("--out", type=Path, required=True)
515
+ s.add_argument("--url", action="append", default=[], metavar="URL",
516
+ help="also capture the theme against a live site, light and "
517
+ "dark; repeatable. Written to <out>/live/ and never "
518
+ "included in a comparison")
519
+ s.add_argument("--only-live", action="store_true",
520
+ help="capture just the --url views, skipping the standard set")
521
+ s.set_defaults(func=cmd_shot)
522
+
523
+ c = sub.add_parser("compare", help="diff two screenshot sets")
524
+ _common(c, theme=False)
525
+ c.add_argument("--base", type=Path, required=True)
526
+ c.add_argument("--head", type=Path, required=True)
527
+ c.add_argument("--out", type=Path, required=True)
528
+ c.add_argument("--platform", default="local")
529
+ c.set_defaults(func=cmd_compare)
530
+
531
+ g = sub.add_parser("catalogue", help="build the themeable UI directory")
532
+ _common(g); _menus(g)
533
+ g.add_argument("--out", type=Path, default=Path("fxcss-catalogue"))
534
+ g.add_argument("--open", action="store_true", help="open the result when done")
535
+ g.add_argument("--self-contained", dest="self_contained", action="store_true",
536
+ help="also write catalogue.html with images inlined, as one file")
537
+ g.set_defaults(func=cmd_catalogue)
538
+
539
+ sn = sub.add_parser("snapshot", help="record this Firefox's chrome names")
540
+ _common(sn)
541
+ sn.add_argument("--out", type=Path, required=True)
542
+ sn.set_defaults(func=cmd_snapshot)
543
+
544
+ d = sub.add_parser("doctor", help="report what this Firefox supports")
545
+ _common(d)
546
+ d.set_defaults(func=cmd_doctor)
547
+
548
+ args = ap.parse_args(argv)
549
+ # Long-running commands are a live log; keep them readable when piped.
550
+ try:
551
+ sys.stdout.reconfigure(line_buffering=True)
552
+ except AttributeError:
553
+ pass
554
+ return args.func(args)
555
+
556
+
557
+ if __name__ == "__main__":
558
+ sys.exit(main())