208 lines
7.7 KiB
Python
208 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
OUTPUT = ROOT / "config" / "architecture-inventory.json"
|
|
|
|
|
|
def relative(path: Path) -> str:
|
|
return path.relative_to(ROOT).as_posix()
|
|
|
|
|
|
def source(path: str) -> str:
|
|
return (ROOT / path).read_text(encoding="utf-8")
|
|
|
|
|
|
def page_inventory(html: str) -> list[dict[str, str]]:
|
|
items: list[dict[str, str]] = []
|
|
seen: set[str] = set()
|
|
pattern = re.compile(
|
|
r'<button[^>]+data-view="(?P<id>[^"]+)"[^>]+title="(?P<title>[^"]+)"',
|
|
re.IGNORECASE,
|
|
)
|
|
for match in pattern.finditer(html):
|
|
page_id = match.group("id")
|
|
if page_id in seen:
|
|
continue
|
|
seen.add(page_id)
|
|
items.append({"id": page_id, "title": match.group("title")})
|
|
return items
|
|
|
|
|
|
def api_inventory(server: str) -> dict[str, list[str]]:
|
|
routes = json.loads(source("config/api.config.json"))["routes"]
|
|
exact = sorted(
|
|
{item["path"] for item in routes if item["match"] == "exact"}
|
|
)
|
|
prefixes = sorted(
|
|
set(re.findall(r'parsed\.path\.startswith\(\s*"(/api/[^"]+)"', server))
|
|
)
|
|
patterns = sorted(
|
|
{item["path"] for item in routes if item["match"] == "regex"}
|
|
)
|
|
return {"exact": exact, "prefixes": prefixes, "patterns": patterns}
|
|
|
|
|
|
def database_inventory(sources: list[str]) -> list[str]:
|
|
tables: list[str] = []
|
|
for database in sources:
|
|
tables.extend(
|
|
re.findall(
|
|
r"CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)", database
|
|
)
|
|
)
|
|
return list(dict.fromkeys(tables))
|
|
|
|
|
|
def python_functions(path: str, prefixes: tuple[str, ...]) -> list[str]:
|
|
tree = ast.parse(source(path), filename=path)
|
|
result: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith(prefixes):
|
|
result.append(node.name)
|
|
return sorted(set(result))
|
|
|
|
|
|
def css_layers(html: str) -> list[str]:
|
|
return re.findall(r'<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"', html)
|
|
|
|
|
|
def source_metrics(path: Path) -> dict[str, int]:
|
|
text = path.read_text(encoding="utf-8")
|
|
return {
|
|
"bytes": len(text.encode("utf-8")),
|
|
"lines": len(text.splitlines()),
|
|
}
|
|
|
|
|
|
def code_hotspots() -> list[dict[str, Any]]:
|
|
candidates = [
|
|
"backend/application.py",
|
|
"database.py",
|
|
"backend/features/screener/engine.py",
|
|
"backend/features/market/insights.py",
|
|
"backend/data/providers/tushare_client.py",
|
|
"backend/features/heaven/service.py",
|
|
"backend/features/heaven/engine.py",
|
|
"frontend/index.html",
|
|
"frontend/app.js",
|
|
"frontend/styles/styles.css",
|
|
"frontend/styles/redesign-v2.css",
|
|
"frontend/styles/renovation.css",
|
|
"frontend/styles/theme.css",
|
|
"frontend/pages/heaven/page.css",
|
|
"frontend/pages/heaven/page.js",
|
|
"frontend/pages/market/runtime.js",
|
|
]
|
|
rows = []
|
|
for name in candidates:
|
|
path = ROOT / name
|
|
if not path.is_file():
|
|
continue
|
|
rows.append({"path": name, **source_metrics(path)})
|
|
return sorted(rows, key=lambda item: item["bytes"], reverse=True)
|
|
|
|
|
|
def build() -> dict[str, Any]:
|
|
html = source("frontend/index.html")
|
|
server = source("backend/application.py")
|
|
database_sources = [source("database.py")]
|
|
database_sources.extend(
|
|
path.read_text(encoding="utf-8")
|
|
for path in sorted((ROOT / "backend" / "database" / "migrations").glob("m*.py"))
|
|
)
|
|
database_sources.append(
|
|
source("backend/database/migrations/runner.py")
|
|
)
|
|
pages = page_inventory(html)
|
|
api = api_inventory(server)
|
|
tables = database_inventory(database_sources)
|
|
return {
|
|
"schema_version": 1,
|
|
"captured_from": "app accepted modular runtime",
|
|
"runtime": {
|
|
"http_server": "http.server.ThreadingHTTPServer",
|
|
"application_processes": 1,
|
|
"database": "SQLite WAL",
|
|
"frontend": "build-free HTML/CSS/JavaScript",
|
|
"container_port": 8765,
|
|
},
|
|
"counts": {
|
|
"primary_pages": len(pages),
|
|
"api_exact_paths": len(api["exact"]),
|
|
"api_prefixes": len(api["prefixes"]),
|
|
"api_patterns": len(api["patterns"]),
|
|
"database_tables": len(tables),
|
|
},
|
|
"pages": pages,
|
|
"api": api,
|
|
"database_tables": tables,
|
|
"background_job_methods": python_functions(
|
|
"backend/application.py",
|
|
("_background", "_run_background", "_schedule_", "run_automatic"),
|
|
),
|
|
"external_data_adapters": [
|
|
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "primary deterministic market data"},
|
|
{"provider": "ifind", "path": "backend/data/providers/ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
|
|
{"provider": "eastmoney", "path": "backend/features/market/charts.py", "runtime_role": "display chart fallback"},
|
|
{"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation"},
|
|
{"provider": "tencent", "path": "backend/data/realtime.py", "runtime_role": "index observation fallback"},
|
|
],
|
|
"provider_construction": [
|
|
{"client": "TushareClient", "owner": "backend/data/providers/tushare.py", "compatibility_fallback": "backend/features/market/service.py"},
|
|
{"client": "IfindHttpClient", "owner": "backend/data/gateway.py"},
|
|
{"client": "MarketChartClient", "owner": "backend/data/gateway.py"},
|
|
{"client": "WebRealtimeAggregator", "owner": "backend/data/gateway.py"},
|
|
],
|
|
"numeric_normalization": [
|
|
{"function": "finite_number", "path": "backend/data/numbers.py"},
|
|
{"function": "non_nan_number", "path": "backend/data/numbers.py"},
|
|
],
|
|
"date_formatting": [
|
|
{"function": "display_compact_date", "path": "backend/bootstrap/config.py"},
|
|
],
|
|
"llm_entrypoints": [
|
|
{"function": "stream_with_mentor", "path": "backend/features/mentor/agent.py"},
|
|
{"function": "interpret_heaven", "path": "backend/features/heaven/agent.py"},
|
|
{"function": "stream_review_assistant", "path": "backend/features/review/agent.py"},
|
|
{"function": "compile_strategy_with_llm", "path": "backend/features/screener/compiler.py"},
|
|
{"function": "test_llm_connection", "path": "backend/features/screener/compiler.py"},
|
|
],
|
|
"llm_transport": [
|
|
{"function": "chat_completion", "path": "backend/llm/transport.py"},
|
|
{"function": "stream_chat_completion", "path": "backend/llm/transport.py"},
|
|
],
|
|
"css_layers": css_layers(html),
|
|
"code_hotspots": code_hotspots(),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Build the current architecture inventory")
|
|
parser.add_argument("--check", action="store_true", help="fail when the committed inventory is stale")
|
|
args = parser.parse_args()
|
|
rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
|
if args.check:
|
|
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
|
|
raise SystemExit(
|
|
"architecture inventory is stale; run "
|
|
"tools/build_architecture_inventory.py"
|
|
)
|
|
print("Architecture inventory is current.")
|
|
return 0
|
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUTPUT.write_text(rendered, encoding="utf-8")
|
|
print(relative(OUTPUT))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|