gptdiff 0.1.31__py3-none-any.whl → 0.1.34__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/gptdiff.py +62 -12
- gptdiff/plangptdiff.py +213 -0
- {gptdiff-0.1.31.dist-info → gptdiff-0.1.34.dist-info}/METADATA +20 -2
- gptdiff-0.1.34.dist-info/RECORD +11 -0
- {gptdiff-0.1.31.dist-info → gptdiff-0.1.34.dist-info}/WHEEL +1 -1
- {gptdiff-0.1.31.dist-info → gptdiff-0.1.34.dist-info}/entry_points.txt +1 -0
- gptdiff-0.1.31.dist-info/RECORD +0 -10
- {gptdiff-0.1.31.dist-info → gptdiff-0.1.34.dist-info/licenses}/LICENSE.txt +0 -0
- {gptdiff-0.1.31.dist-info → gptdiff-0.1.34.dist-info}/top_level.txt +0 -0
gptdiff/gptdiff.py
CHANGED
@@ -16,6 +16,7 @@ import contextvars
|
|
16
16
|
from pkgutil import get_data
|
17
17
|
import threading
|
18
18
|
from threading import Lock
|
19
|
+
import shutil
|
19
20
|
|
20
21
|
import openai
|
21
22
|
from openai import OpenAI
|
@@ -432,7 +433,7 @@ prepended to the system prompt.
|
|
432
433
|
prepend = ""
|
433
434
|
|
434
435
|
diff_tag = "```diff"
|
435
|
-
system_prompt = prepend + f"Output a git diff into a \"{diff_tag}\" block."
|
436
|
+
system_prompt = prepend + f"Output a full unified git diff into a \"{diff_tag}\" block."
|
436
437
|
_, diff_text, _, _, _ = call_llm_for_diff(
|
437
438
|
system_prompt,
|
438
439
|
goal,
|
@@ -527,7 +528,10 @@ def parse_arguments():
|
|
527
528
|
parser.add_argument('--temperature', type=float, default=1.0, help='Temperature parameter for model creativity (0.0 to 2.0)')
|
528
529
|
parser.add_argument('--max_tokens', type=int, default=30000, help='Temperature parameter for model creativity (0.0 to 2.0)')
|
529
530
|
parser.add_argument('--model', type=str, default=None, help='Model to use for the API call.')
|
530
|
-
parser.add_argument(
|
531
|
+
parser.add_argument(
|
532
|
+
'--applymodel', type=str, default=None,
|
533
|
+
help='Model to use for applying the diff. Overrides GPTDIFF_SMARTAPPLY_MODEL env var; if not set, defaults to "openai/gpt-4.1-mini".')
|
534
|
+
|
531
535
|
parser.add_argument('--nowarn', action='store_true', help='Disable large token warning')
|
532
536
|
parser.add_argument('--anthropic_budget_tokens', type=int, default=None, help='Budget tokens for Anthropic extended thinking')
|
533
537
|
parser.add_argument('--verbose', action='store_true', help='Enable verbose output with detailed information')
|
@@ -702,13 +706,15 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
|
|
702
706
|
print(f"File {file_path} does not exist, treating as new file")
|
703
707
|
|
704
708
|
# Use SMARTAPPLY-specific environment variables if set, otherwise fallback.
|
705
|
-
|
706
|
-
if
|
707
|
-
model = smart_apply_model
|
708
|
-
elif hasattr(args, "applymodel") and args.applymodel:
|
709
|
+
# Determine model for smartapply: CLI flag > environment > recommended default
|
710
|
+
if hasattr(args, "applymodel") and args.applymodel:
|
709
711
|
model = args.applymodel
|
710
712
|
else:
|
711
|
-
|
713
|
+
smart_apply_model = os.getenv("GPTDIFF_SMARTAPPLY_MODEL", "").strip()
|
714
|
+
if smart_apply_model:
|
715
|
+
model = smart_apply_model
|
716
|
+
else:
|
717
|
+
model = 'openai/gpt-4.1-mini'
|
712
718
|
|
713
719
|
smart_api_key = os.getenv("GPTDIFF_SMARTAPPLY_API_KEY")
|
714
720
|
if smart_api_key and smart_api_key.strip():
|
@@ -843,7 +849,7 @@ def main():
|
|
843
849
|
if prepend != "":
|
844
850
|
prepend += "\n"
|
845
851
|
|
846
|
-
system_prompt = prepend + f"Output a git diff into a ```diff block"
|
852
|
+
system_prompt = prepend + f"Output a full unified git diff into a ```diff block"
|
847
853
|
|
848
854
|
files_content = ""
|
849
855
|
for file, content in project_files:
|
@@ -857,11 +863,55 @@ def main():
|
|
857
863
|
args.model = os.getenv('GPTDIFF_MODEL', 'deepseek-reasoner')
|
858
864
|
|
859
865
|
if not args.call and not args.apply:
|
860
|
-
|
861
|
-
|
866
|
+
"""
|
867
|
+
For convenience:
|
868
|
+
• macOS & Linux → copy prompt directly to the system clipboard
|
869
|
+
• Windows → fall back to prompt.txt (no reliable native clipboard CLI)
|
870
|
+
"""
|
871
|
+
wrote_location = None
|
872
|
+
|
873
|
+
try:
|
874
|
+
if os.name == "nt":
|
875
|
+
# Windows: keep legacy behaviour
|
876
|
+
with open("prompt.txt", "w") as f:
|
877
|
+
f.write(full_prompt)
|
878
|
+
wrote_location = "prompt.txt"
|
879
|
+
else:
|
880
|
+
# macOS first
|
881
|
+
if sys.platform == "darwin" and shutil.which("pbcopy"):
|
882
|
+
subprocess.run(["pbcopy"], input=full_prompt.encode(), check=True)
|
883
|
+
wrote_location = "clipboard (pbcopy)"
|
884
|
+
# Linux – prefer wl-copy, then xclip
|
885
|
+
elif shutil.which("wl-copy"):
|
886
|
+
subprocess.run(["wl-copy"], input=full_prompt.encode(), check=True)
|
887
|
+
wrote_location = "clipboard (wl-copy)"
|
888
|
+
elif shutil.which("xclip"):
|
889
|
+
subprocess.run(
|
890
|
+
["xclip", "-selection", "clipboard"],
|
891
|
+
input=full_prompt.encode(),
|
892
|
+
check=True,
|
893
|
+
)
|
894
|
+
wrote_location = "clipboard (xclip)"
|
895
|
+
# No clipboard utility available – revert to file
|
896
|
+
else:
|
897
|
+
with open("prompt.txt", "w") as f:
|
898
|
+
f.write(full_prompt)
|
899
|
+
wrote_location = "prompt.txt (clipboard utility not found)"
|
900
|
+
except Exception as e:
|
901
|
+
# Any failure falls back to prompt.txt as a safe default
|
902
|
+
print(
|
903
|
+
f"\033[1;31mClipboard write failed: {e}. Falling back to prompt.txt\033[0m"
|
904
|
+
)
|
905
|
+
with open("prompt.txt", "w") as f:
|
906
|
+
f.write(full_prompt)
|
907
|
+
wrote_location = "prompt.txt (clipboard write failed)"
|
908
|
+
|
862
909
|
print(f"Total tokens: {token_count:5d}")
|
863
|
-
print(f"\033[1;32mWrote full prompt to
|
864
|
-
|
910
|
+
print(f"\033[1;32mWrote full prompt to {wrote_location}.\033[0m")
|
911
|
+
if wrote_location.startswith("prompt.txt"):
|
912
|
+
print(
|
913
|
+
"Tip: install 'xclip' or 'wl-clipboard' to enable automatic clipboard copy on Linux."
|
914
|
+
)
|
865
915
|
exit(0)
|
866
916
|
else:
|
867
917
|
# Validate API key presence before any API operations
|
gptdiff/plangptdiff.py
ADDED
@@ -0,0 +1,213 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Generate *planprompt.txt* that tells an LLM to run **gptdiff** on only the files
|
4
|
+
that matter.
|
5
|
+
|
6
|
+
Workflow
|
7
|
+
--------
|
8
|
+
1. Accept the natural‑language command you would normally give *plan*.
|
9
|
+
2. Use **ripgrep** (`rg`) to locate files whose **paths** *or* **contents**
|
10
|
+
match keywords from the command.
|
11
|
+
3. Always include any file whose path contains “schema”.
|
12
|
+
4. Build a `gptdiff` command pre‑populated with those files.
|
13
|
+
5. Write a ready‑to‑paste prompt to **planprompt.txt** –
|
14
|
+
just like `gptdiff` does with *prompt.txt*.
|
15
|
+
"""
|
16
|
+
|
17
|
+
from __future__ import annotations
|
18
|
+
|
19
|
+
import argparse
|
20
|
+
import os
|
21
|
+
import re
|
22
|
+
import shlex
|
23
|
+
import shutil
|
24
|
+
import subprocess
|
25
|
+
from pathlib import Path
|
26
|
+
from typing import List, Set
|
27
|
+
from gptdiff.gptdiff import load_gitignore_patterns, is_ignored
|
28
|
+
|
29
|
+
# LLM helpers
|
30
|
+
import json
|
31
|
+
from gptdiff.gptdiff import call_llm, domain_for_url
|
32
|
+
|
33
|
+
|
34
|
+
# --------------------------------------------------------------------------- #
|
35
|
+
# Keyword extraction via LLM #
|
36
|
+
# --------------------------------------------------------------------------- #
|
37
|
+
|
38
|
+
def _unique(seq: List[str]) -> List[str]:
|
39
|
+
"""Preserve order while removing duplicates."""
|
40
|
+
seen: Set[str] = set()
|
41
|
+
out: List[str] = []
|
42
|
+
for item in seq:
|
43
|
+
if item not in seen:
|
44
|
+
seen.add(item)
|
45
|
+
out.append(item)
|
46
|
+
return out
|
47
|
+
|
48
|
+
|
49
|
+
def _parse_keywords(requirement: str) -> List[str]:
|
50
|
+
"""
|
51
|
+
Ask the configured **GPTDIFF_MODEL** LLM to emit the most relevant, UNIQUE
|
52
|
+
search terms for *ripgrep*.
|
53
|
+
|
54
|
+
Fallback: returns simple heuristics if no API key is configured or the call
|
55
|
+
fails.
|
56
|
+
"""
|
57
|
+
api_key = os.getenv("GPTDIFF_LLM_API_KEY")
|
58
|
+
model = os.getenv("GPTDIFF_MODEL", "deepseek-reasoner")
|
59
|
+
base_url = os.getenv("GPTDIFF_LLM_BASE_URL", "https://nano-gpt.com/api/v1/")
|
60
|
+
|
61
|
+
# Heuristic fallback when no key available
|
62
|
+
if not api_key:
|
63
|
+
return _unique(
|
64
|
+
[w.lower() for w in re.findall(r"[A-Za-z_]{4,}", requirement)]
|
65
|
+
)
|
66
|
+
|
67
|
+
system_prompt = (
|
68
|
+
"You are an expert software search assistant.\n"
|
69
|
+
"Given a natural‑language coding requirement, output **only** a JSON "
|
70
|
+
"array of 3‑10 UNIQUE, lowercase keywords that will best locate the "
|
71
|
+
"relevant source files. Exclude common stop‑words and keep each keyword "
|
72
|
+
"to a single token if possible."
|
73
|
+
)
|
74
|
+
|
75
|
+
messages = [
|
76
|
+
{"role": "system", "content": system_prompt},
|
77
|
+
{"role": "user", "content": requirement},
|
78
|
+
]
|
79
|
+
|
80
|
+
try:
|
81
|
+
response = call_llm(
|
82
|
+
api_key=api_key,
|
83
|
+
base_url=base_url,
|
84
|
+
model=model,
|
85
|
+
messages=messages,
|
86
|
+
max_tokens=128,
|
87
|
+
temperature=0.0,
|
88
|
+
)
|
89
|
+
content = response.choices[0].message.content.strip()
|
90
|
+
|
91
|
+
# In case the model adds commentary, grab the first JSON array found.
|
92
|
+
json_start = content.find("[")
|
93
|
+
json_end = content.rfind("]")
|
94
|
+
if json_start == -1 or json_end == -1:
|
95
|
+
raise ValueError("No JSON array detected.")
|
96
|
+
|
97
|
+
keywords = json.loads(content[json_start : json_end + 1])
|
98
|
+
if not isinstance(keywords, list):
|
99
|
+
raise ValueError("Expected a JSON list of keywords.")
|
100
|
+
|
101
|
+
return _unique([str(k).lower() for k in keywords])
|
102
|
+
|
103
|
+
except Exception as e: # noqa: BLE001
|
104
|
+
# Print a hint and fall back to heuristic extraction.
|
105
|
+
print(
|
106
|
+
f"\033[33m⚠️ Keyword LLM extraction failed ({e}); "
|
107
|
+
"falling back to simple parsing.\033[0m"
|
108
|
+
)
|
109
|
+
return _unique(
|
110
|
+
[w.lower() for w in re.findall(r"[A-Za-z_]{4,}", requirement)]
|
111
|
+
)
|
112
|
+
|
113
|
+
|
114
|
+
def _rg(arguments: List[str]) -> Set[str]:
|
115
|
+
"""Thin wrapper around ripgrep – returns an *empty* set if rg isn’t present."""
|
116
|
+
if not shutil.which("rg"):
|
117
|
+
return set()
|
118
|
+
|
119
|
+
completed = subprocess.run(
|
120
|
+
["rg", "--follow", "--no-config", "--color", "never"] + arguments,
|
121
|
+
check=False,
|
122
|
+
capture_output=True,
|
123
|
+
text=True,
|
124
|
+
)
|
125
|
+
return {p for p in completed.stdout.splitlines() if p}
|
126
|
+
|
127
|
+
|
128
|
+
def find_relevant_files(keywords: List[str], include_schema: bool = True) -> List[str]:
|
129
|
+
"""Locate files worth passing to gptdiff."""
|
130
|
+
files: Set[str] = set()
|
131
|
+
|
132
|
+
# 1. Path matches
|
133
|
+
for kw in keywords:
|
134
|
+
files |= _rg(["-i", "-g", f"*{kw}*"])
|
135
|
+
|
136
|
+
# 2. Content matches
|
137
|
+
for kw in keywords:
|
138
|
+
files |= _rg(["-i", "--files-with-matches", kw])
|
139
|
+
|
140
|
+
# 3. Anything with “schema” in the path
|
141
|
+
if include_schema:
|
142
|
+
files |= _rg(["-i", "-g", "*schema*"])
|
143
|
+
|
144
|
+
return sorted(files)
|
145
|
+
|
146
|
+
|
147
|
+
def build_gptdiff_command(cmd: str, files: List[str], apply: bool) -> str:
|
148
|
+
pieces = ["gptdiff", shlex.quote(cmd)]
|
149
|
+
if files:
|
150
|
+
pieces.append("--files " + " ".join(shlex.quote(f) for f in files))
|
151
|
+
if apply:
|
152
|
+
pieces.append("--apply")
|
153
|
+
return " ".join(pieces)
|
154
|
+
|
155
|
+
|
156
|
+
# --------------------------------------------------------------------------- #
|
157
|
+
# CLI #
|
158
|
+
# --------------------------------------------------------------------------- #
|
159
|
+
|
160
|
+
|
161
|
+
def main() -> None:
|
162
|
+
parser = argparse.ArgumentParser(
|
163
|
+
prog="plangptdiff",
|
164
|
+
description="Create planprompt.txt that invokes gptdiff on relevant files.",
|
165
|
+
)
|
166
|
+
parser.add_argument(
|
167
|
+
"command",
|
168
|
+
nargs=argparse.REMAINDER,
|
169
|
+
help="The natural‑language instruction you would normally give plan.",
|
170
|
+
)
|
171
|
+
parser.add_argument(
|
172
|
+
"--apply",
|
173
|
+
action="store_true",
|
174
|
+
help="Add --apply to the generated gptdiff command.",
|
175
|
+
)
|
176
|
+
args = parser.parse_args()
|
177
|
+
|
178
|
+
if not args.command:
|
179
|
+
parser.error("You must provide a command, e.g. plangptdiff 'add logging'")
|
180
|
+
|
181
|
+
original_cmd = " ".join(args.command).strip()
|
182
|
+
keywords = _parse_keywords(original_cmd)
|
183
|
+
files = find_relevant_files(keywords)
|
184
|
+
|
185
|
+
# Exclude prompt.txt and any files listed in .gitignore or .gptignore
|
186
|
+
ignore_patterns: List[str] = []
|
187
|
+
cwd = Path.cwd()
|
188
|
+
gitignore_path = cwd / ".gitignore"
|
189
|
+
gptignore_path = cwd / ".gptignore"
|
190
|
+
if gitignore_path.exists():
|
191
|
+
ignore_patterns.extend(load_gitignore_patterns(str(gitignore_path)))
|
192
|
+
if gptignore_path.exists():
|
193
|
+
ignore_patterns.extend(load_gitignore_patterns(str(gptignore_path)))
|
194
|
+
# Always ignore prompt.txt explicitly
|
195
|
+
ignore_patterns.append("prompt.txt")
|
196
|
+
# Filter out ignored files
|
197
|
+
files = [f for f in files if not is_ignored(f, ignore_patterns)]
|
198
|
+
|
199
|
+
gptdiff_cmd = build_gptdiff_command(original_cmd, files, args.apply)
|
200
|
+
|
201
|
+
prompt = f"""You are working in a repository where **gptdiff** is installed.
|
202
|
+
Run the command below to implement the requested change:
|
203
|
+
|
204
|
+
```bash
|
205
|
+
{gptdiff_cmd}
|
206
|
+
```"""
|
207
|
+
|
208
|
+
Path("planprompt.txt").write_text(prompt, encoding="utf8")
|
209
|
+
print(f"📝 planprompt.txt written – {len(files)} file(s) included.")
|
210
|
+
|
211
|
+
|
212
|
+
if __name__ == "__main__":
|
213
|
+
main()
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: gptdiff
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.34
|
4
4
|
Summary: A tool to generate and apply git diffs using LLMs
|
5
5
|
Author: 255labs
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
@@ -21,6 +21,7 @@ Dynamic: author
|
|
21
21
|
Dynamic: classifier
|
22
22
|
Dynamic: description
|
23
23
|
Dynamic: description-content-type
|
24
|
+
Dynamic: license-file
|
24
25
|
Dynamic: provides-extra
|
25
26
|
Dynamic: requires-dist
|
26
27
|
Dynamic: summary
|
@@ -347,3 +348,20 @@ pytest tests/
|
|
347
348
|
```
|
348
349
|
|
349
350
|
This will execute all unit tests verifying core diff generation and application logic.
|
351
|
+
|
352
|
+
### plangptdiff: Generate *plan* prompts that call GPTDiff
|
353
|
+
|
354
|
+
`plangptdiff` scans your repo with **ripgrep**, selects only the files likely to
|
355
|
+
change (always including anything named *schema*), and writes a ready‑to‑paste
|
356
|
+
prompt to **planprompt.txt**:
|
357
|
+
|
358
|
+
```bash
|
359
|
+
# Prompt only
|
360
|
+
plangptdiff "add validation to the signup form"
|
361
|
+
|
362
|
+
# Prompt that will auto‑apply the diff
|
363
|
+
plangptdiff "upgrade to Django 5" --apply
|
364
|
+
```
|
365
|
+
|
366
|
+
The file list is appended to the generated `gptdiff` command so the LLM sees
|
367
|
+
only the files that matter, keeping prompts lean and costs down.
|
@@ -0,0 +1,11 @@
|
|
1
|
+
gptdiff/__init__.py,sha256=o1hrK4GFvbfKcHPlLVArz4OunE3euIicEBYaLrdDo0k,198
|
2
|
+
gptdiff/applydiff.py,sha256=FMLkEtNnOdnU7c8LgYafGuROsDz3utpyXKJVFRs_BvI,11473
|
3
|
+
gptdiff/gptdiff.py,sha256=Wpj9AReofBFDXdmckqrMsdPONGkVGT9sWh33cApY3_w,39536
|
4
|
+
gptdiff/gptpatch.py,sha256=Vqk2vliYs_BxtuTpwdS88n3A8XToh6RvrCA4N8VqOu0,2759
|
5
|
+
gptdiff/plangptdiff.py,sha256=EkcBM0xWMYzVpkjg6pe0nZW-SAoqu5vWoTPpgUVz9xA,6934
|
6
|
+
gptdiff-0.1.34.dist-info/licenses/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
|
7
|
+
gptdiff-0.1.34.dist-info/METADATA,sha256=ajqqT9MaOuVXOXih2zKPD9YM8I1Hz06za-E4c7uckgQ,9923
|
8
|
+
gptdiff-0.1.34.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
|
9
|
+
gptdiff-0.1.34.dist-info/entry_points.txt,sha256=UX4OYAj1HKIT1JmDQ7fkG6Fbf6Vqfpbojw4psdODR2c,121
|
10
|
+
gptdiff-0.1.34.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
|
11
|
+
gptdiff-0.1.34.dist-info/RECORD,,
|
gptdiff-0.1.31.dist-info/RECORD
DELETED
@@ -1,10 +0,0 @@
|
|
1
|
-
gptdiff/__init__.py,sha256=o1hrK4GFvbfKcHPlLVArz4OunE3euIicEBYaLrdDo0k,198
|
2
|
-
gptdiff/applydiff.py,sha256=FMLkEtNnOdnU7c8LgYafGuROsDz3utpyXKJVFRs_BvI,11473
|
3
|
-
gptdiff/gptdiff.py,sha256=EC2PpES2NchJvcTtNo0U0CfKLH9DdV86OuzOzLCyTr8,37424
|
4
|
-
gptdiff/gptpatch.py,sha256=Vqk2vliYs_BxtuTpwdS88n3A8XToh6RvrCA4N8VqOu0,2759
|
5
|
-
gptdiff-0.1.31.dist-info/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
|
6
|
-
gptdiff-0.1.31.dist-info/METADATA,sha256=YPye_cME35OcmzklE__dMLncBYY9bhdjKf7j9wczChU,9343
|
7
|
-
gptdiff-0.1.31.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
|
8
|
-
gptdiff-0.1.31.dist-info/entry_points.txt,sha256=0VlVNr-gc04a3SZD5_qKIBbtg_L5P2x3xlKE5ftcdkc,82
|
9
|
-
gptdiff-0.1.31.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
|
10
|
-
gptdiff-0.1.31.dist-info/RECORD,,
|
File without changes
|
File without changes
|