Files
document-haness/scripts/studio-save.py
T
DongHyeonkaandClaude Opus 5 a5215eda31 feat(studio-save): 그림 쪽에도 다리를 놓는다
문장 쪽에는 놨는데 그림 쪽에는 안 놨다. 화살표를 뒤집어도 관문이 전부 0 이고 경고도 비어
자동 통과했다.

설계가 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고 적었는데 그
정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만 스크립트가 들어 있는지는
문자열로 판정된다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이라 error 로 막는다.
script·foreignObject·이벤트 처리기·바깥 href·바깥 리소스·@import 여섯이다.
#fragment 와 data: 는 바깥으로 안 나가므로 막지 않는다.

그림이 검토 뒤에 바뀌면 경고로 올린다. 무엇이 바뀌었는지는 기계가 못 말하지만 바뀌었으니
보라는 말할 수 있다. 이것이 들어가면서 읽기 계약이 그림에도 걸린다.

실제 흐름과 개념 설명을 data-figure-kind 로 가른다. 표시가 없으면 error 가 아니라
warning 이다 — 지금 저장소의 그림 272장에 이 표시가 하나도 없고, 없다고 전부 막으면
정상을 막는 쪽으로 넘어간다. 설명용이라 밝힌 그림도 막지 않는다.

검사기를 조일 때마다 정상이 통과하는 대조를 같이 넣는다. 이번 대조군은 평범한 그림,
설명용이라 밝힌 그림, 내부·data 참조, 그리고 이 저장소의 그림 272장 전부다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 11:32:51 +09:00

462 lines
22 KiB
Python

#!/usr/bin/env python3
"""Studio 저장 어댑터. 승인된 최종본만 넣고, 저장한 뒤 되읽어 대조한다.
**게시하지 않는다.** 이 파일에 게시 요청을 만드는 코드가 없고, 만들어 낸 요청이 게시 경로를
가리키면 `_reject_forbidden_paths` 가 막는다. 스킬 문서의 「게시 버튼을 누르지 않는다」는
문장 하나로 막던 것을 코드로 옮긴 것이다.
**무인 저장은 꺼져 있다.** `UNATTENDED_SAVE_ENABLED` 가 `False` 다. 서버 권한이
`studio:read`·`studio:write` 둘뿐이라 저장할 수 있는 계정은 게시도 할 수 있고, 그래서
사람이 안 보는 사이에 저장을 돌리지 않는다. 이 값을 `True` 로 바꾸는 것은 서버에
`studio:publish` 가 갈라져 들어온 뒤의 일이다.
**자격증명을 이 저장소에 적지 않는다.** 세션 쿠키와 CSRF 토큰은 환경 변수로만 받는다.
서버 계약 (tech-log-backend @ a000f87 의 src/config/openapi/studio-v1.yaml 과 소스에서 읽었다)
| 무엇 | 값 |
|---|---|
| 만들기 | `POST /api/v1/studio/documents` → 201 |
| 읽기 | `GET /api/v1/studio/documents/{id}` |
| 저장 | `PUT /api/v1/studio/documents/{id}` → 200 |
| 멱등 키 | `Idempotency-Key` 헤더 **필수**, 200자 이하 (`StudioIdempotency.java:34,77-88`) |
| 재생 여부 | 응답 헤더 `Idempotency-Replayed` |
| 낙관적 락 | `SaveDocumentCommand.expectedVersion` **필수 · minimum 1** |
| 충돌 | `VERSION_CONFLICT` 409, `details.latestDocument` 에 현재 문서 전체 |
| 인증 | 쿠키 `TECHLOG_SESSION` + 변경 요청에 `X-CSRF-TOKEN` |
python3 scripts/studio-save.py --record <기록.md> --package <review-package.json>
python3 scripts/studio-save.py --record <기록.md> --package <pkg.json> -o <plan.json>
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다.
# 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001).
UNATTENDED_SAVE_ENABLED = False
UNATTENDED_SAVE_BLOCKED_BY = (
"CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. "
"studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다"
)
BASE_PATH = "/api/v1/studio"
FORBIDDEN_PATH = re.compile(r"/(publish|unpublish)(/|$)")
MAX_KEY_LENGTH = 200 # StudioIdempotency.java:34
IDEMPOTENCY_HEADER = "Idempotency-Key"
REPLAYED_HEADER = "Idempotency-Replayed"
# 기록의 `##` 이름 → Studio 입력 칸. studio-form-map.md 가 정본이다
FIELD_MAP = {
"CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment",
"재현 조건": "reproduction", "본문": "bodyMarkdown"},
"CONCEPT": {"본문": "bodyMarkdown"},
"REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen",
"예외": "exceptions", "예시": "examples"},
"QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns",
"제약": "constraints", "선택지": "options", "다음 검증": "nextVerification"},
"PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale",
"영향": "impact"},
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
# SVG 에서 **문자열로 판정되는 것**. 사람 판단이 필요 없으므로 error 로 막는다.
# `plan/01` §8 이 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고
# 적었는데 그 정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만
# 스크립트가 들어 있는지는 안 그렇다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이다
SVG_FORBIDDEN = (
(re.compile(r"<\s*script\b", re.I), "스크립트가 들어 있다"),
(re.compile(r"<\s*foreignObject\b", re.I), "foreignObject 는 임의의 HTML 을 싣는다"),
(re.compile(r"\son[a-z]+\s*=", re.I), "이벤트 처리기 속성이 있다"),
# 따옴표는 \x22 \x27 로 적는다. 정규식 안에 두 종류를 함께 쓰면 파이썬 문자열이 먼저 닫힌다
(re.compile(r"(?:xlink:)?href\s*=\s*[\x22\x27](?:https?:)?//", re.I),
"바깥 주소를 가리킨다"),
(re.compile(r"<\s*(?:image|use)\b[^>]*(?:xlink:)?href\s*=\s*[\x22\x27](?!#|data:)", re.I),
"바깥 리소스를 불러온다"),
(re.compile(r"@import\b", re.I), "바깥 스타일을 불러온다"),
)
# 그림이 **실제 흐름**을 그린 것인지 **개념 설명**인지. `plan/01` §8 이 표시하라고 적었다.
# 없으면 error 가 아니라 warning 이다 — 지금 저장소의 그림 272장에 이 표시가 없고,
# 없다고 전부 막으면 정상을 막는 쪽으로 넘어간다
FIGURE_KIND = re.compile(
r"data-figure-kind\s*=\s*[\x22\x27](evidence|illustrative)[\x22\x27]", re.I)
class Refused(Exception):
"""어댑터가 스스로 거절한 것. 서버 오류가 아니다."""
def inspect_svg(path: str) -> tuple[list[str], str | None]:
"""(막아야 할 것, 그림 종류). 종류를 못 읽으면 None."""
try:
text = open(path, encoding="utf-8", errors="replace").read()
except OSError as e:
return [f"열지 못했다: {e}"], None
hits = [why for pat, why in SVG_FORBIDDEN if pat.search(text)]
m = FIGURE_KIND.search(text)
return hits, (m.group(1).lower() if m else None)
def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]:
"""그림을 본다. 막는 것과 읽어야 할 것을 나눠 낸다.
**바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다.**
화살표를 뒤집어도 `gates` 는 전부 0 이고 `warnings` 도 비어 자동 통과했다. 문장 쪽에는
다리를 놨는데 그림 쪽에는 안 놨던 자리다.
"""
errors, warnings = [], []
for asset in package.get("assets") or []:
rel = asset.get("path", "")
full = os.path.join(ROOT, rel)
if not asset.get("exists") or not rel.endswith(".svg"):
continue
hits, kind = inspect_svg(full)
for why in hits:
errors.append({"id": "그림 허용 정책", "asset": rel, "detail": why})
now = _sha256_file(full)
if asset.get("sha256") and now != asset["sha256"]:
warnings.append({
"id": "그림이 검토 뒤에 바뀌었다", "asset": rel,
"detail": f"검토 시점 {asset['sha256'][:12]} → 지금 {now[:12]}",
"note": "무엇이 바뀌었는지는 이 어댑터가 말하지 못한다. "
"화살표 방향·주체는 사람이 그림을 열어 봐야 안다",
})
if kind is None:
warnings.append({
"id": "그림 종류가 표시돼 있지 않다", "asset": rel,
"detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다',
"note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. "
"설명용을 증거로 읽는 것을 막을 수 없다",
})
elif kind == "illustrative":
warnings.append({
"id": "설명용 그림", "asset": rel,
"detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다",
"note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다",
})
return errors, warnings
def _sha256_file(path: str) -> str:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
def _sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def idempotency_key(op: str, record_rel: str, payload: dict | None = None) -> str:
"""기록 경로에서 유도한 안정 키.
프런트는 호출마다 `crypto.randomUUID()` 로 새 키를 만든다(`local-id.ts:8`). 그래서
「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고
만들어진 상황을 키 정책이 빠져나간다.
- **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다.
- **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를
재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로
조용히 재생돼 버린다.
"""
base = _sha256_text(record_rel)[:32]
key = f"studio-{op}-{base}"
if payload is not None:
key += "-" + _sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))[:32]
if len(key) > MAX_KEY_LENGTH: # 서버가 200자 초과를 거절한다
raise Refused(f"멱등 키가 {len(key)}자다. 서버 상한은 {MAX_KEY_LENGTH}자")
return key
def _front_matter(text: str) -> dict:
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
out: dict[str, str] = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
def _sections(text: str) -> dict[str, str]:
a, b = text.find(BODY_START), text.find(BODY_END)
marks = []
for m in re.finditer(r"^##\s+(.+)$", text, re.M):
if a >= 0 <= b and a < m.start() < b:
continue
marks.append((m.group(1).strip(), m.end()))
out: dict[str, str] = {}
for i, (name, start) in enumerate(marks):
stop = marks[i + 1][1] - len(f"## {marks[i + 1][0]}") if i + 1 < len(marks) else len(text)
chunk = text[start:stop]
if name == "본문":
chunk = chunk.replace(BODY_START, "").replace(BODY_END, "")
out[name] = chunk.strip()
return out
def _summary(text: str) -> str:
m = re.search(r"^#\s+.+$", text, re.M)
if not m:
return ""
after = re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.M)[0]
for para in (p.strip() for p in after.split("\n\n")):
if para and not para.startswith("<!--"):
return para
return ""
def build_input(record_path: str) -> dict:
"""기록 `.md` 를 `WorkingCopyInput` 으로. 없는 칸을 지어내지 않는다."""
text = open(record_path, encoding="utf-8").read()
fm = _front_matter(text)
kind = (fm.get("kind") or "").upper()
if kind not in FIELD_MAP:
raise Refused(f"모르는 kind: {fm.get('kind')!r}")
found = _sections(text)
doc: dict = {
"kind": kind,
"title": fm.get("title", ""),
"slug": fm.get("slug", ""),
"summary": _summary(text),
"topicId": None, # 이름→uuid 해석은 catalog 조회가 필요하다. 지어내지 않는다
"projectId": None,
"relations": [],
}
for section, field in FIELD_MAP[kind].items():
if section in found:
doc[field] = found[section]
if kind == "CASE":
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
return doc
def approved(record_path: str, package_path: str) -> dict:
"""검토를 지난 최종본만 통과시킨다.
묶음의 `target.sha256` 은 검토가 본 파일의 해시다. 지금 디스크의 파일이 그것과 다르면
**검토 뒤에 바뀐 것**이라 그 판정을 이 파일에 붙일 수 없다.
"""
pkg = json.load(open(package_path, encoding="utf-8"))
now = _sha256_file(record_path)
want = (pkg.get("target") or {}).get("sha256")
if not want:
raise Refused(f"묶음에 target.sha256 이 없다: {package_path}")
if want != now:
raise Refused(
"검토가 본 파일과 지금 파일이 다르다 — 그 판정을 이 파일에 붙일 수 없다\n"
f" 검토 시점 {want}\n 지금 {now}")
failed = [g["cmd"] for g in pkg.get("gates", []) if g.get("exit") not in (0, "0")]
if failed:
raise Refused("관문이 통과하지 못한 묶음이다:\n " + "\n ".join(failed))
return pkg
def _reject_forbidden_paths(plan: list[dict]) -> None:
"""게시 경로가 계획에 들어 있으면 멈춘다.
한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. 되돌릴 수 없는
동작을 프롬프트가 아니라 코드가 막는다.
"""
for step in plan:
if FORBIDDEN_PATH.search(step["path"]):
raise Refused(f"게시 경로는 이 어댑터가 만들지 않는다: {step['path']}")
def plan_requests(record_path: str, doc: dict, document_id: str | None,
expected_version: int | None) -> list[dict]:
"""보낼 요청을 그대로 적어 낸다. 보내지 않는다."""
rel = os.path.relpath(os.path.abspath(record_path), ROOT)
steps: list[dict] = []
if not document_id:
steps.append({
"op": "create", "method": "POST", "path": f"{BASE_PATH}/documents",
"headers": {IDEMPOTENCY_HEADER: idempotency_key("create", rel),
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": doc,
"expect": {"status": 201, "readHeader": REPLAYED_HEADER},
})
else:
if expected_version is None:
raise Refused(
"expectedVersion 을 모른 채 저장하지 않는다. 먼저 GET 으로 현재 version 을 "
"읽는다 — 안 보내면 서버가 0 으로 채워 사실상 항상 충돌한다"
" (StudioDocumentController.java:196)")
if expected_version < 1:
raise Refused(f"expectedVersion 은 1 이상이어야 한다 (스키마 minimum 1): {expected_version}")
body = {"expectedVersion": expected_version, "document": doc}
steps.append({
"op": "save", "method": "PUT", "path": f"{BASE_PATH}/documents/{document_id}",
"headers": {IDEMPOTENCY_HEADER: idempotency_key("save", rel, body),
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": body,
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
"onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"},
})
steps.append({
"op": "verify", "method": "GET",
"path": f"{BASE_PATH}/documents/{document_id or '<생성된 id>'}",
"headers": {}, "cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": None,
"expect": {"status": 200,
"compare": "정규화한 본문·칸·자료를 보낸 것과 견준다",
"private": "currentPublication 이 null 이거나 status != PUBLISHED"},
})
_reject_forbidden_paths(steps)
return steps
# 이 어댑터가 값을 대조하지 않는 칸. 서버가 이름을 uuid 로 바꿔 돌려주거나 목록을 다시
# 정렬하는 자리라 문자열로 견줄 수 없다. **빼먹은 것이 아니라 못 보는 것**이므로
# 보고서에 그대로 적는다. 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다
NOT_COMPARED = {
"topicId": "이름→uuid 해석을 이 어댑터가 하지 않는다",
"projectId": "같음",
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
}
def normalize(value) -> str:
"""되읽어 견주기 전에 줄 끝 공백과 줄바꿈 표기만 맞춘다.
**정규화가 차이를 지울 수 있다.** 그래서 이 함수는 뜻이 바뀌지 않는 것만 건드리고,
`compare_saved` 는 정규화한 값과 **원문 둘 다** 견준다. 정규화 뒤에만 같아지는 차이는
통과가 아니라 `whitespace-only` 로 보고한다.
"""
if value is None:
return ""
text = str(value).replace("\r\n", "\n")
return "\n".join(line.rstrip() for line in text.split("\n")).strip()
def compare_saved(sent: dict, fetched: dict) -> dict:
"""보낸 것과 되읽은 것을 견준다.
문자열 비교 하나로 「같다」를 판정하지 않는다. 세 가지를 나눠 낸다.
- `differences` — 정규화한 뒤에도 다르다. 값이 잘렸거나 바뀐 것이다
- `whitespaceOnly` — 정규화 뒤에만 같아진다. 통과로 세지 않는다
- `notCompared` · `unexpected` — 이 어댑터가 못 본 칸과 서버가 더 준 칸
**잘림은 길이로 한 번 더 본다.** Studio 는 제목 30/31자·본문 4157/4187자로 조용히 자른
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
"""
differences, whitespace_only, truncated = [], [], []
for field, want in sent.items():
if field in NOT_COMPARED:
continue
got = fetched.get(field)
nw, ng = normalize(want), normalize(got)
raw_same = str(want or "") == str(got or "")
if nw != ng:
entry = {"field": field, "sentChars": len(nw), "gotChars": len(ng),
"sent": nw[:120], "got": ng[:120]}
if ng and nw.startswith(ng):
entry["shape"] = "잘림 — 되읽은 값이 보낸 값의 앞부분이다"
truncated.append(entry)
differences.append(entry)
elif not raw_same:
whitespace_only.append({"field": field,
"note": "정규화 뒤에만 같다. 공백이나 줄바꿈이 달라졌다"})
unexpected = [k for k in fetched
if k not in sent and k not in NOT_COMPARED and k not in ("id", "version")]
return {
"same": not differences and not whitespace_only,
"differences": differences,
"truncated": truncated,
"whitespaceOnly": whitespace_only,
"notCompared": NOT_COMPARED,
"unexpected": unexpected,
"comparedFields": [f for f in sent if f not in NOT_COMPARED],
}
def is_private(detail: dict) -> tuple[bool, str]:
pub = detail.get("currentPublication")
if pub is None:
return True, "currentPublication 이 없다"
status = pub.get("status")
if status == "PUBLISHED":
return False, f"이미 게시된 문서다 — publicPath={pub.get('publicPath')}"
return True, f"게시 상태가 {status} 다"
def main() -> int:
ap = argparse.ArgumentParser(description="Studio 저장 어댑터. 게시하지 않는다.")
ap.add_argument("--record", required=True)
ap.add_argument("--package", required=True, help="review-package.py 가 만든 묶음")
ap.add_argument("--document-id", help="이미 있는 문서. 없으면 만들기 계획을 낸다")
ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version")
ap.add_argument("-o", "--out", help="계획을 적을 파일")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
args = ap.parse_args()
try:
for p in (args.record, args.package):
if not os.path.isfile(p):
raise Refused(f"그런 파일이 없다: {p}")
pkg = approved(args.record, args.package)
asset_errors, asset_warnings = inspect_assets(pkg)
if asset_errors:
raise Refused("그림 허용 정책에 걸린다 — 저장 단계에서 막는다\n " +
"\n ".join(f"{e['asset']}: {e['detail']}" for e in asset_errors))
doc = build_input(args.record)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version)
if args.send and not UNATTENDED_SAVE_ENABLED:
raise Refused("무인 저장이 꺼져 있다 — " + UNATTENDED_SAVE_BLOCKED_BY)
except Refused as e:
print(f"거절: {e}", file=sys.stderr)
return 3
plan = {
"schemaVersion": 1,
"mode": "plan",
"unattendedSaveEnabled": UNATTENDED_SAVE_ENABLED,
"unattendedSaveBlockedBy": UNATTENDED_SAVE_BLOCKED_BY,
"record": os.path.relpath(os.path.abspath(args.record), ROOT),
"recordSha256": _sha256_file(args.record),
"packageSha256": _sha256_file(args.package),
"approvedAgainst": pkg["target"]["sha256"],
"checkerVersions": pkg.get("checkerVersions"),
"requests": steps,
"warnings": asset_warnings,
"notes": [
"이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.",
"게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.",
"자격증명은 환경 변수 자리표시자로만 적혀 있다. 값은 이 저장소에 없다.",
"**warnings 가 비어 있어야 자동 통과다.** 관문이 전부 통과해도 그것만으로는 "
"아니다. 그림이 검토 뒤에 바뀌었다는 경고는 무엇이 바뀌었는지 말하지 못한다 — "
"화살표 방향과 주체는 사람이 그림을 열어 봐야 안다.",
],
}
text = json.dumps(plan, ensure_ascii=False, indent=2) + "\n"
if args.out:
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
open(args.out, "w", encoding="utf-8").write(text)
ops = " · ".join(f"{s['op']} {s['method']}" for s in steps)
print(f"{args.out}{ops} · 그림 {len(pkg.get('assets') or [])}장 "
f"· 경고 {len(asset_warnings)}건 "
f"· 무인 저장 {'켬' if UNATTENDED_SAVE_ENABLED else '끔'}")
if asset_warnings:
print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr)
else:
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())