Files
CM95/cm93_importer_production_source_snapshot_v1.py
2026-09-02 09:50:12 +08:00

614 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()