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.
- examples/__init__.py +1 -0
- examples/codegen/__init__.py +5 -0
- examples/codegen/models/__init__.py +13 -0
- examples/codegen/models/schemas.py +76 -0
- examples/codegen/tests/__init__.py +1 -0
- examples/codegen/tests/test_ai_helpers.py +235 -0
- examples/codegen/tests/test_ast_analysis.py +174 -0
- examples/codegen/tests/test_code_analysis.py +134 -0
- examples/codegen/tests/test_code_context.py +301 -0
- examples/codegen/tests/test_code_nav.py +89 -0
- examples/codegen/tests/test_dependency_tools.py +119 -0
- examples/codegen/tests/test_example_tools.py +185 -0
- examples/codegen/tests/test_git_tools.py +112 -0
- examples/codegen/tests/test_impl_agent_schemas.py +193 -0
- examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
- examples/codegen/tests/test_jedi_analysis.py +226 -0
- examples/codegen/tests/test_meta_tools.py +250 -0
- examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
- examples/codegen/tests/test_syntax_tools.py +85 -0
- examples/codegen/tests/test_synthesize_prompt.py +94 -0
- examples/codegen/tests/test_template_tools.py +244 -0
- examples/codegen/tools/__init__.py +80 -0
- examples/codegen/tools/ai_helpers.py +420 -0
- examples/codegen/tools/ast_analysis.py +92 -0
- examples/codegen/tools/code_context.py +180 -0
- examples/codegen/tools/code_nav.py +52 -0
- examples/codegen/tools/dependency_tools.py +120 -0
- examples/codegen/tools/example_tools.py +188 -0
- examples/codegen/tools/git_tools.py +151 -0
- examples/codegen/tools/impl_executor.py +614 -0
- examples/codegen/tools/jedi_analysis.py +311 -0
- examples/codegen/tools/meta_tools.py +202 -0
- examples/codegen/tools/syntax_tools.py +26 -0
- examples/codegen/tools/template_tools.py +356 -0
- examples/fastapi_interview.py +167 -0
- examples/npc/api/__init__.py +1 -0
- examples/npc/api/app.py +100 -0
- examples/npc/api/routes/__init__.py +5 -0
- examples/npc/api/routes/encounter.py +182 -0
- examples/npc/api/session.py +330 -0
- examples/npc/demo.py +387 -0
- examples/npc/nodes/__init__.py +5 -0
- examples/npc/nodes/image_node.py +92 -0
- examples/npc/run_encounter.py +230 -0
- examples/shared/__init__.py +0 -0
- examples/shared/replicate_tool.py +238 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +12 -0
- examples/storyboard/nodes/animated_character_node.py +248 -0
- examples/storyboard/nodes/animated_image_node.py +138 -0
- examples/storyboard/nodes/character_node.py +162 -0
- examples/storyboard/nodes/image_node.py +118 -0
- examples/storyboard/nodes/replicate_tool.py +49 -0
- examples/storyboard/retry_images.py +118 -0
- scripts/demo_async_executor.py +212 -0
- scripts/demo_interview_e2e.py +200 -0
- scripts/demo_streaming.py +140 -0
- scripts/run_interview_demo.py +94 -0
- scripts/test_interrupt_fix.py +26 -0
- tests/__init__.py +1 -0
- tests/conftest.py +178 -0
- tests/integration/__init__.py +1 -0
- tests/integration/test_animated_storyboard.py +63 -0
- tests/integration/test_cli_commands.py +242 -0
- tests/integration/test_colocated_prompts.py +139 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +283 -0
- tests/integration/test_npc_api/__init__.py +1 -0
- tests/integration/test_npc_api/test_routes.py +357 -0
- tests/integration/test_npc_api/test_session.py +216 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/integration/test_subgraph_integration.py +295 -0
- tests/integration/test_subgraph_interrupt.py +106 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +355 -0
- tests/unit/test_async_executor.py +346 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_checkpointer_factory.py +212 -0
- tests/unit/test_cli.py +121 -0
- tests/unit/test_cli_package.py +81 -0
- tests/unit/test_compile_graph_map.py +132 -0
- tests/unit/test_conditions_routing.py +253 -0
- tests/unit/test_config.py +93 -0
- tests/unit/test_conversation_memory.py +276 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +172 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +149 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_feature_brainstorm.py +194 -0
- tests/unit/test_format_prompt.py +145 -0
- tests/unit/test_generic_report.py +200 -0
- tests/unit/test_graph_commands.py +327 -0
- tests/unit/test_graph_linter.py +627 -0
- tests/unit/test_graph_loader.py +357 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_interrupt_node.py +182 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_json_extract.py +134 -0
- tests/unit/test_langsmith.py +600 -0
- tests/unit/test_langsmith_tools.py +204 -0
- tests/unit/test_llm_factory.py +109 -0
- tests/unit/test_llm_factory_async.py +118 -0
- tests/unit/test_loops.py +403 -0
- tests/unit/test_map_node.py +144 -0
- tests/unit/test_no_backward_compat.py +56 -0
- tests/unit/test_node_factory.py +348 -0
- tests/unit/test_passthrough_node.py +126 -0
- tests/unit/test_prompts.py +324 -0
- tests/unit/test_python_nodes.py +198 -0
- tests/unit/test_reliability.py +298 -0
- tests/unit/test_result_export.py +234 -0
- tests/unit/test_router.py +296 -0
- tests/unit/test_sanitize.py +99 -0
- tests/unit/test_schema_loader.py +295 -0
- tests/unit/test_shell_tools.py +229 -0
- tests/unit/test_state_builder.py +331 -0
- tests/unit/test_state_builder_map.py +104 -0
- tests/unit/test_state_config.py +197 -0
- tests/unit/test_streaming.py +307 -0
- tests/unit/test_subgraph.py +596 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_call_integration.py +164 -0
- tests/unit/test_tool_call_node.py +178 -0
- tests/unit/test_tool_nodes.py +129 -0
- tests/unit/test_websearch.py +234 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +159 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +231 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +541 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +70 -0
- yamlgraph/error_handlers.py +227 -0
- yamlgraph/executor.py +290 -0
- yamlgraph/executor_async.py +288 -0
- yamlgraph/graph_loader.py +451 -0
- yamlgraph/map_compiler.py +150 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +181 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +768 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +240 -0
- yamlgraph/storage/__init__.py +20 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/checkpointer_factory.py +123 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +320 -0
- yamlgraph/tools/graph_linter.py +388 -0
- yamlgraph/tools/langsmith_tools.py +125 -0
- yamlgraph/tools/nodes.py +126 -0
- yamlgraph/tools/python_tool.py +179 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/tools/websearch.py +242 -0
- yamlgraph/utils/__init__.py +48 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +245 -0
- yamlgraph/utils/json_extract.py +104 -0
- yamlgraph/utils/langsmith.py +416 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +104 -0
- yamlgraph/utils/prompts.py +171 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.3.9.dist-info/METADATA +1105 -0
- yamlgraph-0.3.9.dist-info/RECORD +185 -0
- yamlgraph-0.3.9.dist-info/WHEEL +5 -0
- yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
- yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
- 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
|