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.
Files changed (45) hide show
  1. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/PKG-INFO +1 -1
  2. codegraph_ai-0.2.2/codegraph/adapters/python_adapter.py +692 -0
  3. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/core.py +97 -1
  4. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/models.py +17 -0
  5. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/PKG-INFO +1 -1
  6. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/pyproject.toml +1 -1
  7. codegraph_ai-0.2.1/codegraph/adapters/python_adapter.py +0 -337
  8. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/README.md +0 -0
  9. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/__init__.py +0 -0
  10. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/__main__.py +0 -0
  11. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/__init__.py +0 -0
  12. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/base.py +0 -0
  13. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/c_adapter.py +0 -0
  14. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/java_adapter.py +0 -0
  15. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/adapters/js_adapter.py +0 -0
  16. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/analyzer.py +0 -0
  17. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/bug_locator.py +0 -0
  18. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/bug_parser.py +0 -0
  19. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/cli.py +0 -0
  20. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/github_client.py +0 -0
  21. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/issue_cache.py +0 -0
  22. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/issue_fetcher.py +0 -0
  23. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/mcp_server.py +0 -0
  24. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph/qa.py +0 -0
  25. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/SOURCES.txt +0 -0
  26. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/dependency_links.txt +0 -0
  27. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/entry_points.txt +0 -0
  28. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/requires.txt +0 -0
  29. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/codegraph_ai.egg-info/top_level.txt +0 -0
  30. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/setup.cfg +0 -0
  31. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_adapters.py +0 -0
  32. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_advanced.py +0 -0
  33. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_bug_locator.py +0 -0
  34. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_bug_parser.py +0 -0
  35. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_core_schema.py +0 -0
  36. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_cross_locate.py +0 -0
  37. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_impact.py +0 -0
  38. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_incremental.py +0 -0
  39. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_indexing.py +0 -0
  40. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_integration.py +0 -0
  41. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_issue_cache.py +0 -0
  42. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_java_adapter.py +0 -0
  43. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_js_adapter.py +0 -0
  44. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_models.py +0 -0
  45. {codegraph_ai-0.2.1 → codegraph_ai-0.2.2}/tests/test_similar.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-ai
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Hybrid graph + vector code intelligence powered by NeuG and zvec
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: neug
@@ -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