codegraph-ai 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/PKG-INFO +1 -1
- codegraph_ai-0.2.2/codegraph/adapters/python_adapter.py +692 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/core.py +97 -1
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/models.py +17 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/PKG-INFO +1 -1
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/pyproject.toml +1 -1
- codegraph_ai-0.2.1/codegraph/adapters/python_adapter.py +0 -337
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/README.md +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/__init__.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/__main__.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/__init__.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/base.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/c_adapter.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/java_adapter.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/js_adapter.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/analyzer.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/bug_locator.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/bug_parser.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/cli.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/github_client.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/issue_cache.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/issue_fetcher.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/mcp_server.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/qa.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/SOURCES.txt +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/dependency_links.txt +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/entry_points.txt +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/requires.txt +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/top_level.txt +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/setup.cfg +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_adapters.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_advanced.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_bug_locator.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_bug_parser.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_core_schema.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_cross_locate.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_impact.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_incremental.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_indexing.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_integration.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_issue_cache.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_java_adapter.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_js_adapter.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_models.py +0 -0
- {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_similar.py +0 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""Python source code adapter using tree-sitter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tree_sitter_language_pack import get_parser
|
|
6
|
+
|
|
7
|
+
from codegraph.adapters.base import BaseAdapter
|
|
8
|
+
from codegraph.models import (
|
|
9
|
+
CallInfo,
|
|
10
|
+
ParsedClass,
|
|
11
|
+
ParsedField,
|
|
12
|
+
ParsedFunction,
|
|
13
|
+
ParsedImport,
|
|
14
|
+
ParseResult,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _node_text(node) -> str:
|
|
19
|
+
"""Return the UTF-8 text of a tree-sitter node."""
|
|
20
|
+
return node.text.decode("utf-8") if node.text else ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _extract_docstring(body_node) -> str:
|
|
24
|
+
"""Return the leading docstring of a function/class body, if any."""
|
|
25
|
+
if body_node is None or body_node.child_count == 0:
|
|
26
|
+
return ""
|
|
27
|
+
first = body_node.children[0]
|
|
28
|
+
# tree-sitter-python may represent the docstring as:
|
|
29
|
+
# 1. expression_statement > string
|
|
30
|
+
# 2. string (directly under block)
|
|
31
|
+
string_node = None
|
|
32
|
+
if first.type == "expression_statement" and first.child_count > 0:
|
|
33
|
+
candidate = first.children[0]
|
|
34
|
+
if candidate.type == "string":
|
|
35
|
+
string_node = candidate
|
|
36
|
+
elif first.type == "string":
|
|
37
|
+
string_node = first
|
|
38
|
+
|
|
39
|
+
if string_node is not None:
|
|
40
|
+
# Try extracting from string_content child first (newer grammar)
|
|
41
|
+
for child in string_node.children:
|
|
42
|
+
if child.type == "string_content":
|
|
43
|
+
return _node_text(child).strip()
|
|
44
|
+
# Fallback: strip surrounding quotes manually
|
|
45
|
+
raw = _node_text(string_node)
|
|
46
|
+
for q in ('"""', "'''", '"', "'"):
|
|
47
|
+
if raw.startswith(q) and raw.endswith(q):
|
|
48
|
+
return raw[len(q) : -len(q)].strip()
|
|
49
|
+
return raw
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_signature(func_node, source_lines: list[str]) -> str:
|
|
54
|
+
"""Build a human-readable signature from a function_definition node."""
|
|
55
|
+
name_node = func_node.child_by_field_name("name")
|
|
56
|
+
params_node = func_node.child_by_field_name("parameters")
|
|
57
|
+
ret_node = func_node.child_by_field_name("return_type")
|
|
58
|
+
|
|
59
|
+
name = _node_text(name_node) if name_node else "?"
|
|
60
|
+
params = _node_text(params_node) if params_node else "()"
|
|
61
|
+
ret = ""
|
|
62
|
+
if ret_node:
|
|
63
|
+
ret = f" -> {_node_text(ret_node)}"
|
|
64
|
+
return f"def {name}{params}{ret}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _collect_calls(node, calls: list[CallInfo]) -> None:
|
|
68
|
+
"""Recursively collect function calls with receiver context."""
|
|
69
|
+
if node.type == "call":
|
|
70
|
+
func = node.child_by_field_name("function")
|
|
71
|
+
if func:
|
|
72
|
+
if func.type == "attribute":
|
|
73
|
+
obj_node = func.child_by_field_name("object")
|
|
74
|
+
attr_node = func.child_by_field_name("attribute")
|
|
75
|
+
receiver = _node_text(obj_node) if obj_node else None
|
|
76
|
+
callee = _node_text(attr_node) if attr_node else _node_text(func)
|
|
77
|
+
if receiver and "." in receiver:
|
|
78
|
+
receiver = receiver.rsplit(".", 1)[-1]
|
|
79
|
+
calls.append(CallInfo(
|
|
80
|
+
callee_name=callee,
|
|
81
|
+
receiver=receiver,
|
|
82
|
+
raw_expression=_node_text(func),
|
|
83
|
+
))
|
|
84
|
+
else:
|
|
85
|
+
callee = _node_text(func)
|
|
86
|
+
calls.append(CallInfo(
|
|
87
|
+
callee_name=callee,
|
|
88
|
+
receiver=None,
|
|
89
|
+
raw_expression=callee,
|
|
90
|
+
))
|
|
91
|
+
for child in node.children:
|
|
92
|
+
_collect_calls(child, calls)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_type_name(type_node) -> tuple[str | None, bool, bool]:
|
|
96
|
+
"""Extract the base type name from a type annotation node.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
(type_name, is_optional, is_list)
|
|
100
|
+
"""
|
|
101
|
+
if type_node is None:
|
|
102
|
+
return None, False, False
|
|
103
|
+
|
|
104
|
+
type_text = _node_text(type_node).strip()
|
|
105
|
+
if not type_text:
|
|
106
|
+
return None, False, False
|
|
107
|
+
|
|
108
|
+
is_optional = False
|
|
109
|
+
is_list = False
|
|
110
|
+
|
|
111
|
+
# Handle Optional[X] or X | None patterns
|
|
112
|
+
if type_text.startswith("Optional[") and type_text.endswith("]"):
|
|
113
|
+
is_optional = True
|
|
114
|
+
type_text = type_text[9:-1].strip()
|
|
115
|
+
elif " | None" in type_text or "None | " in type_text:
|
|
116
|
+
is_optional = True
|
|
117
|
+
type_text = type_text.replace(" | None", "").replace("None | ", "").strip()
|
|
118
|
+
|
|
119
|
+
# Handle List[X], list[X], Sequence[X]
|
|
120
|
+
for prefix in ("List[", "list[", "Sequence[", "Iterable["):
|
|
121
|
+
if type_text.startswith(prefix) and type_text.endswith("]"):
|
|
122
|
+
is_list = True
|
|
123
|
+
type_text = type_text[len(prefix):-1].strip()
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# Handle nested Optional in List
|
|
127
|
+
if type_text.startswith("Optional[") and type_text.endswith("]"):
|
|
128
|
+
is_optional = True
|
|
129
|
+
type_text = type_text[9:-1].strip()
|
|
130
|
+
|
|
131
|
+
# Extract simple type name (ignore generics like Dict[str, int])
|
|
132
|
+
if "[" in type_text:
|
|
133
|
+
# For complex types, just take what's before the bracket
|
|
134
|
+
type_text = type_text.split("[")[0].strip()
|
|
135
|
+
|
|
136
|
+
# Handle qualified names like module.ClassName
|
|
137
|
+
if "." in type_text:
|
|
138
|
+
type_text = type_text.rsplit(".", 1)[-1]
|
|
139
|
+
|
|
140
|
+
return type_text if type_text else None, is_optional, is_list
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_init_assignments(init_body) -> list[tuple[str, str | None]]:
|
|
144
|
+
"""Extract self.xxx = assignments from __init__ body.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of (field_name, assigned_type) where assigned_type is the
|
|
148
|
+
constructor call name (e.g., 'LlamaModel' from 'LlamaModel(...)').
|
|
149
|
+
"""
|
|
150
|
+
assignments: list[tuple[str, str | None]] = []
|
|
151
|
+
if init_body is None:
|
|
152
|
+
return assignments
|
|
153
|
+
|
|
154
|
+
for stmt in init_body.children:
|
|
155
|
+
# Handle expression_statement containing assignment
|
|
156
|
+
if stmt.type == "expression_statement":
|
|
157
|
+
for child in stmt.children:
|
|
158
|
+
if child.type == "assignment":
|
|
159
|
+
_process_assignment(child, assignments)
|
|
160
|
+
elif stmt.type == "assignment":
|
|
161
|
+
_process_assignment(stmt, assignments)
|
|
162
|
+
# Handle with statements (context managers)
|
|
163
|
+
elif stmt.type == "with_statement":
|
|
164
|
+
body = stmt.child_by_field_name("body")
|
|
165
|
+
if body:
|
|
166
|
+
assignments.extend(_extract_init_assignments(body))
|
|
167
|
+
# Handle if/try statements
|
|
168
|
+
elif stmt.type in ("if_statement", "try_statement"):
|
|
169
|
+
for child in stmt.children:
|
|
170
|
+
if child.type == "block":
|
|
171
|
+
assignments.extend(_extract_init_assignments(child))
|
|
172
|
+
|
|
173
|
+
return assignments
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _process_assignment(assign_node, assignments: list[tuple[str, str | None, bool]]) -> None:
|
|
177
|
+
"""Process a single assignment node to extract self.xxx = patterns.
|
|
178
|
+
|
|
179
|
+
Now returns (field_name, type_hint, is_optional) tuples.
|
|
180
|
+
"""
|
|
181
|
+
left = assign_node.child_by_field_name("left")
|
|
182
|
+
right = assign_node.child_by_field_name("right")
|
|
183
|
+
type_node = assign_node.child_by_field_name("type")
|
|
184
|
+
|
|
185
|
+
if left is None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Check if left side is self.xxx
|
|
189
|
+
if left.type == "attribute":
|
|
190
|
+
obj = left.child_by_field_name("object")
|
|
191
|
+
attr = left.child_by_field_name("attribute")
|
|
192
|
+
if obj and _node_text(obj) == "self" and attr:
|
|
193
|
+
field_name = _node_text(attr)
|
|
194
|
+
assigned_type = None
|
|
195
|
+
is_optional = False
|
|
196
|
+
|
|
197
|
+
# First, check if there's a type annotation (e.g., self.cache: Optional[X] = None)
|
|
198
|
+
if type_node:
|
|
199
|
+
assigned_type, is_optional, _ = _extract_type_name(type_node)
|
|
200
|
+
else:
|
|
201
|
+
# Try to extract from constructor call in RHS
|
|
202
|
+
assigned_type = _extract_constructor_type(right)
|
|
203
|
+
|
|
204
|
+
# If right side is None, mark as optional
|
|
205
|
+
if right and _node_text(right) == "None":
|
|
206
|
+
is_optional = True
|
|
207
|
+
|
|
208
|
+
assignments.append((field_name, assigned_type, is_optional))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _extract_constructor_type(node) -> str | None:
|
|
212
|
+
"""Recursively extract the constructor type from a call expression.
|
|
213
|
+
|
|
214
|
+
Handles patterns like:
|
|
215
|
+
- LlamaModel(...)
|
|
216
|
+
- internals.LlamaModel(...)
|
|
217
|
+
- self._stack.enter_context(contextlib.closing(internals.LlamaModel(...)))
|
|
218
|
+
- tokenizer or LlamaTokenizer(self) # conditional/or expression
|
|
219
|
+
"""
|
|
220
|
+
if node is None:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
# Handle conditional expression: x or Y() / x if cond else Y()
|
|
224
|
+
if node.type == "boolean_operator":
|
|
225
|
+
# For "a or b", try both sides
|
|
226
|
+
for child in node.children:
|
|
227
|
+
if child.is_named:
|
|
228
|
+
result = _extract_constructor_type(child)
|
|
229
|
+
if result:
|
|
230
|
+
return result
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
if node.type == "conditional_expression":
|
|
234
|
+
# For "a if cond else b", check consequence and alternative
|
|
235
|
+
for child in node.children:
|
|
236
|
+
if child.is_named and child.type != "identifier":
|
|
237
|
+
result = _extract_constructor_type(child)
|
|
238
|
+
if result:
|
|
239
|
+
return result
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
if node.type == "call":
|
|
243
|
+
func = node.child_by_field_name("function")
|
|
244
|
+
args = node.child_by_field_name("arguments")
|
|
245
|
+
|
|
246
|
+
if func:
|
|
247
|
+
func_text = _node_text(func)
|
|
248
|
+
|
|
249
|
+
# Check if this looks like a constructor call (starts with uppercase or is qualified)
|
|
250
|
+
if func.type == "identifier":
|
|
251
|
+
# Simple call like LlamaModel(...)
|
|
252
|
+
if func_text and func_text[0].isupper():
|
|
253
|
+
return func_text
|
|
254
|
+
elif func.type == "attribute":
|
|
255
|
+
# Qualified call like internals.LlamaModel(...)
|
|
256
|
+
attr_node = func.child_by_field_name("attribute")
|
|
257
|
+
if attr_node:
|
|
258
|
+
attr_text = _node_text(attr_node)
|
|
259
|
+
if attr_text and attr_text[0].isupper():
|
|
260
|
+
return attr_text
|
|
261
|
+
|
|
262
|
+
# This might be a wrapper call like enter_context(...) or closing(...)
|
|
263
|
+
# Look into the arguments for constructor calls
|
|
264
|
+
if args:
|
|
265
|
+
for arg in args.children:
|
|
266
|
+
if arg.is_named:
|
|
267
|
+
result = _extract_constructor_type(arg)
|
|
268
|
+
if result:
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class PythonAdapter(BaseAdapter):
|
|
275
|
+
"""Extract functions, classes, calls and imports from Python files."""
|
|
276
|
+
|
|
277
|
+
def __init__(self) -> None:
|
|
278
|
+
self._parser = get_parser("python")
|
|
279
|
+
|
|
280
|
+
# -- BaseAdapter interface ------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def language_name(self) -> str:
|
|
283
|
+
return "python"
|
|
284
|
+
|
|
285
|
+
def supported_extensions(self) -> list[str]:
|
|
286
|
+
return [".py"]
|
|
287
|
+
|
|
288
|
+
def parse_file(self, source: bytes, file_path: str) -> ParseResult:
|
|
289
|
+
tree = self._parser.parse(source)
|
|
290
|
+
root = tree.root_node
|
|
291
|
+
source_lines = source.decode("utf-8", errors="replace").splitlines()
|
|
292
|
+
|
|
293
|
+
functions: list[ParsedFunction] = []
|
|
294
|
+
classes: list[ParsedClass] = []
|
|
295
|
+
imports: list[ParsedImport] = []
|
|
296
|
+
|
|
297
|
+
self._walk_top_level(
|
|
298
|
+
root, file_path, source_lines, functions, classes, imports
|
|
299
|
+
)
|
|
300
|
+
return ParseResult(functions=functions, classes=classes, imports=imports)
|
|
301
|
+
|
|
302
|
+
# -- Internal helpers -----------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def _walk_top_level(
|
|
305
|
+
self,
|
|
306
|
+
node,
|
|
307
|
+
file_path: str,
|
|
308
|
+
source_lines: list[str],
|
|
309
|
+
functions: list[ParsedFunction],
|
|
310
|
+
classes: list[ParsedClass],
|
|
311
|
+
imports: list[ParsedImport],
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Walk top-level children of *node* and populate lists."""
|
|
314
|
+
for child in node.children:
|
|
315
|
+
if child.type == "function_definition":
|
|
316
|
+
self._extract_function(
|
|
317
|
+
child, file_path, source_lines, functions, class_name=None
|
|
318
|
+
)
|
|
319
|
+
elif child.type == "decorated_definition":
|
|
320
|
+
inner = _decorated_inner(child)
|
|
321
|
+
if inner is not None and inner.type == "function_definition":
|
|
322
|
+
self._extract_function(
|
|
323
|
+
inner, file_path, source_lines, functions, class_name=None
|
|
324
|
+
)
|
|
325
|
+
elif inner is not None and inner.type == "class_definition":
|
|
326
|
+
self._extract_class(
|
|
327
|
+
inner, file_path, source_lines, functions, classes
|
|
328
|
+
)
|
|
329
|
+
elif child.type == "class_definition":
|
|
330
|
+
self._extract_class(
|
|
331
|
+
child, file_path, source_lines, functions, classes
|
|
332
|
+
)
|
|
333
|
+
elif child.type in ("import_statement", "import_from_statement"):
|
|
334
|
+
self._extract_import(child, file_path, imports)
|
|
335
|
+
|
|
336
|
+
def _extract_function(
|
|
337
|
+
self,
|
|
338
|
+
func_node,
|
|
339
|
+
file_path: str,
|
|
340
|
+
source_lines: list[str],
|
|
341
|
+
functions: list[ParsedFunction],
|
|
342
|
+
class_name: str | None,
|
|
343
|
+
) -> None:
|
|
344
|
+
name_node = func_node.child_by_field_name("name")
|
|
345
|
+
name = _node_text(name_node) if name_node else "unknown"
|
|
346
|
+
start_line = func_node.start_point[0] + 1
|
|
347
|
+
end_line = func_node.end_point[0] + 1
|
|
348
|
+
|
|
349
|
+
qualified = f"{file_path}:{name}" if not class_name else f"{file_path}:{class_name}.{name}"
|
|
350
|
+
sig = _build_signature(func_node, source_lines)
|
|
351
|
+
|
|
352
|
+
body_node = func_node.child_by_field_name("body")
|
|
353
|
+
doc = _extract_docstring(body_node)
|
|
354
|
+
|
|
355
|
+
calls: list[CallInfo] = []
|
|
356
|
+
if body_node:
|
|
357
|
+
_collect_calls(body_node, calls)
|
|
358
|
+
|
|
359
|
+
functions.append(
|
|
360
|
+
ParsedFunction(
|
|
361
|
+
name=name,
|
|
362
|
+
qualified_name=qualified,
|
|
363
|
+
signature=sig,
|
|
364
|
+
file_path=file_path,
|
|
365
|
+
start_line=start_line,
|
|
366
|
+
end_line=end_line,
|
|
367
|
+
doc_comment=doc,
|
|
368
|
+
call_names=[c.callee_name for c in calls],
|
|
369
|
+
calls=calls,
|
|
370
|
+
class_name=class_name,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _extract_class(
|
|
375
|
+
self,
|
|
376
|
+
class_node,
|
|
377
|
+
file_path: str,
|
|
378
|
+
source_lines: list[str],
|
|
379
|
+
functions: list[ParsedFunction],
|
|
380
|
+
classes: list[ParsedClass],
|
|
381
|
+
) -> None:
|
|
382
|
+
name_node = class_node.child_by_field_name("name")
|
|
383
|
+
cls_name = _node_text(name_node) if name_node else "unknown"
|
|
384
|
+
start_line = class_node.start_point[0] + 1
|
|
385
|
+
end_line = class_node.end_point[0] + 1
|
|
386
|
+
qualified = f"{file_path}:{cls_name}"
|
|
387
|
+
|
|
388
|
+
base_classes: list[str] = []
|
|
389
|
+
superclasses = class_node.child_by_field_name("superclasses")
|
|
390
|
+
if superclasses:
|
|
391
|
+
for child in superclasses.children:
|
|
392
|
+
if child.is_named:
|
|
393
|
+
text = _node_text(child)
|
|
394
|
+
if text and text not in ("object",):
|
|
395
|
+
base_classes.append(text)
|
|
396
|
+
|
|
397
|
+
method_names: list[str] = []
|
|
398
|
+
fields: list[ParsedField] = []
|
|
399
|
+
init_method = None
|
|
400
|
+
|
|
401
|
+
# Maps field_name -> ParsedField for merging info from multiple sources
|
|
402
|
+
field_map: dict[str, ParsedField] = {}
|
|
403
|
+
|
|
404
|
+
body = class_node.child_by_field_name("body")
|
|
405
|
+
if body:
|
|
406
|
+
for child in body.children:
|
|
407
|
+
# Extract class-level annotated assignments: field: Type = value
|
|
408
|
+
if child.type == "expression_statement":
|
|
409
|
+
for inner in child.children:
|
|
410
|
+
if inner.type == "assignment":
|
|
411
|
+
self._extract_class_level_field(inner, field_map)
|
|
412
|
+
elif child.type == "typed_parameter" or (
|
|
413
|
+
child.type == "expression_statement" and
|
|
414
|
+
child.child_count > 0 and
|
|
415
|
+
child.children[0].type == "typed_parameter"
|
|
416
|
+
):
|
|
417
|
+
# Handle standalone type annotations (Python dataclass style)
|
|
418
|
+
self._extract_annotated_field(child, field_map)
|
|
419
|
+
|
|
420
|
+
# Extract methods
|
|
421
|
+
if child.type == "function_definition":
|
|
422
|
+
m_name = _node_text(child.child_by_field_name("name"))
|
|
423
|
+
method_names.append(m_name)
|
|
424
|
+
self._extract_function(
|
|
425
|
+
child, file_path, source_lines, functions, class_name=cls_name
|
|
426
|
+
)
|
|
427
|
+
if m_name == "__init__":
|
|
428
|
+
init_method = child
|
|
429
|
+
elif child.type == "decorated_definition":
|
|
430
|
+
inner = _decorated_inner(child)
|
|
431
|
+
if inner is not None and inner.type == "function_definition":
|
|
432
|
+
m_name = _node_text(inner.child_by_field_name("name"))
|
|
433
|
+
method_names.append(m_name)
|
|
434
|
+
self._extract_function(
|
|
435
|
+
inner, file_path, source_lines, functions, class_name=cls_name
|
|
436
|
+
)
|
|
437
|
+
if m_name == "__init__":
|
|
438
|
+
init_method = inner
|
|
439
|
+
|
|
440
|
+
# Extract fields from __init__ method
|
|
441
|
+
if init_method:
|
|
442
|
+
self._extract_init_fields(init_method, field_map)
|
|
443
|
+
|
|
444
|
+
# Convert field_map to list
|
|
445
|
+
fields = list(field_map.values())
|
|
446
|
+
|
|
447
|
+
classes.append(
|
|
448
|
+
ParsedClass(
|
|
449
|
+
name=cls_name,
|
|
450
|
+
qualified_name=qualified,
|
|
451
|
+
file_path=file_path,
|
|
452
|
+
start_line=start_line,
|
|
453
|
+
end_line=end_line,
|
|
454
|
+
method_names=method_names,
|
|
455
|
+
base_classes=base_classes,
|
|
456
|
+
fields=fields,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def _extract_class_level_field(
|
|
461
|
+
self,
|
|
462
|
+
assign_node,
|
|
463
|
+
field_map: dict[str, ParsedField],
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Extract class-level field from assignment with optional type annotation."""
|
|
466
|
+
left = assign_node.child_by_field_name("left")
|
|
467
|
+
right = assign_node.child_by_field_name("right")
|
|
468
|
+
type_node = assign_node.child_by_field_name("type")
|
|
469
|
+
|
|
470
|
+
if left is None:
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
# Handle annotated assignment: field: Type = value
|
|
474
|
+
if left.type == "identifier":
|
|
475
|
+
field_name = _node_text(left)
|
|
476
|
+
type_hint, is_optional, is_list = _extract_type_name(type_node)
|
|
477
|
+
|
|
478
|
+
# Check if default is None
|
|
479
|
+
if right and _node_text(right) == "None":
|
|
480
|
+
is_optional = True
|
|
481
|
+
|
|
482
|
+
if field_name not in field_map:
|
|
483
|
+
field_map[field_name] = ParsedField(
|
|
484
|
+
name=field_name,
|
|
485
|
+
type_hint=type_hint,
|
|
486
|
+
is_optional=is_optional,
|
|
487
|
+
is_list=is_list,
|
|
488
|
+
assigned_in_init=False,
|
|
489
|
+
)
|
|
490
|
+
else:
|
|
491
|
+
# Merge with existing info
|
|
492
|
+
if type_hint:
|
|
493
|
+
field_map[field_name].type_hint = type_hint
|
|
494
|
+
if is_optional:
|
|
495
|
+
field_map[field_name].is_optional = True
|
|
496
|
+
if is_list:
|
|
497
|
+
field_map[field_name].is_list = True
|
|
498
|
+
|
|
499
|
+
def _extract_annotated_field(
|
|
500
|
+
self,
|
|
501
|
+
node,
|
|
502
|
+
field_map: dict[str, ParsedField],
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Extract a type-annotated field declaration (dataclass style)."""
|
|
505
|
+
# Handle: field_name: Type
|
|
506
|
+
if node.type == "typed_parameter":
|
|
507
|
+
name_node = node.child_by_field_name("name")
|
|
508
|
+
type_node = node.child_by_field_name("type")
|
|
509
|
+
if name_node:
|
|
510
|
+
field_name = _node_text(name_node)
|
|
511
|
+
type_hint, is_optional, is_list = _extract_type_name(type_node)
|
|
512
|
+
if field_name not in field_map:
|
|
513
|
+
field_map[field_name] = ParsedField(
|
|
514
|
+
name=field_name,
|
|
515
|
+
type_hint=type_hint,
|
|
516
|
+
is_optional=is_optional,
|
|
517
|
+
is_list=is_list,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
def _extract_init_fields(
|
|
521
|
+
self,
|
|
522
|
+
init_node,
|
|
523
|
+
field_map: dict[str, ParsedField],
|
|
524
|
+
) -> None:
|
|
525
|
+
"""Extract fields from __init__ parameters and body assignments."""
|
|
526
|
+
# Extract parameter types
|
|
527
|
+
params_node = init_node.child_by_field_name("parameters")
|
|
528
|
+
param_types: dict[str, tuple[str | None, bool, bool]] = {}
|
|
529
|
+
|
|
530
|
+
if params_node:
|
|
531
|
+
for child in params_node.children:
|
|
532
|
+
if child.type == "typed_parameter":
|
|
533
|
+
name_node = child.child_by_field_name("name")
|
|
534
|
+
type_node = child.child_by_field_name("type")
|
|
535
|
+
if name_node:
|
|
536
|
+
param_name = _node_text(name_node)
|
|
537
|
+
param_types[param_name] = _extract_type_name(type_node)
|
|
538
|
+
elif child.type == "typed_default_parameter":
|
|
539
|
+
name_node = child.child_by_field_name("name")
|
|
540
|
+
type_node = child.child_by_field_name("type")
|
|
541
|
+
default_node = child.child_by_field_name("value")
|
|
542
|
+
if name_node:
|
|
543
|
+
param_name = _node_text(name_node)
|
|
544
|
+
type_hint, is_optional, is_list = _extract_type_name(type_node)
|
|
545
|
+
# If default is None, mark as optional
|
|
546
|
+
if default_node and _node_text(default_node) == "None":
|
|
547
|
+
is_optional = True
|
|
548
|
+
param_types[param_name] = (type_hint, is_optional, is_list)
|
|
549
|
+
elif child.type == "default_parameter":
|
|
550
|
+
name_node = child.child_by_field_name("name")
|
|
551
|
+
default_node = child.child_by_field_name("value")
|
|
552
|
+
if name_node:
|
|
553
|
+
param_name = _node_text(name_node)
|
|
554
|
+
is_optional = default_node and _node_text(default_node) == "None"
|
|
555
|
+
param_types[param_name] = (None, is_optional, False)
|
|
556
|
+
|
|
557
|
+
# Extract body assignments
|
|
558
|
+
body = init_node.child_by_field_name("body")
|
|
559
|
+
assignments = _extract_init_assignments(body)
|
|
560
|
+
|
|
561
|
+
for field_name, assigned_type, assign_is_optional in assignments:
|
|
562
|
+
# Skip private implementation details and primitive assignments
|
|
563
|
+
if field_name.startswith("__") and field_name.endswith("__"):
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
# Try to determine type from various sources
|
|
567
|
+
type_hint = assigned_type
|
|
568
|
+
is_optional = assign_is_optional
|
|
569
|
+
is_list = False
|
|
570
|
+
|
|
571
|
+
# Check if this field comes from a parameter with the same name
|
|
572
|
+
# e.g., self.cache = cache, where cache: Optional[BaseLlamaCache]
|
|
573
|
+
clean_name = field_name.lstrip("_")
|
|
574
|
+
for param_name, (param_type, param_opt, param_list) in param_types.items():
|
|
575
|
+
if param_name == clean_name or param_name == field_name:
|
|
576
|
+
if param_type and not type_hint:
|
|
577
|
+
type_hint = param_type
|
|
578
|
+
is_optional = is_optional or param_opt
|
|
579
|
+
is_list = is_list or param_list
|
|
580
|
+
break
|
|
581
|
+
|
|
582
|
+
if field_name in field_map:
|
|
583
|
+
# Merge with existing
|
|
584
|
+
existing = field_map[field_name]
|
|
585
|
+
if type_hint and not existing.type_hint:
|
|
586
|
+
existing.type_hint = type_hint
|
|
587
|
+
existing.assigned_in_init = True
|
|
588
|
+
if is_optional:
|
|
589
|
+
existing.is_optional = True
|
|
590
|
+
if is_list:
|
|
591
|
+
existing.is_list = True
|
|
592
|
+
else:
|
|
593
|
+
# Only add if we have type information
|
|
594
|
+
if type_hint:
|
|
595
|
+
field_map[field_name] = ParsedField(
|
|
596
|
+
name=field_name,
|
|
597
|
+
type_hint=type_hint,
|
|
598
|
+
is_optional=is_optional,
|
|
599
|
+
is_list=is_list,
|
|
600
|
+
assigned_in_init=True,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
@staticmethod
|
|
604
|
+
def _extract_import(
|
|
605
|
+
node,
|
|
606
|
+
file_path: str,
|
|
607
|
+
imports: list[ParsedImport],
|
|
608
|
+
) -> None:
|
|
609
|
+
"""Extract import with imported names and relative import support."""
|
|
610
|
+
if node.type == "import_statement":
|
|
611
|
+
for child in node.children:
|
|
612
|
+
if child.type == "dotted_name":
|
|
613
|
+
imports.append(
|
|
614
|
+
ParsedImport(
|
|
615
|
+
source_path=file_path,
|
|
616
|
+
target_module=_node_text(child),
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
elif child.type == "aliased_import":
|
|
620
|
+
name_node = child.child_by_field_name("name")
|
|
621
|
+
if name_node:
|
|
622
|
+
imports.append(
|
|
623
|
+
ParsedImport(
|
|
624
|
+
source_path=file_path,
|
|
625
|
+
target_module=_node_text(name_node),
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
elif node.type == "import_from_statement":
|
|
629
|
+
module_node = node.child_by_field_name("module_name")
|
|
630
|
+
if module_node is None:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
raw_module = _node_text(module_node)
|
|
634
|
+
|
|
635
|
+
is_relative = False
|
|
636
|
+
relative_level = 0
|
|
637
|
+
target_module = raw_module
|
|
638
|
+
|
|
639
|
+
if module_node.type == "relative_import":
|
|
640
|
+
is_relative = True
|
|
641
|
+
for ch in raw_module:
|
|
642
|
+
if ch == ".":
|
|
643
|
+
relative_level += 1
|
|
644
|
+
else:
|
|
645
|
+
break
|
|
646
|
+
target_module = raw_module[relative_level:]
|
|
647
|
+
elif raw_module.startswith("."):
|
|
648
|
+
is_relative = True
|
|
649
|
+
for ch in raw_module:
|
|
650
|
+
if ch == ".":
|
|
651
|
+
relative_level += 1
|
|
652
|
+
else:
|
|
653
|
+
break
|
|
654
|
+
target_module = raw_module[relative_level:]
|
|
655
|
+
|
|
656
|
+
imported_names: list[str] = []
|
|
657
|
+
past_import = False
|
|
658
|
+
for child in node.children:
|
|
659
|
+
if not child.is_named and _node_text(child) == "import":
|
|
660
|
+
past_import = True
|
|
661
|
+
continue
|
|
662
|
+
if not past_import:
|
|
663
|
+
continue
|
|
664
|
+
if child.type == "dotted_name":
|
|
665
|
+
imported_names.append(_node_text(child))
|
|
666
|
+
elif child.type == "aliased_import":
|
|
667
|
+
name_node = child.child_by_field_name("name")
|
|
668
|
+
if name_node:
|
|
669
|
+
imported_names.append(_node_text(name_node))
|
|
670
|
+
elif child.type == "wildcard_import":
|
|
671
|
+
imported_names.append("*")
|
|
672
|
+
|
|
673
|
+
imports.append(
|
|
674
|
+
ParsedImport(
|
|
675
|
+
source_path=file_path,
|
|
676
|
+
target_module=target_module,
|
|
677
|
+
imported_names=imported_names,
|
|
678
|
+
is_relative=is_relative,
|
|
679
|
+
relative_level=relative_level,
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# -- Utilities ---------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _decorated_inner(node):
|
|
688
|
+
"""Return the actual definition node wrapped by a decorated_definition."""
|
|
689
|
+
for child in node.children:
|
|
690
|
+
if child.type in ("function_definition", "class_definition"):
|
|
691
|
+
return child
|
|
692
|
+
return None
|