Files
Aislo/docs/tools/extract_api.py
T
eomsangdonandClaude Opus 5.5 9e315a8abb 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>
2026-10-19 18:53:44 +09:00

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()