feat(478-1 문서 추출): 추출 스크립트 셋 + JSON — ts-morph · ast · app.openapi() · 한 명령 extract_all
- 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>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""docs/tools/extract_api.py — 서버 주소 추출(PLAN 478-1 · FastAPI app.openapi()).
|
||||
|
||||
서버를 켜지 않고 `main.app` 을 불러 주소 목록을 얻고, TS 추출의 서버 주소 글자
|
||||
(`raw/ts_api_calls.json`)와 경로 패턴으로 짝지어 씀:
|
||||
api.json [{method, path, handler, callers[]}]
|
||||
— 범위 파이썬이 받거나 범위 TS 가 부르는 주소
|
||||
api_unmatched.json {ts_calls: 짝 없는 TS 글자,
|
||||
routes_without_caller: 범위 주소 중 부르는 TS 없음}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA = ROOT / "docs/_data"
|
||||
|
||||
|
||||
def handler_id(endpoint) -> str | None:
|
||||
fn = inspect.unwrap(endpoint)
|
||||
try:
|
||||
file = Path(inspect.getsourcefile(fn)).resolve().relative_to(ROOT).as_posix()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f"py:{file}#{fn.__qualname__.replace('.<locals>.', '.')}"
|
||||
|
||||
|
||||
def load_routes() -> list[dict]:
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
logging.disable(logging.CRITICAL)
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
import main
|
||||
|
||||
endpoints = {
|
||||
(m, r.path): r.endpoint
|
||||
for r in main.app.routes
|
||||
if isinstance(r, APIRoute)
|
||||
for m in r.methods
|
||||
}
|
||||
out = []
|
||||
for path, ops in main.app.openapi()["paths"].items():
|
||||
for method in ops:
|
||||
m = method.upper()
|
||||
ep = endpoints.get((m, path))
|
||||
out.append({"method": m, "path": path, "handler": handler_id(ep) if ep else None})
|
||||
return out
|
||||
|
||||
|
||||
def segments(path: str) -> list[str]:
|
||||
return [s for s in re.sub(r"^/api(?=/)", "", path).split("/") if s]
|
||||
|
||||
|
||||
def score(ts_path: str, route_path: str) -> int | None:
|
||||
"""맞으면 어긋남 수(작을수록 또렷) · 안 맞으면 None. TS `{}` = 아무 글자 · 서버 `{x}` = 칸."""
|
||||
a, b = segments(ts_path), segments(route_path)
|
||||
if len(a) != len(b):
|
||||
return None
|
||||
miss = 0
|
||||
for t, r in zip(a, b):
|
||||
param = r.startswith("{") and r.endswith("}")
|
||||
if "{}" in t:
|
||||
pat = "^" + ".*".join(re.escape(x) for x in t.split("{}")) + "$"
|
||||
if param:
|
||||
continue
|
||||
if not re.match(pat, r):
|
||||
return None
|
||||
miss += t == "{}" # 통째 `{}` 가 글자 칸을 먹으면 어긋남 하나
|
||||
elif param:
|
||||
miss += 1 # 서버 칸이 TS 글자를 먹음
|
||||
elif t != r:
|
||||
return None
|
||||
return miss
|
||||
|
||||
|
||||
def match(call: dict, routes: list[dict]) -> list[dict]:
|
||||
method = call["method"] # 글자 · "*"(변수 · 펼침 = 아무 메서드) · None(모름 = GET 먼저)
|
||||
if method == "*":
|
||||
pool = routes
|
||||
elif method:
|
||||
pool = [r for r in routes if r["method"] == method]
|
||||
else:
|
||||
pool = [r for r in routes if r["method"] == "GET"]
|
||||
scored = [(s, r) for r in pool if (s := score(call["path"], r["path"])) is not None]
|
||||
if not scored and not method:
|
||||
scored = [(s, r) for r in routes if (s := score(call["path"], r["path"])) is not None]
|
||||
if not scored:
|
||||
return []
|
||||
best = min(s for s, _ in scored)
|
||||
return [r for s, r in scored if s == best]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
routes = load_routes()
|
||||
ts_calls = json.loads((DATA / "raw/ts_api_calls.json").read_text(encoding="utf-8"))
|
||||
py_ids = {
|
||||
f["id"] for f in json.loads((DATA / "raw/functions_py.json").read_text(encoding="utf-8"))
|
||||
}
|
||||
callers: dict[tuple[str, str], set[str]] = {}
|
||||
unmatched = []
|
||||
for call in ts_calls:
|
||||
hits = match(call, routes)
|
||||
if not hits:
|
||||
unmatched.append(call)
|
||||
for r in hits:
|
||||
callers.setdefault((r["method"], r["path"]), set()).add(call["fn"])
|
||||
api = [
|
||||
{**r, "callers": sorted(callers.get((r["method"], r["path"]), ()))}
|
||||
for r in routes
|
||||
if r["handler"] in py_ids or (r["method"], r["path"]) in callers
|
||||
]
|
||||
api.sort(key=lambda r: (r["path"], r["method"]))
|
||||
lonely = [f"{r['method']} {r['path']}" for r in api if not r["callers"]]
|
||||
(DATA / "api.json").write_text(
|
||||
json.dumps(api, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
|
||||
)
|
||||
(DATA / "api_unmatched.json").write_text(
|
||||
json.dumps(
|
||||
{"ts_calls": unmatched, "routes_without_caller": lonely}, ensure_ascii=False, indent=1
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
f"api: 서버 주소 전체 {len(routes)} · 범위 {len(api)} · 짝 없는 TS 글자 {len(unmatched)} · "
|
||||
f"부르는 TS 없는 주소 {len(lonely)}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user