#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CM93 -> MySQL Dataset Audit + Geometry Issue Classification v1.0 目标: 1. 审计 Dataset ID=1 2. 分类 geometry issue 3. 导出 CSV / JSON / TXT 安全: - 只读事务 - 不执行 INSERT / UPDATE / DELETE / DDL - 不修 geometry - 不涉及 PBF 依赖: pip install pymysql 示例: python cm93_mysql_audit.py --host 127.0.0.1 --port 3306 --user root --database YOUR_DB --dataset-id 1 """ from __future__ import annotations import argparse import csv import getpass import json import os import re import sys import traceback from collections import Counter, defaultdict from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple try: import pymysql from pymysql.cursors import DictCursor except ImportError: print("缺少 pymysql,请执行: pip install pymysql") sys.exit(2) CATEGORY_RULES: List[Tuple[str, List[str]]] = [ ("INVALID_COORDINATE", [ r"\bnan\b", r"\binf(?:inity)?\b", r"invalid coordinate", r"coordinate.*out of range", r"latitude.*out of range", r"longitude.*out of range", r"non[- ]?finite" ]), ("INSUFFICIENT_POINTS", [ r"insufficient point", r"too few point", r"not enough point", r"minimum.*point", r"less than .* points?", r"requires? .* points?" ]), ("RING_NOT_CLOSED", [ r"ring.*not closed", r"not closed.*ring", r"unclosed ring", r"closed linestring" ]), ("SELF_INTERSECTION", [ r"self[- ]?intersection", r"self intersect", r"ring self", r"bow[- ]?tie" ]), ("INVALID_RING", [ r"invalid ring", r"ring.*invalid", r"illegal ring", r"shell.*invalid", r"hole.*invalid", r"interior ring", r"exterior ring" ]), ("DEGENERATE_GEOMETRY", [ r"degenerate", r"zero[- ]?length", r"zero[- ]?area", r"collapsed", r"duplicate point", r"all points.*same", r"too few distinct" ]), ("MULTIPART_ASSEMBLY", [ r"multipart", r"multi[- ]?part", r"cannot.*assemble", r"failed.*assemble", r"assembly failed", r"cannot.*merge", r"failed.*merge", r"stitch" ]), ("TOPOLOGY_MISMATCH", [ r"topolog", r"missing edge", r"edge.*missing", r"missing node", r"node.*missing", r"edge reference", r"node reference", r"primitive reference", r"dangling edge", r"broken chain", r"disconnected" ]), ("GEOMETRY_TYPE_MISMATCH", [ r"type mismatch", r"geometry type", r"expected.*(?:point|line|polygon|area)", r"unexpected.*(?:point|line|polygon|area)", r"wrong.*geometry" ]), ("EMPTY_GEOMETRY", [ r"empty geometry", r"geometry.*empty", r"no geometry", r"null geometry", r"geometry is null", r"no coordinates?", r"empty point", r"empty line", r"empty polygon" ]), ("CONVERSION_EXCEPTION", [ r"\bwkb\b", r"\bwkt\b", r"parse exception", r"conversion exception", r"geometry exception", r"failed to convert", r"cannot convert", r"invalid gis data", r"data truncation", r"spatial.*exception" ]), ] STAGE_RULES: List[Tuple[str, List[str]]] = [ ("MYSQL_WRITE", [r"mysql", r"insert", r"database", r"spatial", r"\bwkb\b", r"\bwkt\b"]), ("VALIDATION", [r"valid", r"self[- ]?intersect", r"ring", r"geometry.*invalid"]), ("ASSEMBLY", [r"assemble", r"assembly", r"merge", r"stitch", r"multipart", r"chain"]), ("TOPOLOGY", [r"topolog", r"edge", r"node", r"primitive"]), ("DECODE", [r"decode", r"parse", r"binary", r"record"]), ("SOURCE", [r"source", r"raw", r"cm93"]), ] @dataclass class TableInfo: name: str columns: List[str] def qi(name: str) -> str: return "`" + name.replace("`", "``") + "`" def text(v: Any) -> str: if v is None: return "" if isinstance(v, (bytes, bytearray)): return v.decode("utf-8", errors="replace") return str(v) def score_name(name: str, candidates: Sequence[str]) -> int: n = name.lower() best = 0 for c in candidates: c = c.lower() if n == c: best = max(best, 100) elif n.endswith("_" + c) or n.startswith(c + "_"): best = max(best, 45) elif c in n: best = max(best, 10) return best def best_col(cols: Sequence[str], candidates: Sequence[str]) -> Optional[str]: ranked = sorted(((score_name(c, candidates), c) for c in cols), reverse=True) return ranked[0][1] if ranked and ranked[0][0] > 0 else None def best_table(tables: List[TableInfo], candidates: Sequence[str], must_have: Sequence[str] = ()) -> Optional[TableInfo]: ranked = [] for t in tables: if must_have and not all(any(x.lower() in c.lower() for c in t.columns) for x in must_have): continue ranked.append((score_name(t.name, candidates), t.name, t)) ranked.sort(reverse=True) return ranked[0][2] if ranked and ranked[0][0] > 0 else None def discover_tables(cur, db: str) -> List[TableInfo]: cur.execute(""" SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=%s ORDER BY TABLE_NAME, ORDINAL_POSITION """, (db,)) grouped: Dict[str, List[str]] = defaultdict(list) for r in cur.fetchall(): grouped[r["TABLE_NAME"]].append(r["COLUMN_NAME"]) return [TableInfo(k, v) for k, v in grouped.items()] def resolve_schema(tables: List[TableInfo]) -> Dict[str, Optional[TableInfo]]: issue_candidates = [] for t in tables: n = t.name.lower() if ("geometry" in n or "geom" in n) and ("issue" in n or "error" in n): issue_candidates.append(t) issue_candidates.sort(key=lambda t: (len(t.name), t.name)) return { "dataset": best_table(tables, ["datasets", "dataset"]), "cell": best_table(tables, ["cells", "cell"]), "feature": best_table(tables, ["features", "feature"]), "attribute": best_table(tables, ["feature_attributes", "feature_attribute", "attributes", "attribute"]), "sounding": best_table(tables, ["sounding_points", "sounding_point", "soundings", "sounding"]), "geometry_issue": issue_candidates[0] if issue_candidates else best_table(tables, ["geometry_issues", "geometry_issue", "geom_issue"]), } def scalar(cur, sql: str, params=()) -> Any: cur.execute(sql, params) r = cur.fetchone() return next(iter(r.values())) if r else None def dataset_col(t: Optional[TableInfo]) -> Optional[str]: if not t: return None return best_col(t.columns, ["dataset_id", "datasetid", "dataset"]) def count_table(cur, t: Optional[TableInfo], dataset_id: int) -> Dict[str, Any]: if not t: return {"table": None, "count": None, "dataset_column": None, "error": "table not found"} ds = dataset_col(t) try: if ds: n = scalar(cur, f"SELECT COUNT(*) AS n FROM {qi(t.name)} WHERE {qi(ds)}=%s", (dataset_id,)) else: n = scalar(cur, f"SELECT COUNT(*) AS n FROM {qi(t.name)}") return {"table": t.name, "count": int(n), "dataset_column": ds, "error": None} except Exception as e: return {"table": t.name, "count": None, "dataset_column": ds, "error": str(e)} def orphan_count(cur, child: Optional[TableInfo], parent: Optional[TableInfo], child_fk_names, parent_pk_names, dataset_id: int) -> Dict[str, Any]: out = { "child": child.name if child else None, "parent": parent.name if parent else None, "child_fk": None, "parent_pk": None, "count": None, "error": None, } if not child or not parent: out["error"] = "table not found" return out cfk = best_col(child.columns, child_fk_names) ppk = best_col(parent.columns, parent_pk_names) cds = dataset_col(child) out["child_fk"] = cfk out["parent_pk"] = ppk if not cfk or not ppk: out["error"] = "required columns not found" return out try: where = [f"c.{qi(cfk)} IS NOT NULL", f"p.{qi(ppk)} IS NULL"] params = [] if cds: where.insert(0, f"c.{qi(cds)}=%s") params.append(dataset_id) sql = f""" SELECT COUNT(*) AS n FROM {qi(child.name)} c LEFT JOIN {qi(parent.name)} p ON c.{qi(cfk)} = p.{qi(ppk)} WHERE {' AND '.join(where)} """ out["count"] = int(scalar(cur, sql, params)) except Exception as e: out["error"] = str(e) return out def geometry_audit(cur, feature: Optional[TableInfo], dataset_id: int) -> Dict[str, Any]: out: Dict[str, Any] = { "table": feature.name if feature else None, "geometry_column": None, "null_count": None, "empty_count": None, "valid_count": None, "invalid_count": None, "types": {}, "errors": [], } if not feature: out["errors"].append("feature table not found") return out geom = best_col(feature.columns, ["geometry", "geom", "shape"]) ds = dataset_col(feature) out["geometry_column"] = geom if not geom: out["errors"].append("geometry column not found") return out params = [dataset_id] if ds else [] base = f"{qi(ds)}=%s" if ds else "1=1" try: out["null_count"] = int(scalar(cur, f"SELECT COUNT(*) AS n FROM {qi(feature.name)} WHERE {base} AND {qi(geom)} IS NULL", params)) except Exception as e: out["errors"].append(f"NULL count failed: {e}") try: cur.execute(f""" SELECT ST_GeometryType({qi(geom)}) AS geometry_type, COUNT(*) AS n FROM {qi(feature.name)} WHERE {base} AND {qi(geom)} IS NOT NULL GROUP BY ST_GeometryType({qi(geom)}) ORDER BY n DESC """, params) out["types"] = {text(r["geometry_type"] or "NULL"): int(r["n"]) for r in cur.fetchall()} except Exception as e: out["errors"].append(f"ST_GeometryType failed: {e}") try: cur.execute(f""" SELECT SUM(CASE WHEN ST_IsEmpty({qi(geom)}) THEN 1 ELSE 0 END) AS empty_count, SUM(CASE WHEN ST_IsValid({qi(geom)}) THEN 1 ELSE 0 END) AS valid_count, SUM(CASE WHEN NOT ST_IsValid({qi(geom)}) THEN 1 ELSE 0 END) AS invalid_count FROM {qi(feature.name)} WHERE {base} AND {qi(geom)} IS NOT NULL """, params) r = cur.fetchone() or {} out["empty_count"] = int(r.get("empty_count") or 0) out["valid_count"] = int(r.get("valid_count") or 0) out["invalid_count"] = int(r.get("invalid_count") or 0) except Exception as e: out["errors"].append(f"ST_IsEmpty/ST_IsValid failed: {e}") return out def issue_text(row: Dict[str, Any]) -> str: first = [] rest = [] for k, v in row.items(): if v is None: continue s = text(v).strip() if not s: continue kl = k.lower() part = f"{k}={s}" if any(x in kl for x in ["issue", "error", "reason", "message", "detail", "exception", "stage", "type", "status"]): first.append(part) else: rest.append(part) return " | ".join(first + rest) def classify_issue(s: str) -> str: for category, patterns in CATEGORY_RULES: for p in patterns: if re.search(p, s, re.I): return category return "OTHER" def classify_stage(s: str) -> str: for stage, patterns in STAGE_RULES: for p in patterns: if re.search(p, s, re.I): return stage return "UNKNOWN" def fetch_issues(cur, issue: Optional[TableInfo], feature: Optional[TableInfo], cell: Optional[TableInfo], dataset_id: int, max_rows: int): meta = { "table": issue.name if issue else None, "dataset_column": None, "feature_fk": None, "joined_feature": False, "joined_cell": False, "error": None, } if not issue: meta["error"] = "geometry issue table not found" return [], meta ids = dataset_col(issue) ifk = best_col(issue.columns, ["feature_id", "featureid", "feature"]) meta["dataset_column"] = ids meta["feature_fk"] = ifk joins = [] extras = [] where = [] params = [] fpk = best_col(feature.columns, ["id", "feature_id", "featureid"]) if feature else None fds = dataset_col(feature) fcell = best_col(feature.columns, ["cell_id", "cellid", "cell"]) if feature else None if feature and ifk and fpk: joins.append(f"LEFT JOIN {qi(feature.name)} f ON i.{qi(ifk)} = f.{qi(fpk)}") meta["joined_feature"] = True for c in feature.columns: cl = c.lower() if any(x in cl for x in ["object", "class", "type", "geom", "cell", "feature"]): extras.append(f"f.{qi(c)} AS {qi('feature__' + c)}") if cell and fcell: cpk = best_col(cell.columns, ["id", "cell_id", "cellid"]) if cpk: joins.append(f"LEFT JOIN {qi(cell.name)} c ON f.{qi(fcell)} = c.{qi(cpk)}") meta["joined_cell"] = True for cc in cell.columns: ccl = cc.lower() if any(x in ccl for x in ["name", "cell", "file", "path"]): extras.append(f"c.{qi(cc)} AS {qi('cell__' + cc)}") if ids: where.append(f"i.{qi(ids)}=%s") params.append(dataset_id) elif meta["joined_feature"] and fds: where.append(f"f.{qi(fds)}=%s") params.append(dataset_id) select_extra = (", " + ", ".join(extras)) if extras else "" sql = f""" SELECT i.*{select_extra} FROM {qi(issue.name)} i {' '.join(joins)} {'WHERE ' + ' AND '.join(where) if where else ''} LIMIT {int(max_rows)} """ try: cur.execute(sql, params) return list(cur.fetchall()), meta except Exception as e: meta["error"] = str(e) return [], meta def find_object_class(row: Dict[str, Any]) -> str: for k, v in row.items(): kl = k.lower() if v is not None and "object" in kl and ("class" in kl or "type" in kl or "code" in kl or "name" in kl): return text(v) for k, v in row.items(): if v is not None and "class" in k.lower(): return text(v) return "" def find_cell(row: Dict[str, Any]) -> str: for k, v in row.items(): kl = k.lower() if v is not None and kl.startswith("cell__") and any(x in kl for x in ["name", "file", "path", "cell"]): return text(v) for k, v in row.items(): if v is not None and "cell" in k.lower(): return text(v) return "" def enrich(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: out = [] for r in rows: s = issue_text(r) x = dict(r) x["_issue_text"] = s x["_issue_category"] = classify_issue(s) x["_issue_stage"] = classify_stage(s) x["_object_class"] = find_object_class(r) x["_cell"] = find_cell(r) out.append(x) return out def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]: by_cat = Counter(r["_issue_category"] for r in rows) by_stage = Counter(r["_issue_stage"] for r in rows) cross = Counter((r["_issue_stage"], r["_issue_category"]) for r in rows) obj = Counter(r["_object_class"] for r in rows if r["_object_class"]) cell = Counter(r["_cell"] for r in rows if r["_cell"]) return { "total": len(rows), "by_category": dict(by_cat.most_common()), "by_stage": dict(by_stage.most_common()), "by_stage_category": [ {"stage": s, "category": c, "count": n} for (s, c), n in cross.most_common() ], "by_object_class_top20": [ {"object_class": k, "count": v} for k, v in obj.most_common(20) ], "by_cell_top30": [ {"cell": k, "count": v} for k, v in cell.most_common(30) ], } def duplicate_audit(rows: List[Dict[str, Any]]) -> Dict[str, Any]: groups = defaultdict(int) for r in rows: parts = [] for k, v in r.items(): kl = k.lower() if v is not None and any(x in kl for x in ["feature_id", "featureid", "reason", "message", "detail", "exception", "stage", "issue_type", "error_type"]): parts.append(f"{k}={text(v)}") if parts: groups[tuple(parts)] += 1 dup = [(k, n) for k, n in groups.items() if n > 1] dup.sort(key=lambda x: x[1], reverse=True) return { "duplicate_groups": len(dup), "duplicate_rows_beyond_first": sum(n - 1 for _, n in dup), "examples": [{"count": n, "identity": " | ".join(k)[:1500]} for k, n in dup[:20]], } def write_csv(path: Path, rows: List[Dict[str, Any]], fields: Optional[List[str]] = None): path.parent.mkdir(parents=True, exist_ok=True) if fields is None: fields = [] seen = set() for r in rows: for k in r: if k not in seen: seen.add(k) fields.append(k) with path.open("w", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") w.writeheader() for r in rows: w.writerow({k: text(r.get(k, "")) for k in fields}) def make_samples(rows: List[Dict[str, Any]], n=5) -> List[Dict[str, Any]]: buckets = defaultdict(list) for r in rows: cat = r["_issue_category"] if len(buckets[cat]) < n: buckets[cat].append(r) result = [] for cat in sorted(buckets): result.extend(buckets[cat]) return result def parse_args(): p = argparse.ArgumentParser(description="CM93 MySQL Dataset audit + geometry issue classification") p.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1")) p.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306"))) p.add_argument("--user", default=os.getenv("MYSQL_USER", "root")) p.add_argument("--password", default=os.getenv("MYSQL_PASSWORD")) p.add_argument("--database", default=os.getenv("MYSQL_DATABASE")) p.add_argument("--dataset-id", type=int, default=1) p.add_argument("--output", default=None) p.add_argument("--max-issues", type=int, default=1000000) return p.parse_args() def main(): a = parse_args() if not a.database: a.database = input("MySQL database name: ").strip() if not a.password: a.password = getpass.getpass("MySQL password: ") stamp = datetime.now().strftime("%Y%m%d_%H%M%S") outdir = Path(a.output or f"cm93_audit_dataset_{a.dataset_id}_{stamp}") outdir.mkdir(parents=True, exist_ok=True) report: Dict[str, Any] = { "generated_at": datetime.now().isoformat(timespec="seconds"), "database": a.database, "dataset_id": a.dataset_id, "read_only": True, "schema": {}, "counts": {}, "relationships": {}, "geometry": {}, "issues": {}, "warnings": [], } conn = None try: conn = pymysql.connect( host=a.host, port=a.port, user=a.user, password=a.password, database=a.database, charset="utf8mb4", cursorclass=DictCursor, autocommit=False, connect_timeout=20, read_timeout=300, write_timeout=30, ) with conn.cursor() as cur: try: cur.execute("SET SESSION TRANSACTION READ ONLY") except Exception as e: report["warnings"].append(f"SET SESSION TRANSACTION READ ONLY failed: {e}") cur.execute("START TRANSACTION READ ONLY") print("[1/7] 扫描 schema...") tables = discover_tables(cur, a.database) schema = resolve_schema(tables) report["schema"]["resolved_tables"] = {k: (v.name if v else None) for k, v in schema.items()} report["schema"]["all_tables"] = {t.name: t.columns for t in tables} for k, v in report["schema"]["resolved_tables"].items(): print(f" {k:16s}: {v}") print("[2/7] 数量审计...") for k in ["dataset", "cell", "feature", "attribute", "sounding", "geometry_issue"]: report["counts"][k] = count_table(cur, schema[k], a.dataset_id) print("[3/7] 关系完整性审计...") report["relationships"]["feature_to_cell"] = orphan_count( cur, schema["feature"], schema["cell"], ["cell_id", "cellid", "cell"], ["id", "cell_id", "cellid"], a.dataset_id) report["relationships"]["attribute_to_feature"] = orphan_count( cur, schema["attribute"], schema["feature"], ["feature_id", "featureid", "feature"], ["id", "feature_id", "featureid"], a.dataset_id) report["relationships"]["sounding_to_feature"] = orphan_count( cur, schema["sounding"], schema["feature"], ["feature_id", "featureid", "feature"], ["id", "feature_id", "featureid"], a.dataset_id) report["relationships"]["issue_to_feature"] = orphan_count( cur, schema["geometry_issue"], schema["feature"], ["feature_id", "featureid", "feature"], ["id", "feature_id", "featureid"], a.dataset_id) print("[4/7] Geometry 基础审计...") report["geometry"] = geometry_audit(cur, schema["feature"], a.dataset_id) print("[5/7] 读取 geometry issues...") raw, meta = fetch_issues(cur, schema["geometry_issue"], schema["feature"], schema["cell"], a.dataset_id, a.max_issues) report["issues"]["meta"] = meta print(f" loaded={len(raw)}") print("[6/7] 分类 geometry issues...") rows = enrich(raw) sm = summarize(rows) dup = duplicate_audit(rows) report["issues"]["summary"] = sm report["issues"]["duplicates"] = dup write_csv(outdir / "geometry_issues_classified.csv", rows) write_csv(outdir / "geometry_issue_samples.csv", make_samples(rows, 5)) write_csv(outdir / "geometry_issue_stage_category.csv", sm["by_stage_category"], ["stage", "category", "count"]) write_csv(outdir / "geometry_issue_object_class_top20.csv", sm["by_object_class_top20"], ["object_class", "count"]) write_csv(outdir / "geometry_issue_cell_top30.csv", sm["by_cell_top30"], ["cell", "count"]) expected = { "cell": 784, "feature": 322465, "attribute": 405153, "sounding": 275700, "geometry_issue": 4406, } lines = [] lines.append("CM93 -> MySQL Dataset Audit + Geometry Issue Classification") lines.append("=" * 72) lines.append(f"Generated: {report['generated_at']}") lines.append(f"Database: {a.database}") lines.append(f"Dataset ID: {a.dataset_id}") lines.append("") lines.append("Resolved tables:") for k, v in report["schema"]["resolved_tables"].items(): lines.append(f" {k:16s}: {v}") lines.append("") lines.append("Counts:") for k, v in report["counts"].items(): lines.append(f" {k:16s}: count={v['count']} table={v['table']} dataset_col={v['dataset_column']} error={v['error']}") lines.append("") lines.append("Relationships:") for k, v in report["relationships"].items(): lines.append(f" {k:24s}: orphan={v['count']} error={v['error']}") lines.append("") lines.append("Geometry audit:") lines.append(json.dumps(report["geometry"], ensure_ascii=False, indent=2, default=str)) lines.append("") lines.append(f"Geometry issues loaded: {sm['total']}") lines.append("By category:") for k, n in sm["by_category"].items(): pct = n / sm["total"] * 100 if sm["total"] else 0 lines.append(f" {k:28s} {n:8d} {pct:7.3f}%") lines.append("") lines.append("By stage:") for k, n in sm["by_stage"].items(): pct = n / sm["total"] * 100 if sm["total"] else 0 lines.append(f" {k:16s} {n:8d} {pct:7.3f}%") lines.append("") lines.append(f"Duplicate groups: {dup['duplicate_groups']}") lines.append(f"Duplicate rows beyond first: {dup['duplicate_rows_beyond_first']}") lines.append("") lines.append("Acceptance checks:") for k, exp in expected.items(): actual = report["counts"].get(k, {}).get("count") lines.append(f" {k:16s}: actual={actual} expected={exp} => {'PASS' if actual == exp else 'CHECK'}") category_sum = sum(sm["by_category"].values()) lines.append(f" issue loaded total: actual={sm['total']} expected=4406 => {'PASS' if sm['total'] == 4406 else 'CHECK'}") lines.append(f" category sum: actual={category_sum} expected={sm['total']} => {'PASS' if category_sum == sm['total'] else 'FAIL'}") lines.append(f" OTHER count: {sm['by_category'].get('OTHER', 0)}") lines.append("") lines.append("说明:") lines.append(" 1. 本脚本仅根据数据库现有 schema、issue 文本和空间字段进行只读审计。") lines.append(" 2. OTHER 表示现有文本证据不足,不表示该 issue 可以忽略。") lines.append(" 3. 本脚本没有修复 geometry,也没有修改任何数据库记录。") lines.append(" 4. 不包含任何 PBF 逻辑。") (outdir / "audit_summary.txt").write_text("\n".join(lines), encoding="utf-8") (outdir / "audit_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") conn.rollback() print("[7/7] 完成") print("") print("\n".join(lines[-18:])) print("") print(f"输出目录: {outdir.resolve()}") except KeyboardInterrupt: if conn: try: conn.rollback() except Exception: pass print("已中断。") sys.exit(130) except Exception: if conn: try: conn.rollback() except Exception: pass err = traceback.format_exc() print(err) try: (outdir / "fatal_error.txt").write_text(err, encoding="utf-8") except Exception: pass sys.exit(1) finally: if conn: try: conn.close() except Exception: pass if __name__ == "__main__": main()