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
patchllm/main.py
CHANGED
@@ -1,326 +1,4 @@
|
|
1
|
-
import
|
2
|
-
import argparse
|
3
|
-
import litellm
|
4
|
-
import pprint
|
5
|
-
import os
|
6
|
-
from dotenv import load_dotenv
|
7
|
-
from rich.console import Console
|
8
|
-
from rich.panel import Panel
|
9
|
-
|
10
|
-
from .context import build_context
|
11
|
-
from .parser import paste_response
|
12
|
-
from .utils import load_from_py_file
|
13
|
-
|
14
|
-
console = Console()
|
15
|
-
|
16
|
-
# --- Core Functions ---
|
17
|
-
|
18
|
-
def collect_context(scope_name, scopes):
|
19
|
-
"""Builds the code context from a provided scope dictionary."""
|
20
|
-
console.print("\n--- Building Code Context... ---", style="bold")
|
21
|
-
if not scopes:
|
22
|
-
raise FileNotFoundError("Could not find a 'scopes.py' file.")
|
23
|
-
selected_scope = scopes.get(scope_name)
|
24
|
-
if selected_scope is None:
|
25
|
-
raise KeyError(f"Context scope '{scope_name}' not found in provided scopes file.")
|
26
|
-
|
27
|
-
context_object = build_context(selected_scope)
|
28
|
-
if context_object:
|
29
|
-
tree, context = context_object.values()
|
30
|
-
console.print("--- Context Building Finished. The following files were extracted ---", style="bold")
|
31
|
-
console.print(tree)
|
32
|
-
return context
|
33
|
-
else:
|
34
|
-
console.print("--- Context Building Failed (No files found) ---", style="yellow")
|
35
|
-
return None
|
36
|
-
|
37
|
-
def run_llm_query(task_instructions, model_name, history, context=None):
|
38
|
-
"""
|
39
|
-
Assembles the final prompt, sends it to the LLM, and returns the response.
|
40
|
-
"""
|
41
|
-
console.print("\n--- Sending Prompt to LLM... ---", style="bold")
|
42
|
-
final_prompt = task_instructions
|
43
|
-
if context:
|
44
|
-
final_prompt = f"{context}\n\n{task_instructions}"
|
45
|
-
|
46
|
-
history.append({"role": "user", "content": final_prompt})
|
47
|
-
|
48
|
-
try:
|
49
|
-
with console.status("[bold cyan]Waiting for LLM response...", spinner="dots"):
|
50
|
-
response = litellm.completion(model=model_name, messages=history)
|
51
|
-
|
52
|
-
assistant_response_content = response.choices[0].message.content
|
53
|
-
history.append({"role": "assistant", "content": assistant_response_content})
|
54
|
-
|
55
|
-
if not assistant_response_content or not assistant_response_content.strip():
|
56
|
-
console.print("⚠️ Response is empty. Nothing to process.", style="yellow")
|
57
|
-
return None
|
58
|
-
|
59
|
-
return assistant_response_content
|
60
|
-
|
61
|
-
except Exception as e:
|
62
|
-
history.pop() # Keep history clean on error
|
63
|
-
raise RuntimeError(f"An error occurred while communicating with the LLM via litellm: {e}") from e
|
64
|
-
|
65
|
-
def write_to_file(file_path, content):
|
66
|
-
"""Utility function to write content to a file."""
|
67
|
-
console.print(f"Writing to {file_path}..", style="cyan")
|
68
|
-
try:
|
69
|
-
with open(file_path, "w", encoding="utf-8") as file:
|
70
|
-
file.write(content)
|
71
|
-
console.print(f'✅ Content saved to {file_path}', style="green")
|
72
|
-
except Exception as e:
|
73
|
-
raise RuntimeError(f"Failed to write to file {file_path}: {e}") from e
|
74
|
-
|
75
|
-
def read_from_file(file_path):
|
76
|
-
"""Utility function to read and return the content of a file."""
|
77
|
-
console.print(f"Importing from {file_path}..", style="cyan")
|
78
|
-
try:
|
79
|
-
with open(file_path, "r", encoding="utf-8") as file:
|
80
|
-
content = file.read()
|
81
|
-
console.print("✅ Finished reading file.", style="green")
|
82
|
-
return content
|
83
|
-
except Exception as e:
|
84
|
-
raise RuntimeError(f"Failed to read from file {file_path}: {e}") from e
|
85
|
-
|
86
|
-
def create_new_scope(scopes, scopes_file_str):
|
87
|
-
"""Interactively creates a new scope and saves it to the specified scopes file."""
|
88
|
-
console.print(f"\n--- Creating a new scope in '{scopes_file_str}' ---", style="bold")
|
89
|
-
|
90
|
-
try:
|
91
|
-
name = console.input("[bold]Enter a name for the new scope: [/]").strip()
|
92
|
-
if not name:
|
93
|
-
console.print("❌ Scope name cannot be empty.", style="red")
|
94
|
-
return
|
95
|
-
|
96
|
-
if name in scopes:
|
97
|
-
overwrite = console.input(f"Scope '[bold]{name}[/]' already exists. Overwrite? (y/n): ").lower()
|
98
|
-
if overwrite not in ['y', 'yes']:
|
99
|
-
console.print("Operation cancelled.", style="yellow")
|
100
|
-
return
|
101
|
-
|
102
|
-
path = console.input("[bold]Enter the base path[/] (e.g., '.' for current directory): ").strip() or "."
|
103
|
-
|
104
|
-
console.print("\nEnter comma-separated glob patterns for files to include.")
|
105
|
-
include_raw = console.input('[cyan]> (e.g., "[bold]**/*.py, src/**/*.js[/]"): [/]').strip()
|
106
|
-
include_patterns = [p.strip() for p in include_raw.split(',') if p.strip()]
|
107
|
-
|
108
|
-
console.print("\nEnter comma-separated glob patterns for files to exclude (optional).")
|
109
|
-
exclude_raw = console.input('[cyan]> (e.g., "[bold]**/tests/*, venv/*[/]"): [/]').strip()
|
110
|
-
exclude_patterns = [p.strip() for p in exclude_raw.split(',') if p.strip()]
|
111
|
-
|
112
|
-
new_scope_data = {
|
113
|
-
"path": path,
|
114
|
-
"include_patterns": include_patterns,
|
115
|
-
"exclude_patterns": exclude_patterns
|
116
|
-
}
|
117
|
-
|
118
|
-
scopes[name] = new_scope_data
|
119
|
-
|
120
|
-
with open(scopes_file_str, "w", encoding="utf-8") as f:
|
121
|
-
f.write("# scopes.py\n")
|
122
|
-
f.write("scopes = ")
|
123
|
-
f.write(pprint.pformat(scopes, indent=4))
|
124
|
-
f.write("\n")
|
125
|
-
|
126
|
-
console.print(f"\n✅ Successfully created and saved scope '[bold]{name}[/]' in '[bold]{scopes_file_str}[/]'.", style="green")
|
127
|
-
|
128
|
-
except KeyboardInterrupt:
|
129
|
-
console.print("\n\n⚠️ Scope creation cancelled by user.", style="yellow")
|
130
|
-
return
|
131
|
-
|
132
|
-
def main():
|
133
|
-
"""
|
134
|
-
Main entry point for the patchllm command-line tool.
|
135
|
-
"""
|
136
|
-
load_dotenv()
|
137
|
-
|
138
|
-
scopes_file_path = os.getenv("PATCHLLM_SCOPES_FILE", "./scopes.py")
|
139
|
-
|
140
|
-
parser = argparse.ArgumentParser(
|
141
|
-
description="A CLI tool to apply code changes using an LLM.",
|
142
|
-
formatter_class=argparse.RawTextHelpFormatter
|
143
|
-
)
|
144
|
-
|
145
|
-
# --- Group: Core Patching Flow ---
|
146
|
-
patch_group = parser.add_argument_group('Core Patching Flow')
|
147
|
-
patch_group.add_argument("-s", "--scope", type=str, default=None, help="Name of the scope to use from the scopes file.")
|
148
|
-
patch_group.add_argument("-t", "--task", type=str, default=None, help="The task instructions to guide the assistant.")
|
149
|
-
patch_group.add_argument("-p", "--patch", action="store_true", help="Query the LLM and directly apply the file updates from the response. Requires --task.")
|
150
|
-
|
151
|
-
# --- Group: Scope Management ---
|
152
|
-
scope_group = parser.add_argument_group('Scope Management')
|
153
|
-
scope_group.add_argument("-i", "--init", action="store_true", help="Create a new scope interactively.")
|
154
|
-
scope_group.add_argument("-sl", "--list-scopes", action="store_true", help="List all available scopes from the scopes file and exit.")
|
155
|
-
scope_group.add_argument("-ss", "--show-scope", type=str, help="Display the settings for a specific scope and exit.")
|
156
|
-
|
157
|
-
# --- Group: I/O Utils---
|
158
|
-
code_io = parser.add_argument_group('Code I/O')
|
159
|
-
code_io.add_argument("-co", "--context-out", nargs='?', const="context.md", default=None, help="Export the generated context to a file. Defaults to 'context.md'.")
|
160
|
-
code_io.add_argument("-ci", "--context-in", type=str, default=None, help="Import a previously saved context from a file.")
|
161
|
-
code_io.add_argument("-tf", "--to-file", nargs='?', const="response.md", default=None, help="Query the LLM and save the response to a file. Requires --task. Defaults to 'response.md'.")
|
162
|
-
code_io.add_argument("-tc", "--to-clipboard", action="store_true", help="Query the LLM and save the response to the clipboard. Requires --task.")
|
163
|
-
code_io.add_argument("-ff", "--from-file", type=str, default=None, help="Apply code updates directly from a file.")
|
164
|
-
code_io.add_argument("-fc", "--from-clipboard", action="store_true", help="Apply code updates directly from the clipboard.")
|
165
|
-
|
166
|
-
# --- Group: General Options ---
|
167
|
-
options_group = parser.add_argument_group('General Options')
|
168
|
-
options_group.add_argument("-m", "--model", type=str, default="gemini/gemini-2.5-flash", help="Model name to use (e.g., 'gpt-4o', 'claude-3-sonnet').")
|
169
|
-
options_group.add_argument("-v", "--voice", type=str, default="False", help="Enable voice interaction for providing task instructions. (True/False)")
|
170
|
-
|
171
|
-
args = parser.parse_args()
|
172
|
-
|
173
|
-
try:
|
174
|
-
scopes = load_from_py_file(scopes_file_path, "scopes")
|
175
|
-
except FileNotFoundError:
|
176
|
-
scopes = {}
|
177
|
-
if not any([args.init, args.list_scopes, args.show_scope]):
|
178
|
-
console.print(f"⚠️ Scope file '{scopes_file_path}' not found. You can create one with the --init flag.", style="yellow")
|
179
|
-
|
180
|
-
|
181
|
-
if args.list_scopes:
|
182
|
-
console.print(f"Available scopes in '[bold]{scopes_file_path}[/]':", style="bold")
|
183
|
-
if not scopes:
|
184
|
-
console.print(f" -> No scopes found or '{scopes_file_path}' is missing.")
|
185
|
-
else:
|
186
|
-
for scope_name in scopes:
|
187
|
-
console.print(f" - {scope_name}")
|
188
|
-
return
|
189
|
-
|
190
|
-
if args.show_scope:
|
191
|
-
scope_name = args.show_scope
|
192
|
-
if not scopes:
|
193
|
-
console.print(f"⚠️ Scope file '{scopes_file_path}' not found or is empty.", style="yellow")
|
194
|
-
return
|
195
|
-
|
196
|
-
scope_data = scopes.get(scope_name)
|
197
|
-
if scope_data:
|
198
|
-
pretty_scope = pprint.pformat(scope_data, indent=2)
|
199
|
-
console.print(
|
200
|
-
Panel(
|
201
|
-
pretty_scope,
|
202
|
-
title=f"[bold cyan]Scope: '{scope_name}'[/]",
|
203
|
-
subtitle=f"[dim]from {scopes_file_path}[/dim]",
|
204
|
-
border_style="blue"
|
205
|
-
)
|
206
|
-
)
|
207
|
-
else:
|
208
|
-
console.print(f"❌ Scope '[bold]{scope_name}[/]' not found in '{scopes_file_path}'.", style="red")
|
209
|
-
return
|
210
|
-
|
211
|
-
if args.init:
|
212
|
-
create_new_scope(scopes, scopes_file_path)
|
213
|
-
return
|
214
|
-
|
215
|
-
if args.from_clipboard:
|
216
|
-
try:
|
217
|
-
import pyperclip
|
218
|
-
updates = pyperclip.paste()
|
219
|
-
if updates:
|
220
|
-
console.print("--- Parsing updates from clipboard ---", style="bold")
|
221
|
-
paste_response(updates)
|
222
|
-
else:
|
223
|
-
console.print("⚠️ Clipboard is empty. Nothing to parse.", style="yellow")
|
224
|
-
except ImportError:
|
225
|
-
console.print("❌ The 'pyperclip' library is required for clipboard functionality.", style="red")
|
226
|
-
console.print("Please install it using: pip install pyperclip", style="cyan")
|
227
|
-
except Exception as e:
|
228
|
-
console.print(f"❌ An error occurred while reading from the clipboard: {e}", style="red")
|
229
|
-
return
|
230
|
-
|
231
|
-
if args.from_file:
|
232
|
-
updates = read_from_file(args.from_file)
|
233
|
-
paste_response(updates)
|
234
|
-
return
|
235
|
-
|
236
|
-
system_prompt = textwrap.dedent("""
|
237
|
-
You are an expert pair programmer. Your purpose is to help users by modifying files based on their instructions.
|
238
|
-
Follow these rules strictly:
|
239
|
-
Your output should be a single file including all the updated files. For each file-block:
|
240
|
-
1. Only include code for files that need to be updated / edited.
|
241
|
-
2. For updated files, do not exclude any code even if it is unchanged code; assume the file code will be copy-pasted full in the file.
|
242
|
-
3. Do not include verbose inline comments explaining what every small change does. Try to keep comments concise but informative, if any.
|
243
|
-
4. Only update the relevant parts of each file relative to the provided task; do not make irrelevant edits even if you notice areas of improvements elsewhere.
|
244
|
-
5. Do not use diffs.
|
245
|
-
6. Make sure each file-block is returned in the following exact format. No additional text, comments, or explanations should be outside these blocks.
|
246
|
-
Expected format for a modified or new file:
|
247
|
-
<file_path:/absolute/path/to/your/file.py>
|
248
|
-
```python
|
249
|
-
# The full, complete content of /absolute/path/to/your/file.py goes here.
|
250
|
-
def example_function():
|
251
|
-
return "Hello, World!"
|
252
|
-
```
|
253
|
-
""")
|
254
|
-
history = [{"role": "system", "content": system_prompt}]
|
255
|
-
|
256
|
-
context = None
|
257
|
-
if args.voice not in ["False", "false"]:
|
258
|
-
from .listener import listen, speak
|
259
|
-
speak("Say your task instruction.")
|
260
|
-
task = listen()
|
261
|
-
if not task:
|
262
|
-
speak("No instruction heard. Exiting.")
|
263
|
-
return
|
264
|
-
speak(f"You said: {task}. Should I proceed?")
|
265
|
-
confirm = listen()
|
266
|
-
if confirm and "yes" in confirm.lower():
|
267
|
-
if not args.scope:
|
268
|
-
parser.error("A --scope name is required when using --voice.")
|
269
|
-
context = collect_context(args.scope, scopes)
|
270
|
-
llm_response = run_llm_query(task, args.model, history, context)
|
271
|
-
if llm_response:
|
272
|
-
paste_response(llm_response)
|
273
|
-
speak("Changes applied.")
|
274
|
-
else:
|
275
|
-
speak("Cancelled.")
|
276
|
-
return
|
277
|
-
|
278
|
-
# --- Main LLM Task Logic ---
|
279
|
-
if args.task:
|
280
|
-
action_flags = [args.patch, args.to_file is not None, args.to_clipboard]
|
281
|
-
if sum(action_flags) == 0:
|
282
|
-
parser.error("A task was provided, but no action was specified. Use --patch, --to-file, or --to-clipboard.")
|
283
|
-
if sum(action_flags) > 1:
|
284
|
-
parser.error("Please specify only one action: --patch, --to-file, or --to-clipboard.")
|
285
|
-
|
286
|
-
if args.context_in:
|
287
|
-
context = read_from_file(args.context_in)
|
288
|
-
else:
|
289
|
-
if not args.scope:
|
290
|
-
parser.error("A --scope name is required to build context for a task.")
|
291
|
-
context = collect_context(args.scope, scopes)
|
292
|
-
if context and args.context_out:
|
293
|
-
write_to_file(args.context_out, context)
|
294
|
-
|
295
|
-
if not context:
|
296
|
-
console.print("Proceeding with task but without any file context.", style="yellow")
|
297
|
-
|
298
|
-
llm_response = run_llm_query(args.task, args.model, history, context)
|
299
|
-
|
300
|
-
if llm_response:
|
301
|
-
if args.patch:
|
302
|
-
console.print("\n--- Updating files ---", style="bold")
|
303
|
-
paste_response(llm_response)
|
304
|
-
console.print("--- File Update Process Finished ---", style="bold")
|
305
|
-
|
306
|
-
elif args.to_file is not None:
|
307
|
-
write_to_file(args.to_file, llm_response)
|
308
|
-
|
309
|
-
elif args.to_clipboard:
|
310
|
-
try:
|
311
|
-
import pyperclip
|
312
|
-
pyperclip.copy(llm_response)
|
313
|
-
console.print("✅ Copied LLM response to clipboard.", style="green")
|
314
|
-
except ImportError:
|
315
|
-
console.print("❌ The 'pyperclip' library is required for clipboard functionality.", style="red")
|
316
|
-
console.print("Please install it using: pip install pyperclip", style="cyan")
|
317
|
-
except Exception as e:
|
318
|
-
console.print(f"❌ An error occurred while copying to the clipboard: {e}", style="red")
|
319
|
-
|
320
|
-
elif args.scope and args.context_out:
|
321
|
-
context = collect_context(args.scope, scopes)
|
322
|
-
if context:
|
323
|
-
write_to_file(args.context_out, context)
|
1
|
+
from .cli.entrypoint import main
|
324
2
|
|
325
3
|
if __name__ == "__main__":
|
326
4
|
main()
|
patchllm/parser.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
import difflib
|
1
2
|
import re
|
2
3
|
from pathlib import Path
|
3
4
|
from rich.console import Console
|
@@ -6,80 +7,135 @@ from rich.text import Text
|
|
6
7
|
|
7
8
|
console = Console()
|
8
9
|
|
9
|
-
def
|
10
|
+
def _parse_file_blocks(response: str) -> list[tuple[Path, str]]:
|
11
|
+
"""Parses the LLM response to extract file paths and their content."""
|
12
|
+
pattern = r"<file_path:(.*?)>\n```(?:\w+\n)?(.*?)\n```"
|
13
|
+
matches = re.findall(pattern, response, re.DOTALL)
|
14
|
+
|
15
|
+
parsed_blocks = []
|
16
|
+
for path_str, content in matches:
|
17
|
+
path_obj = Path(path_str.strip()).resolve()
|
18
|
+
# --- CORRECTION: Strip leading/trailing whitespace from content ---
|
19
|
+
parsed_blocks.append((path_obj, content.strip()))
|
20
|
+
|
21
|
+
return parsed_blocks
|
22
|
+
|
23
|
+
def parse_change_summary(response: str) -> str | None:
|
24
|
+
"""Parses the LLM response to extract the change summary."""
|
25
|
+
pattern = r"<change_summary>(.*?)</change_summary>"
|
26
|
+
match = re.search(pattern, response, re.DOTALL)
|
27
|
+
if match:
|
28
|
+
return match.group(1).strip()
|
29
|
+
return None
|
30
|
+
|
31
|
+
def paste_response(response: str):
|
32
|
+
"""Applies all file updates from the LLM's response to the local filesystem."""
|
33
|
+
parsed_blocks = _parse_file_blocks(response)
|
34
|
+
if not parsed_blocks:
|
35
|
+
console.print("⚠️ Could not find any file blocks to apply in the response.", style="yellow")
|
36
|
+
return
|
37
|
+
|
38
|
+
for file_path, new_content in parsed_blocks:
|
39
|
+
try:
|
40
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
41
|
+
file_path.write_text(new_content, encoding="utf-8")
|
42
|
+
console.print(f"✅ Updated [bold cyan]{file_path.name}[/bold cyan]", style="green")
|
43
|
+
except Exception as e:
|
44
|
+
console.print(f"❌ Failed to write to {file_path}: {e}", style="red")
|
45
|
+
|
46
|
+
def paste_response_selectively(response: str, files_to_apply: list[str]):
|
10
47
|
"""
|
11
|
-
|
12
|
-
handling both absolute and relative paths safely.
|
48
|
+
Applies file updates from the LLM's response only to the user-selected files.
|
13
49
|
|
14
50
|
Args:
|
15
|
-
|
51
|
+
response (str): The full response from the LLM.
|
52
|
+
files_to_apply (list[str]): A list of absolute file path strings to modify.
|
16
53
|
"""
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
54
|
+
parsed_blocks = _parse_file_blocks(response)
|
55
|
+
if not parsed_blocks:
|
56
|
+
console.print("⚠️ Could not find any file blocks to apply in the response.", style="yellow")
|
57
|
+
return
|
21
58
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
59
|
+
files_to_apply_set = set(files_to_apply)
|
60
|
+
applied_count = 0
|
61
|
+
|
62
|
+
for file_path, new_content in parsed_blocks:
|
63
|
+
if file_path.as_posix() in files_to_apply_set:
|
64
|
+
try:
|
65
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
66
|
+
file_path.write_text(new_content, encoding="utf-8")
|
67
|
+
console.print(f"✅ Updated [bold cyan]{file_path.name}[/bold cyan]", style="green")
|
68
|
+
applied_count += 1
|
69
|
+
except Exception as e:
|
70
|
+
console.print(f"❌ Failed to write to {file_path}: {e}", style="red")
|
71
|
+
|
72
|
+
if applied_count == 0:
|
73
|
+
console.print("No changes were applied.", style="yellow")
|
74
|
+
|
75
|
+
|
76
|
+
def summarize_changes(response: str) -> dict:
|
77
|
+
"""Summarizes which files will be created and which will be modified."""
|
78
|
+
parsed_blocks = _parse_file_blocks(response)
|
79
|
+
summary = {"created": [], "modified": []}
|
80
|
+
for file_path, _ in parsed_blocks:
|
81
|
+
if file_path.exists():
|
82
|
+
summary["modified"].append(file_path.as_posix())
|
43
83
|
else:
|
44
|
-
|
84
|
+
summary["created"].append(file_path.as_posix())
|
85
|
+
return summary
|
45
86
|
|
46
|
-
|
87
|
+
def get_diff_for_file(file_path_str: str, response: str) -> str:
|
88
|
+
"""Generates a colorized, unified diff for a single file from the response."""
|
89
|
+
parsed_blocks = _parse_file_blocks(response)
|
90
|
+
file_path = Path(file_path_str).resolve()
|
91
|
+
|
92
|
+
new_content = None
|
93
|
+
for p, content in parsed_blocks:
|
94
|
+
if p == file_path:
|
95
|
+
new_content = content
|
96
|
+
break
|
97
|
+
|
98
|
+
if new_content is None:
|
99
|
+
return f"Could not find content for {file_path_str} in the response."
|
47
100
|
|
101
|
+
original_content = ""
|
102
|
+
if file_path.exists():
|
48
103
|
try:
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
with open(target_path, 'r', encoding='utf-8') as existing_file:
|
53
|
-
if existing_file.read() == code_content:
|
54
|
-
console.print(f" -> No changes for '[cyan]{target_path}[/]', skipping.", style="dim")
|
55
|
-
files_skipped.append(target_path)
|
56
|
-
continue
|
104
|
+
original_content = file_path.read_text(encoding="utf-8")
|
105
|
+
except Exception:
|
106
|
+
return f"Could not read original content of {file_path_str}."
|
57
107
|
|
58
|
-
|
59
|
-
|
108
|
+
diff = difflib.unified_diff(
|
109
|
+
original_content.splitlines(keepends=True),
|
110
|
+
new_content.splitlines(keepends=True),
|
111
|
+
fromfile=f"a/{file_path.name}",
|
112
|
+
tofile=f"b/{file_path.name}",
|
113
|
+
)
|
60
114
|
|
61
|
-
|
62
|
-
|
115
|
+
diff_text = Text()
|
116
|
+
for line in diff:
|
117
|
+
if line.startswith('+'):
|
118
|
+
diff_text.append(line, style="green")
|
119
|
+
elif line.startswith('-'):
|
120
|
+
diff_text.append(line, style="red")
|
121
|
+
elif line.startswith('^'):
|
122
|
+
diff_text.append(line, style="blue")
|
123
|
+
else:
|
124
|
+
diff_text.append(line)
|
125
|
+
|
126
|
+
return diff_text
|
63
127
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
else:
|
75
|
-
if files_written:
|
76
|
-
summary_text.append(f"Successfully wrote {len(files_written)} file(s).\n", style="green")
|
77
|
-
if files_skipped:
|
78
|
-
summary_text.append(f"Skipped {len(files_skipped)} file(s) (no changes).\n", style="cyan")
|
79
|
-
if files_failed:
|
80
|
-
summary_text.append(f"Failed to write {len(files_failed)} file(s).\n", style="red")
|
128
|
+
def display_diff(response: str):
|
129
|
+
"""Displays the diff for all changes proposed in the response."""
|
130
|
+
summary = summarize_changes(response)
|
131
|
+
all_files = summary.get("modified", []) + summary.get("created", [])
|
132
|
+
|
133
|
+
console.print("\n--- Proposed Changes (Diff) ---", style="bold yellow")
|
134
|
+
|
135
|
+
if not all_files:
|
136
|
+
console.print("No file changes detected in the response.")
|
137
|
+
return
|
81
138
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
console.print(Panel(summary_text, title="[bold]Summary[/bold]", border_style="blue"))
|
139
|
+
for file_path in all_files:
|
140
|
+
diff_text = get_diff_for_file(file_path, response)
|
141
|
+
console.print(Panel(diff_text, title=f"[bold cyan]Diff: {Path(file_path).name}[/bold cyan]", border_style="yellow"))
|
patchllm/patcher.py
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
import subprocess
|
2
|
+
import re
|
3
|
+
from pathlib import Path
|
4
|
+
from rich.console import Console
|
5
|
+
from InquirerPy import prompt
|
6
|
+
from InquirerPy.exceptions import InvalidArgument
|
7
|
+
import textwrap
|
8
|
+
|
9
|
+
from .parser import get_diff_for_file, _parse_file_blocks, paste_response
|
10
|
+
from .scopes.helpers import find_files
|
11
|
+
|
12
|
+
console = Console()
|
13
|
+
|
14
|
+
def _is_diff_format(text: str) -> bool:
|
15
|
+
"""Checks if the text appears to be in the unified diff format."""
|
16
|
+
for line in text.splitlines():
|
17
|
+
if line.strip(): # Find the first non-empty line
|
18
|
+
return line.startswith("--- a/")
|
19
|
+
return False
|
20
|
+
|
21
|
+
def _apply_diff(diff_text: str, base_path: Path):
|
22
|
+
"""Applies a patch using the system's `patch` command."""
|
23
|
+
try:
|
24
|
+
# --- MODIFICATION: Added --ignore-whitespace for robustness ---
|
25
|
+
proc = subprocess.run(
|
26
|
+
["patch", "-p1", "--ignore-whitespace"],
|
27
|
+
input=diff_text,
|
28
|
+
text=True,
|
29
|
+
capture_output=True,
|
30
|
+
check=True,
|
31
|
+
cwd=base_path
|
32
|
+
)
|
33
|
+
console.print("✅ Patch applied successfully.", style="green")
|
34
|
+
if proc.stdout:
|
35
|
+
console.print(proc.stdout)
|
36
|
+
except FileNotFoundError:
|
37
|
+
console.print("❌ Failed to apply patch.", style="red")
|
38
|
+
console.print("Error: The 'patch' command-line utility was not found.", style="yellow")
|
39
|
+
console.print("Please install it to apply diff-formatted patches:", style="yellow")
|
40
|
+
console.print(" - On Debian/Ubuntu: sudo apt-get install patch", style="cyan")
|
41
|
+
console.print(" - On macOS (Homebrew): brew install patch", style="cyan")
|
42
|
+
console.print(" - On Termux: pkg install patch", style="cyan")
|
43
|
+
except subprocess.CalledProcessError as e:
|
44
|
+
console.print("❌ Failed to apply patch.", style="red")
|
45
|
+
console.print("The `patch` command failed. This may be due to a mismatch between the diff and the file content.", style="yellow")
|
46
|
+
if e.stderr:
|
47
|
+
console.print("Error details:", style="yellow")
|
48
|
+
console.print(e.stderr)
|
49
|
+
except Exception as e:
|
50
|
+
console.print(f"❌ An unexpected error occurred while applying the patch: {e}", style="red")
|
51
|
+
|
52
|
+
|
53
|
+
def _interactive_file_selection(base_path: Path) -> Path | None:
|
54
|
+
"""Prompts the user to select a file from the project."""
|
55
|
+
try:
|
56
|
+
all_files = find_files(base_path, ["**/*"])
|
57
|
+
choices = [p.relative_to(base_path).as_posix() for p in all_files]
|
58
|
+
if not choices:
|
59
|
+
console.print("No files found in the project.", style="yellow")
|
60
|
+
return None
|
61
|
+
|
62
|
+
question = {
|
63
|
+
"type": "fuzzy", "name": "file", "message": "Which file should this code be applied to?",
|
64
|
+
"choices": choices, "border": True
|
65
|
+
}
|
66
|
+
result = prompt([question], vi_mode=True)
|
67
|
+
return base_path / result.get("file") if result else None
|
68
|
+
except (InvalidArgument, IndexError, KeyError):
|
69
|
+
return None
|
70
|
+
|
71
|
+
def apply_external_patch(content: str, base_path: Path):
|
72
|
+
"""
|
73
|
+
Intelligently applies code updates from an external source.
|
74
|
+
Handles standard diffs, patchllm's format, and ambiguous code blocks.
|
75
|
+
"""
|
76
|
+
if _is_diff_format(content):
|
77
|
+
console.print("Detected `diff` format. Attempting to apply with `patch`...", style="cyan")
|
78
|
+
_apply_diff(content, base_path)
|
79
|
+
return
|
80
|
+
|
81
|
+
clean_content = textwrap.dedent(content).strip()
|
82
|
+
|
83
|
+
parsed_blocks = _parse_file_blocks(clean_content)
|
84
|
+
if parsed_blocks:
|
85
|
+
console.print("Detected `patchllm` format. Applying changes...", style="cyan")
|
86
|
+
paste_response(clean_content)
|
87
|
+
return
|
88
|
+
|
89
|
+
console.print("Could not parse a standard format. Entering interactive mode...", style="yellow")
|
90
|
+
|
91
|
+
code_block_match = re.search(r"```(?:\w+)?\s*\n(.*?)\n\s*```", clean_content, re.DOTALL)
|
92
|
+
if not code_block_match:
|
93
|
+
console.print("❌ No code blocks found in the input.", style="red")
|
94
|
+
return
|
95
|
+
|
96
|
+
code_to_apply = code_block_match.group(1).strip()
|
97
|
+
target_file = _interactive_file_selection(base_path)
|
98
|
+
|
99
|
+
if not target_file:
|
100
|
+
console.print("Cancelled.", style="yellow")
|
101
|
+
return
|
102
|
+
|
103
|
+
response_for_diff = f"<file_path:{target_file.as_posix()}>\n```\n{code_to_apply}\n```"
|
104
|
+
diff_text = get_diff_for_file(str(target_file), response_for_diff)
|
105
|
+
console.print("\n--- Proposed Changes ---", style="bold yellow")
|
106
|
+
console.print(diff_text)
|
107
|
+
|
108
|
+
try:
|
109
|
+
confirm_q = {"type": "confirm", "name": "confirm", "message": f"Apply these changes to '{target_file.name}'?", "default": True}
|
110
|
+
result = prompt([confirm_q])
|
111
|
+
if result and result.get("confirm"):
|
112
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
113
|
+
target_file.write_text(code_to_apply, encoding="utf-8")
|
114
|
+
console.print(f"✅ Updated [bold cyan]{target_file.name}[/bold cyan]", style="green")
|
115
|
+
else:
|
116
|
+
console.print("Cancelled.", style="yellow")
|
117
|
+
except (InvalidArgument, IndexError, KeyError):
|
118
|
+
console.print("Cancelled.", style="yellow")
|
File without changes
|