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 ADDED
@@ -0,0 +1,3 @@
1
+ """QuickAST — Instant codebase intelligence for AI coding agents."""
2
+
3
+ __version__ = "0.1.2"
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