- extract_ts.mjs — 함수(kind · exported · doc · params · returns · calls · called_by · 다시 내보내기) · 세션 칸 · 서버 주소 글자 · 계산 등록부 그대로 - extract_py.py — def 전부 · docstring 첫 줄 · 부르는 함수 · FastAPI 라우트 데코레이터 - extract_api.py — 서버 안 켜고 openapi 주소 → handler · TS 부르는 곳 짝 · 짝 없는 것 따로 - 범위 = scope.json 한 곳 · 세는 규칙 = README · 시험 test_478_extract(6) - 수치 = 함수 5746 · 세션 칸 45 · 서버 주소 41 · 등록부 45 · 14.3 s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""docs/tools/extract_py.py — 파이썬 추출(PLAN 478-1 · ast).
|
|
|
|
범위(`scope.json`)의 .py 를 읽어 `docs/_data/raw/functions_py.json` 에 씀(꼴 = PLAN 478 「추출 꼴」
|
|
+ FastAPI 라우트 데코레이터 `routes` [{method, path}]). 세는 규칙 = docs/tools/README.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SCOPE = json.loads((ROOT / "docs/tools/scope.json").read_text(encoding="utf-8"))
|
|
OUT = ROOT / "docs/_data/raw/functions_py.json"
|
|
HTTP = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
|
|
|
|
def rel(p: Path) -> str:
|
|
return p.resolve().relative_to(ROOT).as_posix()
|
|
|
|
|
|
def skipped(r: str) -> bool:
|
|
return any(s in r for s in SCOPE["skip"])
|
|
|
|
|
|
def module_file(mod: str) -> Path | None:
|
|
base = ROOT.joinpath(*mod.split("."))
|
|
for cand in (base.with_suffix(".py"), base / "__init__.py"):
|
|
if cand.is_file():
|
|
return cand
|
|
return None
|
|
|
|
|
|
def resolve_from(file: Path, node: ast.ImportFrom) -> str:
|
|
"""`from <x> import` 의 절대 모듈 이름(상대 import 풀기)."""
|
|
if not node.level:
|
|
return node.module or ""
|
|
parts = list(Path(rel(file)).parent.parts)
|
|
parts = parts[: len(parts) - (node.level - 1)] if node.level > 1 else parts
|
|
return ".".join([*parts, *([node.module] if node.module else [])])
|
|
|
|
|
|
def imports_of(file: Path, tree: ast.Module) -> dict[str, tuple[str, str | None]]:
|
|
"""이름 → (모듈, 그 모듈 안 이름 | None=모듈 자체)."""
|
|
out: dict[str, tuple[str, str | None]] = {}
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for a in node.names:
|
|
if a.asname:
|
|
out[a.asname] = (a.name, None)
|
|
else:
|
|
out[a.name] = (a.name, None)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
mod = resolve_from(file, node)
|
|
for a in node.names:
|
|
sub = f"{mod}.{a.name}" if mod else a.name
|
|
out[a.asname or a.name] = (sub, None) if module_file(sub) else (mod, a.name)
|
|
return out
|
|
|
|
|
|
def scope_files() -> list[Path]:
|
|
seen: dict[str, Path] = {}
|
|
queue = [p for r in SCOPE["roots"] for p in (ROOT / r).rglob("*.py")]
|
|
# 계산 등록부(registry.json · extract_ts 가 먼저 씀)가 짚는 follow 폴더 파이썬도 범위
|
|
registry = ROOT / "docs/_data/registry.json"
|
|
named = json.loads(registry.read_text(encoding="utf-8")) if registry.is_file() else []
|
|
for ref in (x for e in named for x in (e.get("pair"), e.get("runner")) if x):
|
|
file = ref.removeprefix("py:").split("#")[0]
|
|
if (
|
|
file.endswith(".py")
|
|
and any(file.startswith(f) for f in SCOPE["follow"])
|
|
and (ROOT / file).is_file()
|
|
):
|
|
queue.append(ROOT / file)
|
|
while queue:
|
|
f = queue.pop()
|
|
r = rel(f)
|
|
if r in seen or skipped(r):
|
|
continue
|
|
seen[r] = f
|
|
tree = ast.parse(f.read_text(encoding="utf-8"), filename=r)
|
|
for mod, _ in imports_of(f, tree).values():
|
|
target = module_file(mod)
|
|
if target and any(rel(target).startswith(x) for x in SCOPE["follow"]):
|
|
queue.append(target)
|
|
return [seen[k] for k in sorted(seen)]
|
|
|
|
|
|
def first_line(node: ast.AST) -> str | None:
|
|
doc = (
|
|
ast.get_docstring(node)
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
else None
|
|
)
|
|
lines = [ln.strip() for ln in (doc or "").splitlines() if ln.strip()]
|
|
return lines[0] if lines else None
|
|
|
|
|
|
def params_of(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict]:
|
|
a = fn.args
|
|
pos = [*a.posonlyargs, *a.args]
|
|
out = [
|
|
{"name": p.arg, "type": ast.unparse(p.annotation) if p.annotation else None} for p in pos
|
|
]
|
|
if a.vararg:
|
|
out.append(
|
|
{
|
|
"name": f"*{a.vararg.arg}",
|
|
"type": ast.unparse(a.vararg.annotation) if a.vararg.annotation else None,
|
|
}
|
|
)
|
|
out += [
|
|
{"name": p.arg, "type": ast.unparse(p.annotation) if p.annotation else None}
|
|
for p in a.kwonlyargs
|
|
]
|
|
if a.kwarg:
|
|
out.append(
|
|
{
|
|
"name": f"**{a.kwarg.arg}",
|
|
"type": ast.unparse(a.kwarg.annotation) if a.kwarg.annotation else None,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def router_prefixes(tree: ast.Module) -> dict[str, str]:
|
|
"""모듈 머리 `router = APIRouter(prefix="...")` → {router: prefix}."""
|
|
out: dict[str, str] = {}
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
callee = node.value.func
|
|
name = callee.id if isinstance(callee, ast.Name) else getattr(callee, "attr", "")
|
|
if name != "APIRouter":
|
|
continue
|
|
prefix = next(
|
|
(
|
|
k.value.value
|
|
for k in node.value.keywords
|
|
if k.arg == "prefix" and isinstance(k.value, ast.Constant)
|
|
),
|
|
"",
|
|
)
|
|
for t in node.targets:
|
|
if isinstance(t, ast.Name):
|
|
out[t.id] = prefix
|
|
return out
|
|
|
|
|
|
def routes_of(fn: ast.FunctionDef | ast.AsyncFunctionDef, prefixes: dict[str, str]) -> list[dict]:
|
|
out = []
|
|
for d in fn.decorator_list:
|
|
if not (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute)):
|
|
continue
|
|
verb = d.func.attr
|
|
owner = d.func.value.id if isinstance(d.func.value, ast.Name) else ""
|
|
path = d.args[0].value if d.args and isinstance(d.args[0], ast.Constant) else None
|
|
if path is None or (verb not in HTTP and verb != "api_route"):
|
|
continue
|
|
methods = [verb.upper()] if verb in HTTP else ["GET"]
|
|
for k in d.keywords:
|
|
if (
|
|
verb == "api_route"
|
|
and k.arg == "methods"
|
|
and isinstance(k.value, (ast.List, ast.Tuple))
|
|
):
|
|
methods = [e.value.upper() for e in k.value.elts if isinstance(e, ast.Constant)]
|
|
out += [{"method": m, "path": prefixes.get(owner, "") + path} for m in methods]
|
|
return out
|
|
|
|
|
|
def own_calls(fn: ast.AST):
|
|
"""fn 몸통의 부름 — 안쪽 def · class 몫은 빼고 lambda · 내포는 fn 몫."""
|
|
for child in ast.iter_child_nodes(fn):
|
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
continue
|
|
if isinstance(child, ast.Call):
|
|
yield child
|
|
yield from own_calls(child)
|
|
|
|
|
|
class Collector(ast.NodeVisitor):
|
|
def __init__(self, file: Path, tree: ast.Module, used: dict[str, int]):
|
|
self.file = rel(file)
|
|
self.imports = imports_of(file, tree)
|
|
self.prefixes = router_prefixes(tree)
|
|
self.used = used
|
|
self.stack: list[tuple[str, str]] = [] # (이름, "class" | "def")
|
|
self.records: list[dict] = []
|
|
self.raw_calls: dict[str, list[tuple[ast.expr, list[str], str | None]]] = {}
|
|
|
|
def _qual(self, name: str) -> str:
|
|
return ".".join([*(n for n, _ in self.stack), name])
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
self.stack.append((node.name, "class"))
|
|
self.generic_visit(node)
|
|
self.stack.pop()
|
|
|
|
def _fn(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
qual = self._qual(node.name)
|
|
rid = f"py:{self.file}#{qual}"
|
|
self.used[rid] = self.used.get(rid, 0) + 1
|
|
if self.used[rid] > 1:
|
|
rid = f"{rid}~{self.used[rid]}"
|
|
in_class = bool(self.stack) and self.stack[-1][1] == "class"
|
|
top = not self.stack or (in_class and len(self.stack) == 1)
|
|
rec = {
|
|
"id": rid,
|
|
"lang": "py",
|
|
"file": self.file,
|
|
"line": node.lineno,
|
|
"name": qual,
|
|
"kind": "method" if in_class else "function",
|
|
"exported": top and not node.name.startswith("_"),
|
|
"doc": first_line(node),
|
|
"params": params_of(node),
|
|
"returns": ast.unparse(node.returns) if node.returns else None,
|
|
"calls": [],
|
|
"called_by": [],
|
|
}
|
|
routes = routes_of(node, self.prefixes)
|
|
if routes:
|
|
rec["routes"] = routes
|
|
self.records.append(rec)
|
|
cls = self.stack[-1][0] if in_class else None
|
|
# 이름 찾는 차례 = 자기 안 → 감싼 def(안쪽부터) → 모듈(클래스 칸은 건너뜀 = 파이썬 규칙)
|
|
defs = [
|
|
".".join(n for n, _ in self.stack[: i + 1])
|
|
for i, (_, k) in enumerate(self.stack)
|
|
if k == "def"
|
|
]
|
|
scopes = [qual, *reversed(defs), ""]
|
|
self.raw_calls[rid] = [(c.func, scopes, cls) for c in own_calls(node)]
|
|
self.stack.append((node.name, "def"))
|
|
self.generic_visit(node)
|
|
self.stack.pop()
|
|
|
|
visit_FunctionDef = _fn
|
|
visit_AsyncFunctionDef = _fn
|
|
|
|
def resolve(
|
|
self, func: ast.expr, scopes: list[str], cls: str | None, ids: set[str]
|
|
) -> str | None:
|
|
def local(qual: str) -> str | None:
|
|
rid = f"py:{self.file}#{qual}"
|
|
return rid if rid in ids else None
|
|
|
|
def external(mod: str, name: str) -> str | None:
|
|
target = module_file(mod)
|
|
rid = f"py:{rel(target)}#{name}" if target else None
|
|
return rid if rid in ids else None
|
|
|
|
if isinstance(func, ast.Name):
|
|
for s in scopes:
|
|
hit = local(f"{s}.{func.id}" if s else func.id)
|
|
if hit:
|
|
return hit
|
|
if func.id in self.imports:
|
|
mod, name = self.imports[func.id]
|
|
return external(mod, name) if name else None
|
|
return None
|
|
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
base = func.value.id
|
|
if base in ("self", "cls") and cls:
|
|
return local(f"{cls}.{func.attr}")
|
|
if local(f"{base}.{func.attr}"):
|
|
return local(f"{base}.{func.attr}")
|
|
if base in self.imports:
|
|
mod, name = self.imports[base]
|
|
return (
|
|
external(mod, func.attr)
|
|
if name is None
|
|
else external(mod, f"{name}.{func.attr}")
|
|
)
|
|
return None
|
|
|
|
|
|
def main() -> None:
|
|
used: dict[str, int] = {}
|
|
collectors = []
|
|
for f in scope_files():
|
|
tree = ast.parse(f.read_text(encoding="utf-8"), filename=rel(f))
|
|
c = Collector(f, tree, used)
|
|
c.visit(tree)
|
|
collectors.append(c)
|
|
records = [r for c in collectors for r in c.records]
|
|
ids = {r["id"] for r in records}
|
|
index = {r["id"]: r for r in records}
|
|
for c in collectors:
|
|
for rid, calls in c.raw_calls.items():
|
|
hits = {c.resolve(func, scopes, cls, ids) for func, scopes, cls in calls}
|
|
index[rid]["calls"] = sorted(h for h in hits if h and h != rid)
|
|
for r in records:
|
|
for target in r["calls"]:
|
|
index[target]["called_by"].append(r["id"])
|
|
for r in records:
|
|
r["called_by"] = sorted(set(r["called_by"]))
|
|
records.sort(key=lambda r: r["id"])
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT.write_text(json.dumps(records, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
routes = sum(len(r.get("routes", [])) for r in records)
|
|
print(f"py: 파일 {len(collectors)} · 함수 {len(records)} · 라우트 데코레이터 {routes}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|