patchllm 0.2.1__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 -283
- 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.1.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.1.dist-info/METADATA +0 -127
- patchllm-0.2.1.dist-info/RECORD +0 -12
- patchllm-0.2.1.dist-info/entry_points.txt +0 -2
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/licenses/LICENSE +0 -0
patchllm/main.py
CHANGED
@@ -1,286 +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(config_name, configs):
|
19
|
-
"""Builds the code context from a provided configuration dictionary."""
|
20
|
-
console.print("\n--- Building Code Context... ---", style="bold")
|
21
|
-
if not configs:
|
22
|
-
raise FileNotFoundError("Could not find a 'configs.py' file.")
|
23
|
-
selected_config = configs.get(config_name)
|
24
|
-
if selected_config is None:
|
25
|
-
raise KeyError(f"Context config '{config_name}' not found in provided configs file.")
|
26
|
-
|
27
|
-
context_object = build_context(selected_config)
|
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_update(task_instructions, model_name, history, context=None):
|
38
|
-
"""
|
39
|
-
Assembles the final prompt, sends it to the LLM, and applies file updates.
|
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 paste.", style="yellow")
|
57
|
-
return
|
58
|
-
|
59
|
-
console.print("\n--- Updating files ---", style="bold")
|
60
|
-
paste_response(assistant_response_content)
|
61
|
-
console.print("--- File Update Process Finished ---", style="bold")
|
62
|
-
|
63
|
-
except Exception as e:
|
64
|
-
history.pop() # Keep history clean on error
|
65
|
-
raise RuntimeError(f"An error occurred while communicating with the LLM via litellm: {e}") from e
|
66
|
-
|
67
|
-
def write_context_to_file(file_path, context):
|
68
|
-
"""Utility function to write the context to a file."""
|
69
|
-
console.print("Exporting context..", style="cyan")
|
70
|
-
with open(file_path, "w", encoding="utf-8") as file:
|
71
|
-
file.write(context)
|
72
|
-
console.print(f'✅ Context exported to {file_path.split("/")[-1]}', style="green")
|
73
|
-
|
74
|
-
def read_from_file(file_path):
|
75
|
-
"""Utility function to read and return the content of a file."""
|
76
|
-
console.print(f"Importing from {file_path}..", style="cyan")
|
77
|
-
try:
|
78
|
-
with open(file_path, "r", encoding="utf-8") as file:
|
79
|
-
content = file.read()
|
80
|
-
console.print("✅ Finished reading file.", style="green")
|
81
|
-
return content
|
82
|
-
except Exception as e:
|
83
|
-
raise RuntimeError(f"Failed to read from file {file_path}: {e}") from e
|
84
|
-
|
85
|
-
def create_new_config(configs, configs_file_str):
|
86
|
-
"""Interactively creates a new configuration and saves it to the specified configs file."""
|
87
|
-
console.print(f"\n--- Creating a new configuration in '{configs_file_str}' ---", style="bold")
|
88
|
-
|
89
|
-
try:
|
90
|
-
name = console.input("[bold]Enter a name for the new configuration: [/]").strip()
|
91
|
-
if not name:
|
92
|
-
console.print("❌ Configuration name cannot be empty.", style="red")
|
93
|
-
return
|
94
|
-
|
95
|
-
if name in configs:
|
96
|
-
overwrite = console.input(f"Configuration '[bold]{name}[/]' already exists. Overwrite? (y/n): ").lower()
|
97
|
-
if overwrite not in ['y', 'yes']:
|
98
|
-
console.print("Operation cancelled.", style="yellow")
|
99
|
-
return
|
100
|
-
|
101
|
-
path = console.input("[bold]Enter the base path[/] (e.g., '.' for current directory): ").strip() or "."
|
102
|
-
|
103
|
-
console.print("\nEnter comma-separated glob patterns for files to include.")
|
104
|
-
include_raw = console.input('[cyan]> (e.g., "[bold]**/*.py, src/**/*.js[/]"): [/]').strip()
|
105
|
-
include_patterns = [p.strip() for p in include_raw.split(',') if p.strip()]
|
106
|
-
|
107
|
-
console.print("\nEnter comma-separated glob patterns for files to exclude (optional).")
|
108
|
-
exclude_raw = console.input('[cyan]> (e.g., "[bold]**/tests/*, venv/*[/]"): [/]').strip()
|
109
|
-
exclude_patterns = [p.strip() for p in exclude_raw.split(',') if p.strip()]
|
110
|
-
|
111
|
-
console.print("\nEnter comma-separated URLs to include as context (optional).")
|
112
|
-
urls_raw = console.input('[cyan]> (e.g., "[bold]https://docs.example.com, ...[/]"): [/]').strip()
|
113
|
-
urls = [u.strip() for u in urls_raw.split(',') if u.strip()]
|
114
|
-
|
115
|
-
new_config_data = {
|
116
|
-
"path": path,
|
117
|
-
"include_patterns": include_patterns,
|
118
|
-
"exclude_patterns": exclude_patterns,
|
119
|
-
"urls": urls,
|
120
|
-
}
|
121
|
-
|
122
|
-
configs[name] = new_config_data
|
123
|
-
|
124
|
-
with open(configs_file_str, "w", encoding="utf-8") as f:
|
125
|
-
f.write("# configs.py\n")
|
126
|
-
f.write("configs = ")
|
127
|
-
f.write(pprint.pformat(configs, indent=4))
|
128
|
-
f.write("\n")
|
129
|
-
|
130
|
-
console.print(f"\n✅ Successfully created and saved configuration '[bold]{name}[/]' in '[bold]{configs_file_str}[/]'.", style="green")
|
131
|
-
|
132
|
-
except KeyboardInterrupt:
|
133
|
-
console.print("\n\n⚠️ Configuration creation cancelled by user.", style="yellow")
|
134
|
-
return
|
135
|
-
|
136
|
-
def main():
|
137
|
-
"""
|
138
|
-
Main entry point for the patchllm command-line tool.
|
139
|
-
"""
|
140
|
-
load_dotenv()
|
141
|
-
|
142
|
-
configs_file_path = os.getenv("PATCHLLM_CONFIGS_FILE", "./configs.py")
|
143
|
-
|
144
|
-
parser = argparse.ArgumentParser(
|
145
|
-
description="A CLI tool to apply code changes using an LLM.",
|
146
|
-
formatter_class=argparse.RawTextHelpFormatter
|
147
|
-
)
|
148
|
-
|
149
|
-
parser.add_argument("-i", "--init", action="store_true", help="Create a new configuration interactively.")
|
150
|
-
|
151
|
-
parser.add_argument("-c", "--config", type=str, default=None, help="Name of the config key to use from the configs file.")
|
152
|
-
parser.add_argument("-t", "--task", type=str, default=None, help="The task instructions to guide the assistant.")
|
153
|
-
|
154
|
-
parser.add_argument("-co", "--context-out", nargs='?', const="context.md", default=None, help="Export the generated context to a file. Defaults to 'context.md'.")
|
155
|
-
parser.add_argument("-ci", "--context-in", type=str, default=None, help="Import a previously saved context from a file.")
|
156
|
-
|
157
|
-
parser.add_argument("-u", "--update", type=str, default="True", help="Control whether to send the context to the LLM for updates. (True/False)")
|
158
|
-
parser.add_argument("-ff", "--from-file", type=str, default=None, help="Apply updates directly from a file instead of the LLM.")
|
159
|
-
parser.add_argument("-fc", "--from-clipboard", action="store_true", help="Apply updates directly from the clipboard.")
|
160
|
-
|
161
|
-
parser.add_argument("--model", type=str, default="gemini/gemini-1.5-flash", help="Model name to use (e.g., 'gpt-4o', 'claude-3-sonnet').")
|
162
|
-
parser.add_argument("--voice", type=str, default="False", help="Enable voice interaction for providing task instructions. (True/False)")
|
163
|
-
|
164
|
-
parser.add_argument("--list-configs", action="store_true", help="List all available configurations from the configs file and exit.")
|
165
|
-
parser.add_argument("--show-config", type=str, help="Display the settings for a specific configuration and exit.")
|
166
|
-
|
167
|
-
args = parser.parse_args()
|
168
|
-
|
169
|
-
try:
|
170
|
-
configs = load_from_py_file(configs_file_path, "configs")
|
171
|
-
except FileNotFoundError:
|
172
|
-
configs = {}
|
173
|
-
if not any([args.init, args.list_configs, args.show_config]):
|
174
|
-
console.print(f"⚠️ Config file '{configs_file_path}' not found. You can create one with the --init flag.", style="yellow")
|
175
|
-
|
176
|
-
|
177
|
-
if args.list_configs:
|
178
|
-
console.print(f"Available configurations in '[bold]{configs_file_path}[/]':", style="bold")
|
179
|
-
if not configs:
|
180
|
-
console.print(f" -> No configurations found or '{configs_file_path}' is missing.")
|
181
|
-
else:
|
182
|
-
for config_name in configs:
|
183
|
-
console.print(f" - {config_name}")
|
184
|
-
return
|
185
|
-
|
186
|
-
if args.show_config:
|
187
|
-
config_name = args.show_config
|
188
|
-
if not configs:
|
189
|
-
console.print(f"⚠️ Config file '{configs_file_path}' not found or is empty.", style="yellow")
|
190
|
-
return
|
191
|
-
|
192
|
-
config_data = configs.get(config_name)
|
193
|
-
if config_data:
|
194
|
-
pretty_config = pprint.pformat(config_data, indent=2)
|
195
|
-
console.print(
|
196
|
-
Panel(
|
197
|
-
pretty_config,
|
198
|
-
title=f"[bold cyan]Configuration: '{config_name}'[/]",
|
199
|
-
subtitle=f"[dim]from {configs_file_path}[/dim]",
|
200
|
-
border_style="blue"
|
201
|
-
)
|
202
|
-
)
|
203
|
-
else:
|
204
|
-
console.print(f"❌ Configuration '[bold]{config_name}[/]' not found in '{configs_file_path}'.", style="red")
|
205
|
-
return
|
206
|
-
|
207
|
-
if args.init:
|
208
|
-
create_new_config(configs, configs_file_path)
|
209
|
-
return
|
210
|
-
|
211
|
-
if args.from_clipboard:
|
212
|
-
try:
|
213
|
-
import pyperclip
|
214
|
-
updates = pyperclip.paste()
|
215
|
-
if updates:
|
216
|
-
console.print("--- Parsing updates from clipboard ---", style="bold")
|
217
|
-
paste_response(updates)
|
218
|
-
else:
|
219
|
-
console.print("⚠️ Clipboard is empty. Nothing to parse.", style="yellow")
|
220
|
-
except ImportError:
|
221
|
-
console.print("❌ The 'pyperclip' library is required for clipboard functionality.", style="red")
|
222
|
-
console.print("Please install it using: pip install pyperclip", style="cyan")
|
223
|
-
except Exception as e:
|
224
|
-
console.print(f"❌ An error occurred while reading from the clipboard: {e}", style="red")
|
225
|
-
return
|
226
|
-
|
227
|
-
if args.from_file:
|
228
|
-
updates = read_from_file(args.from_file)
|
229
|
-
paste_response(updates)
|
230
|
-
return
|
231
|
-
|
232
|
-
system_prompt = textwrap.dedent("""
|
233
|
-
You are an expert pair programmer. Your purpose is to help users by modifying files based on their instructions.
|
234
|
-
Follow these rules strictly:
|
235
|
-
Your output should be a single file including all the updated files. For each file-block:
|
236
|
-
1. Only include code for files that need to be updated / edited.
|
237
|
-
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.
|
238
|
-
3. Do not include verbose inline comments explaining what every small change does. Try to keep comments concise but informative, if any.
|
239
|
-
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.
|
240
|
-
5. Do not use diffs.
|
241
|
-
6. Make sure each file-block is returned in the following exact format. No additional text, comments, or explanations should be outside these blocks.
|
242
|
-
Expected format for a modified or new file:
|
243
|
-
<file_path:/absolute/path/to/your/file.py>
|
244
|
-
```python
|
245
|
-
# The full, complete content of /absolute/path/to/your/file.py goes here.
|
246
|
-
def example_function():
|
247
|
-
return "Hello, World!"
|
248
|
-
```
|
249
|
-
""")
|
250
|
-
history = [{"role": "system", "content": system_prompt}]
|
251
|
-
|
252
|
-
context = None
|
253
|
-
if args.voice not in ["False", "false"]:
|
254
|
-
from .listener import listen, speak
|
255
|
-
speak("Say your task instruction.")
|
256
|
-
task = listen()
|
257
|
-
if not task:
|
258
|
-
speak("No instruction heard. Exiting.")
|
259
|
-
return
|
260
|
-
speak(f"You said: {task}. Should I proceed?")
|
261
|
-
confirm = listen()
|
262
|
-
if confirm and "yes" in confirm.lower():
|
263
|
-
context = collect_context(args.config, configs)
|
264
|
-
run_update(task, args.model, history, context)
|
265
|
-
speak("Changes applied.")
|
266
|
-
else:
|
267
|
-
speak("Cancelled.")
|
268
|
-
return
|
269
|
-
|
270
|
-
if args.context_in:
|
271
|
-
context = read_from_file(args.context_in)
|
272
|
-
else:
|
273
|
-
if not args.config:
|
274
|
-
parser.error("A --config name is required unless using other flags like --context-in or other utility flags.")
|
275
|
-
context = collect_context(args.config, configs)
|
276
|
-
if context and args.context_out:
|
277
|
-
write_context_to_file(args.context_out, context)
|
278
|
-
|
279
|
-
if args.update not in ["False", "false"]:
|
280
|
-
if not args.task:
|
281
|
-
parser.error("The --task argument is required to generate updates.")
|
282
|
-
if context:
|
283
|
-
run_update(args.task, args.model, history, context)
|
1
|
+
from .cli.entrypoint import main
|
284
2
|
|
285
3
|
if __name__ == "__main__":
|
286
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
|
@@ -0,0 +1,55 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from rich.console import Console
|
3
|
+
|
4
|
+
from . import resolvers, structure, helpers
|
5
|
+
from .constants import DEFAULT_EXCLUDE_EXTENSIONS
|
6
|
+
|
7
|
+
console = Console()
|
8
|
+
|
9
|
+
def build_context_from_files(file_paths: list[Path], base_path: Path) -> dict | None:
|
10
|
+
"""Builds the context string directly from a provided list of file paths."""
|
11
|
+
if not file_paths:
|
12
|
+
console.print("\n⚠️ No files were provided to build the context.", style="yellow")
|
13
|
+
return None
|
14
|
+
return helpers._format_context(file_paths, [], base_path)
|
15
|
+
|
16
|
+
def build_context(scope_name: str, scopes: dict, base_path: Path) -> dict | None:
|
17
|
+
"""Builds the context string from files, handling static and dynamic scopes."""
|
18
|
+
if scope_name == "@structure":
|
19
|
+
return structure._build_structure_context(base_path)
|
20
|
+
|
21
|
+
relevant_files = []
|
22
|
+
urls = []
|
23
|
+
|
24
|
+
if scope_name.startswith('@'):
|
25
|
+
console.print(f"Resolving dynamic scope: [bold cyan]{scope_name}[/bold cyan]")
|
26
|
+
relevant_files = resolvers.resolve_dynamic_scope(scope_name, base_path)
|
27
|
+
else:
|
28
|
+
scope = scopes.get(scope_name)
|
29
|
+
if not scope:
|
30
|
+
console.print(f"❌ Static scope '{scope_name}' not found.", style="red")
|
31
|
+
return None
|
32
|
+
|
33
|
+
scope_path = Path(scope.get("path", ".")).resolve()
|
34
|
+
include = scope.get("include_patterns", [])
|
35
|
+
exclude = scope.get("exclude_patterns", [])
|
36
|
+
search = scope.get("search_words", [])
|
37
|
+
urls = scope.get("urls", [])
|
38
|
+
|
39
|
+
relevant_files = helpers.find_files(scope_path, include, exclude)
|
40
|
+
if search:
|
41
|
+
relevant_files = helpers.filter_files_by_keyword(relevant_files, search)
|
42
|
+
|
43
|
+
if not relevant_files and not urls:
|
44
|
+
console.print("\n⚠️ No files or URLs matched the specified criteria.", style="yellow")
|
45
|
+
return None
|
46
|
+
|
47
|
+
exclude_exts = scopes.get(scope_name, {}).get("exclude_extensions", DEFAULT_EXCLUDE_EXTENSIONS)
|
48
|
+
norm_ext = {ext.lower() for ext in exclude_exts}
|
49
|
+
relevant_files = [p for p in relevant_files if p.suffix.lower() not in norm_ext]
|
50
|
+
|
51
|
+
if not relevant_files and not urls:
|
52
|
+
console.print("\n⚠️ No files left after extension filtering.", style="yellow")
|
53
|
+
return None
|
54
|
+
|
55
|
+
return helpers._format_context(relevant_files, urls, base_path)
|