#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CM93 Importer 生产源码快照与接入点提取 Version: production-snapshot-v1 目的 ==== 完全独立,不连接数据库,不读取 V10/V11/V12,不扫描历史审计脚本。 只针对当前项目真正的生产源码: - src/cm93db/importer.py - src/cm93db/geometry.py - src/cm93db/cm93_binary.py - src/cm93db/cell_parser.py - src/cm93db/audit.py - sql/001_schema_mysql57.sql - README.md - AGENTS.md(若存在) 生成: 1. 上述文件的完整只读快照副本; 2. SHA-256; 3. Python 函数/类清单; 4. 与 normalization / geometry issue / geotype / bbox / counters / DB write / raw source 相关的完整函数摘录; 5. SQL schema 中关键表完整 CREATE TABLE 片段; 6. 一个小 ZIP,供 ChatGPT 精确分析后生成真正修改版。 不做: - 不修改源码 - 不连接 MySQL - 不执行 SQL - 不生成 patch - 不生成 PBF """ from __future__ import annotations import argparse import ast import csv import hashlib import json import re import shutil import sys import zipfile from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple PRODUCTION_FILES = [ "src/cm93db/importer.py", "src/cm93db/geometry.py", "src/cm93db/cm93_binary.py", "src/cm93db/cell_parser.py", "src/cm93db/audit.py", "sql/001_schema_mysql57.sql", "README.md", "AGENTS.md", ] KEY_PATTERNS = { "normalization": [ r"\bis_valid\b", r"\bmake_valid\b", r"\bpolygonize\b", r"\bpolygonize_full\b", r"\bunary_union\b", r"\bnormalize\b", r"\bexplain_validity\b", r"\bbuffer\s*\(\s*0", ], "geometry_issue": [ r"geometry_issue", r"geometry missing", r"geometry_missing", r"geometry_repaired", r"invalid geometry", ], "geotype": [ r"raw_geotype", r"\bgeotype\b", r"geometry_type", r"\bMETA\b", r"\bAREA\b", ], "bbox": [ r"bbox_min_lon", r"bbox_min_lat", r"bbox_max_lon", r"bbox_max_lat", r"\bbbox\b", r"\bbounds\b", ], "counters": [ r"geometry_invalid_count", r"geometry_repaired_count", r"summary_json", r"chart_import_job", ], "db_write": [ r"chart_area_geometry", r"chart_coverage", r"chart_feature", r"INSERT\s+INTO", r"UPDATE\s+", r"executemany\s*\(", r"execute\s*\(", ], "raw_source": [ r"raw_feature_blob", r"chart_raw_record", r"source_record_index", r"raw_blob", r"raw_metadata_json", ], "geometry_build": [ r"\bPolygon\s*\(", r"\bMultiPolygon\s*\(", r"\bLineString\s*\(", r"\bPoint\s*\(", r"\bwkb\b", r"\bshapely\b", ], "srid": [ r"\bSRID\b", r"\bsrid\b", r"4326", r"normalized_srid", ], } SCHEMA_TABLES = [ "chart_feature", "chart_area_geometry", "chart_coverage", "chart_import_job", "chart_raw_record", ] def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) return h.hexdigest() def safe_text(path: Path) -> Optional[str]: for enc in ("utf-8", "utf-8-sig", "cp932", "latin-1"): try: return path.read_text(encoding=enc) except Exception: pass return None def write_csv(path: Path, rows: Iterable[Dict[str, Any]]) -> None: rows = list(rows) keys: List[str] = [] seen = set() for row in rows: for key in row: if key not in seen: seen.add(key) keys.append(key) with path.open("w", encoding="utf-8-sig", newline="") as f: if not keys: return w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore") w.writeheader() for row in rows: out = {} for key in keys: v = row.get(key) if isinstance(v, (list, tuple, dict)): v = json.dumps(v, ensure_ascii=False, default=str) out[key] = "" if v is None else v w.writerow(out) def ast_functions(path: Path, text: str, rel: str): functions: List[Dict[str, Any]] = [] classes: List[Dict[str, Any]] = [] if path.suffix.lower() != ".py": return functions, classes try: tree = ast.parse(text) except Exception as e: functions.append({ "file": rel, "function_name": "__AST_PARSE_ERROR__", "line": None, "end_line": None, "error": f"{type(e).__name__}: {e}", }) return functions, classes class_stack: List[str] = [] class Visitor(ast.NodeVisitor): def visit_ClassDef(self, node: ast.ClassDef): classes.append({ "file": rel, "class_name": node.name, "line": node.lineno, "end_line": getattr(node, "end_lineno", None), }) class_stack.append(node.name) self.generic_visit(node) class_stack.pop() def visit_FunctionDef(self, node: ast.FunctionDef): functions.append({ "file": rel, "class_name": ".".join(class_stack), "function_name": node.name, "line": node.lineno, "end_line": getattr(node, "end_lineno", None), "async": 0, "args": [a.arg for a in node.args.args], }) self.generic_visit(node) def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef): functions.append({ "file": rel, "class_name": ".".join(class_stack), "function_name": node.name, "line": node.lineno, "end_line": getattr(node, "end_lineno", None), "async": 1, "args": [a.arg for a in node.args.args], }) self.generic_visit(node) Visitor().visit(tree) return functions, classes def line_hits(rel: str, text: str) -> List[Dict[str, Any]]: rows = [] lines = text.splitlines() compiled = { group: [re.compile(p, re.I) for p in pats] for group, pats in KEY_PATTERNS.items() } for no, line in enumerate(lines, 1): for group, pats in compiled.items(): matched = [p.pattern for p in pats if p.search(line)] if not matched: continue start = max(1, no - 3) end = min(len(lines), no + 3) context = "\n".join( f"{i:05d}: {lines[i-1]}" for i in range(start, end + 1) ) rows.append({ "file": rel, "line": no, "group": group, "code": line.strip(), "matched_patterns": matched, "context": context, }) return rows def function_key_groups( fn: Dict[str, Any], text: str, ) -> List[str]: if fn.get("line") is None: return [] lines = text.splitlines() start = max(1, int(fn["line"])) end = fn.get("end_line") if end is None: end = min(len(lines), start + 80) else: end = min(len(lines), int(end)) body = "\n".join(lines[start - 1:end]) groups = [] for group, pats in KEY_PATTERNS.items(): if any(re.search(p, body, re.I) for p in pats): groups.append(group) return groups def make_function_excerpts( root: Path, function_rows: List[Dict[str, Any]], texts: Dict[str, str], ) -> str: chunks: List[str] = [] for fn in sorted( function_rows, key=lambda x: ( x.get("file") or "", int(x.get("line") or 0), ) ): rel = fn.get("file") text = texts.get(rel) if not text or fn.get("line") is None: continue groups = function_key_groups(fn, text) if not groups: continue lines = text.splitlines() start = max(1, int(fn["line"])) end_val = fn.get("end_line") end = ( min(len(lines), int(end_val)) if end_val is not None else min(len(lines), start + 100) ) # 防止单个超大函数让结果失控,但保留足够上下文。 if end - start > 260: end = start + 260 cls = fn.get("class_name") or "" name = fn.get("function_name") or "" display = f"{cls}.{name}" if cls else name chunks.append("=" * 110) chunks.append( f"FILE={rel} FUNCTION={display} " f"LINES={start}-{fn.get('end_line')} " f"GROUPS={','.join(groups)}" ) chunks.append("=" * 110) for no in range(start, end + 1): chunks.append(f"{no:05d}: {lines[no-1]}") chunks.append("") return "\n".join(chunks) def extract_create_table(sql_text: str, table: str) -> Optional[str]: # 兼容 CREATE TABLE `name` (...) ENGINE=... pat = re.compile( rf"CREATE\s+TABLE\s+`?{re.escape(table)}`?\s*\(", re.I, ) m = pat.search(sql_text) if not m: return None start = m.start() pos = m.end() - 1 depth = 0 in_single = False in_double = False escape = False for i in range(pos, len(sql_text)): ch = sql_text[i] if escape: escape = False continue if ch == "\\": escape = True continue if ch == "'" and not in_double: in_single = not in_single continue if ch == '"' and not in_single: in_double = not in_double continue if in_single or in_double: continue if ch == "(": depth += 1 elif ch == ")": depth -= 1 if depth == 0: # 到分号为止 semi = sql_text.find(";", i) if semi == -1: semi = i return sql_text[start:semi + 1] return None def parse_args(): p = argparse.ArgumentParser( description="CM93 importer production source snapshot" ) p.add_argument("--project-root", default=".") p.add_argument("--output", default=None) return p.parse_args() def main(): args = parse_args() root = Path(args.project_root).expanduser().resolve() if not root.is_dir(): print(f"项目目录不存在:{root}") sys.exit(2) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") outdir = Path( args.output or root / f"cm93_importer_production_snapshot_v1_{stamp}" ).expanduser().resolve() outdir.mkdir(parents=True, exist_ok=True) snapshot_dir = outdir / "source_snapshot" snapshot_dir.mkdir(parents=True, exist_ok=True) inventory = [] all_functions = [] all_classes = [] all_hits = [] texts: Dict[str, str] = {} print("[1/5] 收集生产文件 ...") for rel in PRODUCTION_FILES: src = root / rel if not src.exists(): inventory.append({ "file": rel, "exists": 0, "size_bytes": None, "sha256": None, }) continue dest = snapshot_dir / rel dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) inventory.append({ "file": rel, "exists": 1, "size_bytes": src.stat().st_size, "sha256": sha256_file(src), }) text = safe_text(src) if text is None: continue texts[rel] = text funcs, classes = ast_functions(src, text, rel) all_functions.extend(funcs) all_classes.extend(classes) all_hits.extend(line_hits(rel, text)) print("[2/5] 提取相关完整函数 ...") excerpts = make_function_excerpts( root, all_functions, texts, ) (outdir / "生产源码关键函数完整摘录_v1.txt").write_text( excerpts, encoding="utf-8", ) print("[3/5] 提取关键 schema ...") schema_text = texts.get("sql/001_schema_mysql57.sql", "") schema_chunks = [] for table in SCHEMA_TABLES: ddl = extract_create_table(schema_text, table) schema_chunks.append("=" * 100) schema_chunks.append(f"TABLE: {table}") schema_chunks.append("=" * 100) schema_chunks.append( ddl if ddl is not None else "NOT FOUND" ) schema_chunks.append("") (outdir / "关键表DDL_v1.txt").write_text( "\n".join(schema_chunks), encoding="utf-8", ) print("[4/5] 写清单与报告 ...") write_csv(outdir / "生产文件SHA256_v1.csv", inventory) write_csv(outdir / "生产Python函数清单_v1.csv", all_functions) write_csv(outdir / "生产Python类清单_v1.csv", all_classes) write_csv(outdir / "生产源码关键命中_v1.csv", all_hits) group_counts = {} for row in all_hits: group_counts[row["group"]] = ( group_counts.get(row["group"], 0) + 1 ) report = { "version": "production-snapshot-v1", "generated_at": datetime.now().isoformat(timespec="seconds"), "project_root": str(root), "database_dependency": False, "history_dependency": False, "read_only": True, "pbf": "OUT_OF_SCOPE", "production_files": inventory, "function_count": len(all_functions), "class_count": len(all_classes), "key_hit_count": len(all_hits), "key_hit_group_counts": group_counts, "snapshot_contains_only_explicit_production_files": True, } (outdir / "生产源码快照报告_v1.json").write_text( json.dumps( report, ensure_ascii=False, indent=2, default=str, ), encoding="utf-8", ) summary = [ "CM93 Importer 生产源码快照与接入点提取 v1", "=" * 90, f"Project: {root}", "", "仅包含明确指定的生产文件,不扫描任何 V* 历史审计脚本。", "", "FILES", ] for row in inventory: summary.append( f" {'OK' if row['exists'] else 'MISSING':7s} " f"{row['file']}" ) summary += [ "", f"Python functions: {len(all_functions)}", f"Python classes: {len(all_classes)}", f"Key hits: {len(all_hits)}", "", "NO DATABASE / NO SOURCE MODIFY / NO PBF", ] (outdir / "生产源码快照摘要_v1.txt").write_text( "\n".join(summary), encoding="utf-8", ) print("[5/5] 压缩 ...") zip_path = outdir.with_suffix(".zip") with zipfile.ZipFile( zip_path, "w", compression=zipfile.ZIP_DEFLATED, ) as z: for p in sorted(outdir.rglob("*")): if p.is_file(): z.write( p, p.relative_to(outdir.parent), ) print("") print("PRODUCTION SNAPSHOT COMPLETE: YES") print(f"Output: {outdir}") print(f"ZIP: {zip_path}") print("把这个 ZIP 上传给 ChatGPT。") if __name__ == "__main__": main()