yamlgraph 0.3.9__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.
Files changed (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
@@ -0,0 +1,420 @@
1
+ """AI helper tools for implementation agent.
2
+
3
+ Tools that help AI assistants work more effectively:
4
+ - summarize_module: Compress module info for context windows
5
+ - diff_preview: Validate patches before suggesting
6
+ - find_similar_code: Find similar patterns to follow
7
+ """
8
+
9
+ import ast
10
+ import difflib
11
+ from pathlib import Path
12
+
13
+
14
+ def summarize_module(file_path: str, max_length: int = 1500) -> dict:
15
+ """Summarize a Python module for AI context compression.
16
+
17
+ Extracts:
18
+ - Module docstring
19
+ - Class names with method signatures
20
+ - Function signatures (no bodies)
21
+ - Import summary
22
+
23
+ Args:
24
+ file_path: Path to the Python file
25
+ max_length: Maximum summary length in characters
26
+
27
+ Returns:
28
+ dict with 'summary' string and 'original_lines' count
29
+ or dict with 'error' key if failed
30
+ """
31
+ path = Path(file_path)
32
+ if not path.exists():
33
+ return {"error": f"File not found: {file_path}"}
34
+
35
+ try:
36
+ source = path.read_text()
37
+ tree = ast.parse(source)
38
+ except SyntaxError as e:
39
+ return {"error": f"Syntax error: {e}"}
40
+
41
+ original_lines = len(source.splitlines())
42
+ parts = []
43
+
44
+ # Module docstring
45
+ docstring = ast.get_docstring(tree)
46
+ if docstring:
47
+ # Truncate long docstrings
48
+ if len(docstring) > 200:
49
+ docstring = docstring[:200] + "..."
50
+ parts.append(f'"""{docstring}"""')
51
+
52
+ # Imports (summarized)
53
+ imports = []
54
+ for node in ast.iter_child_nodes(tree):
55
+ if isinstance(node, ast.Import):
56
+ imports.extend(alias.name for alias in node.names)
57
+ elif isinstance(node, ast.ImportFrom):
58
+ imports.append(f"{node.module or ''}")
59
+
60
+ if imports:
61
+ # Group and summarize
62
+ unique_imports = sorted(set(imports))[:10] # Top 10
63
+ parts.append(f"# Imports: {', '.join(unique_imports)}")
64
+
65
+ # Classes and functions
66
+ for node in ast.iter_child_nodes(tree):
67
+ if isinstance(node, ast.ClassDef):
68
+ class_doc = ast.get_docstring(node) or ""
69
+ if len(class_doc) > 100:
70
+ class_doc = class_doc[:100] + "..."
71
+
72
+ # Get bases
73
+ bases = [_get_name(b) for b in node.bases]
74
+ bases_str = f"({', '.join(bases)})" if bases else ""
75
+
76
+ parts.append(f"\nclass {node.name}{bases_str}:")
77
+ if class_doc:
78
+ parts.append(f' """{class_doc}"""')
79
+
80
+ # Method signatures only
81
+ for item in node.body:
82
+ if isinstance(item, ast.FunctionDef):
83
+ sig = _get_signature(item)
84
+ parts.append(f" {sig}")
85
+
86
+ elif isinstance(node, ast.FunctionDef):
87
+ func_doc = ast.get_docstring(node) or ""
88
+ if len(func_doc) > 100:
89
+ func_doc = func_doc[:100] + "..."
90
+
91
+ sig = _get_signature(node)
92
+ parts.append(f"\n{sig}")
93
+ if func_doc:
94
+ parts.append(f' """{func_doc}"""')
95
+
96
+ summary = "\n".join(parts)
97
+
98
+ # Truncate if needed
99
+ if len(summary) > max_length:
100
+ summary = summary[: max_length - 3] + "..."
101
+
102
+ return {"summary": summary, "original_lines": original_lines}
103
+
104
+
105
+ def _get_name(node: ast.expr) -> str:
106
+ """Get name from AST node."""
107
+ if isinstance(node, ast.Name):
108
+ return node.id
109
+ elif isinstance(node, ast.Attribute):
110
+ return f"{_get_name(node.value)}.{node.attr}"
111
+ return "?"
112
+
113
+
114
+ def _get_signature(func: ast.FunctionDef) -> str:
115
+ """Extract function signature as string."""
116
+ args = []
117
+ for arg in func.args.args:
118
+ arg_str = arg.arg
119
+ if arg.annotation:
120
+ arg_str += f": {_get_annotation(arg.annotation)}"
121
+ args.append(arg_str)
122
+
123
+ # Add *args, **kwargs
124
+ if func.args.vararg:
125
+ args.append(f"*{func.args.vararg.arg}")
126
+ if func.args.kwarg:
127
+ args.append(f"**{func.args.kwarg.arg}")
128
+
129
+ args_str = ", ".join(args)
130
+
131
+ # Return type
132
+ returns = ""
133
+ if func.returns:
134
+ returns = f" -> {_get_annotation(func.returns)}"
135
+
136
+ # Decorators
137
+ decorators = ""
138
+ for dec in func.decorator_list:
139
+ dec_name = _get_name(dec) if isinstance(dec, ast.Name | ast.Attribute) else "?"
140
+ decorators += f"@{dec_name}\n "
141
+
142
+ return f"{decorators}def {func.name}({args_str}){returns}: ..."
143
+
144
+
145
+ def _get_annotation(node: ast.expr) -> str:
146
+ """Get type annotation as string."""
147
+ if isinstance(node, ast.Name):
148
+ return node.id
149
+ elif isinstance(node, ast.Constant):
150
+ return repr(node.value)
151
+ elif isinstance(node, ast.Subscript):
152
+ return f"{_get_name(node.value)}[{_get_annotation(node.slice)}]"
153
+ elif isinstance(node, ast.Tuple):
154
+ return ", ".join(_get_annotation(e) for e in node.elts)
155
+ return "..."
156
+
157
+
158
+ def diff_preview(
159
+ file_path: str,
160
+ line: int,
161
+ action: str,
162
+ new_code: str,
163
+ validate_syntax: bool = False,
164
+ ) -> dict:
165
+ """Preview what a patch would look like applied.
166
+
167
+ Args:
168
+ file_path: Path to the file
169
+ line: Line number (1-indexed)
170
+ action: ADD, MODIFY, or DELETE
171
+ new_code: New code to add/replace
172
+ validate_syntax: Whether to check syntax of result
173
+
174
+ Returns:
175
+ dict with 'diff' string showing the change
176
+ or dict with 'error' key if failed
177
+ """
178
+ path = Path(file_path)
179
+ if not path.exists():
180
+ return {"error": f"File not found: {file_path}"}
181
+
182
+ lines = path.read_text().splitlines(keepends=True)
183
+
184
+ if line < 1 or line > len(lines) + 1:
185
+ return {"error": f"Invalid line {line}, file has {len(lines)} lines"}
186
+
187
+ # Create modified version
188
+ new_lines = lines.copy()
189
+ action = action.upper()
190
+
191
+ if action == "ADD":
192
+ # Insert after the specified line
193
+ insert_pos = min(line, len(new_lines))
194
+ new_lines.insert(insert_pos, new_code + "\n")
195
+ elif action == "MODIFY":
196
+ if line > len(lines):
197
+ return {"error": f"Cannot modify line {line}, file has {len(lines)} lines"}
198
+ new_lines[line - 1] = new_code + "\n"
199
+ elif action == "DELETE":
200
+ if line > len(lines):
201
+ return {"error": f"Cannot delete line {line}, file has {len(lines)} lines"}
202
+ del new_lines[line - 1]
203
+ else:
204
+ return {"error": f"Invalid action: {action}. Use ADD, MODIFY, or DELETE"}
205
+
206
+ # Generate unified diff
207
+ diff = difflib.unified_diff(
208
+ lines,
209
+ new_lines,
210
+ fromfile=f"a/{file_path}",
211
+ tofile=f"b/{file_path}",
212
+ lineterm="",
213
+ )
214
+ diff_str = "\n".join(diff)
215
+
216
+ result = {"diff": diff_str}
217
+
218
+ # Optionally validate syntax
219
+ if validate_syntax:
220
+ try:
221
+ ast.parse("".join(new_lines))
222
+ result["syntax_valid"] = True
223
+ except SyntaxError:
224
+ result["syntax_valid"] = False
225
+
226
+ return result
227
+
228
+
229
+ def find_similar_code(
230
+ file_path: str,
231
+ symbol_name: str,
232
+ project_path: str,
233
+ max_results: int = 5,
234
+ ) -> dict:
235
+ """Find code similar to the specified function/class.
236
+
237
+ Similarity based on:
238
+ - Parameter patterns
239
+ - Return type
240
+ - Decorator usage
241
+ - Error handling patterns
242
+
243
+ Args:
244
+ file_path: Path to file containing the symbol
245
+ symbol_name: Name of function/class to find similar to
246
+ project_path: Root path to search for similar code
247
+ max_results: Maximum number of results
248
+
249
+ Returns:
250
+ dict with 'similar' list of matches
251
+ or dict with 'error' key if failed
252
+ """
253
+ source_path = Path(file_path)
254
+ if not source_path.exists():
255
+ return {"error": f"File not found: {file_path}"}
256
+
257
+ project = Path(project_path)
258
+ if not project.exists():
259
+ return {"error": f"Project path not found: {project_path}"}
260
+
261
+ # Parse source and find target symbol
262
+ try:
263
+ source = source_path.read_text()
264
+ tree = ast.parse(source)
265
+ except SyntaxError as e:
266
+ return {"error": f"Syntax error in {file_path}: {e}"}
267
+
268
+ target = None
269
+ for node in ast.walk(tree):
270
+ if (
271
+ isinstance(node, ast.FunctionDef)
272
+ and node.name == symbol_name
273
+ or isinstance(node, ast.ClassDef)
274
+ and node.name == symbol_name
275
+ ):
276
+ target = node
277
+ break
278
+
279
+ if target is None:
280
+ return {"error": f"Symbol '{symbol_name}' not found in {file_path}"}
281
+
282
+ # Extract target characteristics
283
+ target_traits = _extract_traits(target)
284
+
285
+ # Search for similar code
286
+ similar = []
287
+
288
+ for py_file in project.rglob("*.py"):
289
+ if py_file == source_path:
290
+ continue
291
+
292
+ try:
293
+ file_source = py_file.read_text()
294
+ file_tree = ast.parse(file_source)
295
+ except (SyntaxError, UnicodeDecodeError):
296
+ continue
297
+
298
+ for node in ast.walk(file_tree):
299
+ if isinstance(node, ast.FunctionDef | ast.ClassDef):
300
+ if node.name == symbol_name:
301
+ continue # Skip exact matches
302
+
303
+ node_traits = _extract_traits(node)
304
+ score, reasons = _compare_traits(target_traits, node_traits)
305
+
306
+ if score > 0.3: # Threshold
307
+ # Get code snippet
308
+ lines = file_source.splitlines()
309
+ start = node.lineno - 1
310
+ end = min(start + 5, len(lines))
311
+ snippet = "\n".join(lines[start:end])
312
+
313
+ similar.append(
314
+ {
315
+ "file": str(py_file),
316
+ "name": node.name,
317
+ "line": node.lineno,
318
+ "score": round(score, 2),
319
+ "reason": ", ".join(reasons),
320
+ "snippet": snippet,
321
+ }
322
+ )
323
+
324
+ # Sort by score and limit
325
+ similar.sort(key=lambda x: x["score"], reverse=True)
326
+ similar = similar[:max_results]
327
+
328
+ return {"similar": similar}
329
+
330
+
331
+ def _extract_traits(node: ast.FunctionDef | ast.ClassDef) -> dict:
332
+ """Extract characteristics for comparison."""
333
+ traits = {
334
+ "type": type(node).__name__,
335
+ "param_count": 0,
336
+ "has_return_type": False,
337
+ "decorators": [],
338
+ "has_try_except": False,
339
+ "returns_dict": False,
340
+ "has_docstring": False,
341
+ }
342
+
343
+ if isinstance(node, ast.FunctionDef):
344
+ traits["param_count"] = len(node.args.args)
345
+ traits["has_return_type"] = node.returns is not None
346
+ traits["decorators"] = [
347
+ _get_name(d)
348
+ for d in node.decorator_list
349
+ if isinstance(d, ast.Name | ast.Attribute)
350
+ ]
351
+ traits["has_docstring"] = ast.get_docstring(node) is not None
352
+
353
+ # Check for try/except and dict return
354
+ for child in ast.walk(node):
355
+ if isinstance(child, ast.Try):
356
+ traits["has_try_except"] = True
357
+ if isinstance(child, ast.Return) and isinstance(child.value, ast.Dict):
358
+ traits["returns_dict"] = True
359
+
360
+ elif isinstance(node, ast.ClassDef):
361
+ traits["has_docstring"] = ast.get_docstring(node) is not None
362
+ traits["decorators"] = [
363
+ _get_name(d)
364
+ for d in node.decorator_list
365
+ if isinstance(d, ast.Name | ast.Attribute)
366
+ ]
367
+ # Count methods
368
+ traits["param_count"] = sum(
369
+ 1 for n in node.body if isinstance(n, ast.FunctionDef)
370
+ )
371
+
372
+ return traits
373
+
374
+
375
+ def _compare_traits(a: dict, b: dict) -> tuple[float, list[str]]:
376
+ """Compare two trait dicts and return similarity score + reasons."""
377
+ score = 0.0
378
+ reasons = []
379
+
380
+ # Same type (function vs class)
381
+ if a["type"] == b["type"]:
382
+ score += 0.2
383
+ reasons.append(f"same type ({a['type']})")
384
+
385
+ # Similar param count
386
+ if (
387
+ a["type"] == "FunctionDef"
388
+ and b["type"] == "FunctionDef"
389
+ and abs(a["param_count"] - b["param_count"]) <= 1
390
+ ):
391
+ score += 0.2
392
+ reasons.append("similar params")
393
+
394
+ # Both have return types
395
+ if a.get("has_return_type") and b.get("has_return_type"):
396
+ score += 0.1
397
+ reasons.append("typed return")
398
+
399
+ # Both have try/except
400
+ if a.get("has_try_except") and b.get("has_try_except"):
401
+ score += 0.2
402
+ reasons.append("error handling")
403
+
404
+ # Both return dict
405
+ if a.get("returns_dict") and b.get("returns_dict"):
406
+ score += 0.2
407
+ reasons.append("returns dict")
408
+
409
+ # Shared decorators
410
+ shared_decorators = set(a["decorators"]) & set(b["decorators"])
411
+ if shared_decorators:
412
+ score += 0.1
413
+ reasons.append(f"decorators: {shared_decorators}")
414
+
415
+ # Both have docstrings
416
+ if a.get("has_docstring") and b.get("has_docstring"):
417
+ score += 0.1
418
+ reasons.append("documented")
419
+
420
+ return score, reasons
@@ -0,0 +1,92 @@
1
+ """AST-based code analysis tools.
2
+
3
+ Provides structural analysis of Python files using the stdlib ast module.
4
+ Returns classes, functions, imports with line numbers for precise navigation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def get_module_structure(file_path: str) -> dict:
17
+ """Extract structure from Python file using AST.
18
+
19
+ Args:
20
+ file_path: Path to Python file
21
+
22
+ Returns:
23
+ Dict with imports, classes, functions and their line numbers.
24
+ Returns {"error": "..."} on failure.
25
+ """
26
+ path = Path(file_path)
27
+ if not path.exists():
28
+ return {"error": f"File not found: {file_path}"}
29
+
30
+ try:
31
+ source = path.read_text()
32
+ tree = ast.parse(source)
33
+ except SyntaxError as e:
34
+ return {"error": f"Syntax error: {e}"}
35
+
36
+ return {
37
+ "file": str(path),
38
+ "docstring": ast.get_docstring(tree),
39
+ "imports": _extract_imports(tree),
40
+ "classes": _extract_classes(tree),
41
+ "functions": _extract_functions(tree.body),
42
+ }
43
+
44
+
45
+ def _extract_imports(tree: ast.Module) -> list[dict]:
46
+ """Extract import statements from module."""
47
+ imports = []
48
+ for node in tree.body:
49
+ if isinstance(node, ast.Import):
50
+ for alias in node.names:
51
+ imports.append({"module": alias.name, "alias": alias.asname})
52
+ elif isinstance(node, ast.ImportFrom):
53
+ imports.append(
54
+ {
55
+ "module": node.module,
56
+ "names": [a.name for a in node.names],
57
+ }
58
+ )
59
+ return imports
60
+
61
+
62
+ def _extract_classes(tree: ast.Module) -> list[dict]:
63
+ """Extract class definitions from module."""
64
+ return [
65
+ {
66
+ "name": node.name,
67
+ "bases": [ast.unparse(b) for b in node.bases],
68
+ "methods": [n.name for n in node.body if isinstance(n, ast.FunctionDef)],
69
+ "line": node.lineno,
70
+ "end_line": node.end_lineno,
71
+ "docstring": ast.get_docstring(node),
72
+ }
73
+ for node in tree.body
74
+ if isinstance(node, ast.ClassDef)
75
+ ]
76
+
77
+
78
+ def _extract_functions(body: list) -> list[dict]:
79
+ """Extract function definitions from module body (top-level only)."""
80
+ return [
81
+ {
82
+ "name": node.name,
83
+ "args": [arg.arg for arg in node.args.args],
84
+ "returns": ast.unparse(node.returns) if node.returns else None,
85
+ "decorators": [ast.unparse(d) for d in node.decorator_list],
86
+ "line": node.lineno,
87
+ "end_line": node.end_lineno,
88
+ "docstring": ast.get_docstring(node),
89
+ }
90
+ for node in body
91
+ if isinstance(node, ast.FunctionDef)
92
+ ]
@@ -0,0 +1,180 @@
1
+ """Code context tools for reading specific code sections.
2
+
3
+ Provides targeted reading after structure analysis identifies relevant locations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def read_lines(file_path: str, start_line: int, end_line: int) -> str | dict:
16
+ """Read specific lines from a file.
17
+
18
+ Use this AFTER getting line ranges from structure tools like get_module_structure.
19
+
20
+ Args:
21
+ file_path: Path to file
22
+ start_line: Start line (1-indexed, inclusive)
23
+ end_line: End line (1-indexed, inclusive)
24
+
25
+ Returns:
26
+ String with the requested lines, or error dict if file not found.
27
+ """
28
+ # Validate line arguments - handle placeholder strings like 'TBD' or '<dynamic>'
29
+ try:
30
+ start_line = int(start_line)
31
+ end_line = int(end_line)
32
+ except (ValueError, TypeError):
33
+ return {
34
+ "error": f"Invalid line numbers: start_line={start_line!r}, end_line={end_line!r}. "
35
+ "Use get_structure first to get actual line numbers."
36
+ }
37
+
38
+ path = Path(file_path)
39
+ if not path.exists():
40
+ return {"error": f"File not found: {file_path}"}
41
+
42
+ lines = path.read_text().splitlines(keepends=True)
43
+
44
+ # Convert to 0-indexed
45
+ start = max(0, start_line - 1)
46
+ end = min(len(lines), end_line)
47
+
48
+ # Handle invalid range
49
+ if start >= end:
50
+ return ""
51
+
52
+ return "".join(lines[start:end])
53
+
54
+
55
+ def find_related_tests(symbol_name: str, tests_path: str = "tests") -> list[dict]:
56
+ """Find test functions related to a symbol.
57
+
58
+ Searches test files for functions that mention the symbol name.
59
+ Uses simple text matching (case-insensitive) in test function bodies.
60
+
61
+ Args:
62
+ symbol_name: Name of symbol to search for (function, class, etc.)
63
+ tests_path: Path to tests directory
64
+
65
+ Returns:
66
+ List of test info dicts with file, line, test_name.
67
+ """
68
+ path = Path(tests_path)
69
+ if not path.exists():
70
+ return []
71
+
72
+ results = []
73
+ symbol_lower = symbol_name.lower()
74
+
75
+ for test_file in sorted(path.rglob("test_*.py")):
76
+ # Skip __pycache__
77
+ if "__pycache__" in str(test_file):
78
+ continue
79
+
80
+ try:
81
+ source = test_file.read_text()
82
+ tree = ast.parse(source)
83
+ except SyntaxError:
84
+ logger.warning(f"Skipping {test_file}: syntax error")
85
+ continue
86
+
87
+ for node in ast.walk(tree):
88
+ if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
89
+ # Get the source of the test function
90
+ try:
91
+ test_source = ast.unparse(node)
92
+ except Exception:
93
+ # Fallback: check if symbol appears in function body lines
94
+ func_lines = source.splitlines()[node.lineno - 1 : node.end_lineno]
95
+ test_source = "\n".join(func_lines)
96
+
97
+ if symbol_lower in test_source.lower():
98
+ results.append(
99
+ {
100
+ "file": str(test_file),
101
+ "line": node.lineno,
102
+ "test_name": node.name,
103
+ }
104
+ )
105
+
106
+ return results
107
+
108
+
109
+ def search_in_file(
110
+ file_path: str, pattern: str, case_sensitive: bool = False
111
+ ) -> list[dict] | dict:
112
+ """Search for a pattern in a file and return matching lines.
113
+
114
+ Use this to verify if a symbol/field exists before suggesting changes.
115
+
116
+ Args:
117
+ file_path: Path to file to search
118
+ pattern: Text pattern to search for
119
+ case_sensitive: If True, match case exactly (default: False)
120
+
121
+ Returns:
122
+ List of matches with line number and text, or error dict.
123
+ """
124
+ path = Path(file_path)
125
+ if not path.exists():
126
+ return {"error": f"File not found: {file_path}"}
127
+
128
+ results = []
129
+ search_pattern = pattern if case_sensitive else pattern.lower()
130
+
131
+ for i, line in enumerate(path.read_text().splitlines(), start=1):
132
+ check_line = line if case_sensitive else line.lower()
133
+ if search_pattern in check_line:
134
+ results.append({"line": i, "text": line.strip()})
135
+
136
+ return results
137
+
138
+
139
+ def search_codebase(directory: str, query: str, pattern: str = "*.py") -> list[dict]:
140
+ """Search for a pattern across multiple files in a directory.
141
+
142
+ Like grep -r, searches recursively for text matches.
143
+
144
+ Args:
145
+ directory: Root directory to search
146
+ query: Text pattern to search for (case-insensitive)
147
+ pattern: Glob pattern for files to search (default: *.py)
148
+
149
+ Returns:
150
+ List of file results, each with file path and list of matches.
151
+ """
152
+ path = Path(directory)
153
+ if not path.exists():
154
+ return []
155
+
156
+ results = []
157
+ search_text = query.lower()
158
+
159
+ for file_path in sorted(path.rglob(pattern)):
160
+ # Skip __pycache__ and other hidden dirs
161
+ if "__pycache__" in str(file_path) or "/.git/" in str(file_path):
162
+ continue
163
+
164
+ if not file_path.is_file():
165
+ continue
166
+
167
+ try:
168
+ content = file_path.read_text()
169
+ except (OSError, UnicodeDecodeError):
170
+ continue
171
+
172
+ matches = []
173
+ for i, line in enumerate(content.splitlines(), start=1):
174
+ if search_text in line.lower():
175
+ matches.append({"line": i, "text": line.strip()})
176
+
177
+ if matches:
178
+ results.append({"file": str(file_path), "matches": matches})
179
+
180
+ return results