suitable-loop 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.
- suitable_loop/__init__.py +3 -0
- suitable_loop/__main__.py +5 -0
- suitable_loop/analyzers/__init__.py +1 -0
- suitable_loop/analyzers/code_analyzer.py +652 -0
- suitable_loop/analyzers/git_analyzer.py +510 -0
- suitable_loop/analyzers/log_analyzer.py +663 -0
- suitable_loop/config.py +60 -0
- suitable_loop/db.py +497 -0
- suitable_loop/graph/__init__.py +1 -0
- suitable_loop/graph/engine.py +341 -0
- suitable_loop/models.py +131 -0
- suitable_loop/server.py +46 -0
- suitable_loop/tools/__init__.py +1 -0
- suitable_loop/tools/code_tools.py +104 -0
- suitable_loop/tools/git_tools.py +52 -0
- suitable_loop/tools/log_tools.py +53 -0
- suitable_loop/tools/util_tools.py +49 -0
- suitable_loop-0.1.0.dist-info/METADATA +12 -0
- suitable_loop-0.1.0.dist-info/RECORD +21 -0
- suitable_loop-0.1.0.dist-info/WHEEL +4 -0
- suitable_loop-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Suitable Loop analysis engines."""
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""AST-based Python code analyzer for Suitable Loop.
|
|
2
|
+
|
|
3
|
+
Walks a project directory, parses Python source files, and extracts
|
|
4
|
+
functions, classes, imports, and call relationships into the database.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from suitable_loop.config import SuitableLoopConfig
|
|
17
|
+
from suitable_loop.db import Database
|
|
18
|
+
from suitable_loop.models import (
|
|
19
|
+
CallEdge,
|
|
20
|
+
ClassEntity,
|
|
21
|
+
FileDependency,
|
|
22
|
+
FileEntity,
|
|
23
|
+
FunctionEntity,
|
|
24
|
+
ImportEntity,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _end_line(node: ast.AST) -> int:
|
|
31
|
+
"""Return the end line of an AST node, falling back to start line."""
|
|
32
|
+
return getattr(node, "end_lineno", None) or node.lineno
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_docstring(node: ast.AST) -> str | None:
|
|
36
|
+
"""Extract the docstring from a function or class node."""
|
|
37
|
+
try:
|
|
38
|
+
return ast.get_docstring(node)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _signature_from_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
44
|
+
"""Build a human-readable signature string from function arguments."""
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
args = node.args
|
|
47
|
+
|
|
48
|
+
# Positional-only args
|
|
49
|
+
for arg in args.posonlyargs:
|
|
50
|
+
parts.append(arg.arg)
|
|
51
|
+
if args.posonlyargs:
|
|
52
|
+
parts.append("/")
|
|
53
|
+
|
|
54
|
+
# Regular positional args
|
|
55
|
+
num_defaults = len(args.defaults)
|
|
56
|
+
num_args = len(args.args)
|
|
57
|
+
for i, arg in enumerate(args.args):
|
|
58
|
+
name = arg.arg
|
|
59
|
+
default_idx = i - (num_args - num_defaults)
|
|
60
|
+
if default_idx >= 0:
|
|
61
|
+
name += "=..."
|
|
62
|
+
parts.append(name)
|
|
63
|
+
|
|
64
|
+
# *args
|
|
65
|
+
if args.vararg:
|
|
66
|
+
parts.append(f"*{args.vararg.arg}")
|
|
67
|
+
elif args.kwonlyargs:
|
|
68
|
+
parts.append("*")
|
|
69
|
+
|
|
70
|
+
# Keyword-only args
|
|
71
|
+
for i, arg in enumerate(args.kwonlyargs):
|
|
72
|
+
name = arg.arg
|
|
73
|
+
if i < len(args.kw_defaults) and args.kw_defaults[i] is not None:
|
|
74
|
+
name += "=..."
|
|
75
|
+
parts.append(name)
|
|
76
|
+
|
|
77
|
+
# **kwargs
|
|
78
|
+
if args.kwarg:
|
|
79
|
+
parts.append(f"**{args.kwarg.arg}")
|
|
80
|
+
|
|
81
|
+
return f"({', '.join(parts)})"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _CallCollector(ast.NodeVisitor):
|
|
85
|
+
"""Collect function calls within a function body.
|
|
86
|
+
|
|
87
|
+
Each call is recorded as (callee_name, line_number). For attribute
|
|
88
|
+
calls like ``self.foo()`` or ``module.bar()`` the full dotted name
|
|
89
|
+
is stored.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self.calls: list[tuple[str, int]] = []
|
|
94
|
+
|
|
95
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
|
|
96
|
+
name = self._resolve_call_name(node.func)
|
|
97
|
+
if name:
|
|
98
|
+
self.calls.append((name, node.lineno))
|
|
99
|
+
self.generic_visit(node)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _resolve_call_name(node: ast.expr) -> str | None:
|
|
103
|
+
if isinstance(node, ast.Name):
|
|
104
|
+
return node.id
|
|
105
|
+
if isinstance(node, ast.Attribute):
|
|
106
|
+
parts: list[str] = [node.attr]
|
|
107
|
+
current = node.value
|
|
108
|
+
while isinstance(current, ast.Attribute):
|
|
109
|
+
parts.append(current.attr)
|
|
110
|
+
current = current.value
|
|
111
|
+
if isinstance(current, ast.Name):
|
|
112
|
+
parts.append(current.id)
|
|
113
|
+
parts.reverse()
|
|
114
|
+
return ".".join(parts)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CodeAnalyzer:
|
|
119
|
+
"""Indexes a Python codebase by parsing ASTs and persisting entities."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, db: Database, config: SuitableLoopConfig) -> None:
|
|
122
|
+
self.db = db
|
|
123
|
+
self.config = config
|
|
124
|
+
# Temporary storage for raw calls accumulated during file indexing.
|
|
125
|
+
# List of (file_path, caller_qualified_name, callee_name, line).
|
|
126
|
+
self._raw_calls: list[tuple[str, str, str, int]] = []
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Public API
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def index_codebase(self, project_path: str, force: bool = False) -> dict:
|
|
133
|
+
"""Walk *project_path* and index every ``.py`` file.
|
|
134
|
+
|
|
135
|
+
Returns a summary dict with counts of files, functions, classes,
|
|
136
|
+
imports, and call edges processed.
|
|
137
|
+
"""
|
|
138
|
+
root = Path(project_path).resolve()
|
|
139
|
+
if not root.is_dir():
|
|
140
|
+
raise FileNotFoundError(f"Project path does not exist: {root}")
|
|
141
|
+
|
|
142
|
+
self._raw_calls.clear()
|
|
143
|
+
|
|
144
|
+
py_files = self._discover_files(root)
|
|
145
|
+
logger.info("Discovered %d Python files in %s", len(py_files), root)
|
|
146
|
+
|
|
147
|
+
stats: dict[str, int] = {
|
|
148
|
+
"files_total": len(py_files),
|
|
149
|
+
"files_indexed": 0,
|
|
150
|
+
"files_skipped": 0,
|
|
151
|
+
"functions": 0,
|
|
152
|
+
"classes": 0,
|
|
153
|
+
"imports": 0,
|
|
154
|
+
"call_edges": 0,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for file_path in py_files:
|
|
158
|
+
file_stats = self._index_file(file_path, str(root), force=force)
|
|
159
|
+
if file_stats["indexed"]:
|
|
160
|
+
stats["files_indexed"] += 1
|
|
161
|
+
stats["functions"] += file_stats["functions"]
|
|
162
|
+
stats["classes"] += file_stats["classes"]
|
|
163
|
+
stats["imports"] += file_stats["imports"]
|
|
164
|
+
else:
|
|
165
|
+
stats["files_skipped"] += 1
|
|
166
|
+
|
|
167
|
+
# Resolve file-level dependencies now that all files are in the DB.
|
|
168
|
+
self._resolve_file_dependencies(str(root))
|
|
169
|
+
|
|
170
|
+
# Resolve raw calls across the whole project into call edges.
|
|
171
|
+
edge_count = self._resolve_calls(str(root))
|
|
172
|
+
stats["call_edges"] = edge_count
|
|
173
|
+
|
|
174
|
+
self.db.commit()
|
|
175
|
+
logger.info(
|
|
176
|
+
"Indexing complete: %d files indexed, %d skipped, %d functions, "
|
|
177
|
+
"%d classes, %d call edges",
|
|
178
|
+
stats["files_indexed"],
|
|
179
|
+
stats["files_skipped"],
|
|
180
|
+
stats["functions"],
|
|
181
|
+
stats["classes"],
|
|
182
|
+
stats["call_edges"],
|
|
183
|
+
)
|
|
184
|
+
return stats
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
# File-level indexing
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def _index_file(
|
|
191
|
+
self, file_path: Path, project_root: str, *, force: bool = False
|
|
192
|
+
) -> dict:
|
|
193
|
+
"""Index a single Python file.
|
|
194
|
+
|
|
195
|
+
Returns a small dict with keys ``indexed`` (bool) and entity counts.
|
|
196
|
+
"""
|
|
197
|
+
result = {"indexed": False, "functions": 0, "classes": 0, "imports": 0}
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
logger.warning("Cannot read %s: %s", file_path, exc)
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
content_hash = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
|
206
|
+
stat = file_path.stat()
|
|
207
|
+
rel_path = str(file_path)
|
|
208
|
+
|
|
209
|
+
# Check whether the file has changed since the last indexing.
|
|
210
|
+
existing = self.db.get_file_by_path(rel_path)
|
|
211
|
+
if existing and not force and existing.hash == content_hash:
|
|
212
|
+
logger.debug("Skipping unchanged file: %s", rel_path)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
# Upsert the file entity.
|
|
216
|
+
file_entity = FileEntity(
|
|
217
|
+
path=rel_path,
|
|
218
|
+
project_root=project_root,
|
|
219
|
+
size_bytes=stat.st_size,
|
|
220
|
+
last_modified=stat.st_mtime,
|
|
221
|
+
last_indexed=time.time(),
|
|
222
|
+
line_count=source.count("\n") + 1,
|
|
223
|
+
hash=content_hash,
|
|
224
|
+
)
|
|
225
|
+
file_id = self.db.upsert_file(file_entity)
|
|
226
|
+
|
|
227
|
+
# Clear stale data for this file before re-inserting.
|
|
228
|
+
self.db.clear_file_data(file_id)
|
|
229
|
+
|
|
230
|
+
# Parse the AST.
|
|
231
|
+
try:
|
|
232
|
+
functions, classes, imports, raw_calls = self._parse_ast(source, rel_path)
|
|
233
|
+
except SyntaxError as exc:
|
|
234
|
+
logger.warning("Syntax error in %s: %s", rel_path, exc)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
# Compute cyclomatic complexity for each function.
|
|
238
|
+
complexity_map = self._compute_complexity(source)
|
|
239
|
+
|
|
240
|
+
# Persist functions.
|
|
241
|
+
for func in functions:
|
|
242
|
+
func.file_id = file_id
|
|
243
|
+
func.complexity = complexity_map.get(func.name, 0)
|
|
244
|
+
self.db.insert_function(func)
|
|
245
|
+
result["functions"] = len(functions)
|
|
246
|
+
|
|
247
|
+
# Persist classes.
|
|
248
|
+
for cls in classes:
|
|
249
|
+
cls.file_id = file_id
|
|
250
|
+
self.db.insert_class(cls)
|
|
251
|
+
result["classes"] = len(classes)
|
|
252
|
+
|
|
253
|
+
# Persist imports (file dependencies are resolved in a second pass).
|
|
254
|
+
for imp in imports:
|
|
255
|
+
imp.file_id = file_id
|
|
256
|
+
self.db.insert_import(imp)
|
|
257
|
+
result["imports"] = len(imports)
|
|
258
|
+
|
|
259
|
+
# Stash raw calls for cross-file resolution later.
|
|
260
|
+
for caller_qname, callee_name, line in raw_calls:
|
|
261
|
+
self._raw_calls.append((rel_path, caller_qname, callee_name, line))
|
|
262
|
+
|
|
263
|
+
self.db.commit()
|
|
264
|
+
result["indexed"] = True
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# AST parsing
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _parse_ast(
|
|
272
|
+
self, source: str, file_path: str
|
|
273
|
+
) -> tuple[
|
|
274
|
+
list[FunctionEntity],
|
|
275
|
+
list[ClassEntity],
|
|
276
|
+
list[ImportEntity],
|
|
277
|
+
list[tuple[str, str, int]],
|
|
278
|
+
]:
|
|
279
|
+
"""Parse *source* and extract entities.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
(functions, classes, imports, raw_calls)
|
|
283
|
+
where raw_calls is a list of ``(caller_qualified_name, callee_name, line)``.
|
|
284
|
+
"""
|
|
285
|
+
tree = ast.parse(source, filename=file_path)
|
|
286
|
+
|
|
287
|
+
functions: list[FunctionEntity] = []
|
|
288
|
+
classes: list[ClassEntity] = []
|
|
289
|
+
imports: list[ImportEntity] = []
|
|
290
|
+
raw_calls: list[tuple[str, str, int]] = []
|
|
291
|
+
|
|
292
|
+
module_name = self._path_to_module(file_path)
|
|
293
|
+
|
|
294
|
+
for node in ast.iter_child_nodes(tree):
|
|
295
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
296
|
+
func = self._extract_function(node, module_name, class_name=None)
|
|
297
|
+
functions.append(func)
|
|
298
|
+
raw_calls.extend(
|
|
299
|
+
self._collect_calls(node, func.qualified_name)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
elif isinstance(node, ast.ClassDef):
|
|
303
|
+
cls = self._extract_class(node, module_name)
|
|
304
|
+
classes.append(cls)
|
|
305
|
+
# Extract methods inside the class.
|
|
306
|
+
for item in ast.iter_child_nodes(node):
|
|
307
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
308
|
+
method = self._extract_function(
|
|
309
|
+
item, module_name, class_name=node.name
|
|
310
|
+
)
|
|
311
|
+
functions.append(method)
|
|
312
|
+
raw_calls.extend(
|
|
313
|
+
self._collect_calls(item, method.qualified_name)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
elif isinstance(node, ast.Import):
|
|
317
|
+
for alias in node.names:
|
|
318
|
+
imports.append(
|
|
319
|
+
ImportEntity(module=alias.name, alias=alias.asname)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
elif isinstance(node, ast.ImportFrom):
|
|
323
|
+
base = node.module or ""
|
|
324
|
+
for alias in node.names:
|
|
325
|
+
full_module = f"{base}.{alias.name}" if base else alias.name
|
|
326
|
+
imports.append(
|
|
327
|
+
ImportEntity(module=full_module, alias=alias.asname)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return functions, classes, imports, raw_calls
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
# Entity extraction helpers
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _extract_function(
|
|
338
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
339
|
+
module_name: str,
|
|
340
|
+
class_name: str | None,
|
|
341
|
+
) -> FunctionEntity:
|
|
342
|
+
if class_name:
|
|
343
|
+
qualified_name = f"{module_name}.{class_name}.{node.name}"
|
|
344
|
+
else:
|
|
345
|
+
qualified_name = f"{module_name}.{node.name}"
|
|
346
|
+
|
|
347
|
+
return FunctionEntity(
|
|
348
|
+
name=node.name,
|
|
349
|
+
qualified_name=qualified_name,
|
|
350
|
+
class_name=class_name,
|
|
351
|
+
line_start=node.lineno,
|
|
352
|
+
line_end=_end_line(node),
|
|
353
|
+
signature=_signature_from_args(node),
|
|
354
|
+
docstring=_get_docstring(node),
|
|
355
|
+
is_method=class_name is not None,
|
|
356
|
+
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
@staticmethod
|
|
360
|
+
def _extract_class(node: ast.ClassDef, module_name: str) -> ClassEntity:
|
|
361
|
+
bases = []
|
|
362
|
+
for base in node.bases:
|
|
363
|
+
if isinstance(base, ast.Name):
|
|
364
|
+
bases.append(base.id)
|
|
365
|
+
elif isinstance(base, ast.Attribute):
|
|
366
|
+
parts: list[str] = [base.attr]
|
|
367
|
+
current = base.value
|
|
368
|
+
while isinstance(current, ast.Attribute):
|
|
369
|
+
parts.append(current.attr)
|
|
370
|
+
current = current.value
|
|
371
|
+
if isinstance(current, ast.Name):
|
|
372
|
+
parts.append(current.id)
|
|
373
|
+
parts.reverse()
|
|
374
|
+
bases.append(".".join(parts))
|
|
375
|
+
|
|
376
|
+
return ClassEntity(
|
|
377
|
+
name=node.name,
|
|
378
|
+
qualified_name=f"{module_name}.{node.name}",
|
|
379
|
+
line_start=node.lineno,
|
|
380
|
+
line_end=_end_line(node),
|
|
381
|
+
bases=bases,
|
|
382
|
+
docstring=_get_docstring(node),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
@staticmethod
|
|
386
|
+
def _collect_calls(
|
|
387
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef, caller_qname: str
|
|
388
|
+
) -> list[tuple[str, str, int]]:
|
|
389
|
+
collector = _CallCollector()
|
|
390
|
+
collector.visit(node)
|
|
391
|
+
return [(caller_qname, name, line) for name, line in collector.calls]
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
# File dependency resolution (second pass)
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def _resolve_file_dependencies(self, project_root: str) -> None:
|
|
398
|
+
"""Resolve imports to file dependencies now that all files are indexed."""
|
|
399
|
+
all_files = self.db.get_all_files(project_root)
|
|
400
|
+
|
|
401
|
+
for f in all_files:
|
|
402
|
+
self.db.delete_file_dependencies_by_source(f.id)
|
|
403
|
+
imports = self.db.get_imports_by_file(f.id)
|
|
404
|
+
for imp in imports:
|
|
405
|
+
resolved = self._resolve_import_to_file(imp.module, project_root)
|
|
406
|
+
if resolved is not None and resolved.id != f.id:
|
|
407
|
+
self.db.insert_file_dependency(
|
|
408
|
+
FileDependency(source_file_id=f.id, target_file_id=resolved.id)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
self.db.commit()
|
|
412
|
+
|
|
413
|
+
# ------------------------------------------------------------------
|
|
414
|
+
# Call resolution
|
|
415
|
+
# ------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def _resolve_calls(self, project_root: str) -> int:
|
|
418
|
+
"""Resolve accumulated raw calls into :class:`CallEdge` records.
|
|
419
|
+
|
|
420
|
+
Resolution strategy (in order):
|
|
421
|
+
1. ``self.<method>()`` — look for a method with the same class in the
|
|
422
|
+
same file.
|
|
423
|
+
2. Bare ``func()`` — look in the same module, then across the project.
|
|
424
|
+
3. ``module.func()`` — try to match via imports recorded in the same
|
|
425
|
+
file.
|
|
426
|
+
|
|
427
|
+
Returns the number of call edges inserted.
|
|
428
|
+
"""
|
|
429
|
+
# Build lookup caches.
|
|
430
|
+
all_files = self.db.get_all_files(project_root)
|
|
431
|
+
# qname -> FunctionEntity (with id populated)
|
|
432
|
+
qname_index: dict[str, FunctionEntity] = {}
|
|
433
|
+
# bare name -> list of FunctionEntity
|
|
434
|
+
name_index: dict[str, list[FunctionEntity]] = {}
|
|
435
|
+
# file_path -> file_id
|
|
436
|
+
file_id_index: dict[str, int] = {}
|
|
437
|
+
|
|
438
|
+
for f in all_files:
|
|
439
|
+
file_id_index[f.path] = f.id # type: ignore[arg-type]
|
|
440
|
+
for func in self.db.get_functions_by_file(f.id): # type: ignore[arg-type]
|
|
441
|
+
qname_index[func.qualified_name] = func
|
|
442
|
+
name_index.setdefault(func.name, []).append(func)
|
|
443
|
+
|
|
444
|
+
# Build import alias index: (file_path, alias_or_module_tail) -> full module
|
|
445
|
+
import_alias_index: dict[tuple[str, str], str] = {}
|
|
446
|
+
for f in all_files:
|
|
447
|
+
for imp in self.db.get_imports_by_file(f.id): # type: ignore[arg-type]
|
|
448
|
+
key_name = imp.alias or imp.module.rsplit(".", 1)[-1]
|
|
449
|
+
import_alias_index[(f.path, key_name)] = imp.module
|
|
450
|
+
|
|
451
|
+
inserted = 0
|
|
452
|
+
for file_path, caller_qname, callee_name, line in self._raw_calls:
|
|
453
|
+
caller = qname_index.get(caller_qname)
|
|
454
|
+
if caller is None:
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
file_id = file_id_index.get(file_path)
|
|
458
|
+
callee = self._resolve_single_call(
|
|
459
|
+
caller_qname, callee_name, file_path,
|
|
460
|
+
qname_index, name_index, import_alias_index,
|
|
461
|
+
)
|
|
462
|
+
if callee is None:
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
edge = CallEdge(
|
|
466
|
+
caller_id=caller.id,
|
|
467
|
+
callee_id=callee.id,
|
|
468
|
+
file_id=file_id,
|
|
469
|
+
line_number=line,
|
|
470
|
+
)
|
|
471
|
+
if self.db.insert_call_edge(edge) is not None:
|
|
472
|
+
inserted += 1
|
|
473
|
+
|
|
474
|
+
self._raw_calls.clear()
|
|
475
|
+
return inserted
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
def _resolve_single_call(
|
|
479
|
+
caller_qname: str,
|
|
480
|
+
callee_name: str,
|
|
481
|
+
file_path: str,
|
|
482
|
+
qname_index: dict[str, FunctionEntity],
|
|
483
|
+
name_index: dict[str, list[FunctionEntity]],
|
|
484
|
+
import_alias_index: dict[tuple[str, str], str],
|
|
485
|
+
) -> FunctionEntity | None:
|
|
486
|
+
# Derive the module part of the caller's qualified name.
|
|
487
|
+
# For "pkg.mod.Class.method" the module is "pkg.mod".
|
|
488
|
+
caller_parts = caller_qname.rsplit(".", 1)
|
|
489
|
+
caller_module = caller_parts[0] if len(caller_parts) > 1 else ""
|
|
490
|
+
# If caller is a method, module is one level higher.
|
|
491
|
+
# e.g. "pkg.mod.MyClass.my_method" -> class_prefix = "pkg.mod.MyClass"
|
|
492
|
+
# We try the class prefix first for self.* calls.
|
|
493
|
+
|
|
494
|
+
# 1. self.<method> — resolve within the same class.
|
|
495
|
+
if "." in callee_name:
|
|
496
|
+
prefix, method_name = callee_name.rsplit(".", 1)
|
|
497
|
+
if prefix == "self":
|
|
498
|
+
# caller_qname is like "mod.Class.method"; class qname is "mod.Class"
|
|
499
|
+
class_qname = caller_qname.rsplit(".", 1)[0]
|
|
500
|
+
candidate_qname = f"{class_qname}.{method_name}"
|
|
501
|
+
if candidate_qname in qname_index:
|
|
502
|
+
return qname_index[candidate_qname]
|
|
503
|
+
|
|
504
|
+
# 3. module.func() — resolve via imports.
|
|
505
|
+
# prefix might be an import alias.
|
|
506
|
+
resolved_module = import_alias_index.get((file_path, prefix))
|
|
507
|
+
if resolved_module:
|
|
508
|
+
candidate_qname = f"{resolved_module}.{method_name}"
|
|
509
|
+
if candidate_qname in qname_index:
|
|
510
|
+
return qname_index[candidate_qname]
|
|
511
|
+
|
|
512
|
+
# Try direct dotted qualified name in the same module.
|
|
513
|
+
candidate_qname = f"{caller_module}.{callee_name}"
|
|
514
|
+
if candidate_qname in qname_index:
|
|
515
|
+
return qname_index[candidate_qname]
|
|
516
|
+
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
# 2. Bare func() — same module first, then project-wide.
|
|
520
|
+
same_module_qname = f"{caller_module}.{callee_name}"
|
|
521
|
+
if same_module_qname in qname_index:
|
|
522
|
+
return qname_index[same_module_qname]
|
|
523
|
+
|
|
524
|
+
# Check if callee_name matches an import alias that points to a function.
|
|
525
|
+
resolved_module = import_alias_index.get((file_path, callee_name))
|
|
526
|
+
if resolved_module and resolved_module in qname_index:
|
|
527
|
+
return qname_index[resolved_module]
|
|
528
|
+
|
|
529
|
+
# Fall back to first project-wide match by bare name.
|
|
530
|
+
candidates = name_index.get(callee_name, [])
|
|
531
|
+
if len(candidates) == 1:
|
|
532
|
+
return candidates[0]
|
|
533
|
+
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
# ------------------------------------------------------------------
|
|
537
|
+
# Cyclomatic complexity via radon
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
@staticmethod
|
|
541
|
+
def _compute_complexity(source: str) -> dict[str, int]:
|
|
542
|
+
"""Return ``{function_name: complexity}`` for all functions in *source*.
|
|
543
|
+
|
|
544
|
+
Uses ``radon.complexity`` when available; returns an empty dict if
|
|
545
|
+
radon is not installed or the source cannot be analysed.
|
|
546
|
+
"""
|
|
547
|
+
try:
|
|
548
|
+
from radon.complexity import cc_visit # type: ignore[import-untyped]
|
|
549
|
+
except ImportError:
|
|
550
|
+
logger.debug("radon is not installed; skipping complexity analysis")
|
|
551
|
+
return {}
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
blocks = cc_visit(source)
|
|
555
|
+
except Exception:
|
|
556
|
+
return {}
|
|
557
|
+
|
|
558
|
+
result: dict[str, int] = {}
|
|
559
|
+
for block in blocks:
|
|
560
|
+
result[block.name] = block.complexity
|
|
561
|
+
return result
|
|
562
|
+
|
|
563
|
+
# ------------------------------------------------------------------
|
|
564
|
+
# File discovery
|
|
565
|
+
# ------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
def _discover_files(self, root: Path) -> list[Path]:
|
|
568
|
+
"""Recursively find ``.py`` files under *root*, respecting config."""
|
|
569
|
+
exclude_patterns = self.config.analysis.exclude_patterns
|
|
570
|
+
max_size = self.config.analysis.max_file_size_kb * 1024
|
|
571
|
+
|
|
572
|
+
results: list[Path] = []
|
|
573
|
+
for py_file in root.rglob("*.py"):
|
|
574
|
+
rel = str(py_file.relative_to(root))
|
|
575
|
+
if self._is_excluded(rel, exclude_patterns):
|
|
576
|
+
continue
|
|
577
|
+
try:
|
|
578
|
+
if py_file.stat().st_size > max_size:
|
|
579
|
+
logger.debug("Skipping oversized file: %s", py_file)
|
|
580
|
+
continue
|
|
581
|
+
except OSError:
|
|
582
|
+
continue
|
|
583
|
+
results.append(py_file)
|
|
584
|
+
|
|
585
|
+
return sorted(results)
|
|
586
|
+
|
|
587
|
+
@staticmethod
|
|
588
|
+
def _is_excluded(rel_path: str, patterns: list[str]) -> bool:
|
|
589
|
+
parts = rel_path.replace("\\", "/").split("/")
|
|
590
|
+
for pattern in patterns:
|
|
591
|
+
# Strip **/ wrappers to get the core directory name.
|
|
592
|
+
core = pattern.strip("*").strip("/")
|
|
593
|
+
if core and core in parts:
|
|
594
|
+
return True
|
|
595
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
596
|
+
return True
|
|
597
|
+
return False
|
|
598
|
+
|
|
599
|
+
# ------------------------------------------------------------------
|
|
600
|
+
# Import resolution helpers
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
def _resolve_import_to_file(
|
|
604
|
+
self, module: str, project_root: str
|
|
605
|
+
) -> FileEntity | None:
|
|
606
|
+
"""Try to map an import module string to a file already in the DB.
|
|
607
|
+
|
|
608
|
+
Handles several forms:
|
|
609
|
+
- ``codezero.db`` — full dotted module
|
|
610
|
+
- ``db`` — bare module name
|
|
611
|
+
- ``db.Database`` — module.name where name is a class/function, not a
|
|
612
|
+
sub-module — we try the full path first, then strip the last
|
|
613
|
+
component and retry.
|
|
614
|
+
"""
|
|
615
|
+
root = Path(project_root)
|
|
616
|
+
|
|
617
|
+
# Try progressively shorter suffixes of the dotted path. For each
|
|
618
|
+
# suffix also try dropping the last component (it may be a symbol name,
|
|
619
|
+
# not a sub-package).
|
|
620
|
+
parts = module.split(".")
|
|
621
|
+
attempts: list[list[str]] = []
|
|
622
|
+
for start in range(len(parts)):
|
|
623
|
+
sub = parts[start:]
|
|
624
|
+
if sub:
|
|
625
|
+
attempts.append(sub)
|
|
626
|
+
# Also try without the last component (e.g. "db.Database" -> "db")
|
|
627
|
+
if len(sub) > 1:
|
|
628
|
+
attempts.append(sub[:-1])
|
|
629
|
+
|
|
630
|
+
for sub in attempts:
|
|
631
|
+
candidates = [
|
|
632
|
+
root / Path(*sub).with_suffix(".py"),
|
|
633
|
+
root / Path(*sub) / "__init__.py",
|
|
634
|
+
]
|
|
635
|
+
for candidate in candidates:
|
|
636
|
+
entity = self.db.get_file_by_path(str(candidate))
|
|
637
|
+
if entity is not None:
|
|
638
|
+
return entity
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
@staticmethod
|
|
642
|
+
def _path_to_module(file_path: str) -> str:
|
|
643
|
+
"""Convert a file path to a dotted module name.
|
|
644
|
+
|
|
645
|
+
``/project/pkg/mod.py`` -> ``pkg.mod``
|
|
646
|
+
``/project/pkg/__init__.py`` -> ``pkg``
|
|
647
|
+
"""
|
|
648
|
+
p = Path(file_path)
|
|
649
|
+
parts = list(p.with_suffix("").parts)
|
|
650
|
+
if parts and parts[-1] == "__init__":
|
|
651
|
+
parts.pop()
|
|
652
|
+
return ".".join(parts[-3:]) if len(parts) > 3 else ".".join(parts)
|