patchllm 0.2.2__py3-none-any.whl → 1.0.0__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.
- patchllm/__main__.py +0 -0
- patchllm/agent/__init__.py +0 -0
- patchllm/agent/actions.py +73 -0
- patchllm/agent/executor.py +57 -0
- patchllm/agent/planner.py +76 -0
- patchllm/agent/session.py +425 -0
- patchllm/cli/__init__.py +0 -0
- patchllm/cli/entrypoint.py +120 -0
- patchllm/cli/handlers.py +192 -0
- patchllm/cli/helpers.py +72 -0
- patchllm/interactive/__init__.py +0 -0
- patchllm/interactive/selector.py +100 -0
- patchllm/llm.py +39 -0
- patchllm/main.py +1 -323
- patchllm/parser.py +120 -64
- patchllm/patcher.py +118 -0
- patchllm/scopes/__init__.py +0 -0
- patchllm/scopes/builder.py +55 -0
- patchllm/scopes/constants.py +70 -0
- patchllm/scopes/helpers.py +147 -0
- patchllm/scopes/resolvers.py +82 -0
- patchllm/scopes/structure.py +64 -0
- patchllm/tui/__init__.py +0 -0
- patchllm/tui/completer.py +153 -0
- patchllm/tui/interface.py +703 -0
- patchllm/utils.py +19 -1
- patchllm/voice/__init__.py +0 -0
- patchllm/{listener.py → voice/listener.py} +8 -1
- patchllm-1.0.0.dist-info/METADATA +153 -0
- patchllm-1.0.0.dist-info/RECORD +51 -0
- patchllm-1.0.0.dist-info/entry_points.txt +2 -0
- {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
- tests/__init__.py +0 -0
- tests/conftest.py +112 -0
- tests/test_actions.py +62 -0
- tests/test_agent.py +383 -0
- tests/test_completer.py +121 -0
- tests/test_context.py +140 -0
- tests/test_executor.py +60 -0
- tests/test_interactive.py +64 -0
- tests/test_parser.py +70 -0
- tests/test_patcher.py +71 -0
- tests/test_planner.py +53 -0
- tests/test_recipes.py +111 -0
- tests/test_scopes.py +47 -0
- tests/test_structure.py +48 -0
- tests/test_tui.py +397 -0
- tests/test_utils.py +31 -0
- patchllm/context.py +0 -238
- patchllm-0.2.2.dist-info/METADATA +0 -129
- patchllm-0.2.2.dist-info/RECORD +0 -12
- patchllm-0.2.2.dist-info/entry_points.txt +0 -2
- {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
- {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/licenses/LICENSE +0 -0
tests/test_tui.py
ADDED
@@ -0,0 +1,397 @@
|
|
1
|
+
import pytest
|
2
|
+
from unittest.mock import patch, MagicMock, ANY
|
3
|
+
import sys
|
4
|
+
import os
|
5
|
+
import re
|
6
|
+
from pathlib import Path
|
7
|
+
|
8
|
+
# This import will only work if prompt_toolkit is installed
|
9
|
+
pytest.importorskip("prompt_toolkit")
|
10
|
+
# Add skip for the new dependency to avoid errors if not installed
|
11
|
+
pytest.importorskip("InquirerPy")
|
12
|
+
|
13
|
+
from patchllm.cli.entrypoint import main
|
14
|
+
from patchllm.agent.session import AgentSession
|
15
|
+
from patchllm.tui.interface import _run_scope_management_tui, _interactive_scope_editor, _edit_string_list_interactive, _edit_patterns_interactive, _run_plan_management_tui
|
16
|
+
from patchllm.utils import write_scopes_to_file, load_from_py_file
|
17
|
+
from rich.console import Console
|
18
|
+
|
19
|
+
@pytest.fixture
|
20
|
+
def mock_args():
|
21
|
+
"""Provides a mock argparse.Namespace object for tests."""
|
22
|
+
class MockArgs:
|
23
|
+
def __init__(self):
|
24
|
+
self.model = "default-model"
|
25
|
+
return MockArgs()
|
26
|
+
|
27
|
+
def test_agent_session_initialization(mock_args):
|
28
|
+
session = AgentSession(args=mock_args, scopes={}, recipes={})
|
29
|
+
assert session.goal is None
|
30
|
+
assert session.plan == []
|
31
|
+
|
32
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
33
|
+
def test_tui_launches_and_exits(mock_prompt, temp_project, capsys):
|
34
|
+
os.chdir(temp_project)
|
35
|
+
mock_prompt.return_value = "/exit"
|
36
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
37
|
+
main()
|
38
|
+
captured = capsys.readouterr()
|
39
|
+
assert "Welcome to the PatchLLM Agent" in captured.out
|
40
|
+
assert "Exiting agent session. Goodbye!" in captured.out
|
41
|
+
mock_prompt.assert_called_once()
|
42
|
+
|
43
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
44
|
+
def test_tui_help_command(mock_prompt, temp_project, capsys):
|
45
|
+
os.chdir(temp_project)
|
46
|
+
mock_prompt.side_effect = ["/help", "/exit"]
|
47
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
48
|
+
main()
|
49
|
+
captured = capsys.readouterr()
|
50
|
+
assert "PatchLLM Agent Commands" in captured.out
|
51
|
+
assert "/context <scope>" in captured.out
|
52
|
+
assert "/plan --edit <N> <text>" in captured.out
|
53
|
+
assert "/skip" in captured.out
|
54
|
+
assert "/settings" in captured.out
|
55
|
+
assert "/show [goal|plan|context|history|step]" in captured.out
|
56
|
+
|
57
|
+
@patch('patchllm.tui.interface.AgentSession')
|
58
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
59
|
+
def test_tui_context_command_calls_session(mock_prompt, mock_agent_session, temp_project):
|
60
|
+
os.chdir(temp_project)
|
61
|
+
mock_session_instance = mock_agent_session.return_value
|
62
|
+
mock_session_instance.load_context_from_scope.return_value = "Mocked context summary"
|
63
|
+
mock_prompt.side_effect = ["/context my_scope", "/exit"]
|
64
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
65
|
+
main()
|
66
|
+
mock_session_instance.load_context_from_scope.assert_called_once_with("my_scope")
|
67
|
+
|
68
|
+
@patch('patchllm.tui.interface._run_settings_tui')
|
69
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
70
|
+
def test_tui_settings_command(mock_prompt, mock_settings_tui, temp_project):
|
71
|
+
os.chdir(temp_project)
|
72
|
+
mock_prompt.side_effect = ["/settings", "/exit"]
|
73
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
74
|
+
main()
|
75
|
+
mock_settings_tui.assert_called_once()
|
76
|
+
|
77
|
+
|
78
|
+
@patch('InquirerPy.prompt')
|
79
|
+
@patch('patchllm.tui.interface.AgentSession')
|
80
|
+
def test_tui_settings_change_model_flow(mock_agent_session, mock_inquirer_prompt, temp_project):
|
81
|
+
os.chdir(temp_project)
|
82
|
+
mock_session_instance = mock_agent_session.return_value
|
83
|
+
|
84
|
+
mock_inquirer_prompt.side_effect = [
|
85
|
+
{"action": f"Change Model (current: {mock_session_instance.args.model})"},
|
86
|
+
{"model": "ollama/test-model"},
|
87
|
+
{"action": "Back to agent"}
|
88
|
+
]
|
89
|
+
|
90
|
+
with patch('patchllm.tui.interface.model_list', ['ollama/test-model', 'gemini/flash']):
|
91
|
+
from patchllm.tui.interface import _run_settings_tui, Console
|
92
|
+
_run_settings_tui(mock_session_instance, Console())
|
93
|
+
|
94
|
+
assert mock_session_instance.args.model == "ollama/test-model"
|
95
|
+
mock_session_instance.save_settings.assert_called_once()
|
96
|
+
|
97
|
+
|
98
|
+
@patch('patchllm.tui.interface.AgentSession')
|
99
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
100
|
+
def test_tui_plan_edit_command(mock_prompt, mock_agent_session, temp_project):
|
101
|
+
os.chdir(temp_project)
|
102
|
+
mock_session_instance = mock_agent_session.return_value
|
103
|
+
mock_session_instance.plan = ["step 1"]
|
104
|
+
mock_prompt.side_effect = ["/plan --edit 1 this is the new text", "/exit"]
|
105
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
106
|
+
main()
|
107
|
+
mock_session_instance.edit_plan_step.assert_called_once_with(1, "this is the new text")
|
108
|
+
|
109
|
+
@patch('patchllm.tui.interface.AgentSession')
|
110
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
111
|
+
def test_tui_plan_rm_command(mock_prompt, mock_agent_session, temp_project):
|
112
|
+
os.chdir(temp_project)
|
113
|
+
mock_session_instance = mock_agent_session.return_value
|
114
|
+
mock_session_instance.plan = ["step 1"]
|
115
|
+
mock_prompt.side_effect = ["/plan --rm 1", "/exit"]
|
116
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
117
|
+
main()
|
118
|
+
mock_session_instance.remove_plan_step.assert_called_once_with(1)
|
119
|
+
|
120
|
+
@patch('patchllm.tui.interface.AgentSession')
|
121
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
122
|
+
def test_tui_plan_add_command(mock_prompt, mock_agent_session, temp_project):
|
123
|
+
os.chdir(temp_project)
|
124
|
+
mock_session_instance = mock_agent_session.return_value
|
125
|
+
mock_session_instance.plan = ["step 1"]
|
126
|
+
mock_prompt.side_effect = ["/plan --add a new step", "/exit"]
|
127
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
128
|
+
main()
|
129
|
+
mock_session_instance.add_plan_step.assert_called_once_with("a new step")
|
130
|
+
|
131
|
+
@patch('patchllm.tui.interface.AgentSession')
|
132
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
133
|
+
def test_tui_skip_command(mock_prompt, mock_agent_session, temp_project):
|
134
|
+
os.chdir(temp_project)
|
135
|
+
mock_session_instance = mock_agent_session.return_value
|
136
|
+
mock_session_instance.plan = ["step 1"]
|
137
|
+
mock_prompt.side_effect = ["/skip", "/exit"]
|
138
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
139
|
+
main()
|
140
|
+
mock_session_instance.skip_step.assert_called_once()
|
141
|
+
|
142
|
+
# --- FIX: Removed obsolete test for /add_context command ---
|
143
|
+
|
144
|
+
@pytest.mark.parametrize("sub_command, session_data, expected_output, is_empty", [
|
145
|
+
("plan", {"plan": ["step 1"]}, "Execution Plan", False),
|
146
|
+
("plan", {"plan": []}, "No plan exists.", True),
|
147
|
+
("goal", {"goal": "my test goal"}, "Current Goal", False),
|
148
|
+
("goal", {"goal": None}, "No goal set.", True),
|
149
|
+
("history", {"action_history": ["did a thing"]}, "Session History", False),
|
150
|
+
("history", {"action_history": []}, "No actions recorded yet.", True),
|
151
|
+
("context", {"context_files": [Path("/fake/file.py")]}, "Context Tree", False),
|
152
|
+
("context", {"context_files": []}, "Context is empty.", True),
|
153
|
+
("invalid", {}, "Usage: /show", False),
|
154
|
+
])
|
155
|
+
@patch('patchllm.tui.interface.AgentSession')
|
156
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
157
|
+
def test_tui_show_commands(mock_prompt, mock_agent_session, temp_project, capsys, sub_command, session_data, expected_output, is_empty):
|
158
|
+
os.chdir(temp_project)
|
159
|
+
mock_session_instance = mock_agent_session.return_value
|
160
|
+
|
161
|
+
# Set up the session state based on parametrized data
|
162
|
+
for key, value in session_data.items():
|
163
|
+
setattr(mock_session_instance, key, value)
|
164
|
+
|
165
|
+
# If the session state is empty, ensure the mock reflects that
|
166
|
+
if is_empty:
|
167
|
+
for key in session_data.keys():
|
168
|
+
# Handle None for goal specifically
|
169
|
+
setattr(mock_session_instance, key, None if key == 'goal' else [])
|
170
|
+
|
171
|
+
mock_prompt.side_effect = [f"/show {sub_command}", "/exit"]
|
172
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
173
|
+
main()
|
174
|
+
|
175
|
+
captured = capsys.readouterr()
|
176
|
+
assert expected_output in captured.out
|
177
|
+
|
178
|
+
# --- Tests for Interactive Scope Editor ---
|
179
|
+
|
180
|
+
@patch('InquirerPy.prompt')
|
181
|
+
@patch('patchllm.tui.interface._interactive_scope_editor')
|
182
|
+
def test_scope_management_tui_add_flow(mock_scope_editor, mock_inquirer_prompt, tmp_path):
|
183
|
+
scopes_file = tmp_path / "scopes.py"
|
184
|
+
write_scopes_to_file(scopes_file, {})
|
185
|
+
|
186
|
+
# Simulate user choosing "Add", typing a name, and then the editor returns a new scope
|
187
|
+
mock_inquirer_prompt.side_effect = [
|
188
|
+
{"action": "Add a new scope"},
|
189
|
+
{"name": "my-new-scope"},
|
190
|
+
{"action": "Back to agent"} # To exit the loop
|
191
|
+
]
|
192
|
+
mock_scope_editor.return_value = {"path": "src", "include_patterns": ["**/*.js"]}
|
193
|
+
|
194
|
+
_run_scope_management_tui({}, scopes_file, Console())
|
195
|
+
|
196
|
+
mock_scope_editor.assert_called_once()
|
197
|
+
loaded_scopes = load_from_py_file(scopes_file, "scopes")
|
198
|
+
assert "my-new-scope" in loaded_scopes
|
199
|
+
assert loaded_scopes["my-new-scope"]["path"] == "src"
|
200
|
+
|
201
|
+
@patch('InquirerPy.prompt')
|
202
|
+
@patch('patchllm.tui.interface._interactive_scope_editor')
|
203
|
+
def test_scope_management_tui_update_flow(mock_scope_editor, mock_inquirer_prompt, tmp_path):
|
204
|
+
scopes_file = tmp_path / "scopes.py"
|
205
|
+
initial_scopes = {"existing-scope": {"path": "old/path"}}
|
206
|
+
write_scopes_to_file(scopes_file, initial_scopes)
|
207
|
+
|
208
|
+
# Simulate user choosing "Update", selecting the scope, and editor returning a modified scope
|
209
|
+
mock_inquirer_prompt.side_effect = [
|
210
|
+
{"action": "Update a scope"},
|
211
|
+
{"scope": "existing-scope"},
|
212
|
+
{"action": "Back to agent"}
|
213
|
+
]
|
214
|
+
mock_scope_editor.return_value = {"path": "new/path"}
|
215
|
+
|
216
|
+
_run_scope_management_tui(initial_scopes, scopes_file, Console())
|
217
|
+
|
218
|
+
mock_scope_editor.assert_called_once_with(ANY, existing_scope={"path": "old/path"})
|
219
|
+
loaded_scopes = load_from_py_file(scopes_file, "scopes")
|
220
|
+
assert loaded_scopes["existing-scope"]["path"] == "new/path"
|
221
|
+
|
222
|
+
@patch('patchllm.tui.interface.select_files_interactively')
|
223
|
+
@patch('InquirerPy.prompt')
|
224
|
+
def test_edit_patterns_interactive_with_selector(mock_inquirer_prompt, mock_selector, tmp_path):
|
225
|
+
os.chdir(tmp_path)
|
226
|
+
# Simulate user choosing the interactive selector, then done
|
227
|
+
mock_inquirer_prompt.side_effect = [
|
228
|
+
{"action": "Add from interactive selector"},
|
229
|
+
{"action": "Done"}
|
230
|
+
]
|
231
|
+
mock_selector.return_value = [Path(tmp_path / "src/app.py"), Path(tmp_path / "README.md")]
|
232
|
+
|
233
|
+
result = _edit_patterns_interactive([], "Include", Console())
|
234
|
+
|
235
|
+
mock_selector.assert_called_once()
|
236
|
+
assert "src/app.py" in result
|
237
|
+
assert "README.md" in result
|
238
|
+
assert len(result) == 2
|
239
|
+
|
240
|
+
@patch('InquirerPy.prompt')
|
241
|
+
def test_edit_string_list_interactive_add_and_remove(mock_inquirer_prompt):
|
242
|
+
# Simulate: 1. Add item "c". 2. Remove items "a". 3. Done.
|
243
|
+
mock_inquirer_prompt.side_effect = [
|
244
|
+
{"action": "Add a keyword"},
|
245
|
+
{"item": "c"},
|
246
|
+
{"action": "Remove a keyword"},
|
247
|
+
{"items": ["a"]},
|
248
|
+
{"action": "Done"}
|
249
|
+
]
|
250
|
+
|
251
|
+
initial_list = ["a", "b"]
|
252
|
+
result = _edit_string_list_interactive(initial_list, "keyword", Console())
|
253
|
+
|
254
|
+
assert result == ["b", "c"]
|
255
|
+
|
256
|
+
# --- Tests for Interactive Plan Management ---
|
257
|
+
|
258
|
+
@patch('patchllm.tui.interface._run_plan_management_tui')
|
259
|
+
@patch('patchllm.tui.interface.AgentSession')
|
260
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
261
|
+
def test_tui_plan_command_enters_interactive_mode(mock_prompt, mock_agent_session, mock_plan_tui, temp_project):
|
262
|
+
"""Tests that `/plan` with no args enters interactive mode if a plan exists."""
|
263
|
+
os.chdir(temp_project)
|
264
|
+
mock_session_instance = mock_agent_session.return_value
|
265
|
+
mock_session_instance.plan = ["step 1", "step 2"]
|
266
|
+
mock_session_instance.goal = "a goal"
|
267
|
+
|
268
|
+
mock_prompt.side_effect = ["/plan", "/exit"]
|
269
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
270
|
+
main()
|
271
|
+
|
272
|
+
mock_plan_tui.assert_called_once()
|
273
|
+
mock_session_instance.create_plan.assert_not_called()
|
274
|
+
|
275
|
+
@patch('InquirerPy.prompt')
|
276
|
+
def test_plan_management_tui_edit_flow(mock_inquirer_prompt, mock_args, capsys):
|
277
|
+
"""Tests the edit functionality within the interactive plan manager."""
|
278
|
+
session = AgentSession(args=mock_args, scopes={}, recipes={})
|
279
|
+
session.plan = ["step one", "step two"]
|
280
|
+
|
281
|
+
mock_inquirer_prompt.side_effect = [
|
282
|
+
{"action": "1. step one"},
|
283
|
+
{"sub_action": "Edit"},
|
284
|
+
{"text": "step one EDITED"},
|
285
|
+
{"action": "Done"}
|
286
|
+
]
|
287
|
+
|
288
|
+
_run_plan_management_tui(session, Console())
|
289
|
+
|
290
|
+
assert session.plan == ["step one EDITED", "step two"]
|
291
|
+
captured = capsys.readouterr()
|
292
|
+
assert "Step 1 updated" in captured.out
|
293
|
+
|
294
|
+
@patch('InquirerPy.prompt')
|
295
|
+
def test_plan_management_tui_remove_flow(mock_inquirer_prompt, mock_args, capsys):
|
296
|
+
"""Tests the remove functionality within the interactive plan manager."""
|
297
|
+
session = AgentSession(args=mock_args, scopes={}, recipes={})
|
298
|
+
session.plan = ["step one", "step two"]
|
299
|
+
|
300
|
+
mock_inquirer_prompt.side_effect = [
|
301
|
+
{"action": "2. step two"},
|
302
|
+
{"sub_action": "Remove"},
|
303
|
+
{"action": "Done"}
|
304
|
+
]
|
305
|
+
|
306
|
+
_run_plan_management_tui(session, Console())
|
307
|
+
|
308
|
+
assert session.plan == ["step one"]
|
309
|
+
captured = capsys.readouterr()
|
310
|
+
assert "Step 2 removed" in captured.out
|
311
|
+
|
312
|
+
@patch('InquirerPy.prompt')
|
313
|
+
def test_plan_management_tui_add_flow(mock_inquirer_prompt, mock_args, capsys):
|
314
|
+
"""Tests the add functionality within the interactive plan manager."""
|
315
|
+
session = AgentSession(args=mock_args, scopes={}, recipes={})
|
316
|
+
session.plan = ["step one"]
|
317
|
+
|
318
|
+
mock_inquirer_prompt.side_effect = [
|
319
|
+
{"action": "Add a new step"},
|
320
|
+
{"text": "step two"},
|
321
|
+
{"action": "Done"}
|
322
|
+
]
|
323
|
+
|
324
|
+
_run_plan_management_tui(session, Console())
|
325
|
+
|
326
|
+
assert session.plan == ["step one", "step two"]
|
327
|
+
captured = capsys.readouterr()
|
328
|
+
assert "Step added" in captured.out
|
329
|
+
|
330
|
+
@patch('InquirerPy.prompt')
|
331
|
+
def test_plan_management_tui_reorder_flow(mock_inquirer_prompt, mock_args, capsys):
|
332
|
+
"""Tests the reorder functionality within the interactive plan manager."""
|
333
|
+
session = AgentSession(args=mock_args, scopes={}, recipes={})
|
334
|
+
session.plan = ["A", "B", "C"]
|
335
|
+
|
336
|
+
mock_inquirer_prompt.side_effect = [
|
337
|
+
{"action": "Reorder steps"},
|
338
|
+
{"from": "3. C"},
|
339
|
+
{"to": "Move to position 1"},
|
340
|
+
{"action": "Done"}
|
341
|
+
]
|
342
|
+
|
343
|
+
_run_plan_management_tui(session, Console())
|
344
|
+
|
345
|
+
assert session.plan == ["C", "A", "B"]
|
346
|
+
captured = capsys.readouterr()
|
347
|
+
assert "Step moved from position 3 to 1" in captured.out
|
348
|
+
|
349
|
+
# --- Tests for Selective Approve ---
|
350
|
+
|
351
|
+
@patch('InquirerPy.prompt')
|
352
|
+
@patch('patchllm.tui.interface.AgentSession')
|
353
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
354
|
+
def test_tui_approve_command_interactive_selection(mock_prompt, mock_agent_session, mock_inquirer_prompt, temp_project):
|
355
|
+
"""Tests the /approve command opens a checklist and calls session with the result."""
|
356
|
+
os.chdir(temp_project)
|
357
|
+
mock_session_instance = mock_agent_session.return_value
|
358
|
+
mock_session_instance.last_execution_result = {
|
359
|
+
"summary": {"modified": ["a.py", "b.py"], "created": []}
|
360
|
+
}
|
361
|
+
# User selects only a.py
|
362
|
+
mock_inquirer_prompt.return_value = {"files": ["a.py"]}
|
363
|
+
|
364
|
+
mock_prompt.side_effect = ["/approve", "/exit"]
|
365
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
366
|
+
main()
|
367
|
+
|
368
|
+
mock_inquirer_prompt.assert_called_once()
|
369
|
+
mock_session_instance.approve_changes.assert_called_once_with(["a.py"])
|
370
|
+
|
371
|
+
@patch('patchllm.tui.interface.AgentSession')
|
372
|
+
@patch('prompt_toolkit.PromptSession.prompt')
|
373
|
+
def test_tui_displays_change_summary(mock_prompt, mock_agent_session, temp_project, capsys):
|
374
|
+
"""Tests that the TUI correctly displays the change summary after a run."""
|
375
|
+
os.chdir(temp_project)
|
376
|
+
mock_session_instance = mock_agent_session.return_value
|
377
|
+
mock_session_instance.plan = ["do a thing"]
|
378
|
+
mock_session_instance.current_step = 0
|
379
|
+
|
380
|
+
# Mock the result that comes back from the executor
|
381
|
+
mock_execution_result = {
|
382
|
+
"summary": {"modified": ["a.py"], "created": []},
|
383
|
+
"change_summary": "This is the natural language summary of the changes."
|
384
|
+
}
|
385
|
+
mock_session_instance.run_next_step.return_value = mock_execution_result
|
386
|
+
|
387
|
+
mock_prompt.side_effect = ["/run", "/exit"]
|
388
|
+
with patch.object(sys, 'argv', ['patchllm']):
|
389
|
+
main()
|
390
|
+
|
391
|
+
mock_session_instance.run_next_step.assert_called_once()
|
392
|
+
|
393
|
+
captured = capsys.readouterr()
|
394
|
+
assert "Change Summary" in captured.out
|
395
|
+
assert "This is the natural language summary of the changes." in captured.out
|
396
|
+
assert "Proposed File Changes" in captured.out
|
397
|
+
assert "a.py" in captured.out
|
tests/test_utils.py
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
from patchllm.utils import load_from_py_file, write_scopes_to_file
|
2
|
+
import pytest
|
3
|
+
|
4
|
+
def test_load_from_py_file_success(temp_scopes_file):
|
5
|
+
scopes = load_from_py_file(temp_scopes_file, "scopes")
|
6
|
+
assert isinstance(scopes, dict)
|
7
|
+
assert "base" in scopes
|
8
|
+
|
9
|
+
def test_load_from_py_file_not_found(tmp_path):
|
10
|
+
with pytest.raises(FileNotFoundError):
|
11
|
+
load_from_py_file(tmp_path / "nonexistent.py", "scopes")
|
12
|
+
|
13
|
+
def test_load_from_py_file_dict_not_found(tmp_path):
|
14
|
+
p = tmp_path / "invalid_scopes.py"
|
15
|
+
p.write_text("my_scopes = {}")
|
16
|
+
with pytest.raises(TypeError):
|
17
|
+
load_from_py_file(p, "scopes")
|
18
|
+
|
19
|
+
def test_load_from_py_file_not_a_dict(tmp_path):
|
20
|
+
p = tmp_path / "invalid_type.py"
|
21
|
+
p.write_text("scopes = [1, 2, 3]")
|
22
|
+
with pytest.raises(TypeError):
|
23
|
+
load_from_py_file(p, "scopes")
|
24
|
+
|
25
|
+
def test_write_scopes_to_file(tmp_path):
|
26
|
+
scopes_file = tmp_path / "output_scopes.py"
|
27
|
+
scopes_data = {"test_scope": {"path": "/tmp"}}
|
28
|
+
write_scopes_to_file(scopes_file, scopes_data)
|
29
|
+
assert scopes_file.exists()
|
30
|
+
loaded_scopes = load_from_py_file(scopes_file, "scopes")
|
31
|
+
assert loaded_scopes == scopes_data
|
patchllm/context.py
DELETED
@@ -1,238 +0,0 @@
|
|
1
|
-
import glob
|
2
|
-
import textwrap
|
3
|
-
import subprocess
|
4
|
-
import shutil
|
5
|
-
from pathlib import Path
|
6
|
-
from rich.console import Console
|
7
|
-
|
8
|
-
console = Console()
|
9
|
-
|
10
|
-
# --- Default Settings & Templates ---
|
11
|
-
|
12
|
-
DEFAULT_EXCLUDE_EXTENSIONS = [
|
13
|
-
# General
|
14
|
-
".log", ".lock", ".env", ".bak", ".tmp", ".swp", ".swo", ".db", ".sqlite3",
|
15
|
-
# Python
|
16
|
-
".pyc", ".pyo", ".pyd",
|
17
|
-
# JS/Node
|
18
|
-
".next", ".svelte-kit",
|
19
|
-
# OS-specific
|
20
|
-
".DS_Store",
|
21
|
-
# Media/Binary files
|
22
|
-
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
|
23
|
-
".mp3", ".mp4", ".mov", ".avi", ".pdf",
|
24
|
-
".o", ".so", ".dll", ".exe",
|
25
|
-
# Unity specific
|
26
|
-
".meta",
|
27
|
-
]
|
28
|
-
|
29
|
-
BASE_TEMPLATE = textwrap.dedent('''
|
30
|
-
Source Tree:
|
31
|
-
------------
|
32
|
-
```
|
33
|
-
{{source_tree}}
|
34
|
-
```
|
35
|
-
{{url_contents}}
|
36
|
-
Relevant Files:
|
37
|
-
---------------
|
38
|
-
{{files_content}}
|
39
|
-
''')
|
40
|
-
|
41
|
-
URL_CONTENT_TEMPLATE = textwrap.dedent('''
|
42
|
-
URL Contents:
|
43
|
-
-------------
|
44
|
-
{{content}}
|
45
|
-
''')
|
46
|
-
|
47
|
-
|
48
|
-
# --- Helper Functions (File Discovery, Filtering, Tree Generation) ---
|
49
|
-
|
50
|
-
def find_files(base_path: Path, include_patterns: list[str], exclude_patterns: list[str] | None = None) -> list[Path]:
|
51
|
-
"""Finds all files using glob patterns, handling both relative and absolute paths."""
|
52
|
-
if exclude_patterns is None:
|
53
|
-
exclude_patterns = []
|
54
|
-
|
55
|
-
def _get_files_from_patterns(patterns: list[str]) -> set[Path]:
|
56
|
-
"""Helper to process a list of glob patterns and return matching file paths."""
|
57
|
-
files = set()
|
58
|
-
for pattern_str in patterns:
|
59
|
-
pattern_path = Path(pattern_str)
|
60
|
-
# If the pattern is absolute, use it as is. Otherwise, join it with the base_path.
|
61
|
-
search_path = pattern_path if pattern_path.is_absolute() else base_path / pattern_path
|
62
|
-
|
63
|
-
for match in glob.glob(str(search_path), recursive=True):
|
64
|
-
path_obj = Path(match).resolve()
|
65
|
-
if path_obj.is_file():
|
66
|
-
files.add(path_obj)
|
67
|
-
return files
|
68
|
-
|
69
|
-
included_files = _get_files_from_patterns(include_patterns)
|
70
|
-
excluded_files = _get_files_from_patterns(exclude_patterns)
|
71
|
-
|
72
|
-
return sorted(list(included_files - excluded_files))
|
73
|
-
|
74
|
-
|
75
|
-
def filter_files_by_keyword(file_paths: list[Path], search_words: list[str]) -> list[Path]:
|
76
|
-
"""Returns files from a list that contain any of the specified search words."""
|
77
|
-
if not search_words:
|
78
|
-
return file_paths
|
79
|
-
|
80
|
-
matching_files = []
|
81
|
-
for file_path in file_paths:
|
82
|
-
try:
|
83
|
-
if any(word in file_path.read_text(encoding='utf-8', errors='ignore') for word in search_words):
|
84
|
-
matching_files.append(file_path)
|
85
|
-
except Exception as e:
|
86
|
-
console.print(f"⚠️ Could not read {file_path} for keyword search: {e}", style="yellow")
|
87
|
-
return matching_files
|
88
|
-
|
89
|
-
|
90
|
-
def generate_source_tree(base_path: Path, file_paths: list[Path]) -> str:
|
91
|
-
"""Generates a string representation of the file paths as a tree."""
|
92
|
-
if not file_paths:
|
93
|
-
return "No files found matching the criteria."
|
94
|
-
|
95
|
-
tree = {}
|
96
|
-
for path in file_paths:
|
97
|
-
try:
|
98
|
-
rel_path = path.relative_to(base_path)
|
99
|
-
except ValueError:
|
100
|
-
rel_path = path
|
101
|
-
|
102
|
-
level = tree
|
103
|
-
for part in rel_path.parts:
|
104
|
-
level = level.setdefault(part, {})
|
105
|
-
|
106
|
-
def _format_tree(tree_dict, indent=""):
|
107
|
-
lines = []
|
108
|
-
items = sorted(tree_dict.items(), key=lambda i: (not i[1], i[0]))
|
109
|
-
for i, (name, node) in enumerate(items):
|
110
|
-
last = i == len(items) - 1
|
111
|
-
connector = "└── " if last else "├── "
|
112
|
-
lines.append(f"{indent}{connector}{name}")
|
113
|
-
if node:
|
114
|
-
new_indent = indent + (" " if last else "│ ")
|
115
|
-
lines.extend(_format_tree(node, new_indent))
|
116
|
-
return lines
|
117
|
-
|
118
|
-
return f"{base_path.name}\n" + "\n".join(_format_tree(tree))
|
119
|
-
|
120
|
-
|
121
|
-
def fetch_and_process_urls(urls: list[str]) -> str:
|
122
|
-
"""Downloads and converts a list of URLs to text, returning a formatted string."""
|
123
|
-
if not urls:
|
124
|
-
return ""
|
125
|
-
|
126
|
-
try:
|
127
|
-
import html2text
|
128
|
-
except ImportError:
|
129
|
-
console.print("⚠️ To use the URL feature, please install the required extras:", style="yellow")
|
130
|
-
console.print(" pip install patchllm[url]", style="cyan")
|
131
|
-
return ""
|
132
|
-
|
133
|
-
downloader = None
|
134
|
-
if shutil.which("curl"):
|
135
|
-
downloader = "curl"
|
136
|
-
elif shutil.which("wget"):
|
137
|
-
downloader = "wget"
|
138
|
-
|
139
|
-
if not downloader:
|
140
|
-
console.print("⚠️ Cannot fetch URL content: 'curl' or 'wget' not found in PATH.", style="yellow")
|
141
|
-
return ""
|
142
|
-
|
143
|
-
h = html2text.HTML2Text()
|
144
|
-
h.ignore_links = True
|
145
|
-
h.ignore_images = True
|
146
|
-
|
147
|
-
all_url_contents = []
|
148
|
-
|
149
|
-
console.print("\n--- Fetching URL Content... ---", style="bold")
|
150
|
-
for url in urls:
|
151
|
-
try:
|
152
|
-
console.print(f"Fetching [cyan]{url}[/cyan]...")
|
153
|
-
if downloader == "curl":
|
154
|
-
command = ["curl", "-s", "-L", url]
|
155
|
-
else: # wget
|
156
|
-
command = ["wget", "-q", "-O", "-", url]
|
157
|
-
|
158
|
-
result = subprocess.run(command, capture_output=True, text=True, check=True, timeout=15)
|
159
|
-
html_content = result.stdout
|
160
|
-
text_content = h.handle(html_content)
|
161
|
-
all_url_contents.append(f"<url_content:{url}>\n```\n{text_content}\n```")
|
162
|
-
|
163
|
-
except subprocess.CalledProcessError as e:
|
164
|
-
console.print(f"❌ Failed to fetch {url}: {e.stderr}", style="red")
|
165
|
-
except subprocess.TimeoutExpired:
|
166
|
-
console.print(f"❌ Failed to fetch {url}: Request timed out.", style="red")
|
167
|
-
except Exception as e:
|
168
|
-
console.print(f"❌ An unexpected error occurred while fetching {url}: {e}", style="red")
|
169
|
-
|
170
|
-
if not all_url_contents:
|
171
|
-
return ""
|
172
|
-
|
173
|
-
content_str = "\n\n".join(all_url_contents)
|
174
|
-
return URL_CONTENT_TEMPLATE.replace("{{content}}", content_str)
|
175
|
-
|
176
|
-
# --- Main Context Building Function ---
|
177
|
-
|
178
|
-
def build_context(scope: dict) -> dict | None:
|
179
|
-
"""
|
180
|
-
Builds the context string from files specified in the scope.
|
181
|
-
|
182
|
-
Args:
|
183
|
-
scope (dict): The scope for file searching.
|
184
|
-
|
185
|
-
Returns:
|
186
|
-
dict: A dictionary with the source tree and formatted context, or None.
|
187
|
-
"""
|
188
|
-
base_path = Path(scope.get("path", ".")).resolve()
|
189
|
-
|
190
|
-
include_patterns = scope.get("include_patterns", [])
|
191
|
-
exclude_patterns = scope.get("exclude_patterns", [])
|
192
|
-
exclude_extensions = scope.get("exclude_extensions", DEFAULT_EXCLUDE_EXTENSIONS)
|
193
|
-
search_words = scope.get("search_words", [])
|
194
|
-
urls = scope.get("urls", [])
|
195
|
-
|
196
|
-
# Step 1: Find files
|
197
|
-
relevant_files = find_files(base_path, include_patterns, exclude_patterns)
|
198
|
-
|
199
|
-
# Step 2: Filter by extension
|
200
|
-
count_before_ext = len(relevant_files)
|
201
|
-
norm_ext = {ext.lower() for ext in exclude_extensions}
|
202
|
-
relevant_files = [p for p in relevant_files if p.suffix.lower() not in norm_ext]
|
203
|
-
if count_before_ext > len(relevant_files):
|
204
|
-
console.print(f"Filtered {count_before_ext - len(relevant_files)} files by extension.", style="cyan")
|
205
|
-
|
206
|
-
# Step 3: Filter by keyword
|
207
|
-
if search_words:
|
208
|
-
count_before_kw = len(relevant_files)
|
209
|
-
relevant_files = filter_files_by_keyword(relevant_files, search_words)
|
210
|
-
console.print(f"Filtered {count_before_kw - len(relevant_files)} files by keyword search.", style="cyan")
|
211
|
-
|
212
|
-
if not relevant_files and not urls:
|
213
|
-
console.print("\n⚠️ No files or URLs matched the specified criteria.", style="yellow")
|
214
|
-
return None
|
215
|
-
|
216
|
-
# Generate source tree and file content blocks
|
217
|
-
source_tree_str = generate_source_tree(base_path, relevant_files)
|
218
|
-
|
219
|
-
file_contents = []
|
220
|
-
for file_path in relevant_files:
|
221
|
-
try:
|
222
|
-
display_path = file_path.as_posix()
|
223
|
-
content = file_path.read_text(encoding='utf-8')
|
224
|
-
file_contents.append(f"<file_path:{display_path}>\n```\n{content}\n```")
|
225
|
-
except Exception as e:
|
226
|
-
console.print(f"⚠️ Could not read file {file_path}: {e}", style="yellow")
|
227
|
-
|
228
|
-
files_content_str = "\n\n".join(file_contents)
|
229
|
-
|
230
|
-
# Fetch and process URL contents
|
231
|
-
url_contents_str = fetch_and_process_urls(urls)
|
232
|
-
|
233
|
-
# Assemble the final context using the base template
|
234
|
-
final_context = BASE_TEMPLATE.replace("{{source_tree}}", source_tree_str)
|
235
|
-
final_context = final_context.replace("{{url_contents}}", url_contents_str)
|
236
|
-
final_context = final_context.replace("{{files_content}}", files_content_str)
|
237
|
-
|
238
|
-
return {"tree": source_tree_str, "context": final_context}
|