feat(mcp): finalize Python structural tools with security checks and indentation normalization
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import os
|
||||
import pytest
|
||||
from scripts import py_struct_tools
|
||||
from src import mcp_client
|
||||
|
||||
@pytest.fixture
|
||||
def temp_py_file(tmp_path):
|
||||
p = tmp_path / "sample.py"
|
||||
content = """class MyClass:
|
||||
\"\"\"Docstring.\"\"\"
|
||||
def method1(self):
|
||||
print("m1")
|
||||
|
||||
def top_func():
|
||||
\"\"\"Top doc.\"\"\"
|
||||
print("top")
|
||||
"""
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
def test_find_definition_range():
|
||||
source = """class A:
|
||||
def m(self): pass
|
||||
def f(): pass
|
||||
"""
|
||||
assert py_struct_tools.find_definition_range(source, "A") == (1, 2)
|
||||
assert py_struct_tools.find_definition_range(source, "A.m") == (2, 2)
|
||||
assert py_struct_tools.find_definition_range(source, "f") == (3, 3)
|
||||
assert py_struct_tools.find_definition_range(source, "nonexistent") is None
|
||||
|
||||
def test_shift_indentation():
|
||||
payload = "def f():\n print('hi')" # 2-space
|
||||
shifted = py_struct_tools.shift_indentation(payload, 1)
|
||||
assert shifted == " def f():\n print('hi')" # wait, shift_indentation strips min and prepends.
|
||||
|
||||
# Let's re-test shift_indentation logic
|
||||
# Original:
|
||||
# line 1: 'def f():' (0 indent)
|
||||
# line 2: ' print('hi')' (2 indent)
|
||||
# min_indent = 0
|
||||
# Prepend 1 space:
|
||||
# ' def f():'
|
||||
# ' print('hi')'
|
||||
|
||||
# If payload was:
|
||||
# def f():
|
||||
# print('hi')
|
||||
# min_indent = 2
|
||||
# target_depth = 1
|
||||
# ' def f():'
|
||||
# ' print('hi')'
|
||||
|
||||
payload2 = " def f():\n print('hi')"
|
||||
shifted2 = py_struct_tools.shift_indentation(payload2, 1)
|
||||
assert shifted2 == " def f():\n print('hi')"
|
||||
|
||||
def test_py_remove_def(temp_py_file):
|
||||
err = py_struct_tools.py_remove_def(temp_py_file, "MyClass.method1")
|
||||
assert err == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert "def method1" not in content
|
||||
assert "class MyClass" in content
|
||||
|
||||
def test_py_add_def(temp_py_file):
|
||||
new_code = "def method2(self):\n print('m2')"
|
||||
err = py_struct_tools.py_add_def(temp_py_file, "MyClass", new_code, "after", "method1")
|
||||
assert err == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert "def method2" in content
|
||||
# Check 1-space indentation
|
||||
assert " def method2(self):" in content
|
||||
|
||||
def test_py_region_wrap(temp_py_file):
|
||||
err = py_struct_tools.py_region_wrap(temp_py_file, 6, 8, "MyRegion")
|
||||
assert err == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert "#region: MyRegion" in content
|
||||
assert "#endregion: MyRegion" in content
|
||||
|
||||
def test_mcp_dispatch_integration(temp_py_file):
|
||||
# Mock allowlist
|
||||
mcp_client.configure([{"path": temp_py_file}])
|
||||
|
||||
# Test py_remove_def
|
||||
result = mcp_client.dispatch("py_remove_def", {"path": temp_py_file, "name": "top_func"})
|
||||
assert result == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert "def top_func" not in content
|
||||
|
||||
# Test py_add_def (module level top)
|
||||
result = mcp_client.dispatch("py_add_def", {
|
||||
"path": temp_py_file,
|
||||
"name": "",
|
||||
"new_content": "def head_func():\n print('head')",
|
||||
"anchor_type": "top"
|
||||
})
|
||||
assert result == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert content.startswith("def head_func")
|
||||
|
||||
# Test py_add_def (class bottom)
|
||||
result = mcp_client.dispatch("py_add_def", {
|
||||
"path": temp_py_file,
|
||||
"name": "MyClass",
|
||||
"new_content": "def tail_method(self):\n print('tail')",
|
||||
"anchor_type": "bottom"
|
||||
})
|
||||
assert result == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert "def tail_method" in content
|
||||
assert " def tail_method(self):" in content # Check indent
|
||||
|
||||
# Test py_move_def (cross-file simulated with same file)
|
||||
# We move method1 to after tail_method
|
||||
result = mcp_client.dispatch("py_move_def", {
|
||||
"src_path": temp_py_file,
|
||||
"dest_path": temp_py_file,
|
||||
"name": "MyClass.method1",
|
||||
"dest_name": "MyClass",
|
||||
"anchor_type": "after",
|
||||
"anchor_symbol": "tail_method"
|
||||
})
|
||||
assert result == ""
|
||||
with open(temp_py_file, 'r') as f:
|
||||
content = f.read()
|
||||
# method1 should now be AFTER tail_method
|
||||
assert content.find("def method1") > content.find("def tail_method")
|
||||
|
||||
def test_mcp_dispatch_errors(temp_py_file):
|
||||
mcp_client.configure([{"path": temp_py_file}])
|
||||
|
||||
# Non-existent symbol
|
||||
result = mcp_client.dispatch("py_remove_def", {"path": temp_py_file, "name": "NoSuchSymbol"})
|
||||
assert "ERROR" in result or "not found" in result
|
||||
|
||||
# Denied path
|
||||
result = mcp_client.dispatch("py_remove_def", {"path": "C:/windows/system32/cmd.exe", "name": "foo"})
|
||||
assert "ACCESS DENIED" in result
|
||||
Reference in New Issue
Block a user