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.
Files changed (54) hide show
  1. patchllm/__main__.py +0 -0
  2. patchllm/agent/__init__.py +0 -0
  3. patchllm/agent/actions.py +73 -0
  4. patchllm/agent/executor.py +57 -0
  5. patchllm/agent/planner.py +76 -0
  6. patchllm/agent/session.py +425 -0
  7. patchllm/cli/__init__.py +0 -0
  8. patchllm/cli/entrypoint.py +120 -0
  9. patchllm/cli/handlers.py +192 -0
  10. patchllm/cli/helpers.py +72 -0
  11. patchllm/interactive/__init__.py +0 -0
  12. patchllm/interactive/selector.py +100 -0
  13. patchllm/llm.py +39 -0
  14. patchllm/main.py +1 -323
  15. patchllm/parser.py +120 -64
  16. patchllm/patcher.py +118 -0
  17. patchllm/scopes/__init__.py +0 -0
  18. patchllm/scopes/builder.py +55 -0
  19. patchllm/scopes/constants.py +70 -0
  20. patchllm/scopes/helpers.py +147 -0
  21. patchllm/scopes/resolvers.py +82 -0
  22. patchllm/scopes/structure.py +64 -0
  23. patchllm/tui/__init__.py +0 -0
  24. patchllm/tui/completer.py +153 -0
  25. patchllm/tui/interface.py +703 -0
  26. patchllm/utils.py +19 -1
  27. patchllm/voice/__init__.py +0 -0
  28. patchllm/{listener.py → voice/listener.py} +8 -1
  29. patchllm-1.0.0.dist-info/METADATA +153 -0
  30. patchllm-1.0.0.dist-info/RECORD +51 -0
  31. patchllm-1.0.0.dist-info/entry_points.txt +2 -0
  32. {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
  33. tests/__init__.py +0 -0
  34. tests/conftest.py +112 -0
  35. tests/test_actions.py +62 -0
  36. tests/test_agent.py +383 -0
  37. tests/test_completer.py +121 -0
  38. tests/test_context.py +140 -0
  39. tests/test_executor.py +60 -0
  40. tests/test_interactive.py +64 -0
  41. tests/test_parser.py +70 -0
  42. tests/test_patcher.py +71 -0
  43. tests/test_planner.py +53 -0
  44. tests/test_recipes.py +111 -0
  45. tests/test_scopes.py +47 -0
  46. tests/test_structure.py +48 -0
  47. tests/test_tui.py +397 -0
  48. tests/test_utils.py +31 -0
  49. patchllm/context.py +0 -238
  50. patchllm-0.2.2.dist-info/METADATA +0 -129
  51. patchllm-0.2.2.dist-info/RECORD +0 -12
  52. patchllm-0.2.2.dist-info/entry_points.txt +0 -2
  53. {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
  54. {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}