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 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('--applymodel', type=str, default=None, help='Model to use for applying the diff. Defaults to the value of --model if not specified.')
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
- smart_apply_model = os.getenv("GPTDIFF_SMARTAPPLY_MODEL")
706
- if smart_apply_model and smart_apply_model.strip():
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
- model = os.getenv("GPTDIFF_MODEL", "deepseek-reasoner")
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
- with open('prompt.txt', 'w') as f:
861
- f.write(full_prompt)
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 prompt.txt.\033[0m")
864
- print('Instead, wrote full prompt to prompt.txt. Use `xclip -selection clipboard < prompt.txt` then paste into chatgpt')
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.2
1
+ Metadata-Version: 2.4
2
2
  Name: gptdiff
3
- Version: 0.1.31
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (80.7.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  gptdiff = gptdiff.gptdiff:main
3
3
  gptpatch = gptdiff.gptpatch:main
4
+ plangptdiff = gptdiff.plangptdiff:main
@@ -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,,