- 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>
139 lines
4.6 KiB
Python
139 lines
4.6 KiB
Python
"""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()
|