#!/usr/bin/env python3 """Et Al. command-line client: QED (writing) and Sic (detection) for scientific text. One file, standard library only (Python 3.8+). It talks to the Et Al. API over HTTPS and to nothing else. Text you pass is sent to the API to be processed and is not stored there. etal.py login sign in through the browser; stores an API key for this machine (login --start prints the link and returns; login then waits for approval) etal.py status account, allowance left, models etal.py draft NOTES QED writes a passage from notes etal.py rewrite FILE QED polishes or revoices a passage etal.py check --source A --output B compare an output with its source etal.py detect FILE Sic scores each sentence for machine authorship etal.py paragraphs FILE number the prose paragraphs of a .tex or .md file etal.py rewrite-doc FILE QED rewrites paragraphs of a .tex or .md file into a copy etal.py disclosure draft an AI-use disclosure from the account's recent calls Every command takes --json for the raw API response. FILE may be "-" for standard input. The key is read from $ETAL_API_KEY, or from the credentials file that `login` writes. """ import argparse import json import os import platform import re import stat import sys import time import urllib.error import urllib.request import webbrowser from pathlib import Path VERSION = "1.0" API = os.environ.get("ETAL_API_URL", "https://api.etal-labs.com").rstrip("/") CONFIG = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "etal" CREDENTIALS, PENDING = CONFIG / "credentials.json", CONFIG / "pending.json" BATCH = 5 # paragraphs per request in rewrite-doc, so progress is saved as it goes class Fail(Exception): def __init__(self, message, error=None, status=None): super().__init__(message) self.error, self.status = error or {}, status # ------------------------------------------------------------------ files and credentials def read(path): if path == "-": return sys.stdin.read() try: return Path(path).expanduser().read_text(encoding="utf-8") except FileNotFoundError: raise Fail(f"no such file: {path}") except UnicodeDecodeError: raise Fail(f"{path} is not UTF-8 text") def _load(path): try: return json.loads(path.read_text()) except (OSError, ValueError): return {} def _save(path, data): CONFIG.mkdir(parents=True, exist_ok=True) try: os.chmod(CONFIG, stat.S_IRWXU) except OSError: pass tmp = path.with_suffix(".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # readable by this user only with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) os.replace(tmp, path) def api_key(): key = os.environ.get("ETAL_API_KEY", "").strip() if key: return key return (_load(CREDENTIALS).get(API) or {}).get("api_key") # a key is only ever sent to the API it came from # ------------------------------------------------------------------ HTTP def call(method, path, body=None, auth=True, timeout=180, retry=True): headers = {"User-Agent": f"etal-skill/{VERSION}", "Accept": "application/json"} if auth: key = api_key() if not key: raise Fail("not signed in. Run: etal.py login", {"type": "authentication_required"}, 401) headers["Authorization"] = f"Bearer {key}" data = None if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" req = urllib.request.Request(API + path, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode()) except urllib.error.HTTPError as e: raw = e.read().decode(errors="replace") try: err = json.loads(raw).get("error") or {} except ValueError: err = {} if not isinstance(err, dict): err = {"message": str(err)} wait = err.get("retry_after") if retry and e.code == 429 and err.get("type") in ("rate_limited", "too_many_concurrent_requests") \ and isinstance(wait, int) and wait <= 65: time.sleep(wait) return call(method, path, body, auth, timeout, retry=False) msg = err.get("message") or f"HTTP {e.code}: {raw[:200]}" if e.code == 401 and auth: msg += " Run: etal.py login" raise Fail(msg, err, e.code) except urllib.error.URLError as e: raise Fail(f"could not reach {API}: {e.reason}") except TimeoutError: raise Fail(f"{API} did not answer within {timeout} s") # ------------------------------------------------------------------ sign-in (device flow) def cmd_login(a): if api_key() and not a.force: try: u = call("GET", "/v1/usage") print(f"Already signed in. ${u['used_usd']:.2f} of ${u['allowance_usd']:.2f} used this month. " "Use --force to sign in again.") return 0 except Fail as e: if e.status != 401: raise if os.environ.get("ETAL_API_KEY", "").strip(): raise Fail("the key in ETAL_API_KEY was refused. Unset the variable to sign in through the browser, " "or set it to a valid key.") pend = _load(PENDING) if pend.get("api") != API or pend.get("expires_at", 0) < time.time() + 20: d = call("POST", "/v1/auth/device", {"client": platform.node().split(".")[0]}, auth=False) pend = {"api": API, "device_code": d["device_code"], "user_code": d["user_code"], "url": d["verification_uri_complete"], "interval": d.get("interval", 3), "expires_at": time.time() + d["expires_in"]} _save(PENDING, pend) opened = False if not a.no_browser: try: opened = webbrowser.open(pend["url"]) except Exception: # noqa: BLE001 opened = False print("To sign in, approve this request in the browser (create a free account there if needed):") print(f" {pend['url']}") print(f" code: {pend['user_code']}") print("The page was opened in your browser." if opened else "No browser was opened: open the link above.") else: print(f"Still waiting for approval at {pend['url']} (code {pend['user_code']}).") sys.stdout.flush() if a.start: print("Tell the user to approve, then run login again (without --start) to collect the key.") return 0 deadline = min(time.time() + a.wait, pend["expires_at"]) while time.time() < deadline: time.sleep(pend["interval"]) try: r = call("POST", "/v1/auth/device/token", {"device_code": pend["device_code"]}, auth=False, retry=False) except Fail as e: kind = e.error.get("type") if kind == "authorization_pending" or e.status is None or e.status >= 500: continue PENDING.unlink(missing_ok=True) raise creds = _load(CREDENTIALS) creds[API] = {"api_key": r["api_key"], "email": r.get("email"), "key_name": r.get("key_name")} _save(CREDENTIALS, creds) PENDING.unlink(missing_ok=True) print(f"Signed in as {r.get('email')}. The key \"{r.get('key_name')}\" is stored in {CREDENTIALS}.") return 0 if time.time() >= pend["expires_at"]: PENDING.unlink(missing_ok=True) print("The request expired before it was approved. Run login again.") return 1 print("Not approved yet. After approving in the browser, run login again: it continues with the same code.") return 2 def cmd_logout(a): creds = _load(CREDENTIALS) if creds.pop(API, None): _save(CREDENTIALS, creds) print("Removed the stored key from this machine. To revoke it, use the console: API keys.") else: print("No stored key.") return 0 def cmd_status(a): u = call("GET", "/v1/usage") m = call("GET", "/v1/models") if a.json: return out_json({"usage": u, "models": m}) who = (_load(CREDENTIALS).get(API) or {}).get("email") if not os.environ.get("ETAL_API_KEY") else None print(f"Signed in{' as ' + who if who else ''}. Month {u['month'][:7]}: ${u['used_usd']:.2f} of " f"${u['allowance_usd']:.2f} used. Limits: {u['limits']['requests_per_minute']} requests a minute.") for d in m["data"]: if d.get("status", "current") in ("current", "available", None) or d["id"] in ("qed", "sic"): print(f" {d['id']}: {d.get('name', '')} ({d.get('family', '')})") return 0 # ------------------------------------------------------------------ helpers for the model commands def out_json(obj): json.dump(obj, sys.stdout, indent=2, ensure_ascii=False) print() return 0 def voice(files): """Passages by the author. Each file is one passage, or several separated by a line of three or more dashes.""" passages = [] for f in files or []: passages += [p.strip() for p in re.split(r"(?m)^\s*-{3,}\s*$", read(f)) if p.strip()] if len(passages) > 5: raise Fail(f"{len(passages)} voice passages given; the limit is 5. Two or three of 150 to 350 words work best.") words = sum(len(p.split()) for p in passages) if passages and words < 250: print(f"Note: the voice passages hold {words} words in all. That is little to learn a voice from; two or " "three passages of 150 to 350 words work better. Tell the author.", file=sys.stderr) return {"exemplars": passages} if passages else {} def emit(text, out): if out: p = Path(out).expanduser() p.write_text(text.rstrip("\n") + "\n", encoding="utf-8") print(f"Wrote {p} ({len(text.split())} words).") else: print(text.rstrip("\n")) print() def short(s, n=140): s = " ".join(str(s).split()) return s if len(s) <= n else s[: n - 1] + "…" def show_warning(w, indent=" "): bits = [] for k in ("items", "added", "removed", "value", "count", "lost"): if w.get(k) not in (None, [], ""): v = w[k] bits.append(f"{k}: {', '.join(map(str, v)) if isinstance(v, list) else v}") print(f"{indent}[{w.get('severity', '?')}] {w.get('type')}" + (" (" + "; ".join(bits) + ")" if bits else "")) for s in (w.get("where") or [])[:3]: print(f"{indent} in: \"{short(s)}\"") if w.get("note"): print(f"{indent} {w['note']}") def show_check(c): verdict = "PASS" if c["pass"] else "FAIL" print(f"Check: {verdict}. {c['errors']} to fix, {c['to_review']} to read.") for w in c["warnings"]: show_warning(w) j = c.get("judge") or {} for k in ("added", "missing", "changed"): for item in j.get(k) or []: print(f" claim {k}: {short(item if isinstance(item, str) else json.dumps(item, ensure_ascii=False), 220)}") if "unparsed" in j: print(" The claim check gave no usable answer; read the output against the source yourself.") if not c["pass"]: print(" Fix every [error] before using the text: correct it by hand, or run the command again.") def check(source, output, kind, judge): return call("POST", "/v1/compare", {"source": source, "output": output, "kind": kind, "judge": judge}) def finish_write(a, r, source, kind): """Print a write result, then check it against its source unless told not to.""" c = None unchanged = r.get("unchanged") if not a.no_check and not unchanged: try: c = check(source, r["text"], kind, not a.no_judge) except Fail as e: print(f"The check could not be run ({e}). Check the text against its source yourself.", file=sys.stderr) if a.json: return out_json({"result": r, "check": c}) if unchanged: print("QED returned the text unchanged, so nothing was written and nothing was charged. Tell the author; " "do not present it as edited. For a stronger edit use --mode revoice.") return 0 emit(r["text"], a.out) g = r["guard"] line = f"{r['model']}, {r['task']}: {r['words']} words" if "changed_ratio" in r: line += f", {round(100 * r['changed_ratio'])}% of the text changed" if g["samples"] > 1 or g["repaired"]: line += f"; needed {g['samples']} attempts ({', '.join(g['problems']) or 'repaired'})" print(line + ".") for n in r.get("notes") or []: print(f"Note: {n}") cost = r["usage"]["cost_usd"] if c: show_check(c) cost += c["usage"]["cost_usd"] print(f"Cost: ${cost:.4f}.") return 0 # ------------------------------------------------------------------ QED def cmd_draft(a): notes = read(a.notes) body = {"task": "compose", "brief": notes, "voice": voice(a.voice)} if a.title: body["title"] = a.title if a.words: body["target_words"] = a.words if a.outline or a.so_far or a.thesis or a.section: outline = [ln.strip() for ln in read(a.outline).splitlines() if ln.strip()] if a.outline else [] body["document"] = {"title": a.title, "thesis": a.thesis, "outline": outline, "position": a.section, "so_far": read(a.so_far) if a.so_far else ""} return finish_write(a, call("POST", "/v1/write", body), notes, "draft") def cmd_rewrite(a): text = read(a.file) r = call("POST", "/v1/write", {"task": a.mode, "text": text, "voice": voice(a.voice)}) return finish_write(a, r, text, "rewrite") def cmd_check(a): c = check(read(a.source), read(a.output), a.kind, not a.no_judge) if a.json: return out_json(c) show_check(c) print(f"Cost: ${c['usage']['cost_usd']:.4f}.") return 0 # ------------------------------------------------------------------ Sic def cmd_detect(a): text = read(a.file) r = call("POST", "/v1/detect", {"text": text}) if a.json: return out_json(r) d, th, er = r["document"], r["thresholds"], r["error_rates"] print(f"{r['model']}: {d['sentences']} sentences, {r['usage']['words']} words.") print(f"Above the strict threshold: {d['sentences_above_strict']}. Above the sensitive threshold: " f"{d['sentences_above_sensitive']} (this count includes the strict ones).") cps = list(text) # offsets are counted in Unicode code points for s in r["sentences"]: if s["label"] == "below_thresholds" and not a.all: continue line = "".join(cps[: s["start"]]).count("\n") + 1 tag = {"above_strict": "strict ", "above_sensitive": "sensitive", "below_thresholds": "below "}[s["label"]] print(f" [{tag}] line {line}: \"{short(s.get('text', ''), 110)}\"") def pct(x): return f"{100 * x:.1f}%" if isinstance(x, (int, float)) else "?" print(f"How often human-written text is flagged: strict {pct(er['strict'].get('human_research_papers'))} of research " f"papers and {pct(er['strict'].get('human_commentary'))} of commentary; sensitive " f"{pct(er['sensitive'].get('human_research_papers'))} and {pct(er['sensitive'].get('human_commentary'))}.") print("A flag is evidence, not a verdict. Scores reflect the surrounding passage, so read flags as locating " "passages, not single sentences. Never rephrase text to lower a score.") print(f"Cost: ${r['usage']['cost_usd']:.4f}.") return 0 # ------------------------------------------------------------------ documents def doc_format(path, given): if given: return given ext = Path(path).suffix.lower() if ext in (".tex", ".ltx"): return "latex" if ext in (".md", ".markdown", ".txt", ".qmd", ".rmd"): return "markdown" raise Fail("cannot tell the format from the file name; pass --format latex or --format markdown") def parse_selection(spec): out = [] for part in (spec or "").replace(" ", "").split(","): if not part: continue m = re.fullmatch(r"(\d+)(?:-(\d+))?", part) if not m: raise Fail(f"cannot read paragraph selection {part!r}; use numbers and ranges such as 2,5-8") lo, hi = int(m.group(1)), int(m.group(2) or m.group(1)) out += list(range(lo, hi + 1)) return sorted(set(out)) def cmd_paragraphs(a): content = read(a.file) r = call("POST", "/v1/documents/paragraphs", {"content": content, "format": doc_format(a.file, a.format)}) if a.json: return out_json(r) ok = [p for p in r["paragraphs"] if p["rewritable"]] print(f"{len(r['paragraphs'])} prose paragraphs, {len(ok)} of them rewritable; {len(r['skipped'])} other blocks " "are left alone.") for p in r["paragraphs"]: flag = "" if p["rewritable"] else f" NOT rewritable: {p.get('reason', '')}" print(f" {p['paragraph']:>3} line {p['line']:<5} {p['words']:>4} words {short(p['preview'], 70)}{flag}") if a.skipped: print("Left alone:") for s in r["skipped"]: print(f" line {s['line']:<5} {s['reason']}: {short(s['preview'], 60)}") else: reasons = {} for s in r["skipped"]: reasons[s["reason"]] = reasons.get(s["reason"], 0) + 1 if reasons: print("Left alone: " + ", ".join(f"{n} × {k}" for k, n in sorted(reasons.items(), key=lambda x: -x[1])) + ". Use --skipped to list them.") return 0 def apply_edits(content, edits): """Replace each edit's exact original text with its replacement, back to front. Returns (text, failed edits).""" starts = [0] for ln in content.splitlines(keepends=True): starts.append(starts[-1] + len(ln)) failed = [] for e in sorted(edits, key=lambda e: -e["line"]): at = content.find(e["original"], starts[min(e["line"], len(starts)) - 1]) if at < 0: failed.append(e["paragraph"]) continue content = content[:at] + e["replacement"] + content[at + len(e["original"]):] return content, failed def cmd_rewrite_doc(a): src = Path(a.file).expanduser() content = read(a.file) fmt = doc_format(a.file, a.format) out = Path(a.out).expanduser() if a.out else src.with_name(f"{src.stem}.etal{src.suffix}") if out.resolve() == src.resolve(): raise Fail("the output would overwrite the input; choose another --out. The original is never changed.") listing = call("POST", "/v1/documents/paragraphs", {"content": content, "format": fmt}) known = {p["paragraph"]: p for p in listing["paragraphs"]} sel = parse_selection(a.paragraphs) or sorted(known) bad = [k for k in sel if k not in known] if bad: raise Fail(f"paragraphs {bad} do not exist; the document has {len(known)} prose paragraphs") v = voice(a.voice) if a.voice_from_document and v: raise Fail("pass either --voice or --voice-from-document, not both") edits, report, cost, calls, stopped = [], [], 0.0, 0, None if not a.json: print(f"Rewriting {len(sel)} paragraphs ({a.mode}), about 10 s each. Progress is saved to {out} as it goes.") sys.stdout.flush() for i in range(0, len(sel), BATCH): part = sel[i:i + BATCH] r = call("POST", "/v1/documents/rewrite", { "content": content, "format": fmt, "mode": a.mode, "paragraphs": part, "voice": v, "voice_from_document": a.voice_from_document, "judge": a.judge}, timeout=600) edits += r["edits"] report += r["paragraphs"] cost += r["usage"]["cost_usd"] calls += r["usage"]["model_calls"] new, failed = apply_edits(content, edits) out.write_text(new, encoding="utf-8") if not a.json: for p in r["paragraphs"]: line = f" {p['paragraph']:>3} line {p['line']:<5} {p['status']}" if p.get("changed_ratio") is not None and p["status"] == "rewritten": line += f", {round(100 * p['changed_ratio'])}% changed" if p["paragraph"] in failed: line += " BUT could not be placed in the file; left as it was" for k in ("reason", "note"): if p.get(k): line += f" ({p[k]})" print(line) if p.get("lost"): print(f" lost: {short(json.dumps(p['lost'], ensure_ascii=False), 200)}") if p.get("numbers_changed"): print(f" numbers changed: {short(json.dumps(p['numbers_changed'], ensure_ascii=False), 200)}") for w in p.get("warnings") or []: show_warning(w, indent=" ") j = p.get("judge") or {} for k in ("added", "missing", "changed"): for item in j.get(k) or []: print(f" claim {k}: {short(item if isinstance(item, str) else json.dumps(item, ensure_ascii=False), 200)}") sys.stdout.flush() if r.get("stopped_early"): stopped = r["stopped_early"] break counts = {} for p in report: counts[p["status"]] = counts.get(p["status"], 0) + 1 if a.json: return out_json({"output_file": str(out), "summary": counts, "paragraphs": report, "stopped_early": stopped, "usage": {"model_calls": calls, "cost_usd": round(cost, 6)}}) print(f"Wrote {out}. " + ", ".join(f"{n} {k}" for k, n in sorted(counts.items())) + f". Cost: ${cost:.4f}.") print(f"{src} was not changed. Show the author the changes (for example: diff {src.name} {out.name}) and every " "[error] above before anything replaces the original.") if stopped: print(f"Stopped early: {stopped.get('message')} Paragraphs after that point were not attempted.") return 1 return 0 def cmd_disclosure(a): r = call("POST", "/v1/disclosure", {"style": a.style, "since_hours": a.hours, "sections": a.sections}) if a.json: return out_json(r) if not r.get("statement"): print(r.get("note", "Nothing to disclose in this window.")) return 0 print(r["statement"]) print() print(f"Where it goes: {r['placement']}.") b = r["based_on"] print(f"Based on this account's calls in the last {b['window_hours']} hours: {b['drafting_calls']} drafting, " f"{b['editing_calls']} editing, {b['fidelity_checks']} checks.") print("The authors must confirm before using it:") for s in r["authors_must_confirm"]: print(f" - {s}") return 0 # ------------------------------------------------------------------ command line def main(argv=None): p = argparse.ArgumentParser(prog="etal.py", description="Et Al.: QED writes scientific prose, Sic detects " "machine-written text.", epilog="Documentation: https://etal-labs.com/docs/skill") p.add_argument("--version", action="version", version=f"etal-skill {VERSION}") sub = p.add_subparsers(dest="command", metavar="command") def add(name, fn, help_, json_flag=True): s = sub.add_parser(name, help=help_, description=help_) s.set_defaults(fn=fn) if json_flag: s.add_argument("--json", action="store_true", help="print the raw API response") return s def writing(s): s.add_argument("--voice", action="append", metavar="FILE", help="passages the author wrote themselves: one " "per file, or several in one file separated by a line of ---. Repeatable.") s.add_argument("--out", metavar="FILE", help="write the text to FILE instead of printing it") s.add_argument("--no-check", action="store_true", help="skip the comparison with the source") s.add_argument("--no-judge", action="store_true", help="check numbers, citations and formulas only; skip the " "claim check (about $0.005)") s = add("login", cmd_login, "Sign in through the browser and store an API key for this machine.", False) s.add_argument("--wait", type=int, default=100, metavar="SECONDS", help="how long to wait for approval before " "returning (default 100; run login again to keep waiting)") s.add_argument("--start", action="store_true", help="print the link and code and return at once; run login " "again to wait for the approval") s.add_argument("--no-browser", action="store_true", help="print the link without opening a browser") s.add_argument("--force", action="store_true", help="sign in again even if a key is stored") add("logout", cmd_logout, "Remove the stored key from this machine.", False) add("status", cmd_status, "Show the account, the allowance left and the models.") s = add("draft", cmd_draft, "QED writes a passage of academic prose from notes.") s.add_argument("notes", metavar="NOTES", help="file with the points to cover, one per line, including every " "fact, number and citation the passage should contain") s.add_argument("--title", help="title of the article") s.add_argument("--words", type=int, metavar="N", help="target length, 30 to 1500") s.add_argument("--outline", metavar="FILE", help="for a section of a longer document: the plan, one line per section") s.add_argument("--section", type=int, metavar="N", help="which line of the outline this passage is (1-based)") s.add_argument("--thesis", help="the document's overall point, in a sentence") s.add_argument("--so-far", metavar="FILE", help="the text written so far") writing(s) s = add("rewrite", cmd_rewrite, "QED polishes (light edit) or revoices (full rewrite) a passage of up to about " "400 words. Markdown in and out.") s.add_argument("file", metavar="FILE") s.add_argument("--mode", choices=("polish", "revoice"), default="polish") writing(s) s = add("check", cmd_check, "Compare an output with its source: numbers, citations, formulas, hedging and claims.") s.add_argument("--source", required=True, metavar="FILE", help="the original text, or the notes for a draft") s.add_argument("--output", required=True, metavar="FILE", help="the text to check") s.add_argument("--kind", choices=("rewrite", "draft"), default="rewrite") s.add_argument("--no-judge", action="store_true", help="skip the claim check (about $0.005)") s = add("detect", cmd_detect, "Sic scores each sentence for machine authorship and lists those above a threshold.") s.add_argument("file", metavar="FILE", help="plain text or Markdown, up to the model's word limit") s.add_argument("--all", action="store_true", help="list every sentence, not only those above a threshold") s = add("paragraphs", cmd_paragraphs, "Number the prose paragraphs of a LaTeX or Markdown file.") s.add_argument("file", metavar="FILE") s.add_argument("--format", choices=("latex", "markdown")) s.add_argument("--skipped", action="store_true", help="list every block that is left alone, with the reason") s = add("rewrite-doc", cmd_rewrite_doc, "QED rewrites prose paragraphs of a LaTeX or Markdown file into a copy. " "The original file is never changed.") s.add_argument("file", metavar="FILE") s.add_argument("--format", choices=("latex", "markdown")) s.add_argument("--mode", choices=("polish", "revoice"), default="polish") s.add_argument("--paragraphs", metavar="LIST", help="numbers from `paragraphs`, such as 2,5-8 (default: all)") s.add_argument("--voice", action="append", metavar="FILE", help="passages the author wrote themselves (see draft)") s.add_argument("--voice-from-document", action="store_true", help="take the voice from other paragraphs of this " "file. ONLY if the author wrote the file themselves: on a machine-written draft this imitates it.") s.add_argument("--judge", action="store_true", help="also run the claim check on each paragraph (about $0.005 each)") s.add_argument("--out", metavar="FILE", help="where to write the copy (default: NAME.etal.EXT next to the file)") s = add("disclosure", cmd_disclosure, "Draft an AI-use disclosure from this account's recent calls.") s.add_argument("--style", choices=("generic", "elsevier", "icmje", "springer"), default="generic") s.add_argument("--hours", type=int, default=72, help="how far back to count calls (default 72)") s.add_argument("--sections", help="where the text was used, for example \"the Introduction and Discussion\"") a = p.parse_args(argv) if not getattr(a, "fn", None): p.print_help() return 0 try: return a.fn(a) or 0 except Fail as e: print(f"error: {e}", file=sys.stderr) return 1 except KeyboardInterrupt: return 130 if __name__ == "__main__": sys.exit(main())