Синтетические сообщения об ошибке API (model <synthetic>) не считаются ходом модели (O-18, живой прогон). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
22 KiB
Python
483 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29).
|
||
|
||
Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>]
|
||
[--projects-root <каталог>] [--labels <hook-labels.json>] [--out <metrics.json>]
|
||
[--markdown [--prev <прошлый.metrics.json>]] [--corrections]
|
||
Без флагов вывода печатает JSON метрик (схема 1); --out пишет его в файл; --markdown печатает таблицу «Цифры»
|
||
со сравнением с --prev; --corrections — кандидаты в поправки владельца, по JSON-строке на кандидата.
|
||
Только чтение транскриптов и памяти. Спека: openspec/specs/harness-retro/spec.md.
|
||
"""
|
||
import argparse
|
||
import collections
|
||
import glob
|
||
import json
|
||
import os
|
||
import re
|
||
import shlex
|
||
import statistics
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
from hook_blocks_export import HOOK_ERROR, hook_of, redact # noqa: E402
|
||
|
||
INTERRUPT = "[Request interrupted by user"
|
||
OWNER_DENY = "The user doesn't want to proceed with this tool use"
|
||
CLASSIFIER = re.compile(r"denied by the Claude Code auto mode classifier\. Reason: \[([^\]]+)\]")
|
||
RECALL = re.compile(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I)
|
||
MARKERS = re.compile(r"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати)"
|
||
r"(?!\w)", re.I)
|
||
READERS = ("grep", "rg", "find", "cat", "head")
|
||
SEPARATORS = re.compile(r"&&|\|\||;|\|")
|
||
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
|
||
|
||
|
||
def slug(path):
|
||
"""Имя каталога транскриптов Claude Code: каждый символ кроме [A-Za-z0-9] заменён на «-»."""
|
||
return re.sub(r"[^A-Za-z0-9]", "-", path)
|
||
|
||
|
||
def inside(path, root):
|
||
path, root = os.path.normpath(path), os.path.normpath(root)
|
||
return path == root or path.startswith(root + os.sep)
|
||
|
||
|
||
def read_session(path, bad):
|
||
"""Записи-объекты транскрипта; битые строки считаются в bad["n"]."""
|
||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||
for line in fh:
|
||
if not line.strip():
|
||
continue
|
||
try:
|
||
rec = json.loads(line)
|
||
except ValueError:
|
||
bad["n"] = bad.get("n", 0) + 1
|
||
continue
|
||
if isinstance(rec, dict):
|
||
yield rec
|
||
|
||
|
||
def first_meta(path):
|
||
"""(cwd, метка времени) первых записей, где они есть."""
|
||
cwd = ts = None
|
||
for rec in read_session(path, {}):
|
||
if not cwd and isinstance(rec.get("cwd"), str) and rec["cwd"]:
|
||
cwd = rec["cwd"]
|
||
if not ts and isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
|
||
ts = rec["timestamp"]
|
||
if cwd and ts:
|
||
break
|
||
return cwd, ts
|
||
|
||
|
||
def project_sessions(project, root, since):
|
||
"""(использованные каталоги, транскрипты среза): каталоги по префиксу slug, сессии по первому cwd и since."""
|
||
pre = slug(project)
|
||
best = {}
|
||
for d in sorted(glob.glob(os.path.join(root, "*"))):
|
||
if not (os.path.isdir(d) and os.path.basename(d).startswith(pre)):
|
||
continue
|
||
for f in glob.glob(os.path.join(d, "*.jsonl")):
|
||
sid = os.path.basename(f)
|
||
if sid not in best or os.path.getsize(f) > os.path.getsize(best[sid]):
|
||
best[sid] = f
|
||
files = []
|
||
for f in sorted(best.values()):
|
||
cwd, ts = first_meta(f)
|
||
if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")):
|
||
continue
|
||
if since and (not ts or ts[:10] < since):
|
||
continue
|
||
files.append(f)
|
||
return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files
|
||
|
||
|
||
def blocks(rec):
|
||
c = (rec.get("message") or {}).get("content")
|
||
if isinstance(c, str):
|
||
return [{"type": "text", "text": c}]
|
||
return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
|
||
|
||
|
||
def result_text(b):
|
||
c = b.get("content", "")
|
||
if isinstance(c, list):
|
||
return "\n".join(x.get("text", "") for x in c if isinstance(x, dict))
|
||
return c if isinstance(c, str) else ""
|
||
|
||
|
||
def prompt_text(rec):
|
||
"""Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент."""
|
||
if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
|
||
return None
|
||
if rec.get("userType", "external") != "external":
|
||
return None
|
||
bs = blocks(rec)
|
||
if any(b.get("type") == "tool_result" for b in bs):
|
||
return None
|
||
txt = "\n".join(b.get("text") or "" for b in bs if b.get("type") == "text").strip()
|
||
if not txt or txt.startswith("<") or txt.startswith(INTERRUPT):
|
||
return None
|
||
return txt
|
||
|
||
|
||
def is_interrupt(rec):
|
||
return rec.get("type") == "user" and any(
|
||
b.get("type") == "text" and (b.get("text") or "").startswith(INTERRUPT) for b in blocks(rec))
|
||
|
||
|
||
def split_repo(path):
|
||
"""(основной репозиторий, путь от корня worktree или репозитория) или (None, None)."""
|
||
p = os.path.normpath(path) if path else ""
|
||
m = re.match(r"^(.+?)/\.claude/worktrees/[^/]+/(.*)$", p)
|
||
if m:
|
||
return m.group(1), m.group(2)
|
||
m = re.match(r"^(.+)/\.wt/[^/]+/([^/]+)/(.*)$", p)
|
||
if m:
|
||
return m.group(1) + "/" + m.group(2), m.group(3)
|
||
m = re.match(r"^(.*[^/])\.wt/[^/]+/(.*)$", p)
|
||
if m:
|
||
return m.group(1), m.group(2)
|
||
d = os.path.dirname(p)
|
||
while d and d != os.path.dirname(d):
|
||
if os.path.exists(os.path.join(d, ".git")):
|
||
return d, os.path.relpath(p, d)
|
||
d = os.path.dirname(d)
|
||
return None, None
|
||
|
||
|
||
def code_prefixes(repo, cache):
|
||
if repo not in cache:
|
||
cp = os.path.join(repo, ".claude", "code-paths")
|
||
if os.path.isfile(cp):
|
||
pre = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")]
|
||
cache[repo] = [x for x in pre if x]
|
||
else:
|
||
cache[repo] = None
|
||
return cache[repo]
|
||
|
||
|
||
def is_code(path, cache):
|
||
repo, rel = split_repo(path)
|
||
if not repo:
|
||
return False
|
||
pre = code_prefixes(repo, cache)
|
||
if pre is None:
|
||
return "/src/" in "/" + rel
|
||
return any(rel == x or rel.startswith(x + "/") for x in pre)
|
||
|
||
|
||
def bash_reads_code(cmd, cwd, cache):
|
||
for part in SEPARATORS.split(cmd or ""):
|
||
try:
|
||
words = shlex.split(part)
|
||
except ValueError:
|
||
words = part.split()
|
||
if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])):
|
||
continue
|
||
for w in words[1:]:
|
||
if w.startswith("-") or not (os.path.isabs(w) or cwd):
|
||
continue
|
||
if is_code(w if os.path.isabs(w) else os.path.join(cwd, w), cache):
|
||
return True
|
||
return False
|
||
|
||
|
||
def action_of(b):
|
||
name, inp = b.get("name") or "?", b.get("input") or {}
|
||
if name == "Bash":
|
||
return "Bash: " + " ".join((inp.get("command") or "").split())
|
||
if name in FILE_TOOLS:
|
||
return "%s: %s" % (name, inp.get("file_path") or inp.get("notebook_path") or "")
|
||
return name
|
||
|
||
|
||
def count_tool(s, b, cwd, cache):
|
||
name, inp = b.get("name") or "?", b.get("input") or {}
|
||
s["tools"][name] += 1
|
||
if name in ("Agent", "Task"):
|
||
s["subagents"][inp.get("model") or "не указана"] += 1
|
||
elif name == "Skill":
|
||
sk = inp.get("skill") or "?"
|
||
s["skills"][sk] += 1
|
||
if s["role"] is None and (sk == "role" or sk.endswith(":role")):
|
||
s["role"] = (inp.get("args") or "").strip() or "?"
|
||
if "codegraph_explore" in name:
|
||
s["cg_explore"] += 1
|
||
elif name == "Read":
|
||
s["code_reads"] += is_code(inp.get("file_path") or "", cache)
|
||
elif name in ("Grep", "Glob"):
|
||
s["code_reads"] += 1
|
||
elif name == "Bash":
|
||
cmd = inp.get("command") or ""
|
||
s["cg_init"] += "codegraph init" in cmd
|
||
s["code_reads"] += bash_reads_code(cmd, cwd, cache)
|
||
|
||
|
||
def session_stats(path, cache, bad):
|
||
s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(),
|
||
"subagents": collections.Counter(), "skills": collections.Counter(), "role": None,
|
||
"tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(),
|
||
"classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0,
|
||
"code_reads": 0, "recall": 0, "last_ts": ""}
|
||
seen, pending = set(), {}
|
||
for rec in read_session(path, bad):
|
||
ts = rec.get("timestamp")
|
||
if isinstance(ts, str) and ts > s["last_ts"]:
|
||
s["last_ts"] = ts
|
||
t = rec.get("type")
|
||
if t == "system" and rec.get("subtype") == "compact_boundary":
|
||
s["compactions"] += 1
|
||
elif t == "assistant":
|
||
msg = rec.get("message") or {}
|
||
if rec.get("isApiErrorMessage") or msg.get("model") == "<synthetic>":
|
||
continue # сообщение Claude Code об ошибке API — не ход модели
|
||
mid = msg.get("id")
|
||
if mid is None or mid not in seen:
|
||
if mid is not None:
|
||
seen.add(mid)
|
||
s["turns"] += 1
|
||
u = msg.get("usage") or {}
|
||
if u:
|
||
n = lambda k: u.get(k, 0) or 0
|
||
if s["start"] is None:
|
||
s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens")
|
||
s["tokens"]["output"] += n("output_tokens")
|
||
s["tokens"]["cache_read"] += n("cache_read_input_tokens")
|
||
s["tokens"]["cache_write"] += n("cache_creation_input_tokens")
|
||
for b in blocks(rec):
|
||
if b.get("type") == "tool_use":
|
||
pending[b.get("id")] = b.get("name") or "?"
|
||
count_tool(s, b, rec.get("cwd"), cache)
|
||
elif t == "user":
|
||
txt = prompt_text(rec)
|
||
if txt is not None:
|
||
s["prompts"] += 1
|
||
s["recall"] += bool(RECALL.search(txt))
|
||
elif is_interrupt(rec):
|
||
s["interrupts"] += 1
|
||
for b in blocks(rec):
|
||
if b.get("type") != "tool_result":
|
||
continue
|
||
rt = result_text(b)
|
||
if HOOK_ERROR.match(rt) and "BLOCKED" in rt:
|
||
s["blocks"][hook_of(rt, pending.get(b.get("tool_use_id"), "?"))] += 1
|
||
m = CLASSIFIER.search(rt)
|
||
if m:
|
||
s["classifier"][m.group(1)] += 1
|
||
s["owner_denials"] += OWNER_DENY in rt
|
||
return s
|
||
|
||
|
||
def aggregate(stats):
|
||
total = lambda k: sum(s[k] for s in stats)
|
||
merged = lambda k: sum((s[k] for s in stats), collections.Counter())
|
||
tools, blk, cls, sub = merged("tools"), merged("blocks"), merged("classifier"), merged("subagents")
|
||
tok = merged("tokens")
|
||
starts = [s["start"] for s in stats if s["start"] is not None]
|
||
explore, reads = total("cg_explore"), total("code_reads")
|
||
return {
|
||
"sessions": len(stats), "prompts": total("prompts"), "interrupts": total("interrupts"),
|
||
"turns": total("turns"), "tool_calls": sum(tools.values()), "tools": dict(tools.most_common()),
|
||
"roles": dict(collections.Counter(s["role"] or "не выбрана" for s in stats).most_common()),
|
||
"subagents": sum(sub.values()), "subagent_models": dict(sub.most_common()),
|
||
"skills": dict(merged("skills").most_common()),
|
||
"tokens": {k: tok.get(k, 0) for k in ("output", "cache_read", "cache_write")},
|
||
"compactions": total("compactions"),
|
||
"start_context": {"median": int(statistics.median(starts)), "min": min(starts), "max": max(starts)}
|
||
if starts else None,
|
||
"hook_blocks": sum(blk.values()), "hook_blocks_by_hook": dict(blk.most_common()),
|
||
"classifier_denials": sum(cls.values()), "classifier_by_reason": dict(cls.most_common()),
|
||
"owner_denials": total("owner_denials"),
|
||
"codegraph": {"init": total("cg_init"), "explore": explore, "code_reads": reads,
|
||
"explore_share": round(explore / (explore + reads), 2) if explore + reads else None},
|
||
"recall": total("recall"),
|
||
}
|
||
|
||
|
||
def memory_stats(root, project):
|
||
d = os.path.join(root, slug(project), "memory")
|
||
files = [f for f in glob.glob(os.path.join(d, "**", "*"), recursive=True) if os.path.isfile(f)]
|
||
idx = os.path.join(d, "MEMORY.md")
|
||
return {"index_bytes": os.path.getsize(idx) if os.path.isfile(idx) else 0, "files": len(files),
|
||
"bytes": sum(os.path.getsize(f) for f in files)}
|
||
|
||
|
||
def false_blocks(labels):
|
||
cases = labels.get("cases", {})
|
||
lab = [c for c in cases.values() if "skip" not in c]
|
||
allow = [c for c in lab if c.get("expected") == "allow"]
|
||
known = [c for c in allow if c.get("known")]
|
||
return {"labelled": len(lab), "skipped": len(cases) - len(lab), "false": len(allow),
|
||
"share": round(len(allow) / len(lab), 2) if lab else None, "fixed": len(allow) - len(known),
|
||
"known": len(known)}
|
||
|
||
|
||
def corrections(path):
|
||
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними."""
|
||
out, act, reply, sid = [], "", "", os.path.basename(path)[:8]
|
||
for rec in read_session(path, {}):
|
||
t = rec.get("type")
|
||
if t == "assistant":
|
||
for b in blocks(rec):
|
||
if b.get("type") == "tool_use":
|
||
act = action_of(b)
|
||
elif b.get("type") == "text" and (b.get("text") or "").strip():
|
||
reply = b["text"]
|
||
continue
|
||
if t != "user":
|
||
continue
|
||
txt, kind = prompt_text(rec), None
|
||
if txt is not None:
|
||
if len(txt) <= 2000 and MARKERS.search(txt[:200]):
|
||
kind = "prompt"
|
||
elif is_interrupt(rec):
|
||
kind, txt = "interrupt", "[Request interrupted by user]"
|
||
elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)):
|
||
kind, txt = "denial", "отказ владельца в разрешении"
|
||
if kind:
|
||
out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]),
|
||
"action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])})
|
||
return out
|
||
|
||
|
||
ROWS = [
|
||
("sessions", "Сессий"), ("prompts", "Промптов владельца"), ("interrupts", "Прерываний"),
|
||
("turns", "Ходов модели"), ("tool_calls", "Вызовов инструментов"), ("subagents", "Запусков сабагентов"),
|
||
("tokens.output", "Токенов вывода"), ("tokens.cache_read", "Чтение из кэша, токенов"),
|
||
("tokens.cache_write", "Запись в кэш, токенов"), ("compactions", "Компакций контекста"),
|
||
("start_context.median", "Стартовый контекст, медиана"), ("start_context.min", "Стартовый контекст, мин"),
|
||
("start_context.max", "Стартовый контекст, макс"), ("hook_blocks", "Блокировок хуков"),
|
||
("false_blocks.false", "Ложных блокировок"), ("false_blocks.share", "Доля ложных блокировок"),
|
||
("false_blocks.fixed", "Ложных, уже исправлено"), ("false_blocks.known", "Ложных, ещё не исправлено"),
|
||
("classifier_denials", "Отказов классификатора"), ("owner_denials", "Отказов владельца"),
|
||
("codegraph.init", "codegraph init"), ("codegraph.explore", "Вызовов codegraph_explore"),
|
||
("codegraph.code_reads", "Чтений кода (Read/Grep/Glob/Bash)"),
|
||
("codegraph.explore_share", "Доля codegraph_explore"),
|
||
("corrections", "Кандидатов в поправки владельца"), ("recall", "Промптов «перечитай память/контекст»"),
|
||
("memory.index_bytes", "MEMORY.md, байт"), ("memory.files", "Файлов памяти"), ("memory.bytes", "Память, байт"),
|
||
("cd_main", "cd в основной checkout на main"),
|
||
]
|
||
BREAKDOWNS = [("tools", "Инструменты"), ("roles", "Роли"), ("subagent_models", "Сабагенты по моделям"),
|
||
("skills", "Skills"), ("hook_blocks_by_hook", "Блокировки по хукам"),
|
||
("classifier_by_reason", "Отказы классификатора")]
|
||
|
||
|
||
def get(m, key):
|
||
for k in key.split("."):
|
||
if not isinstance(m, dict) or k not in m:
|
||
return None
|
||
m = m[k]
|
||
return m
|
||
|
||
|
||
def _num(v):
|
||
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
||
|
||
|
||
def fmt(v):
|
||
if v is None:
|
||
return "—"
|
||
if isinstance(v, float):
|
||
return "%.2f" % v
|
||
if _num(v):
|
||
return "{:,}".format(v).replace(",", " ")
|
||
return str(v)
|
||
|
||
|
||
def cell(label, cur, old):
|
||
if isinstance(cur, dict):
|
||
cur = None
|
||
if isinstance(old, dict) and "retired" in old:
|
||
return "| %s | %s | — | не считается: %s |" % (label, fmt(cur), old["retired"])
|
||
if isinstance(old, dict) and "value" in old:
|
||
return "| %s | %s | %s (%s) | — |" % (label, fmt(cur), fmt(old["value"]), old.get("note", "иначе считалось"))
|
||
d = "—"
|
||
if _num(cur) and _num(old):
|
||
diff = cur - old
|
||
d = ("%+.2f" % diff) if isinstance(diff, float) else "{:+,}".format(diff).replace(",", " ")
|
||
return "| %s | %s | %s | %s |" % (label, fmt(cur), fmt(old), d)
|
||
|
||
|
||
def to_markdown(doc, prev):
|
||
m, pm = doc["metrics"], (prev or {}).get("metrics") or {}
|
||
out = ["| Показатель | Сейчас | Прошлое ретро | Δ |", "|---|---|---|---|"]
|
||
for key, label in ROWS:
|
||
cur, old = get(m, key), get(pm, key)
|
||
if cur is None and old is None:
|
||
continue
|
||
out.append(cell(label, cur, old))
|
||
out.append("")
|
||
for key, label in BREAKDOWNS:
|
||
d = m.get(key) or {}
|
||
if d:
|
||
out.append("- %s: %s" % (label, ", ".join("%s %s" % (k, fmt(v)) for k, v in list(d.items())[:10])))
|
||
src, per = doc.get("sources") or {}, doc.get("period") or {}
|
||
out += ["", "Период %s — %s; сессий %s; каталоги транскриптов: %s; битых строк %s." % (
|
||
per.get("since") or "начало", per.get("until") or "—", src.get("sessions"),
|
||
", ".join(src.get("dirs") or []) or "—", src.get("bad_lines"))]
|
||
return "\n".join(out)
|
||
|
||
|
||
def default_since(harness):
|
||
names = [os.path.basename(f)[:10] for f in glob.glob(os.path.join(harness, "docs", "retro", "*.md"))]
|
||
names = [n for n in names if re.match(r"^\d{4}-\d{2}-\d{2}$", n)]
|
||
return max(names) if names else None
|
||
|
||
|
||
def parse_args(argv):
|
||
cc = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
|
||
p = argparse.ArgumentParser(prog="retro_metrics.py", description="Срез метрик обвязки (B12)")
|
||
p.add_argument("project")
|
||
p.add_argument("--since")
|
||
p.add_argument("--harness", default=os.path.normpath(os.path.join(HERE, "..", "..", "..")))
|
||
p.add_argument("--projects-root", default=os.path.join(cc, "projects"))
|
||
p.add_argument("--labels")
|
||
p.add_argument("--out")
|
||
p.add_argument("--markdown", action="store_true")
|
||
p.add_argument("--prev")
|
||
p.add_argument("--corrections", action="store_true")
|
||
return p.parse_args(argv[1:])
|
||
|
||
|
||
def main(argv):
|
||
a = parse_args(argv)
|
||
project = os.path.abspath(a.project)
|
||
since = a.since or default_since(a.harness)
|
||
dirs, files = project_sessions(project, a.projects_root, since)
|
||
bad, cache = {"n": 0}, {}
|
||
stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]]
|
||
if not stats:
|
||
sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n"
|
||
% (project, slug(project), a.projects_root, since or "начала"))
|
||
return 1
|
||
cands = [c for f in files for c in corrections(f)]
|
||
if a.corrections:
|
||
for c in cands:
|
||
print(json.dumps(c, ensure_ascii=False))
|
||
return 0
|
||
m = aggregate(stats)
|
||
m["corrections"] = len(cands)
|
||
m["memory"] = memory_stats(a.projects_root, project)
|
||
if a.labels:
|
||
m["false_blocks"] = false_blocks(json.load(open(a.labels, encoding="utf-8")))
|
||
doc = {"schema": 1, "period": {"since": since, "until": max(s["last_ts"] for s in stats)[:10]},
|
||
"project": project,
|
||
"sources": {"dirs": dirs, "sessions": len(stats), "bytes": sum(os.path.getsize(f) for f in files),
|
||
"bad_lines": bad["n"]},
|
||
"metrics": m}
|
||
if a.out:
|
||
with open(a.out, "w", encoding="utf-8") as fh:
|
||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||
fh.write("\n")
|
||
if a.markdown:
|
||
print(to_markdown(doc, json.load(open(a.prev, encoding="utf-8")) if a.prev else None))
|
||
elif not a.out:
|
||
print(json.dumps(doc, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv))
|