gptdiff 0.1.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.
gptdiff/__init__.py
ADDED
gptdiff/gptdiff.py
ADDED
@@ -0,0 +1,604 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
import openai
|
4
|
+
from openai import OpenAI
|
5
|
+
|
6
|
+
import tiktoken
|
7
|
+
|
8
|
+
import os
|
9
|
+
import json
|
10
|
+
import subprocess
|
11
|
+
from pathlib import Path
|
12
|
+
import sys
|
13
|
+
import fnmatch
|
14
|
+
import argparse
|
15
|
+
import pkgutil
|
16
|
+
import re
|
17
|
+
import contextvars
|
18
|
+
from ai_agent_toolbox import FlatXMLParser, FlatXMLPromptFormatter, Toolbox
|
19
|
+
import threading
|
20
|
+
from pkgutil import get_data
|
21
|
+
|
22
|
+
diff_context = contextvars.ContextVar('diffcontent', default="")
|
23
|
+
def create_toolbox():
|
24
|
+
toolbox = Toolbox()
|
25
|
+
|
26
|
+
def diff(content: str):
|
27
|
+
diff_context.set(content)
|
28
|
+
return content
|
29
|
+
|
30
|
+
toolbox.add_tool(
|
31
|
+
name="diff",
|
32
|
+
fn=diff,
|
33
|
+
args={
|
34
|
+
"content": {
|
35
|
+
"type": "string",
|
36
|
+
"description": "Complete diff."
|
37
|
+
}
|
38
|
+
},
|
39
|
+
description="Save the calculated diff as used in 'git apply'"
|
40
|
+
)
|
41
|
+
return toolbox
|
42
|
+
|
43
|
+
|
44
|
+
def load_gitignore_patterns(gitignore_path):
|
45
|
+
with open(gitignore_path, 'r') as f:
|
46
|
+
patterns = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
47
|
+
return patterns
|
48
|
+
|
49
|
+
def is_ignored(filepath, gitignore_patterns):
|
50
|
+
filepath = Path(filepath).resolve()
|
51
|
+
ignored = False
|
52
|
+
|
53
|
+
for pattern in gitignore_patterns:
|
54
|
+
if pattern.startswith('!'):
|
55
|
+
negated_pattern = pattern[1:]
|
56
|
+
if fnmatch.fnmatch(str(filepath), negated_pattern) or fnmatch.fnmatch(str(filepath.relative_to(Path.cwd())), negated_pattern):
|
57
|
+
ignored = False
|
58
|
+
else:
|
59
|
+
relative_path = str(filepath.relative_to(Path.cwd()))
|
60
|
+
if fnmatch.fnmatch(str(filepath), pattern) or fnmatch.fnmatch(relative_path, pattern):
|
61
|
+
ignored = True
|
62
|
+
break
|
63
|
+
if pattern in relative_path:
|
64
|
+
ignored = True
|
65
|
+
break
|
66
|
+
|
67
|
+
# Ensure .gitignore itself is not ignored unless explicitly mentioned
|
68
|
+
if filepath.name == ".gitignore" and not any(pattern == ".gitignore" for pattern in gitignore_patterns):
|
69
|
+
ignored = False
|
70
|
+
|
71
|
+
return ignored
|
72
|
+
|
73
|
+
def list_files_and_dirs(path, ignore_list=None):
|
74
|
+
if ignore_list is None:
|
75
|
+
ignore_list = []
|
76
|
+
|
77
|
+
result = []
|
78
|
+
|
79
|
+
# List all items in the current directory
|
80
|
+
for item in os.listdir(path):
|
81
|
+
item_path = os.path.join(path, item)
|
82
|
+
|
83
|
+
if is_ignored(item_path, ignore_list):
|
84
|
+
continue
|
85
|
+
|
86
|
+
# Add the item to the result list
|
87
|
+
result.append(item_path)
|
88
|
+
|
89
|
+
# If it's a directory, recurse into it
|
90
|
+
if os.path.isdir(item_path):
|
91
|
+
result.extend(list_files_and_dirs(item_path, ignore_list))
|
92
|
+
|
93
|
+
return result
|
94
|
+
|
95
|
+
# Function to load project files considering .gitignore
|
96
|
+
def load_project_files(project_dir, cwd):
|
97
|
+
"""Load project files while respecting .gitignore and .gptignore rules.
|
98
|
+
|
99
|
+
Recursively scans directories, skipping:
|
100
|
+
- Files/directories matching patterns in .gitignore/.gptignore
|
101
|
+
- Binary files that can't be decoded as UTF-8 text
|
102
|
+
|
103
|
+
Args:
|
104
|
+
project_dir: Root directory to scan for files
|
105
|
+
cwd: Base directory for resolving ignore files
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
List of (absolute_path, file_content) tuples
|
109
|
+
|
110
|
+
Note:
|
111
|
+
Prints skipped files to stdout for visibility
|
112
|
+
"""
|
113
|
+
ignore_paths = [Path(cwd) / ".gitignore", Path(cwd) / ".gptignore"]
|
114
|
+
gitignore_patterns = [".gitignore", "diff.patch", "prompt.txt", ".gptignore", "*.pdf", "*.docx", ".git", "*.orig", "*.rej"]
|
115
|
+
|
116
|
+
for p in ignore_paths:
|
117
|
+
if p.exists():
|
118
|
+
with open(p, 'r') as f:
|
119
|
+
gitignore_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
120
|
+
|
121
|
+
project_files = []
|
122
|
+
for file in list_files_and_dirs(project_dir, gitignore_patterns):
|
123
|
+
if os.path.isfile(file):
|
124
|
+
try:
|
125
|
+
with open(file, 'r') as f:
|
126
|
+
content = f.read()
|
127
|
+
print(file)
|
128
|
+
project_files.append((file, content))
|
129
|
+
except UnicodeDecodeError:
|
130
|
+
print(f"Skipping file {file} due to UnicodeDecodeError")
|
131
|
+
continue
|
132
|
+
|
133
|
+
print("")
|
134
|
+
return project_files
|
135
|
+
|
136
|
+
def load_prepend_file(file):
|
137
|
+
with open(file, 'r') as f:
|
138
|
+
return f.read()
|
139
|
+
|
140
|
+
# Function to call GPT-4 API and calculate the cost
|
141
|
+
def call_gpt4_api(system_prompt, user_prompt, files_content, model, temperature=0.7, max_tokens=2500, api_key=None, base_url=None):
|
142
|
+
|
143
|
+
parser = FlatXMLParser("diff")
|
144
|
+
formatter = FlatXMLPromptFormatter(tag="diff")
|
145
|
+
toolbox = create_toolbox()
|
146
|
+
tool_prompt = formatter.usage_prompt(toolbox)
|
147
|
+
system_prompt += "\n"+tool_prompt
|
148
|
+
|
149
|
+
if model == "gemini-2.0-flash-thinking-exp-01-21":
|
150
|
+
user_prompt = system_prompt+"\n"+user_prompt
|
151
|
+
|
152
|
+
messages = [
|
153
|
+
{"role": "system", "content": system_prompt},
|
154
|
+
{"role": "user", "content": user_prompt + "\n"+files_content},
|
155
|
+
]
|
156
|
+
print("Using", model)
|
157
|
+
print("SYSTEM PROMPT")
|
158
|
+
print(system_prompt)
|
159
|
+
print("USER PROMPT")
|
160
|
+
print(user_prompt, "+", len(files_content), "characters of file content")
|
161
|
+
|
162
|
+
if api_key is None:
|
163
|
+
api_key = os.getenv('NANOGPT_API_KEY')
|
164
|
+
if base_url is None:
|
165
|
+
base_url = os.getenv('NANOGPT_BASE_URL', "https://nano-gpt.com/api/v1/")
|
166
|
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
167
|
+
response = client.chat.completions.create(model=model,
|
168
|
+
messages=messages,
|
169
|
+
max_tokens=max_tokens,
|
170
|
+
temperature=temperature)
|
171
|
+
|
172
|
+
prompt_tokens = response.usage.prompt_tokens
|
173
|
+
completion_tokens = response.usage.completion_tokens
|
174
|
+
total_tokens = response.usage.total_tokens
|
175
|
+
|
176
|
+
# Now, these rates are updated to per million tokens
|
177
|
+
cost_per_million_prompt_tokens = 30
|
178
|
+
cost_per_million_completion_tokens = 60
|
179
|
+
cost = (prompt_tokens / 1_000_000 * cost_per_million_prompt_tokens) + (completion_tokens / 1_000_000 * cost_per_million_completion_tokens)
|
180
|
+
|
181
|
+
full_response = response.choices[0].message.content.strip()
|
182
|
+
|
183
|
+
|
184
|
+
events = parser.parse(full_response)
|
185
|
+
for event in events:
|
186
|
+
toolbox.use(event)
|
187
|
+
diff_response = diff_context.get()
|
188
|
+
|
189
|
+
return full_response, diff_response, prompt_tokens, completion_tokens, total_tokens, cost
|
190
|
+
|
191
|
+
# New API functions
|
192
|
+
def build_environment(files_dict):
|
193
|
+
"""Rebuild environment string from file dictionary"""
|
194
|
+
env = []
|
195
|
+
for path, content in files_dict.items():
|
196
|
+
env.append(f"File: {path}")
|
197
|
+
env.append("Content:")
|
198
|
+
env.append(content)
|
199
|
+
return '\n'.join(env)
|
200
|
+
|
201
|
+
def generate_diff(environment, goal, model='deepseek-reasoner', temperature=0.7, max_tokens=32000, api_key=None, base_url=None, prepend=None):
|
202
|
+
"""API: Generate diff from environment and goal"""
|
203
|
+
if prepend:
|
204
|
+
prepend = load_prepend_file(args.prepend)
|
205
|
+
print("Including prepend",len(enc.encode(json.dumps(prepend))), "tokens")
|
206
|
+
else:
|
207
|
+
prepend = ""
|
208
|
+
|
209
|
+
system_prompt = f"Output a git diff into a <diff> block."
|
210
|
+
_, diff_text, _, _, _, _ = call_gpt4_api(
|
211
|
+
system_prompt,
|
212
|
+
goal,
|
213
|
+
environment,
|
214
|
+
model=model,
|
215
|
+
api_key=api_key,
|
216
|
+
base_url=base_url,
|
217
|
+
max_tokens=max_tokens,
|
218
|
+
temperature=temperature
|
219
|
+
)
|
220
|
+
return diff_text
|
221
|
+
|
222
|
+
def smartapply(diff_text, files, model='deepseek-reasoner', api_key=None, base_url=None):
|
223
|
+
"""Applies unified diffs to file contents with AI-powered conflict resolution.
|
224
|
+
|
225
|
+
Key features:
|
226
|
+
- Handles file creations, modifications, and deletions
|
227
|
+
- Maintains idempotency - reapplying same diff produces same result
|
228
|
+
- Uses LLM to resolve ambiguous changes while preserving context
|
229
|
+
- Returns new files dictionary without modifying input
|
230
|
+
|
231
|
+
Args:
|
232
|
+
diff_text: Unified diff string compatible with git apply
|
233
|
+
files: Dictionary of {file_path: content} to modify
|
234
|
+
model: LLM to use for conflict resolution (default: deepseek-reasoner)
|
235
|
+
api_key: Optional API key override
|
236
|
+
base_url: Optional API base URL override
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
New dictionary with updated file contents. Deleted files are omitted.
|
240
|
+
|
241
|
+
Raises:
|
242
|
+
APIError: If LLM API calls fail
|
243
|
+
|
244
|
+
Example:
|
245
|
+
>>> original = {"file.py": "def old():\n pass"}
|
246
|
+
>>> diff = '''diff --git a/file.py b/file.py
|
247
|
+
... --- a/file.py
|
248
|
+
... +++ b/file.py
|
249
|
+
... @@ -1,2 +1,2 @@
|
250
|
+
... -def old():
|
251
|
+
... +def new():'''
|
252
|
+
>>> updated = smartapply(diff, original)
|
253
|
+
>>> print(updated["file.py"])
|
254
|
+
def new():
|
255
|
+
pass
|
256
|
+
"""
|
257
|
+
parsed_diffs = parse_diff_per_file(diff_text)
|
258
|
+
print("SMARTAPPLY", diff_text)
|
259
|
+
|
260
|
+
def process_file(path, patch):
|
261
|
+
original = files.get(path, '')
|
262
|
+
# Handle file deletions
|
263
|
+
if '+++ /dev/null' in patch:
|
264
|
+
if path in files:
|
265
|
+
del files[path]
|
266
|
+
else:
|
267
|
+
updated = call_llm_for_apply(path, original, patch, model, api_key=api_key, base_url=base_url)
|
268
|
+
files[path] = updated.strip()
|
269
|
+
|
270
|
+
for path, patch in parsed_diffs:
|
271
|
+
process_file(path, patch)
|
272
|
+
|
273
|
+
return files
|
274
|
+
|
275
|
+
# Function to apply diff to project files
|
276
|
+
def apply_diff(project_dir, diff_text):
|
277
|
+
diff_file = Path(project_dir) / "diff.patch"
|
278
|
+
with open(diff_file, 'w') as f:
|
279
|
+
f.write(diff_text)
|
280
|
+
|
281
|
+
return False
|
282
|
+
result = subprocess.run(["patch", "-p1", "--remove-empty-files", "--input", str(diff_file)], cwd=project_dir, capture_output=True, text=True)
|
283
|
+
if result.returncode != 0:
|
284
|
+
return False
|
285
|
+
else:
|
286
|
+
return True
|
287
|
+
|
288
|
+
def parse_arguments():
|
289
|
+
parser = argparse.ArgumentParser(description='Generate and optionally apply git diffs using GPT-4.')
|
290
|
+
parser.add_argument('prompt', type=str, help='Prompt that runs on the codebase.')
|
291
|
+
parser.add_argument('--apply', action='store_true', help='Attempt to apply the generated git diff. Uses smartapply if applying the patch fails.')
|
292
|
+
parser.add_argument('--prepend', type=str, default=None, help='Path to content prepended to system prompt')
|
293
|
+
|
294
|
+
parser.add_argument('--nobeep', action='store_false', dest='beep', default=True, help='Disable completion notification beep')
|
295
|
+
# New flag --prompt that does not call the API but instead writes the full prompt to prompt.txt
|
296
|
+
parser.add_argument('--call', action='store_true',
|
297
|
+
help='Call the GPT-4 API. Writes the full prompt to prompt.txt if not specified.')
|
298
|
+
parser.add_argument('files', nargs='*', default=[], help='Specify additional files or directories to include.')
|
299
|
+
parser.add_argument('--temperature', type=float, default=0.7, help='Temperature parameter for model creativity (0.0 to 2.0)')
|
300
|
+
parser.add_argument('--model', type=str, default='deepseek-reasoner', help='Model to use for the API call.')
|
301
|
+
|
302
|
+
parser.add_argument('--nowarn', action='store_true', help='Disable large token warning')
|
303
|
+
|
304
|
+
return parser.parse_args()
|
305
|
+
|
306
|
+
def absolute_to_relative(absolute_path):
|
307
|
+
cwd = os.getcwd()
|
308
|
+
relative_path = os.path.relpath(absolute_path, cwd)
|
309
|
+
return relative_path
|
310
|
+
|
311
|
+
def parse_diff_per_file(diff_text):
|
312
|
+
"""Parse unified diff text into individual file patches.
|
313
|
+
|
314
|
+
Splits a multi-file diff into per-file entries for processing. Handles:
|
315
|
+
- File creations (+++ /dev/null)
|
316
|
+
- File deletions (--- /dev/null)
|
317
|
+
- Standard modifications
|
318
|
+
|
319
|
+
Args:
|
320
|
+
diff_text: Unified diff string as generated by `git diff`
|
321
|
+
|
322
|
+
Returns:
|
323
|
+
List of tuples (file_path, patch) where:
|
324
|
+
- file_path: Relative path to modified file
|
325
|
+
- patch: Full diff fragment for this file
|
326
|
+
|
327
|
+
Note:
|
328
|
+
Uses 'b/' prefix detection from git diffs to determine target paths
|
329
|
+
"""
|
330
|
+
diffs = []
|
331
|
+
file_path = None
|
332
|
+
current_diff = []
|
333
|
+
from_path = None
|
334
|
+
|
335
|
+
for line in diff_text.split('\n'):
|
336
|
+
if line.startswith('diff --git'):
|
337
|
+
if current_diff and file_path is not None:
|
338
|
+
diffs.append((file_path, '\n'.join(current_diff)))
|
339
|
+
current_diff = [line]
|
340
|
+
file_path = None
|
341
|
+
from_path = None
|
342
|
+
parts = line.split()
|
343
|
+
if len(parts) >= 4:
|
344
|
+
b_path = parts[3]
|
345
|
+
file_path = b_path[2:] if b_path.startswith('b/') else b_path
|
346
|
+
else:
|
347
|
+
current_diff.append(line)
|
348
|
+
if line.startswith('--- '):
|
349
|
+
from_path = line[4:].strip()
|
350
|
+
elif line.startswith('+++ '):
|
351
|
+
to_path = line[4:].strip()
|
352
|
+
if to_path == '/dev/null':
|
353
|
+
if from_path:
|
354
|
+
# For deletions, use from_path after stripping 'a/' prefix
|
355
|
+
file_path = from_path[2:] if from_path.startswith('a/') else from_path
|
356
|
+
else:
|
357
|
+
# For normal cases, use to_path after stripping 'b/' prefix
|
358
|
+
file_path = to_path[2:] if to_path.startswith('b/') else to_path
|
359
|
+
|
360
|
+
# Handle remaining diff content after loop
|
361
|
+
if current_diff and file_path is not None:
|
362
|
+
diffs.append((file_path, '\n'.join(current_diff)))
|
363
|
+
|
364
|
+
return diffs
|
365
|
+
|
366
|
+
def call_llm_for_apply(file_path, original_content, file_diff, model, api_key=None, base_url=None):
|
367
|
+
"""AI-powered diff application with conflict resolution.
|
368
|
+
|
369
|
+
Internal workhorse for smartapply that handles individual file patches.
|
370
|
+
Uses LLM to reconcile diffs while preserving code structure and context.
|
371
|
+
|
372
|
+
Args:
|
373
|
+
file_path: Target file path (used for context/error messages)
|
374
|
+
original_content: Current file content as string
|
375
|
+
file_diff: Unified diff snippet to apply
|
376
|
+
model: LLM identifier for processing
|
377
|
+
api_key: Optional override for LLM API credentials
|
378
|
+
base_url: Optional override for LLM API endpoint
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
Updated file content as string with diff applied
|
382
|
+
|
383
|
+
Raises:
|
384
|
+
APIError: If LLM processing fails
|
385
|
+
|
386
|
+
Example:
|
387
|
+
>>> updated = call_llm_for_apply(
|
388
|
+
... file_path='utils.py',
|
389
|
+
... original_content='def old(): pass',
|
390
|
+
... file_diff='''@@ -1 +1 @@
|
391
|
+
... -def old()
|
392
|
+
... +def new()''',
|
393
|
+
... model='deepseek-reasoner'
|
394
|
+
... )
|
395
|
+
>>> print(updated)
|
396
|
+
def new(): pass"""
|
397
|
+
|
398
|
+
system_prompt = """Please apply the diff to this file. Return the result in a block. Write the entire file.
|
399
|
+
|
400
|
+
1. Carefully apply all changes from the diff
|
401
|
+
2. Preserve surrounding context that isn't changed
|
402
|
+
3. Only return the final file content, do not add any additional markup and do not add a code block"""
|
403
|
+
|
404
|
+
user_prompt = f"""File: {file_path}
|
405
|
+
File contents:
|
406
|
+
<filecontents>
|
407
|
+
{original_content}
|
408
|
+
</filecontents>
|
409
|
+
|
410
|
+
Diff to apply:
|
411
|
+
<diff>
|
412
|
+
{file_diff}
|
413
|
+
</diff>"""
|
414
|
+
|
415
|
+
if model == "gemini-2.0-flash-thinking-exp-01-21":
|
416
|
+
user_prompt = system_prompt+"\n"+user_prompt
|
417
|
+
messages = [
|
418
|
+
{"role": "system", "content": system_prompt},
|
419
|
+
{"role": "user", "content": user_prompt},
|
420
|
+
]
|
421
|
+
|
422
|
+
if api_key is None:
|
423
|
+
api_key = os.getenv('NANOGPT_API_KEY')
|
424
|
+
if base_url is None:
|
425
|
+
base_url = os.getenv('NANOGPT_BASE_URL', "https://nano-gpt.com/api/v1/")
|
426
|
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
427
|
+
response = client.chat.completions.create(model=model,
|
428
|
+
messages=messages,
|
429
|
+
temperature=0.0,
|
430
|
+
max_tokens=30000)
|
431
|
+
|
432
|
+
return response.choices[0].message.content
|
433
|
+
|
434
|
+
def build_environment_from_filelist(file_list, cwd):
|
435
|
+
"""Build environment string from list of file paths"""
|
436
|
+
files_dict = {}
|
437
|
+
for file_path in file_list:
|
438
|
+
relative_path = os.path.relpath(file_path, cwd)
|
439
|
+
try:
|
440
|
+
with open(file_path, 'r') as f:
|
441
|
+
content = f.read()
|
442
|
+
files_dict[relative_path] = content
|
443
|
+
except UnicodeDecodeError:
|
444
|
+
print(f"Skipping file {file_path} due to UnicodeDecodeError")
|
445
|
+
continue
|
446
|
+
except IOError as e:
|
447
|
+
print(f"Error reading {file_path}: {e}")
|
448
|
+
continue
|
449
|
+
return build_environment(files_dict)
|
450
|
+
|
451
|
+
def main():
|
452
|
+
# Adding color support for Windows CMD
|
453
|
+
if os.name == 'nt':
|
454
|
+
os.system('color')
|
455
|
+
|
456
|
+
args = parse_arguments()
|
457
|
+
|
458
|
+
# TODO: The 'openai.api_base' option isn't read in the client API. You will need to pass it when you instantiate the client, e.g. 'OpenAI(base_url="https://nano-gpt.com/api/v1/")'
|
459
|
+
# openai.api_base = "https://nano-gpt.com/api/v1/"
|
460
|
+
if len(sys.argv) < 2:
|
461
|
+
print("Usage: python script.py '<user_prompt>' [--apply]")
|
462
|
+
sys.exit(1)
|
463
|
+
|
464
|
+
user_prompt = sys.argv[1]
|
465
|
+
project_dir = os.getcwd()
|
466
|
+
enc = tiktoken.get_encoding("o200k_base")
|
467
|
+
|
468
|
+
|
469
|
+
# Load project files, defaulting to current working directory if no additional paths are specified
|
470
|
+
if not args.files:
|
471
|
+
project_files = load_project_files(project_dir, project_dir)
|
472
|
+
else:
|
473
|
+
project_files = []
|
474
|
+
for additional_path in args.files:
|
475
|
+
if os.path.isfile(additional_path):
|
476
|
+
with open(additional_path, 'r') as f:
|
477
|
+
project_files.append((additional_path, f.read()))
|
478
|
+
elif os.path.isdir(additional_path):
|
479
|
+
project_files.extend(load_project_files(additional_path, project_dir))
|
480
|
+
|
481
|
+
if args.prepend:
|
482
|
+
prepend = load_prepend_file(args.prepend)
|
483
|
+
print("Including prepend",len(enc.encode(json.dumps(prepend))), "tokens")
|
484
|
+
else:
|
485
|
+
prepend = ""
|
486
|
+
|
487
|
+
# Prepare system prompt
|
488
|
+
system_prompt = prepend + f"Output a git diff into a <diff> block."
|
489
|
+
|
490
|
+
files_content = ""
|
491
|
+
for file, content in project_files:
|
492
|
+
print(f"Including {len(enc.encode(content)):5d} tokens", absolute_to_relative(file))
|
493
|
+
|
494
|
+
# Prepare the prompt for GPT-4
|
495
|
+
files_content += f"File: {absolute_to_relative(file)}\nContent:\n{content}\n"
|
496
|
+
|
497
|
+
full_prompt = f"{system_prompt}\n\n{user_prompt}\n\n{files_content}"
|
498
|
+
token_count = len(enc.encode(full_prompt))
|
499
|
+
|
500
|
+
if not args.call and not args.apply:
|
501
|
+
with open('prompt.txt', 'w') as f:
|
502
|
+
f.write(full_prompt)
|
503
|
+
print(f"Total tokens: {token_count:5d}")
|
504
|
+
print(f"\033[1;32mNot calling GPT-4.\033[0m") # Green color for success message
|
505
|
+
print('Instead, wrote full prompt to prompt.txt. Use `xclip -selection clipboard < prompt.txt` then paste into chatgpt')
|
506
|
+
print(f"Total cost: ${0.0:.4f}")
|
507
|
+
exit(0)
|
508
|
+
else:
|
509
|
+
# Validate API key presence before any API operations
|
510
|
+
if not os.getenv('NANOGPT_API_KEY'):
|
511
|
+
print("\033[1;31mError: NANOGPT_API_KEY environment variable required\033[0m")
|
512
|
+
print("Set it with: export NANOGPT_API_KEY='your-key'")
|
513
|
+
sys.exit(1)
|
514
|
+
|
515
|
+
# Confirm large requests without specified files
|
516
|
+
if (not args.nowarn) and (not args.files) and token_count > 10000 and (args.call or args.apply):
|
517
|
+
print(f"\033[1;33mThis is a larger request ({token_count} tokens). Are you sure you want to send it? [y/N]\033[0m")
|
518
|
+
confirmation = input().strip().lower()
|
519
|
+
if confirmation != 'y':
|
520
|
+
print("Request canceled")
|
521
|
+
sys.exit(0)
|
522
|
+
full_text, diff_text, prompt_tokens, completion_tokens, total_tokens, cost = call_gpt4_api(system_prompt, user_prompt, files_content, args.model,
|
523
|
+
temperature=args.temperature,
|
524
|
+
api_key=os.getenv('NANOGPT_API_KEY'),
|
525
|
+
base_url=os.getenv('NANOGPT_BASE_URL', "https://nano-gpt.com/api/v1/")
|
526
|
+
)
|
527
|
+
|
528
|
+
if(diff_text.strip() == ""):
|
529
|
+
print(f"\033[1;33mThere was no data in this diff. The LLM may have returned something invalid.\033[0m")
|
530
|
+
print("Unable to parse diff text. Full response:", full_text)
|
531
|
+
if args.beep:
|
532
|
+
print("\a") # Terminal bell for completion notification
|
533
|
+
return
|
534
|
+
|
535
|
+
# Output result
|
536
|
+
elif args.apply:
|
537
|
+
print("\nAttempting apply with the following diff:")
|
538
|
+
print("\n<diff>")
|
539
|
+
print(diff_text)
|
540
|
+
print("\n</diff>")
|
541
|
+
print("Saved to patch.diff")
|
542
|
+
if apply_diff(project_dir, diff_text):
|
543
|
+
print(f"\033[1;32mPatch applied successfully with 'git apply'.\033[0m") # Green color for success message
|
544
|
+
else:
|
545
|
+
print("Apply failed, attempting smart apply.")
|
546
|
+
parsed_diffs = parse_diff_per_file(diff_text)
|
547
|
+
print("Found", len(parsed_diffs), " files in diff, calling smartdiff for each file concurrently:")
|
548
|
+
|
549
|
+
if(len(parsed_diffs) == 0):
|
550
|
+
print(f"\033[1;33mThere were no entries in this diff. The LLM may have returned something invalid.\033[0m")
|
551
|
+
if args.beep:
|
552
|
+
print("\a") # Terminal bell for completion notification
|
553
|
+
return
|
554
|
+
|
555
|
+
threads = []
|
556
|
+
|
557
|
+
def process_file(file_path, file_diff):
|
558
|
+
full_path = Path(project_dir) / file_path
|
559
|
+
print(f"Processing file: {file_path}")
|
560
|
+
|
561
|
+
# Handle file deletions from diff
|
562
|
+
if '+++ /dev/null' in file_diff:
|
563
|
+
if full_path.exists():
|
564
|
+
full_path.unlink()
|
565
|
+
print(f"\033[1;32mDeleted file {file_path}.\033[0m")
|
566
|
+
else:
|
567
|
+
print(f"\033[1;33mFile {file_path} not found - skipping deletion\033[0m")
|
568
|
+
return
|
569
|
+
|
570
|
+
original_content = ''
|
571
|
+
if full_path.exists():
|
572
|
+
try:
|
573
|
+
original_content = full_path.read_text()
|
574
|
+
except UnicodeDecodeError:
|
575
|
+
print(f"Skipping binary file {file_path}")
|
576
|
+
return
|
577
|
+
|
578
|
+
try:
|
579
|
+
updated_content = call_llm_for_apply(file_path, original_content, file_diff, args.model)
|
580
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
581
|
+
full_path.write_text(updated_content)
|
582
|
+
print(f"\033[1;32mSuccessful 'smartapply' update {file_path}.\033[0m")
|
583
|
+
except Exception as e:
|
584
|
+
print(f"\033[1;31mFailed to process {file_path}: {str(e)}\033[0m")
|
585
|
+
|
586
|
+
threads = []
|
587
|
+
for file_path, file_diff in parsed_diffs:
|
588
|
+
thread = threading.Thread(
|
589
|
+
target=process_file,
|
590
|
+
args=(file_path, file_diff)
|
591
|
+
)
|
592
|
+
thread.start()
|
593
|
+
threads.append(thread)
|
594
|
+
for thread in threads:
|
595
|
+
thread.join()
|
596
|
+
|
597
|
+
|
598
|
+
if args.beep:
|
599
|
+
print("\a") # Terminal bell for completion notification
|
600
|
+
|
601
|
+
print(f"Prompt tokens: {prompt_tokens}")
|
602
|
+
print(f"Completion tokens: {completion_tokens}")
|
603
|
+
print(f"Total tokens: {total_tokens}")
|
604
|
+
#print(f"Total cost: ${cost:.4f}")
|
@@ -0,0 +1,18 @@
|
|
1
|
+
Metadata-Version: 2.2
|
2
|
+
Name: gptdiff
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: A tool to generate git diffs using GPT-4
|
5
|
+
Author: 255labs
|
6
|
+
Requires-Dist: openai>=1.0.0
|
7
|
+
Requires-Dist: tiktoken>=0.5.0
|
8
|
+
Requires-Dist: ai_agent_toolbox>=0.1.0
|
9
|
+
Provides-Extra: test
|
10
|
+
Requires-Dist: pytest; extra == "test"
|
11
|
+
Requires-Dist: pytest-mock; extra == "test"
|
12
|
+
Provides-Extra: docs
|
13
|
+
Requires-Dist: mkdocs; extra == "docs"
|
14
|
+
Requires-Dist: mkdocs-material; extra == "docs"
|
15
|
+
Dynamic: author
|
16
|
+
Dynamic: provides-extra
|
17
|
+
Dynamic: requires-dist
|
18
|
+
Dynamic: summary
|
@@ -0,0 +1,7 @@
|
|
1
|
+
gptdiff/__init__.py,sha256=yGjgwv7tNvH1ZLPsQyoo1CxpTOl1iCAwwDBp-_17ksQ,89
|
2
|
+
gptdiff/gptdiff.py,sha256=MBSZgt43Qq3SRqGzlKwBawwIv1Uewm2NaA_DBx_IPB8,22965
|
3
|
+
gptdiff-0.1.0.dist-info/METADATA,sha256=pf6BzIRLbLCeqVX3u_dwAG91WmPXKmVReTkwUWagPyk,508
|
4
|
+
gptdiff-0.1.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
5
|
+
gptdiff-0.1.0.dist-info/entry_points.txt,sha256=0yvXYEVAZFI-p32kQ4-h3qKVWS0a86jsM9FAwF89t9w,49
|
6
|
+
gptdiff-0.1.0.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
|
7
|
+
gptdiff-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
gptdiff
|