codeabc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""API-endpoint map: extract HTTP routes from web-framework source code.
|
|
2
|
+
|
|
3
|
+
A non-programmer reviewing a backend — a founder auditing an outsourced API,
|
|
4
|
+
say — wants a single answer to "what does this service actually expose?". The
|
|
5
|
+
import graph shows what imports what; this answers the complementary question:
|
|
6
|
+
which URL paths exist, what HTTP methods they accept, and what they do.
|
|
7
|
+
|
|
8
|
+
Supports the most common frameworks by scanning for their decorator or function
|
|
9
|
+
patterns:
|
|
10
|
+
|
|
11
|
+
FastAPI / APIRouter @router.get / .post / .put / .delete / .patch / .options
|
|
12
|
+
Flask / Blueprint @app.route / @bp.route (methods= list)
|
|
13
|
+
Django urlpatterns = [path("...", view, name="...")]
|
|
14
|
+
Express (JS) app.get / app.post / router.get / etc.
|
|
15
|
+
Next.js (App Router) src/app/**/route.ts|js — export async function GET|POST|...
|
|
16
|
+
|
|
17
|
+
Each route entry carries method, path, handler name, brief description (from
|
|
18
|
+
the function's docstring if one is present), file, and line number.
|
|
19
|
+
|
|
20
|
+
Limitations:
|
|
21
|
+
* Patterns are regex-based: computed paths (f-strings, concatenation,
|
|
22
|
+
variables) may be missed or mis-parsed.
|
|
23
|
+
* Dynamic prefix mounting (app.include_router with prefix=, app.register_blueprint
|
|
24
|
+
with url_prefix=) is detected and annotated, but full prefix expansion would
|
|
25
|
+
require data-flow analysis that's out of scope.
|
|
26
|
+
* Results are best-effort — treat them as a navigational index, not a
|
|
27
|
+
definitive spec.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Shared helpers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
_QUOTE_CONTENT = r"""['"]([^'"]+)['"]""" # a quoted string, captured
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lang(path: str) -> str:
|
|
42
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
43
|
+
if ext in ("py",):
|
|
44
|
+
return "python"
|
|
45
|
+
if ext in ("js", "ts", "jsx", "tsx", "mjs", "cjs"):
|
|
46
|
+
return "js"
|
|
47
|
+
return ext
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# FastAPI / Flask / Django (Python)
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
# @router.<method>("/<path>") or @app.<method>("/<path>")
|
|
55
|
+
_FASTAPI_DECORATOR_RE = re.compile(
|
|
56
|
+
r"""@[\w.]+\.(get|post|put|delete|patch|options|head)\s*\("""
|
|
57
|
+
r"""\s*""" + _QUOTE_CONTENT,
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# @app.route("/path", methods=["GET", "POST"]) or @bp.route("/path")
|
|
62
|
+
_FLASK_ROUTE_RE = re.compile(
|
|
63
|
+
r"""@[\w.]+\.route\s*\(\s*""" + _QUOTE_CONTENT + r"""[^)]*\)""",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
_FLASK_METHODS_RE = re.compile(r"""methods\s*=\s*\[([^\]]+)\]""", re.IGNORECASE)
|
|
67
|
+
|
|
68
|
+
# def function_name(...): immediately following decorator
|
|
69
|
+
_PY_FUNC_RE = re.compile(r"""^\s*(?:async\s+)?def\s+(\w+)\s*\(""")
|
|
70
|
+
# docstring: the first string literal after the def line
|
|
71
|
+
_DOCSTRING_RE = re.compile(r"""^\s+['"]{3}(.*?)(?:['"]{3}|$)|^\s+['"]([^'"]+)['"]""")
|
|
72
|
+
|
|
73
|
+
# Django: path("...", view_func, name="...")
|
|
74
|
+
_DJANGO_PATH_RE = re.compile(
|
|
75
|
+
r"""(?:path|re_path|url)\s*\(\s*""" + _QUOTE_CONTENT + r"""\s*,\s*(\w[\w.]*)\s*""",
|
|
76
|
+
re.IGNORECASE,
|
|
77
|
+
)
|
|
78
|
+
# include_router / register_blueprint prefix detection
|
|
79
|
+
_PY_PREFIX_RE = re.compile(
|
|
80
|
+
r"""(?:include_router|register_blueprint)\s*\([^)]*prefix\s*=\s*""" + _QUOTE_CONTENT,
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _py_routes(path: str, content: str) -> list[dict]:
|
|
86
|
+
"""Extract routes from a Python file (FastAPI, Flask, Django)."""
|
|
87
|
+
routes: list[dict] = []
|
|
88
|
+
lines = content.splitlines()
|
|
89
|
+
n = len(lines)
|
|
90
|
+
prefix_hints: list[str] = []
|
|
91
|
+
|
|
92
|
+
for m in _PY_PREFIX_RE.finditer(content):
|
|
93
|
+
prefix_hints.append(m.group(1))
|
|
94
|
+
|
|
95
|
+
i = 0
|
|
96
|
+
while i < n:
|
|
97
|
+
line = lines[i]
|
|
98
|
+
|
|
99
|
+
# FastAPI-style: @router.get("/path")
|
|
100
|
+
fm = _FASTAPI_DECORATOR_RE.search(line)
|
|
101
|
+
if fm:
|
|
102
|
+
method = fm.group(1).upper()
|
|
103
|
+
route_path = fm.group(2)
|
|
104
|
+
handler = ""
|
|
105
|
+
description = ""
|
|
106
|
+
# look ahead for the def
|
|
107
|
+
for j in range(i + 1, min(i + 5, n)):
|
|
108
|
+
dm = _PY_FUNC_RE.match(lines[j])
|
|
109
|
+
if dm:
|
|
110
|
+
handler = dm.group(1)
|
|
111
|
+
# try to grab docstring
|
|
112
|
+
if j + 1 < n:
|
|
113
|
+
dsm = _DOCSTRING_RE.match(lines[j + 1])
|
|
114
|
+
if dsm:
|
|
115
|
+
description = (dsm.group(1) or dsm.group(2) or "").strip()
|
|
116
|
+
break
|
|
117
|
+
routes.append(
|
|
118
|
+
{
|
|
119
|
+
"method": method,
|
|
120
|
+
"path": route_path,
|
|
121
|
+
"handler": handler,
|
|
122
|
+
"description": description,
|
|
123
|
+
"file": path,
|
|
124
|
+
"line": i + 1,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
i += 1
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Flask-style: @app.route("/path", methods=["GET", "POST"])
|
|
131
|
+
flask_m = _FLASK_ROUTE_RE.search(line)
|
|
132
|
+
if flask_m:
|
|
133
|
+
route_path = flask_m.group(1)
|
|
134
|
+
raw = line[flask_m.start() :]
|
|
135
|
+
mm = _FLASK_METHODS_RE.search(raw)
|
|
136
|
+
if mm:
|
|
137
|
+
methods = [
|
|
138
|
+
m.strip().strip("'\"").upper()
|
|
139
|
+
for m in mm.group(1).split(",")
|
|
140
|
+
if m.strip().strip("'\"")
|
|
141
|
+
]
|
|
142
|
+
else:
|
|
143
|
+
methods = ["GET"]
|
|
144
|
+
handler = ""
|
|
145
|
+
description = ""
|
|
146
|
+
for j in range(i + 1, min(i + 5, n)):
|
|
147
|
+
dm = _PY_FUNC_RE.match(lines[j])
|
|
148
|
+
if dm:
|
|
149
|
+
handler = dm.group(1)
|
|
150
|
+
if j + 1 < n:
|
|
151
|
+
dsm = _DOCSTRING_RE.match(lines[j + 1])
|
|
152
|
+
if dsm:
|
|
153
|
+
description = (dsm.group(1) or dsm.group(2) or "").strip()
|
|
154
|
+
break
|
|
155
|
+
for method in methods:
|
|
156
|
+
routes.append(
|
|
157
|
+
{
|
|
158
|
+
"method": method,
|
|
159
|
+
"path": route_path,
|
|
160
|
+
"handler": handler,
|
|
161
|
+
"description": description,
|
|
162
|
+
"file": path,
|
|
163
|
+
"line": i + 1,
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
i += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Django: path("...", view_func)
|
|
170
|
+
dj_m = _DJANGO_PATH_RE.search(line)
|
|
171
|
+
if dj_m:
|
|
172
|
+
route_path = dj_m.group(1)
|
|
173
|
+
handler = dj_m.group(2)
|
|
174
|
+
routes.append(
|
|
175
|
+
{
|
|
176
|
+
"method": "ANY",
|
|
177
|
+
"path": route_path,
|
|
178
|
+
"handler": handler,
|
|
179
|
+
"description": "",
|
|
180
|
+
"file": path,
|
|
181
|
+
"line": i + 1,
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
i += 1
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
i += 1
|
|
188
|
+
|
|
189
|
+
# Annotate with prefix hints if found.
|
|
190
|
+
if prefix_hints and routes:
|
|
191
|
+
for r in routes:
|
|
192
|
+
if not r.get("description"):
|
|
193
|
+
r["description"] = f"(router mounted with prefix: {prefix_hints[0]})"
|
|
194
|
+
|
|
195
|
+
return routes
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Express / Next.js (JavaScript / TypeScript)
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
# app.get("/path", handler) or router.post("/path", ...)
|
|
203
|
+
_EXPRESS_ROUTE_RE = re.compile(
|
|
204
|
+
r"""(?:app|router)\.(get|post|put|delete|patch|options|head|all)\s*\(\s*""" + _QUOTE_CONTENT,
|
|
205
|
+
re.IGNORECASE,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Next.js App Router: export (async) function GET/POST/PUT/DELETE/PATCH
|
|
209
|
+
_NEXTJS_HANDLER_RE = re.compile(
|
|
210
|
+
r"""export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s*\(""",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Next.js route file pattern: app/**/route.(ts|js)
|
|
214
|
+
_NEXTJS_ROUTE_FILE_RE = re.compile(r"""(?:^|/)route\.[jt]sx?$""")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _js_routes(path: str, content: str) -> list[dict]:
|
|
218
|
+
"""Extract routes from a JS/TS file (Express, Next.js)."""
|
|
219
|
+
routes: list[dict] = []
|
|
220
|
+
lines = content.splitlines()
|
|
221
|
+
|
|
222
|
+
is_nextjs_route = bool(_NEXTJS_ROUTE_FILE_RE.search(path.replace("\\", "/")))
|
|
223
|
+
|
|
224
|
+
for i, line in enumerate(lines):
|
|
225
|
+
if is_nextjs_route:
|
|
226
|
+
nm = _NEXTJS_HANDLER_RE.search(line)
|
|
227
|
+
if nm:
|
|
228
|
+
method = nm.group(1).upper()
|
|
229
|
+
# Derive the URL path from the file path:
|
|
230
|
+
# src/app/users/[id]/route.ts → /users/[id]
|
|
231
|
+
fp = path.replace("\\", "/")
|
|
232
|
+
# strip leading everything up to "app/"
|
|
233
|
+
app_idx = fp.find("/app/")
|
|
234
|
+
if app_idx >= 0:
|
|
235
|
+
rel = fp[app_idx + 5 :] # after /app/
|
|
236
|
+
# drop trailing /route.ts
|
|
237
|
+
rel = re.sub(r"/route\.[jt]sx?$", "", rel)
|
|
238
|
+
route_path = "/" + rel if rel else "/"
|
|
239
|
+
else:
|
|
240
|
+
route_path = "/" + fp.rsplit("/", 1)[-1].split(".")[0]
|
|
241
|
+
routes.append(
|
|
242
|
+
{
|
|
243
|
+
"method": method,
|
|
244
|
+
"path": route_path,
|
|
245
|
+
"handler": f"export function {method}",
|
|
246
|
+
"description": "",
|
|
247
|
+
"file": path,
|
|
248
|
+
"line": i + 1,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
em = _EXPRESS_ROUTE_RE.search(line)
|
|
254
|
+
if em:
|
|
255
|
+
method = em.group(1).upper()
|
|
256
|
+
route_path = em.group(2)
|
|
257
|
+
routes.append(
|
|
258
|
+
{
|
|
259
|
+
"method": method,
|
|
260
|
+
"path": route_path,
|
|
261
|
+
"handler": "",
|
|
262
|
+
"description": "",
|
|
263
|
+
"file": path,
|
|
264
|
+
"line": i + 1,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return routes
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Public API
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
_METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ANY", "ALL"]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def scan_api_routes(file_contents: dict[str, str], *, limit: int = 80) -> dict:
|
|
279
|
+
"""Scan file contents for HTTP route declarations.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
file_contents: mapping of path to file text.
|
|
283
|
+
limit: cap on total routes returned.
|
|
284
|
+
|
|
285
|
+
Returns::
|
|
286
|
+
|
|
287
|
+
{
|
|
288
|
+
"total": int,
|
|
289
|
+
"routes": [
|
|
290
|
+
{
|
|
291
|
+
"method": str, # GET / POST / … / ANY / ALL
|
|
292
|
+
"path": str, # route path string as declared
|
|
293
|
+
"handler": str, # handler function/class name
|
|
294
|
+
"description": str,
|
|
295
|
+
"file": str,
|
|
296
|
+
"line": int,
|
|
297
|
+
}, …
|
|
298
|
+
],
|
|
299
|
+
"frameworks": [str, …], # detected framework names
|
|
300
|
+
"notes": [str, …],
|
|
301
|
+
}
|
|
302
|
+
"""
|
|
303
|
+
all_routes: list[dict] = []
|
|
304
|
+
frameworks: set[str] = set()
|
|
305
|
+
|
|
306
|
+
for path, content in file_contents.items():
|
|
307
|
+
if not content:
|
|
308
|
+
continue
|
|
309
|
+
lang_id = _lang(path)
|
|
310
|
+
if lang_id == "python":
|
|
311
|
+
routes = _py_routes(path, content)
|
|
312
|
+
if routes:
|
|
313
|
+
# guess framework from imports in file
|
|
314
|
+
if re.search(r"\bfastapi\b", content, re.IGNORECASE):
|
|
315
|
+
frameworks.add("FastAPI")
|
|
316
|
+
elif re.search(r"\bflask\b", content, re.IGNORECASE):
|
|
317
|
+
frameworks.add("Flask")
|
|
318
|
+
elif re.search(r"\bdjango\b", content, re.IGNORECASE):
|
|
319
|
+
frameworks.add("Django")
|
|
320
|
+
else:
|
|
321
|
+
frameworks.add("Python HTTP")
|
|
322
|
+
all_routes.extend(routes)
|
|
323
|
+
elif lang_id == "js":
|
|
324
|
+
routes = _js_routes(path, content)
|
|
325
|
+
if routes:
|
|
326
|
+
if _NEXTJS_ROUTE_FILE_RE.search(path.replace("\\", "/")):
|
|
327
|
+
frameworks.add("Next.js")
|
|
328
|
+
elif re.search(r"\bexpress\b", content, re.IGNORECASE):
|
|
329
|
+
frameworks.add("Express")
|
|
330
|
+
else:
|
|
331
|
+
frameworks.add("Node HTTP")
|
|
332
|
+
all_routes.extend(routes)
|
|
333
|
+
|
|
334
|
+
total = len(all_routes)
|
|
335
|
+
|
|
336
|
+
# Sort: by path, then method order, then file+line.
|
|
337
|
+
_method_idx = {m: i for i, m in enumerate(_METHOD_ORDER)}
|
|
338
|
+
all_routes.sort(
|
|
339
|
+
key=lambda r: (r["path"], _method_idx.get(r["method"], 99), r["file"], r["line"])
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
notes = _build_notes(total, list(frameworks))
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
"total": total,
|
|
346
|
+
"routes": all_routes[:limit],
|
|
347
|
+
"frameworks": sorted(frameworks),
|
|
348
|
+
"notes": notes,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _build_notes(total: int, frameworks: list[str]) -> list[str]:
|
|
353
|
+
if total == 0:
|
|
354
|
+
return ["未检测到已知 HTTP 框架的路由声明(FastAPI/Flask/Django/Express/Next.js)。"]
|
|
355
|
+
notes = []
|
|
356
|
+
if frameworks:
|
|
357
|
+
notes.append(f"检测到框架:{', '.join(frameworks)}。")
|
|
358
|
+
notes.append(
|
|
359
|
+
f"共发现 {total} 条路由声明。"
|
|
360
|
+
"动态生成的路径(变量、f-string 拼接)可能未被捕获,建议结合框架文档核查。"
|
|
361
|
+
)
|
|
362
|
+
return notes
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def render_apimap_markdown(project_name: str, data: dict | None) -> str:
|
|
366
|
+
"""Render the API route map as a Markdown section, or ``""`` if empty."""
|
|
367
|
+
if not data or not data.get("routes"):
|
|
368
|
+
return ""
|
|
369
|
+
total = data.get("total", 0)
|
|
370
|
+
frameworks = data.get("frameworks", [])
|
|
371
|
+
fw_str = f"({', '.join(frameworks)})" if frameworks else ""
|
|
372
|
+
lines = [
|
|
373
|
+
f"# HTTP 接口地图{fw_str}({project_name})",
|
|
374
|
+
"",
|
|
375
|
+
f"> 共发现 {total} 条路由声明。 动态路径可能未完整捕获,仅供导航参考。",
|
|
376
|
+
"",
|
|
377
|
+
]
|
|
378
|
+
lines.extend(f"- {note}" for note in data.get("notes", []))
|
|
379
|
+
|
|
380
|
+
lines.append("")
|
|
381
|
+
lines.append("## 路由清单")
|
|
382
|
+
lines.append("")
|
|
383
|
+
lines.append("| 方法 | 路径 | 处理函数 | 说明 | 文件 |")
|
|
384
|
+
lines.append("|------|------|----------|------|------|")
|
|
385
|
+
for r in data.get("routes", []):
|
|
386
|
+
handler = r.get("handler") or "—"
|
|
387
|
+
desc = r.get("description") or "—"
|
|
388
|
+
file_link = f"`{r['file']}:{r['line']}`"
|
|
389
|
+
lines.append(f"| `{r['method']}` | `{r['path']}` | `{handler}` | {desc} | {file_link} |")
|
|
390
|
+
|
|
391
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Async surface: which modules run on asyncio, and how much of each is async.
|
|
2
|
+
|
|
3
|
+
Whether a codebase is async — and which parts — changes how you call into it: an
|
|
4
|
+
``async def`` has to be awaited or driven by an event loop, and dropping a
|
|
5
|
+
blocking call into an async path is a classic hang. This maps the async surface
|
|
6
|
+
so a newcomer sees at a glance whether they are in an asyncio codebase and where
|
|
7
|
+
the coroutines live, instead of finding out the first time an ``await`` is
|
|
8
|
+
missing.
|
|
9
|
+
|
|
10
|
+
A module is "async" when it defines at least one ``async def`` (counted anywhere
|
|
11
|
+
in the file, including nested helpers, since an inner coroutine still pulls its
|
|
12
|
+
caller onto the event loop). :func:`scan_async_surface` is pure over the file
|
|
13
|
+
contents (standard-library ``ast``); non-Python, test, and files that don't parse
|
|
14
|
+
are skipped.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_test_path(path: str) -> bool:
|
|
23
|
+
"""Async test helpers are noise for a "how is this project built" map, so tests drop out."""
|
|
24
|
+
lower = path.lower()
|
|
25
|
+
base = lower.rsplit("/", 1)[-1]
|
|
26
|
+
return (
|
|
27
|
+
"/tests/" in lower
|
|
28
|
+
or "/test/" in lower
|
|
29
|
+
or base.startswith("test_")
|
|
30
|
+
or base.endswith("_test.py")
|
|
31
|
+
or base == "conftest.py"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _count_defs(tree: ast.Module) -> tuple[int, int]:
|
|
36
|
+
"""Return (async_defs, sync_defs) for a parsed module, counting nested defs too."""
|
|
37
|
+
async_defs = 0
|
|
38
|
+
sync_defs = 0
|
|
39
|
+
for node in ast.walk(tree):
|
|
40
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
41
|
+
async_defs += 1
|
|
42
|
+
elif isinstance(node, ast.FunctionDef):
|
|
43
|
+
sync_defs += 1
|
|
44
|
+
return async_defs, sync_defs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_async_surface(file_contents: dict[str, str], *, limit: int = 15) -> dict:
|
|
48
|
+
"""Map how much of the project is asynchronous, and where the coroutines live.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
52
|
+
limit: how many of the most-async files to return.
|
|
53
|
+
|
|
54
|
+
Returns ``{"async_functions", "total_functions", "async_modules", "files"}``
|
|
55
|
+
where ``async_modules`` is the count of files that define any coroutine and
|
|
56
|
+
``files`` are the modules with the most ``async def``s (descending), each
|
|
57
|
+
``{"path", "async_defs", "sync_defs"}``.
|
|
58
|
+
"""
|
|
59
|
+
async_functions = 0
|
|
60
|
+
total_functions = 0
|
|
61
|
+
async_modules = 0
|
|
62
|
+
scored: list[dict] = []
|
|
63
|
+
|
|
64
|
+
for path, content in file_contents.items():
|
|
65
|
+
if not content or not path.endswith(".py") or _is_test_path(path):
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
tree = ast.parse(content)
|
|
69
|
+
except (SyntaxError, ValueError):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
async_defs, sync_defs = _count_defs(tree)
|
|
73
|
+
total_functions += async_defs + sync_defs
|
|
74
|
+
if async_defs == 0:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
async_functions += async_defs
|
|
78
|
+
async_modules += 1
|
|
79
|
+
scored.append({"path": path, "async_defs": async_defs, "sync_defs": sync_defs})
|
|
80
|
+
|
|
81
|
+
# Most coroutines first, then path for a stable order.
|
|
82
|
+
scored.sort(key=lambda f: (-f["async_defs"], f["path"]))
|
|
83
|
+
return {
|
|
84
|
+
"async_functions": async_functions,
|
|
85
|
+
"total_functions": total_functions,
|
|
86
|
+
"async_modules": async_modules,
|
|
87
|
+
"files": scored[:limit],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_async_surface_markdown(project_name: str, data: dict | None) -> str:
|
|
92
|
+
"""Render the async-surface map as Markdown, or ``""`` when the project has no coroutines."""
|
|
93
|
+
data = data or {}
|
|
94
|
+
async_functions = data.get("async_functions") or 0
|
|
95
|
+
if not async_functions:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
total = data.get("total_functions") or 0
|
|
99
|
+
modules = data.get("async_modules") or 0
|
|
100
|
+
files = data.get("files") or []
|
|
101
|
+
share = round(async_functions / total * 100) if total else 0
|
|
102
|
+
|
|
103
|
+
lines = [
|
|
104
|
+
f"# {project_name} — 异步面(哪些模块跑在 asyncio 上)",
|
|
105
|
+
"",
|
|
106
|
+
"> 一个项目是不是异步的、哪些部分是异步的,直接决定你怎么调用它:"
|
|
107
|
+
"`async def` 必须被 `await` 或事件循环驱动,往异步路径里塞一个阻塞调用是经典的卡死原因。"
|
|
108
|
+
"这里标出协程都在哪,方便新人一眼看出这是不是个 asyncio 代码库、异步逻辑集中在哪。"
|
|
109
|
+
"(只要文件里有一个 `async def` 就算异步模块,嵌套的协程也计入。)",
|
|
110
|
+
"",
|
|
111
|
+
f"**{async_functions} 个 async 函数**分布在 **{modules} 个模块**里"
|
|
112
|
+
f"(占全部函数的约 {share}%)。",
|
|
113
|
+
"",
|
|
114
|
+
]
|
|
115
|
+
if files:
|
|
116
|
+
lines.append("异步逻辑最集中的文件:")
|
|
117
|
+
lines.append("")
|
|
118
|
+
for f in files:
|
|
119
|
+
lines.append(
|
|
120
|
+
f"- `{f['path']}` — {f['async_defs']} 个 async 函数"
|
|
121
|
+
f"(另有 {f['sync_defs']} 个同步函数)"
|
|
122
|
+
)
|
|
123
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Simple SQLite cache for LLM results, keyed by content hash."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import aiosqlite
|
|
11
|
+
|
|
12
|
+
_DB_PATH = Path.home() / ".codeabc" / "cache.db"
|
|
13
|
+
_db: aiosqlite.Connection | None = None
|
|
14
|
+
|
|
15
|
+
# cache entries expire after 7 days
|
|
16
|
+
_TTL_SECONDS = 7 * 24 * 3600
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def init_db():
|
|
20
|
+
global _db
|
|
21
|
+
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
_db = await aiosqlite.connect(str(_DB_PATH))
|
|
23
|
+
await _db.execute("""
|
|
24
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
25
|
+
key TEXT PRIMARY KEY,
|
|
26
|
+
value TEXT NOT NULL,
|
|
27
|
+
created_at REAL NOT NULL
|
|
28
|
+
)
|
|
29
|
+
""")
|
|
30
|
+
# projects table: persist project metadata + file contents across restarts
|
|
31
|
+
await _db.execute("""
|
|
32
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
data TEXT NOT NULL,
|
|
35
|
+
created_at REAL NOT NULL
|
|
36
|
+
)
|
|
37
|
+
""")
|
|
38
|
+
# rate limiting: daily LLM call count per IP
|
|
39
|
+
await _db.execute("""
|
|
40
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
41
|
+
ip TEXT NOT NULL,
|
|
42
|
+
date TEXT NOT NULL,
|
|
43
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
PRIMARY KEY (ip, date)
|
|
45
|
+
)
|
|
46
|
+
""")
|
|
47
|
+
await _db.commit()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def content_hash(content: str) -> str:
|
|
51
|
+
return hashlib.sha256(content.encode()).hexdigest()[:24]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def get(key: str) -> dict | list | None:
|
|
55
|
+
if _db is None:
|
|
56
|
+
return None
|
|
57
|
+
async with _db.execute("SELECT value, created_at FROM cache WHERE key = ?", (key,)) as cursor:
|
|
58
|
+
row = await cursor.fetchone()
|
|
59
|
+
if row is None:
|
|
60
|
+
return None
|
|
61
|
+
value, created_at = row
|
|
62
|
+
if time.time() - created_at > _TTL_SECONDS:
|
|
63
|
+
await _db.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
64
|
+
await _db.commit()
|
|
65
|
+
return None
|
|
66
|
+
return json.loads(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def put(key: str, value: dict | list):
|
|
70
|
+
if _db is None:
|
|
71
|
+
return
|
|
72
|
+
data = json.dumps(value, ensure_ascii=False)
|
|
73
|
+
await _db.execute(
|
|
74
|
+
"INSERT OR REPLACE INTO cache (key, value, created_at) VALUES (?, ?, ?)",
|
|
75
|
+
(key, data, time.time()),
|
|
76
|
+
)
|
|
77
|
+
await _db.commit()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def save_project(project_id: str, data: dict):
|
|
81
|
+
"""Persist project data (metadata + file contents) to SQLite."""
|
|
82
|
+
if _db is None:
|
|
83
|
+
return
|
|
84
|
+
payload = json.dumps(data, ensure_ascii=False)
|
|
85
|
+
await _db.execute(
|
|
86
|
+
"INSERT OR REPLACE INTO projects (id, data, created_at) VALUES (?, ?, ?)",
|
|
87
|
+
(project_id, payload, time.time()),
|
|
88
|
+
)
|
|
89
|
+
await _db.commit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_DAILY_LIMIT = 20
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def check_rate_limit(ip: str) -> tuple[bool, int]:
|
|
96
|
+
"""Check if IP is within daily free limit.
|
|
97
|
+
|
|
98
|
+
Returns (allowed, remaining).
|
|
99
|
+
"""
|
|
100
|
+
if _db is None:
|
|
101
|
+
return True, _DAILY_LIMIT
|
|
102
|
+
|
|
103
|
+
today = time.strftime("%Y-%m-%d")
|
|
104
|
+
async with _db.execute(
|
|
105
|
+
"SELECT count FROM rate_limits WHERE ip = ? AND date = ?", (ip, today)
|
|
106
|
+
) as cursor:
|
|
107
|
+
row = await cursor.fetchone()
|
|
108
|
+
|
|
109
|
+
current = row[0] if row else 0
|
|
110
|
+
remaining = max(0, _DAILY_LIMIT - current)
|
|
111
|
+
return current < _DAILY_LIMIT, remaining
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def increment_rate_limit(ip: str):
|
|
115
|
+
"""Bump the daily usage counter for an IP."""
|
|
116
|
+
if _db is None:
|
|
117
|
+
return
|
|
118
|
+
today = time.strftime("%Y-%m-%d")
|
|
119
|
+
await _db.execute(
|
|
120
|
+
"""INSERT INTO rate_limits (ip, date, count) VALUES (?, ?, 1)
|
|
121
|
+
ON CONFLICT (ip, date) DO UPDATE SET count = count + 1""",
|
|
122
|
+
(ip, today),
|
|
123
|
+
)
|
|
124
|
+
await _db.commit()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def load_project(project_id: str) -> dict | None:
|
|
128
|
+
"""Load project data from SQLite. Returns None if not found or expired."""
|
|
129
|
+
if _db is None:
|
|
130
|
+
return None
|
|
131
|
+
async with _db.execute(
|
|
132
|
+
"SELECT data, created_at FROM projects WHERE id = ?", (project_id,)
|
|
133
|
+
) as cursor:
|
|
134
|
+
row = await cursor.fetchone()
|
|
135
|
+
if row is None:
|
|
136
|
+
return None
|
|
137
|
+
data, created_at = row
|
|
138
|
+
if time.time() - created_at > _TTL_SECONDS:
|
|
139
|
+
await _db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
|
140
|
+
await _db.commit()
|
|
141
|
+
return None
|
|
142
|
+
return json.loads(data)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def list_projects(limit: int = 50) -> list[dict]:
|
|
146
|
+
"""List stored projects, newest first, for the on-disk library view.
|
|
147
|
+
|
|
148
|
+
Returns lightweight entries (id, name, total_files, created_at) so a user
|
|
149
|
+
can reopen a past analysis without re-scanning. The heavy file contents stay
|
|
150
|
+
in the row and are only read back when a project is actually opened via
|
|
151
|
+
load_project(). Expired rows are skipped (and purged lazily, like get()).
|
|
152
|
+
"""
|
|
153
|
+
if _db is None:
|
|
154
|
+
return []
|
|
155
|
+
async with _db.execute(
|
|
156
|
+
"SELECT id, data, created_at FROM projects ORDER BY created_at DESC"
|
|
157
|
+
) as cursor:
|
|
158
|
+
rows = await cursor.fetchall()
|
|
159
|
+
now = time.time()
|
|
160
|
+
expired: list[str] = []
|
|
161
|
+
projects: list[dict] = []
|
|
162
|
+
for project_id, data, created_at in rows:
|
|
163
|
+
if now - created_at > _TTL_SECONDS:
|
|
164
|
+
expired.append(project_id)
|
|
165
|
+
continue
|
|
166
|
+
if len(projects) >= limit:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
parsed = json.loads(data)
|
|
170
|
+
except (json.JSONDecodeError, ValueError):
|
|
171
|
+
continue
|
|
172
|
+
projects.append(
|
|
173
|
+
{
|
|
174
|
+
"id": project_id,
|
|
175
|
+
"name": parsed.get("name") or project_id,
|
|
176
|
+
"total_files": len(parsed.get("files") or []),
|
|
177
|
+
"created_at": created_at,
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
if expired:
|
|
181
|
+
await _db.executemany("DELETE FROM projects WHERE id = ?", [(pid,) for pid in expired])
|
|
182
|
+
await _db.commit()
|
|
183
|
+
return projects
|