gptdiff 0.1.30__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
@@ -417,6 +418,10 @@ prepended to the system prompt.
417
418
  """
418
419
  if model is None:
419
420
  model = os.getenv('GPTDIFF_MODEL', 'deepseek-reasoner')
421
+ # Use ANTHROPIC_BUDGET_TOKENS env var if set and no cli override provided
422
+ if anthropic_budget_tokens is None:
423
+ anthropic_budget_tokens = os.getenv('ANTHROPIC_BUDGET_TOKENS')
424
+
420
425
  if prepend:
421
426
  if prepend.startswith("http://") or prepend.startswith("https://"):
422
427
  import urllib.request
@@ -428,7 +433,7 @@ prepended to the system prompt.
428
433
  prepend = ""
429
434
 
430
435
  diff_tag = "```diff"
431
- 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."
432
437
  _, diff_text, _, _, _ = call_llm_for_diff(
433
438
  system_prompt,
434
439
  goal,
@@ -438,7 +443,7 @@ prepended to the system prompt.
438
443
  max_tokens=max_tokens,
439
444
  api_key=api_key,
440
445
  base_url=base_url,
441
- budget_tokens=anthropic_budget_tokens
446
+ budget_tokens=int(anthropic_budget_tokens) if anthropic_budget_tokens is not None else None
442
447
  )
443
448
  return diff_text
444
449
 
@@ -523,7 +528,10 @@ def parse_arguments():
523
528
  parser.add_argument('--temperature', type=float, default=1.0, help='Temperature parameter for model creativity (0.0 to 2.0)')
524
529
  parser.add_argument('--max_tokens', type=int, default=30000, help='Temperature parameter for model creativity (0.0 to 2.0)')
525
530
  parser.add_argument('--model', type=str, default=None, help='Model to use for the API call.')
526
- 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
+
527
535
  parser.add_argument('--nowarn', action='store_true', help='Disable large token warning')
528
536
  parser.add_argument('--anthropic_budget_tokens', type=int, default=None, help='Budget tokens for Anthropic extended thinking')
529
537
  parser.add_argument('--verbose', action='store_true', help='Enable verbose output with detailed information')
@@ -698,13 +706,15 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
698
706
  print(f"File {file_path} does not exist, treating as new file")
699
707
 
700
708
  # Use SMARTAPPLY-specific environment variables if set, otherwise fallback.
701
- smart_apply_model = os.getenv("GPTDIFF_SMARTAPPLY_MODEL")
702
- if smart_apply_model and smart_apply_model.strip():
703
- model = smart_apply_model
704
- 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:
705
711
  model = args.applymodel
706
712
  else:
707
- 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'
708
718
 
709
719
  smart_api_key = os.getenv("GPTDIFF_SMARTAPPLY_API_KEY")
710
720
  if smart_api_key and smart_api_key.strip():
@@ -839,7 +849,7 @@ def main():
839
849
  if prepend != "":
840
850
  prepend += "\n"
841
851
 
842
- 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"
843
853
 
844
854
  files_content = ""
845
855
  for file, content in project_files:
@@ -853,11 +863,55 @@ def main():
853
863
  args.model = os.getenv('GPTDIFF_MODEL', 'deepseek-reasoner')
854
864
 
855
865
  if not args.call and not args.apply:
856
- with open('prompt.txt', 'w') as f:
857
- 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
+
858
909
  print(f"Total tokens: {token_count:5d}")
859
- print(f"\033[1;32mWrote full prompt to prompt.txt.\033[0m")
860
- 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
+ )
861
915
  exit(0)
862
916
  else:
863
917
  # Validate API key presence before any API operations
@@ -929,20 +983,17 @@ def swallow_reasoning(full_response: str) -> (str, str):
929
983
  r"(?P<reasoning>>\s*Reasoning.*?Reasoned.*?seconds)",
930
984
  re.DOTALL
931
985
  )
932
- match = pattern.search(full_response)
933
- if match:
986
+ reasoning_list = []
987
+ def replacer(match):
934
988
  raw_reasoning = match.group("reasoning")
935
- # Remove any leading '+' characters and extra whitespace from each line
936
989
  reasoning_lines = [line.lstrip('+').strip() for line in raw_reasoning.splitlines()]
937
990
  reasoning = "\n".join(reasoning_lines).strip()
938
-
939
- # Remove the reasoning block from the response using its exact span
940
- final_content = full_response[:match.start()] + full_response[match.end():]
941
- final_content = final_content.strip()
942
- else:
943
- reasoning = ""
944
- final_content = full_response.strip()
945
- return final_content, reasoning
991
+ reasoning_list.append(reasoning)
992
+ return ""
993
+
994
+ final_content = re.sub(pattern, replacer, full_response)
995
+ reasoning = "\n".join(reasoning_list)
996
+ return final_content.strip(), reasoning
946
997
 
947
998
  def strip_bad_output(updated: str, original: str) -> str:
948
999
  """
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.30
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
@@ -31,30 +32,48 @@ GPTDiff: Create and apply diffs using AI.
31
32
  This tool leverages natural language instructions to modify project codebases.
32
33
  -->
33
34
 
35
+ ## Table of Contents
36
+
37
+ - [Quick Start](#quick-start)
38
+ - [Example Usage](#example-usage-of-gptdiff)
39
+ - [Basic Usage](#basic-usage)
40
+ - [Simple Command Line Agent Loops](#simple-command-line-agent-loops)
41
+ - [Why Choose GPTDiff?](#why-choose-gptdiff)
42
+ - [Core Capabilities](#core-capabilities)
43
+ - [CLI Excellence](#-cli-excellence)
44
+ - [Magic Diff Generation](#-magic-diff-generation)
45
+ - [Smart Apply System](#-smart-apply-system)
46
+ - [Get Started](#get-started)
47
+ - [Installation](#installation)
48
+ - [Configuration](#configuration)
49
+ - [Command Line Usage](#command-line-usage)
50
+
34
51
  🚀 **Create and apply diffs with AI**
35
52
  Modify your project using plain English.
36
53
 
37
- More documentation at [gptdiff.255labs.xyz](gptdiff.255labs.xyz)
54
+ More documentation at [gptdiff.255labs.xyz](https://gptdiff.255labs.xyz)
55
+
56
+ ## Quick Start
38
57
 
58
+ 1. **Install GPTDiff**
59
+
60
+
39
61
  ### Example Usage of `gptdiff`
40
62
 
41
63
  #### Apply a Patch Directly
42
- ```
43
- bash
64
+ ```bash
44
65
  gptdiff "Add button animations on press" --apply
45
66
  ```
46
67
  ✅ Successfully applied patch
47
68
 
48
69
  #### Generate a Patch File
49
- ```
50
- bash
70
+ ```bash
51
71
  gptdiff "Add API documentation" --call
52
72
  ```
53
73
  🔧 Patch written to `diff.patch`
54
74
 
55
75
  #### Generate a Prompt File Without Calling LLM
56
- ```
57
- bash
76
+ ```bash
58
77
  gptdiff "Improve error messages"
59
78
  ```
60
79
  📄 LLM not called, written to `prompt.txt`
@@ -329,3 +348,20 @@ pytest tests/
329
348
  ```
330
349
 
331
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 (75.8.2)
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=i2CYCzfIGn5knvNA7Q5mc4EpGR9akka2l3-aMphK0eU,37362
4
- gptdiff/gptpatch.py,sha256=Vqk2vliYs_BxtuTpwdS88n3A8XToh6RvrCA4N8VqOu0,2759
5
- gptdiff-0.1.30.dist-info/LICENSE.txt,sha256=zCJk7yUYpMjFvlipi1dKtaljF8WdZ2NASndBYYbU8BY,1228
6
- gptdiff-0.1.30.dist-info/METADATA,sha256=6pyrzVyjWTsHWggNr7LkjcGefR24P11oXD9w3w_sZFI,8723
7
- gptdiff-0.1.30.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
8
- gptdiff-0.1.30.dist-info/entry_points.txt,sha256=0VlVNr-gc04a3SZD5_qKIBbtg_L5P2x3xlKE5ftcdkc,82
9
- gptdiff-0.1.30.dist-info/top_level.txt,sha256=XNkQkQGINaDndEwRxg8qToOrJ9coyfAb-EHrSUXzdCE,8
10
- gptdiff-0.1.30.dist-info/RECORD,,