feat(python-tools): Implement core logic for structural MCP tools
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# Implementation Plan: Python Structural MCP Tools
|
# Implementation Plan: Python Structural MCP Tools
|
||||||
|
|
||||||
## Phase 1: Core Logic Implementation
|
## Phase 1: Core Logic Implementation
|
||||||
- [ ] Task: Create `scripts/py_struct_tools.py` to house shared AST and regex manipulation logic.
|
- [x] Task: Create `scripts/py_struct_tools.py` to house shared AST and regex manipulation logic. [fa82c1a]
|
||||||
- [ ] Task: Implement the "Surgical Indentation Shifter" utility to enforce 1-space indentation context without destructive full-file formatting.
|
- [x] Task: Implement the "Surgical Indentation Shifter" utility to enforce 1-space indentation context without destructive full-file formatting. [fa82c1a]
|
||||||
- [ ] Task: Implement `py_remove_def` core logic.
|
- [x] Task: Implement `py_remove_def` core logic. [fa82c1a]
|
||||||
- [ ] Task: Implement `py_add_def` core logic.
|
- [x] Task: Implement `py_add_def` core logic. [fa82c1a]
|
||||||
- [ ] Task: Implement `py_move_def` core logic.
|
- [x] Task: Implement `py_move_def` core logic. [fa82c1a]
|
||||||
- [ ] Task: Implement `py_region_wrap` core logic.
|
- [x] Task: Implement `py_region_wrap` core logic. [fa82c1a]
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Core Logic Implementation' (Protocol in workflow.md)
|
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Core Logic Implementation' (Protocol in workflow.md)
|
||||||
|
|
||||||
## Phase 2: MCP Client Integration
|
## Phase 2: MCP Client Integration
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def find_definition_range(source: str, symbol_path: str) -> tuple[int, int] | None:
|
||||||
|
"""
|
||||||
|
Finds the start and end line numbers for a given symbol path (e.g., 'ClassName', 'ClassName.method', 'function_name').
|
||||||
|
[C: scripts/py_struct_tools.py:py_remove_def, scripts/py_struct_tools.py:py_move_def]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return None
|
||||||
|
parts = symbol_path.split('.')
|
||||||
|
current_nodes = tree.body
|
||||||
|
target_node = None
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
found = False
|
||||||
|
for node in current_nodes:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == part:
|
||||||
|
if i == len(parts) - 1:
|
||||||
|
target_node = node
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if isinstance(node, ast.ClassDef):
|
||||||
|
current_nodes = node.body
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
return None
|
||||||
|
if target_node:
|
||||||
|
start = target_node.lineno
|
||||||
|
if target_node.decorator_list:
|
||||||
|
start = target_node.decorator_list[0].lineno
|
||||||
|
return (start, target_node.end_lineno)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shift_indentation(content: str, target_depth: int) -> str:
|
||||||
|
"""
|
||||||
|
Shifts the indentation of a code block to the target depth using 1-space units.
|
||||||
|
[C: scripts/py_struct_tools.py:py_add_def]
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
min_indent = sys.maxsize
|
||||||
|
for line in lines:
|
||||||
|
if line.strip():
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
if indent < min_indent:
|
||||||
|
min_indent = indent
|
||||||
|
if min_indent == sys.maxsize:
|
||||||
|
min_indent = 0
|
||||||
|
shifted_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if line.strip():
|
||||||
|
shifted_lines.append(" " * target_depth + line[min_indent:])
|
||||||
|
else:
|
||||||
|
shifted_lines.append("")
|
||||||
|
return "\n".join(shifted_lines) + ("\n" if content.endswith("\n") else "")
|
||||||
|
|
||||||
|
def py_remove_def(filepath: str, symbol_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Removes a definition from a file. Returns error message or empty string on success.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return f"File not found: {filepath}"
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
range_info = find_definition_range(content, symbol_path)
|
||||||
|
if not range_info:
|
||||||
|
return f"Symbol '{symbol_path}' not found in {filepath}"
|
||||||
|
start, end = range_info
|
||||||
|
lines = content.splitlines(True)
|
||||||
|
del lines[start-1:end]
|
||||||
|
with open(filepath, 'w', encoding='utf-8', newline='') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def py_add_def(filepath: str, symbol_path: str, new_content: str, anchor_type: str, anchor_symbol: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Adds a definition to a file.
|
||||||
|
anchor_type: 'before', 'after', 'top', 'bottom'
|
||||||
|
anchor_symbol: if 'before' or 'after', the symbol to anchor to.
|
||||||
|
symbol_path: context path (e.g. 'ClassName' or '' for module level)
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return f"File not found: {filepath}"
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
lines = content.splitlines(True)
|
||||||
|
target_depth = 0
|
||||||
|
insert_idx = -1
|
||||||
|
if symbol_path:
|
||||||
|
range_info = find_definition_range(content, symbol_path)
|
||||||
|
if not range_info:
|
||||||
|
return f"Context symbol '{symbol_path}' not found"
|
||||||
|
c_start, c_end = range_info
|
||||||
|
target_depth = 1
|
||||||
|
if anchor_type == 'top':
|
||||||
|
insert_idx = c_start
|
||||||
|
for i in range(c_start, c_end):
|
||||||
|
if '"""' in lines[i] or "'''" in lines[i]:
|
||||||
|
for j in range(i, c_end):
|
||||||
|
if lines[j].count('"""') + lines[j].count("'''") == 2 or (j > i and ('"""' in lines[j] or "'''" in lines[j])):
|
||||||
|
insert_idx = j + 1
|
||||||
|
break
|
||||||
|
break
|
||||||
|
elif anchor_type == 'bottom':
|
||||||
|
insert_idx = c_end
|
||||||
|
else:
|
||||||
|
if anchor_type == 'top':
|
||||||
|
insert_idx = 0
|
||||||
|
elif anchor_type == 'bottom':
|
||||||
|
insert_idx = len(lines)
|
||||||
|
if anchor_type in ('before', 'after') and anchor_symbol:
|
||||||
|
full_anchor_path = f"{symbol_path}.{anchor_symbol}" if symbol_path else anchor_symbol
|
||||||
|
range_info = find_definition_range(content, full_anchor_path)
|
||||||
|
if not range_info:
|
||||||
|
return f"Anchor symbol '{anchor_symbol}' not found"
|
||||||
|
a_start, a_end = range_info
|
||||||
|
insert_idx = a_start - 1 if anchor_type == 'before' else a_end
|
||||||
|
if insert_idx == -1:
|
||||||
|
return f"Invalid anchor_type '{anchor_type}'"
|
||||||
|
formatted_content = shift_indentation(new_content, target_depth)
|
||||||
|
if not formatted_content.endswith('\n'):
|
||||||
|
formatted_content += '\n'
|
||||||
|
# Ensure at least one blank line between definitions
|
||||||
|
if insert_idx > 0 and lines[insert_idx-1].strip():
|
||||||
|
formatted_content = '\n' + formatted_content
|
||||||
|
if insert_idx < len(lines) and lines[insert_idx].strip():
|
||||||
|
formatted_content = formatted_content + '\n'
|
||||||
|
lines.insert(insert_idx, formatted_content)
|
||||||
|
with open(filepath, 'w', encoding='utf-8', newline='') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def py_move_def(src_file: str, dest_file: str, symbol_path: str, dest_symbol_path: str, anchor_type: str, anchor_symbol: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Moves a definition from one location to another.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(src_file):
|
||||||
|
return f"Source file not found: {src_file}"
|
||||||
|
with open(src_file, 'r', encoding='utf-8') as f:
|
||||||
|
src_content = f.read()
|
||||||
|
range_info = find_definition_range(src_content, symbol_path)
|
||||||
|
if not range_info:
|
||||||
|
return f"Symbol '{symbol_path}' not found in {src_file}"
|
||||||
|
start, end = range_info
|
||||||
|
src_lines = src_content.splitlines(True)
|
||||||
|
moved_content = "".join(src_lines[start-1:end])
|
||||||
|
err = py_add_def(dest_file, dest_symbol_path, moved_content, anchor_type, anchor_symbol)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
return py_remove_def(src_file, symbol_path)
|
||||||
|
|
||||||
|
def py_region_wrap(filepath: str, start_line: int, end_line: int, region_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Wraps a line range in region tags.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return f"File not found: {filepath}"
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
if start_line < 1 or end_line > len(lines) or start_line > end_line:
|
||||||
|
return f"Invalid line range: {start_line}-{end_line}"
|
||||||
|
indent = ""
|
||||||
|
first_line = lines[start_line-1]
|
||||||
|
if first_line.strip():
|
||||||
|
indent = first_line[:len(first_line) - len(first_line.lstrip())]
|
||||||
|
# Insert bottom first to avoid shifting top
|
||||||
|
lines.insert(end_line, f"{indent}#endregion: {region_name}\n")
|
||||||
|
lines.insert(start_line-1, f"{indent}#region: {region_name}\n")
|
||||||
|
with open(filepath, 'w', encoding='utf-8', newline='') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Simple CLI for internal use
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user