quickast 0.1.2__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.
- quickast/__init__.py +3 -0
- quickast/cli.py +353 -0
- quickast/db.py +30 -0
- quickast/indexer.py +179 -0
- quickast/parser.py +298 -0
- quickast/queries.py +303 -0
- quickast/schema.py +77 -0
- quickast/watcher.py +137 -0
- quickast-0.1.2.dist-info/METADATA +249 -0
- quickast-0.1.2.dist-info/RECORD +14 -0
- quickast-0.1.2.dist-info/WHEEL +5 -0
- quickast-0.1.2.dist-info/entry_points.txt +2 -0
- quickast-0.1.2.dist-info/licenses/LICENSE +21 -0
- quickast-0.1.2.dist-info/top_level.txt +1 -0
quickast/__init__.py
ADDED
quickast/cli.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Command-line interface for QuickAST."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .db import find_db_path
|
|
7
|
+
from .indexer import Indexer
|
|
8
|
+
from .queries import (
|
|
9
|
+
get_stats, query_callers_of, query_callees, query_changes,
|
|
10
|
+
query_file_symbols, query_impact, query_references, query_route,
|
|
11
|
+
query_routes, query_summary, query_symbol, search_symbols,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
USAGE = """\
|
|
15
|
+
QuickAST — Instant codebase intelligence for AI coding agents.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
quickast init Build the index for the current directory
|
|
19
|
+
quickast watch [--daemon|-d] Start the file watcher (--daemon to background)
|
|
20
|
+
quickast stop Stop the background watcher
|
|
21
|
+
quickast query <name> Find where a symbol is defined
|
|
22
|
+
quickast search <pattern> Fuzzy search symbols (use % wildcards)
|
|
23
|
+
quickast refs <name> Find all files that import a symbol
|
|
24
|
+
quickast file <path> List all symbols defined in a file
|
|
25
|
+
quickast callees <qualified_name> What does function X call?
|
|
26
|
+
quickast callers-of <name> What calls function X?
|
|
27
|
+
quickast impact <name> [depth] Transitive impact analysis
|
|
28
|
+
quickast routes [--type TYPE] List API routes
|
|
29
|
+
quickast route <path> Find a specific route
|
|
30
|
+
quickast changes [hours] Files changed recently (default: 24h)
|
|
31
|
+
quickast summary <path> Module overview
|
|
32
|
+
quickast stats Index statistics
|
|
33
|
+
quickast version Show version
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_project_root() -> Path:
|
|
38
|
+
"""Walk up from cwd to find the project root (directory with .git or pyproject.toml)."""
|
|
39
|
+
cwd = Path.cwd().resolve()
|
|
40
|
+
for parent in [cwd, *cwd.parents]:
|
|
41
|
+
if (parent / ".git").exists() or (parent / "pyproject.toml").exists():
|
|
42
|
+
return parent
|
|
43
|
+
return cwd
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_db_path() -> Path:
|
|
47
|
+
return find_db_path(_find_project_root())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ensure_index(db_path: Path):
|
|
51
|
+
if not db_path.exists():
|
|
52
|
+
print("No QuickAST index found. Run 'quickast init' first.")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_init():
|
|
57
|
+
root = _find_project_root()
|
|
58
|
+
print(f"Indexing {root}...")
|
|
59
|
+
indexer = Indexer(root)
|
|
60
|
+
stats = indexer.build()
|
|
61
|
+
print(f"\nDone! Indexed {stats['total_files']} files:")
|
|
62
|
+
print(f" {stats['indexed']} indexed, {stats['skipped']} unchanged, "
|
|
63
|
+
f"{stats['errors']} errors, {stats['removed']} removed")
|
|
64
|
+
s = get_stats(indexer.db_path)
|
|
65
|
+
print(f"\n {s['symbols']:,} symbols | {s['calls']:,} call refs | "
|
|
66
|
+
f"{s['imports']:,} imports | {s['routes']} routes | "
|
|
67
|
+
f"{s['total_lines']:,} lines of code")
|
|
68
|
+
print(f"\nIndex saved to: {indexer.db_path}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_watch(args: list[str]):
|
|
72
|
+
from .watcher import start_watcher
|
|
73
|
+
root = _find_project_root()
|
|
74
|
+
daemon = "--daemon" in args or "-d" in args
|
|
75
|
+
start_watcher(root, daemon=daemon)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_stop():
|
|
79
|
+
root = _find_project_root()
|
|
80
|
+
pid_file = root / ".quickast.pid"
|
|
81
|
+
if not pid_file.exists():
|
|
82
|
+
print("No watcher running (no .quickast.pid file found).")
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
pid = int(pid_file.read_text().strip())
|
|
86
|
+
import os
|
|
87
|
+
os.kill(pid, 15) # SIGTERM
|
|
88
|
+
print(f"Stopped watcher (PID {pid}).")
|
|
89
|
+
except ProcessLookupError:
|
|
90
|
+
print("Watcher process not found (stale PID file). Cleaning up.")
|
|
91
|
+
pid_file.unlink(missing_ok=True)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Error stopping watcher: {e}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_query(args: list[str]):
|
|
97
|
+
db_path = _get_db_path()
|
|
98
|
+
_ensure_index(db_path)
|
|
99
|
+
if not args:
|
|
100
|
+
print("Usage: quickast query <symbol_name>")
|
|
101
|
+
return
|
|
102
|
+
results = query_symbol(db_path, args[0])
|
|
103
|
+
if not results:
|
|
104
|
+
print(f"No matches: {args[0]}")
|
|
105
|
+
return
|
|
106
|
+
for r in results:
|
|
107
|
+
sig = r.get("signature") or r["name"]
|
|
108
|
+
print(f" {r['type']:8s} {r['relative_path']}:{r['line']} {sig}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_search(args: list[str]):
|
|
112
|
+
db_path = _get_db_path()
|
|
113
|
+
_ensure_index(db_path)
|
|
114
|
+
if not args:
|
|
115
|
+
print("Usage: quickast search <pattern>")
|
|
116
|
+
return
|
|
117
|
+
results = search_symbols(db_path, args[0])
|
|
118
|
+
if not results:
|
|
119
|
+
print(f"No matches: {args[0]}")
|
|
120
|
+
return
|
|
121
|
+
for r in results:
|
|
122
|
+
sig = r.get("signature") or r["name"]
|
|
123
|
+
print(f" {r['type']:8s} {r['relative_path']}:{r['line']} {sig}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_refs(args: list[str]):
|
|
127
|
+
db_path = _get_db_path()
|
|
128
|
+
_ensure_index(db_path)
|
|
129
|
+
if not args:
|
|
130
|
+
print("Usage: quickast refs <symbol_name>")
|
|
131
|
+
return
|
|
132
|
+
results = query_references(db_path, args[0])
|
|
133
|
+
if not results:
|
|
134
|
+
print(f"No imports found for: {args[0]}")
|
|
135
|
+
return
|
|
136
|
+
print(f"{args[0]} is imported by {len(results)} file(s):")
|
|
137
|
+
for r in results:
|
|
138
|
+
alias_str = f" as {r['alias']}" if r.get("alias") else ""
|
|
139
|
+
print(f" {r['relative_path']}:{r['line']} from {r['module']} import {r['name']}{alias_str}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_file(args: list[str]):
|
|
143
|
+
db_path = _get_db_path()
|
|
144
|
+
_ensure_index(db_path)
|
|
145
|
+
if not args:
|
|
146
|
+
print("Usage: quickast file <path>")
|
|
147
|
+
return
|
|
148
|
+
results = query_file_symbols(db_path, args[0])
|
|
149
|
+
if not results:
|
|
150
|
+
print(f"No symbols found in: {args[0]}")
|
|
151
|
+
return
|
|
152
|
+
for r in results:
|
|
153
|
+
indent = " " if "." in (r.get("qualified_name") or "") else " "
|
|
154
|
+
sig = r.get("signature") or r["name"]
|
|
155
|
+
print(f"{indent}{r['type']:8s} L{r['line']:>5d} {sig}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_callees(args: list[str]):
|
|
159
|
+
db_path = _get_db_path()
|
|
160
|
+
_ensure_index(db_path)
|
|
161
|
+
if not args:
|
|
162
|
+
print("Usage: quickast callees <qualified_name>")
|
|
163
|
+
return
|
|
164
|
+
results = query_callees(db_path, args[0])
|
|
165
|
+
if not results:
|
|
166
|
+
print(f"No callees found for: {args[0]}")
|
|
167
|
+
return
|
|
168
|
+
print(f"{args[0]} calls {len(results)} functions:")
|
|
169
|
+
for r in results:
|
|
170
|
+
obj = f"{r['callee_object']}." if r.get("callee_object") else ""
|
|
171
|
+
print(f" L{r['line']:>5d} {obj}{r['callee_name']} ({r['callee_type']})")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_callers_of(args: list[str]):
|
|
175
|
+
db_path = _get_db_path()
|
|
176
|
+
_ensure_index(db_path)
|
|
177
|
+
if not args:
|
|
178
|
+
print("Usage: quickast callers-of <name>")
|
|
179
|
+
return
|
|
180
|
+
results = query_callers_of(db_path, args[0])
|
|
181
|
+
if not results:
|
|
182
|
+
print(f"No callers found for: {args[0]}")
|
|
183
|
+
return
|
|
184
|
+
print(f"{args[0]} is called by {len(results)} site(s):")
|
|
185
|
+
for r in results:
|
|
186
|
+
print(f" {r['relative_path']}:{r['line']} in {r['caller_qualified']}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_impact(args: list[str]):
|
|
190
|
+
db_path = _get_db_path()
|
|
191
|
+
_ensure_index(db_path)
|
|
192
|
+
if not args:
|
|
193
|
+
print("Usage: quickast impact <name> [depth]")
|
|
194
|
+
return
|
|
195
|
+
depth = int(args[1]) if len(args) > 1 else 3
|
|
196
|
+
result = query_impact(db_path, args[0], depth)
|
|
197
|
+
print(f"Impact analysis for {result['name']} (depth={depth}):")
|
|
198
|
+
print(f"\n Upstream callers ({len(result['upstream_callers'])}):")
|
|
199
|
+
for c in result["upstream_callers"][:20]:
|
|
200
|
+
print(f" {c}")
|
|
201
|
+
print(f"\n Downstream callees ({len(result['downstream_callees'])}):")
|
|
202
|
+
for c in result["downstream_callees"][:20]:
|
|
203
|
+
print(f" {c}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_routes(args: list[str]):
|
|
207
|
+
db_path = _get_db_path()
|
|
208
|
+
_ensure_index(db_path)
|
|
209
|
+
route_type = None
|
|
210
|
+
if "--type" in args:
|
|
211
|
+
idx = args.index("--type")
|
|
212
|
+
if idx + 1 < len(args):
|
|
213
|
+
route_type = args[idx + 1]
|
|
214
|
+
results = query_routes(db_path, route_type=route_type)
|
|
215
|
+
if not results:
|
|
216
|
+
print("No routes found.")
|
|
217
|
+
return
|
|
218
|
+
for r in results:
|
|
219
|
+
method = r.get("method") or ""
|
|
220
|
+
rtype = r.get("route_type", "")
|
|
221
|
+
handler = r.get("handler_function", "")
|
|
222
|
+
path = r.get("path", "")
|
|
223
|
+
rel = r.get("relative_path", "")
|
|
224
|
+
line = r.get("line", 0)
|
|
225
|
+
desc = r.get("description") or ""
|
|
226
|
+
print(f" [{rtype:12s}] {method:6s} {path:40s}")
|
|
227
|
+
print(f" Handler: {handler} in {rel}:{line}")
|
|
228
|
+
if desc:
|
|
229
|
+
print(f" Desc: {desc}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_route(args: list[str]):
|
|
233
|
+
db_path = _get_db_path()
|
|
234
|
+
_ensure_index(db_path)
|
|
235
|
+
if not args:
|
|
236
|
+
print("Usage: quickast route <path>")
|
|
237
|
+
return
|
|
238
|
+
results = query_route(db_path, args[0])
|
|
239
|
+
if not results:
|
|
240
|
+
print(f"No route found matching: {args[0]}")
|
|
241
|
+
return
|
|
242
|
+
for r in results:
|
|
243
|
+
method = r.get("method") or ""
|
|
244
|
+
handler = r.get("handler_function", "")
|
|
245
|
+
rel = r.get("relative_path", "")
|
|
246
|
+
line = r.get("line", 0)
|
|
247
|
+
print(f" {method:6s} {r['path']} -> {handler} in {rel}:{line}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_changes(args: list[str]):
|
|
251
|
+
db_path = _get_db_path()
|
|
252
|
+
_ensure_index(db_path)
|
|
253
|
+
hours = int(args[0]) if args else 24
|
|
254
|
+
results = query_changes(db_path, hours)
|
|
255
|
+
if not results:
|
|
256
|
+
print(f"No files changed in the last {hours} hours.")
|
|
257
|
+
return
|
|
258
|
+
print(f"Files changed in the last {hours} hours:")
|
|
259
|
+
for r in results:
|
|
260
|
+
print(f" {r['modified']} {r['relative_path']} ({r['line_count']} lines, {r['symbol_count']} symbols)")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_summary(args: list[str]):
|
|
264
|
+
db_path = _get_db_path()
|
|
265
|
+
_ensure_index(db_path)
|
|
266
|
+
if not args:
|
|
267
|
+
print("Usage: quickast summary <path>")
|
|
268
|
+
return
|
|
269
|
+
result = query_summary(db_path, args[0])
|
|
270
|
+
if not result:
|
|
271
|
+
print(f"File not found in index: {args[0]}")
|
|
272
|
+
return
|
|
273
|
+
print(f"{result['file']} — {result['lines']} lines, {result['size']} bytes")
|
|
274
|
+
print(f" Imports: {result['import_count']}")
|
|
275
|
+
for stype, count in result["symbol_counts"].items():
|
|
276
|
+
print(f" {stype}s: {count}")
|
|
277
|
+
if result["top_symbols"]:
|
|
278
|
+
print(f"\n Top-level symbols:")
|
|
279
|
+
for s in result["top_symbols"]:
|
|
280
|
+
sig = s.get("signature") or s["name"]
|
|
281
|
+
print(f" {s['type']:8s} L{s['line']:>5d} {sig}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_stats():
|
|
285
|
+
db_path = _get_db_path()
|
|
286
|
+
_ensure_index(db_path)
|
|
287
|
+
s = get_stats(db_path)
|
|
288
|
+
print(f"QuickAST Index Statistics")
|
|
289
|
+
print(f" Files: {s['files']:,}")
|
|
290
|
+
print(f" Symbols: {s['symbols']:,}")
|
|
291
|
+
print(f" Imports: {s['imports']:,}")
|
|
292
|
+
print(f" Call refs: {s['calls']:,}")
|
|
293
|
+
print(f" Routes: {s['routes']:,}")
|
|
294
|
+
print(f" Total lines: {s['total_lines']:,}")
|
|
295
|
+
if s.get("by_type"):
|
|
296
|
+
print(f"\n Symbol types:")
|
|
297
|
+
for stype, count in s["by_type"].items():
|
|
298
|
+
print(f" {stype:12s} {count:,}")
|
|
299
|
+
if s.get("route_types"):
|
|
300
|
+
print(f"\n Route types:")
|
|
301
|
+
for rtype, count in s["route_types"].items():
|
|
302
|
+
print(f" {rtype:12s} {count:,}")
|
|
303
|
+
if s.get("largest_files"):
|
|
304
|
+
print(f"\n Largest files:")
|
|
305
|
+
for f in s["largest_files"]:
|
|
306
|
+
print(f" {f['relative_path']:50s} {f['line_count']:,} lines")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def cmd_version():
|
|
310
|
+
from . import __version__
|
|
311
|
+
print(f"quickast {__version__}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main():
|
|
315
|
+
args = sys.argv[1:]
|
|
316
|
+
|
|
317
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
318
|
+
print(USAGE)
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
cmd = args[0]
|
|
322
|
+
rest = args[1:]
|
|
323
|
+
|
|
324
|
+
commands = {
|
|
325
|
+
"init": lambda: cmd_init(),
|
|
326
|
+
"watch": lambda: cmd_watch(rest),
|
|
327
|
+
"query": lambda: cmd_query(rest),
|
|
328
|
+
"search": lambda: cmd_search(rest),
|
|
329
|
+
"refs": lambda: cmd_refs(rest),
|
|
330
|
+
"file": lambda: cmd_file(rest),
|
|
331
|
+
"callees": lambda: cmd_callees(rest),
|
|
332
|
+
"callers-of": lambda: cmd_callers_of(rest),
|
|
333
|
+
"impact": lambda: cmd_impact(rest),
|
|
334
|
+
"routes": lambda: cmd_routes(rest),
|
|
335
|
+
"route": lambda: cmd_route(rest),
|
|
336
|
+
"changes": lambda: cmd_changes(rest),
|
|
337
|
+
"summary": lambda: cmd_summary(rest),
|
|
338
|
+
"stop": lambda: cmd_stop(),
|
|
339
|
+
"stats": lambda: cmd_stats(),
|
|
340
|
+
"version": lambda: cmd_version(),
|
|
341
|
+
"--version": lambda: cmd_version(),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if cmd in commands:
|
|
345
|
+
commands[cmd]()
|
|
346
|
+
else:
|
|
347
|
+
print(f"Unknown command: {cmd}")
|
|
348
|
+
print(USAGE)
|
|
349
|
+
sys.exit(1)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
main()
|
quickast/db.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Database connection and initialization for QuickAST."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .schema import SCHEMA
|
|
7
|
+
|
|
8
|
+
DEFAULT_DB_NAME = ".quickast.db"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_db_path(project_root: Path) -> Path:
|
|
12
|
+
"""Return the path to the QuickAST database for a project."""
|
|
13
|
+
return project_root / DEFAULT_DB_NAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_db(db_path: Path) -> sqlite3.Connection:
|
|
17
|
+
"""Open a connection to the QuickAST database."""
|
|
18
|
+
conn = sqlite3.connect(str(db_path), timeout=10)
|
|
19
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
20
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
21
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
22
|
+
conn.row_factory = sqlite3.Row
|
|
23
|
+
return conn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def init_db(db_path: Path) -> None:
|
|
27
|
+
"""Initialize the database schema."""
|
|
28
|
+
conn = get_db(db_path)
|
|
29
|
+
conn.executescript(SCHEMA)
|
|
30
|
+
conn.close()
|
quickast/indexer.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Core indexer — scans Python files and populates the SQLite index."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .db import get_db, init_db, find_db_path
|
|
9
|
+
from .parser import parse_file
|
|
10
|
+
|
|
11
|
+
# Directories to always skip
|
|
12
|
+
DEFAULT_EXCLUDE_DIRS = {
|
|
13
|
+
"venv", ".venv", "env", ".env",
|
|
14
|
+
"__pycache__", ".git", "node_modules",
|
|
15
|
+
".mypy_cache", ".pytest_cache", ".tox", ".nox",
|
|
16
|
+
"dist", "build", ".eggs", "*.egg-info",
|
|
17
|
+
".cache", ".ruff_cache",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Indexer:
|
|
22
|
+
"""Indexes Python files into the QuickAST SQLite database."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, project_root: Path, db_path: Path | None = None,
|
|
25
|
+
exclude_dirs: set[str] | None = None):
|
|
26
|
+
self.project_root = project_root.resolve()
|
|
27
|
+
self.db_path = db_path or find_db_path(self.project_root)
|
|
28
|
+
self.exclude_dirs = exclude_dirs or DEFAULT_EXCLUDE_DIRS
|
|
29
|
+
init_db(self.db_path)
|
|
30
|
+
|
|
31
|
+
def should_skip(self, path: Path) -> bool:
|
|
32
|
+
"""Check if a path should be excluded from indexing."""
|
|
33
|
+
try:
|
|
34
|
+
parts = path.relative_to(self.project_root).parts
|
|
35
|
+
return any(p in self.exclude_dirs for p in parts)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def find_python_files(self) -> list[Path]:
|
|
40
|
+
"""Walk the project tree and find all Python files."""
|
|
41
|
+
files = []
|
|
42
|
+
for root, dirs, filenames in os.walk(self.project_root):
|
|
43
|
+
dirs[:] = [d for d in dirs if d not in self.exclude_dirs]
|
|
44
|
+
for f in filenames:
|
|
45
|
+
if f.endswith(".py"):
|
|
46
|
+
fp = Path(root) / f
|
|
47
|
+
if not self.should_skip(fp):
|
|
48
|
+
files.append(fp)
|
|
49
|
+
return files
|
|
50
|
+
|
|
51
|
+
def index_file(self, filepath: Path, conn: sqlite3.Connection | None = None) -> bool:
|
|
52
|
+
"""Index a single file. Returns True if file was (re)indexed."""
|
|
53
|
+
if not filepath.exists() or self.should_skip(filepath):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
own_conn = conn is None
|
|
57
|
+
if own_conn:
|
|
58
|
+
conn = get_db(self.db_path)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
stat = filepath.stat()
|
|
62
|
+
relative = str(filepath.relative_to(self.project_root))
|
|
63
|
+
|
|
64
|
+
existing = conn.execute(
|
|
65
|
+
"SELECT id, mtime FROM files WHERE path = ?", (str(filepath),)
|
|
66
|
+
).fetchone()
|
|
67
|
+
|
|
68
|
+
if existing and existing["mtime"] >= stat.st_mtime:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
parsed = parse_file(filepath)
|
|
72
|
+
|
|
73
|
+
if existing:
|
|
74
|
+
conn.execute("DELETE FROM files WHERE id = ?", (existing["id"],))
|
|
75
|
+
|
|
76
|
+
cursor = conn.execute(
|
|
77
|
+
"""INSERT INTO files (path, relative_path, mtime, size, line_count)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
79
|
+
(str(filepath), relative, stat.st_mtime, stat.st_size, parsed["line_count"]),
|
|
80
|
+
)
|
|
81
|
+
file_id = cursor.lastrowid
|
|
82
|
+
|
|
83
|
+
self._insert_symbols(conn, file_id, parsed["symbols"], parent_id=None)
|
|
84
|
+
|
|
85
|
+
for imp in parsed["imports"]:
|
|
86
|
+
conn.execute(
|
|
87
|
+
"""INSERT INTO imports (file_id, module, name, alias, line)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
89
|
+
(file_id, imp["module"], imp["name"], imp["alias"], imp["line"]),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
for call in parsed["calls"]:
|
|
93
|
+
conn.execute(
|
|
94
|
+
"""INSERT INTO call_references (file_id, caller_qualified,
|
|
95
|
+
callee_name, callee_type, callee_object, line)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
97
|
+
(file_id, call["caller"], call["callee_name"],
|
|
98
|
+
call["callee_type"], call.get("callee_object"), call["line"]),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
for route in parsed["routes"]:
|
|
102
|
+
conn.execute(
|
|
103
|
+
"""INSERT INTO api_routes (file_id, route_type, path, method,
|
|
104
|
+
handler_function, handler_qualified, line, description,
|
|
105
|
+
service, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
106
|
+
(file_id, route["route_type"], route["path"], route.get("method"),
|
|
107
|
+
route["handler"], route.get("qualified"), route["line"],
|
|
108
|
+
route.get("description"), None, route.get("extra")),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
conn.commit()
|
|
112
|
+
return True
|
|
113
|
+
finally:
|
|
114
|
+
if own_conn:
|
|
115
|
+
conn.close()
|
|
116
|
+
|
|
117
|
+
def _insert_symbols(self, conn: sqlite3.Connection, file_id: int,
|
|
118
|
+
symbols: list, parent_id: int | None):
|
|
119
|
+
"""Recursively insert symbols into the database."""
|
|
120
|
+
for sym in symbols:
|
|
121
|
+
cursor = conn.execute(
|
|
122
|
+
"""INSERT INTO symbols (file_id, name, qualified_name, type, line,
|
|
123
|
+
end_line, signature, docstring, parent_id)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
125
|
+
(file_id, sym["name"], sym["qualified_name"], sym["type"],
|
|
126
|
+
sym["line"], sym.get("end_line"), sym["signature"],
|
|
127
|
+
sym.get("docstring"), parent_id),
|
|
128
|
+
)
|
|
129
|
+
sym_id = cursor.lastrowid
|
|
130
|
+
if sym.get("children"):
|
|
131
|
+
self._insert_symbols(conn, file_id, sym["children"], parent_id=sym_id)
|
|
132
|
+
|
|
133
|
+
def remove_file(self, filepath: Path):
|
|
134
|
+
"""Remove a file and all its symbols from the index."""
|
|
135
|
+
conn = get_db(self.db_path)
|
|
136
|
+
try:
|
|
137
|
+
conn.execute("DELETE FROM files WHERE path = ?", (str(filepath),))
|
|
138
|
+
conn.commit()
|
|
139
|
+
finally:
|
|
140
|
+
conn.close()
|
|
141
|
+
|
|
142
|
+
def build(self, verbose: bool = True) -> dict:
|
|
143
|
+
"""Full index build. Returns statistics."""
|
|
144
|
+
files = self.find_python_files()
|
|
145
|
+
indexed = skipped = errors = 0
|
|
146
|
+
conn = get_db(self.db_path)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
for f in files:
|
|
150
|
+
try:
|
|
151
|
+
if self.index_file(f, conn=conn):
|
|
152
|
+
indexed += 1
|
|
153
|
+
else:
|
|
154
|
+
skipped += 1
|
|
155
|
+
except Exception as e:
|
|
156
|
+
errors += 1
|
|
157
|
+
if verbose:
|
|
158
|
+
print(f" Error indexing {f}: {e}", file=sys.stderr)
|
|
159
|
+
|
|
160
|
+
removed = self._cleanup_deleted(conn)
|
|
161
|
+
finally:
|
|
162
|
+
conn.close()
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"total_files": len(files), "indexed": indexed,
|
|
166
|
+
"skipped": skipped, "errors": errors, "removed": removed,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _cleanup_deleted(self, conn: sqlite3.Connection) -> int:
|
|
170
|
+
"""Remove entries for files that no longer exist on disk."""
|
|
171
|
+
rows = conn.execute("SELECT id, path FROM files").fetchall()
|
|
172
|
+
removed = 0
|
|
173
|
+
for row in rows:
|
|
174
|
+
if not Path(row["path"]).exists():
|
|
175
|
+
conn.execute("DELETE FROM files WHERE id = ?", (row["id"],))
|
|
176
|
+
removed += 1
|
|
177
|
+
if removed:
|
|
178
|
+
conn.commit()
|
|
179
|
+
return removed
|