auto-code-fixer 0.3.4__tar.gz → 0.3.6__tar.gz
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.
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/PKG-INFO +16 -1
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/README.md +15 -0
- auto_code_fixer-0.3.6/auto_code_fixer/__init__.py +1 -0
- auto_code_fixer-0.3.6/auto_code_fixer/cli.py +554 -0
- auto_code_fixer-0.3.6/auto_code_fixer/fixer.py +156 -0
- auto_code_fixer-0.3.6/auto_code_fixer/patch_protocol.py +102 -0
- auto_code_fixer-0.3.6/auto_code_fixer/patcher.py +67 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/PKG-INFO +16 -1
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/SOURCES.txt +4 -1
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/pyproject.toml +1 -1
- auto_code_fixer-0.3.6/tests/test_atomic_write.py +34 -0
- auto_code_fixer-0.3.6/tests/test_patch_protocol.py +46 -0
- auto_code_fixer-0.3.4/auto_code_fixer/__init__.py +0 -1
- auto_code_fixer-0.3.4/auto_code_fixer/cli.py +0 -311
- auto_code_fixer-0.3.4/auto_code_fixer/fixer.py +0 -79
- auto_code_fixer-0.3.4/auto_code_fixer/patcher.py +0 -31
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/LICENSE +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/command_runner.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/installer.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/models.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/plan.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/runner.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/sandbox.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/traceback_utils.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/utils.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer/venv_manager.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/dependency_links.txt +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/entry_points.txt +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/requires.txt +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/top_level.txt +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/setup.cfg +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/tests/test_fix_imported_file.py +0 -0
- {auto_code_fixer-0.3.4 → auto_code_fixer-0.3.6}/tests/test_internal_imports.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auto-code-fixer
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: Automatically fix Python code using ChatGPT
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -121,6 +121,21 @@ auto-code-fixer main.py --ai-plan
|
|
|
121
121
|
```
|
|
122
122
|
This enables a helper that can suggest which local file to edit. It is best-effort.
|
|
123
123
|
|
|
124
|
+
### Optional structured patch protocol (JSON + sha256)
|
|
125
|
+
```bash
|
|
126
|
+
auto-code-fixer main.py --patch-protocol
|
|
127
|
+
```
|
|
128
|
+
When enabled, the model is asked to return strict JSON with `{files:[{path,new_content,sha256}]}`.
|
|
129
|
+
The tool verifies the SHA-256 hash of `new_content` before applying edits, and falls back to the
|
|
130
|
+
legacy full-text mode if parsing/validation fails.
|
|
131
|
+
|
|
132
|
+
### Optional formatting / linting (best-effort)
|
|
133
|
+
```bash
|
|
134
|
+
auto-code-fixer main.py --format black
|
|
135
|
+
auto-code-fixer main.py --lint ruff --fix
|
|
136
|
+
```
|
|
137
|
+
These run inside the sandbox venv and are skipped if the tools are not installed.
|
|
138
|
+
|
|
124
139
|
---
|
|
125
140
|
|
|
126
141
|
## Environment variables
|
|
@@ -100,6 +100,21 @@ auto-code-fixer main.py --ai-plan
|
|
|
100
100
|
```
|
|
101
101
|
This enables a helper that can suggest which local file to edit. It is best-effort.
|
|
102
102
|
|
|
103
|
+
### Optional structured patch protocol (JSON + sha256)
|
|
104
|
+
```bash
|
|
105
|
+
auto-code-fixer main.py --patch-protocol
|
|
106
|
+
```
|
|
107
|
+
When enabled, the model is asked to return strict JSON with `{files:[{path,new_content,sha256}]}`.
|
|
108
|
+
The tool verifies the SHA-256 hash of `new_content` before applying edits, and falls back to the
|
|
109
|
+
legacy full-text mode if parsing/validation fails.
|
|
110
|
+
|
|
111
|
+
### Optional formatting / linting (best-effort)
|
|
112
|
+
```bash
|
|
113
|
+
auto-code-fixer main.py --format black
|
|
114
|
+
auto-code-fixer main.py --lint ruff --fix
|
|
115
|
+
```
|
|
116
|
+
These run inside the sandbox venv and are skipped if the tools are not installed.
|
|
117
|
+
|
|
103
118
|
---
|
|
104
119
|
|
|
105
120
|
## Environment variables
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.6"
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
from decouple import config
|
|
7
|
+
|
|
8
|
+
from auto_code_fixer.runner import run_code
|
|
9
|
+
from auto_code_fixer.fixer import fix_code_with_gpt
|
|
10
|
+
from auto_code_fixer.installer import check_and_install_missing_lib
|
|
11
|
+
from auto_code_fixer.utils import (
|
|
12
|
+
discover_all_files,
|
|
13
|
+
is_in_project,
|
|
14
|
+
log,
|
|
15
|
+
set_verbose,
|
|
16
|
+
)
|
|
17
|
+
from auto_code_fixer import __version__
|
|
18
|
+
|
|
19
|
+
DEFAULT_MAX_RETRIES = 8 # can be overridden via CLI
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fix_file(
|
|
23
|
+
file_path,
|
|
24
|
+
project_root,
|
|
25
|
+
api_key,
|
|
26
|
+
ask,
|
|
27
|
+
verbose,
|
|
28
|
+
*,
|
|
29
|
+
dry_run: bool,
|
|
30
|
+
model: str | None,
|
|
31
|
+
timeout_s: int,
|
|
32
|
+
max_retries: int,
|
|
33
|
+
run_cmd: str | None,
|
|
34
|
+
patch_protocol: bool,
|
|
35
|
+
max_files_changed: int,
|
|
36
|
+
context_files: int,
|
|
37
|
+
approve: bool,
|
|
38
|
+
fmt: str | None,
|
|
39
|
+
lint: str | None,
|
|
40
|
+
lint_fix: bool,
|
|
41
|
+
) -> bool:
|
|
42
|
+
log(f"Processing entry file: {file_path}")
|
|
43
|
+
|
|
44
|
+
project_root = os.path.abspath(project_root)
|
|
45
|
+
file_path = os.path.abspath(file_path)
|
|
46
|
+
|
|
47
|
+
# Build sandbox with entry + imported local files
|
|
48
|
+
from auto_code_fixer.sandbox import make_sandbox
|
|
49
|
+
sandbox_root, sandbox_entry = make_sandbox(entry_file=file_path, project_root=project_root)
|
|
50
|
+
|
|
51
|
+
# Always delete temp sandbox (best-effort). Also register atexit cleanup in case of crashes.
|
|
52
|
+
import atexit
|
|
53
|
+
|
|
54
|
+
def cleanup_sandbox() -> None:
|
|
55
|
+
try:
|
|
56
|
+
shutil.rmtree(sandbox_root)
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
return
|
|
59
|
+
except Exception as e:
|
|
60
|
+
log(f"WARN: failed to delete sandbox dir {sandbox_root}: {e}")
|
|
61
|
+
|
|
62
|
+
atexit.register(cleanup_sandbox)
|
|
63
|
+
|
|
64
|
+
# Create isolated venv in sandbox
|
|
65
|
+
from auto_code_fixer.venv_manager import create_venv
|
|
66
|
+
from auto_code_fixer.patcher import backup_file
|
|
67
|
+
|
|
68
|
+
venv_python = create_venv(sandbox_root)
|
|
69
|
+
|
|
70
|
+
def _run_optional_formatters_and_linters() -> None:
|
|
71
|
+
# Best-effort formatting/linting (only if tools are installed in the sandbox venv).
|
|
72
|
+
from auto_code_fixer.command_runner import run_command
|
|
73
|
+
|
|
74
|
+
if fmt == "black":
|
|
75
|
+
rc, out, err = run_command(
|
|
76
|
+
"python -m black .",
|
|
77
|
+
timeout_s=max(timeout_s, 60),
|
|
78
|
+
python_exe=venv_python,
|
|
79
|
+
cwd=sandbox_root,
|
|
80
|
+
extra_env={"PYTHONPATH": sandbox_root},
|
|
81
|
+
)
|
|
82
|
+
if rc == 0:
|
|
83
|
+
log("Formatted with black", "DEBUG")
|
|
84
|
+
else:
|
|
85
|
+
# If black isn't installed, ignore.
|
|
86
|
+
if "No module named" in (err or "") and "black" in (err or ""):
|
|
87
|
+
log("black not installed in sandbox venv; skipping format", "DEBUG")
|
|
88
|
+
else:
|
|
89
|
+
log(f"black failed (rc={rc}): {err}", "DEBUG")
|
|
90
|
+
|
|
91
|
+
if lint == "ruff":
|
|
92
|
+
cmd = "python -m ruff check ."
|
|
93
|
+
if lint_fix:
|
|
94
|
+
cmd += " --fix"
|
|
95
|
+
rc, out, err = run_command(
|
|
96
|
+
cmd,
|
|
97
|
+
timeout_s=max(timeout_s, 60),
|
|
98
|
+
python_exe=venv_python,
|
|
99
|
+
cwd=sandbox_root,
|
|
100
|
+
extra_env={"PYTHONPATH": sandbox_root},
|
|
101
|
+
)
|
|
102
|
+
if rc == 0:
|
|
103
|
+
log("ruff check passed", "DEBUG")
|
|
104
|
+
else:
|
|
105
|
+
if "No module named" in (err or "") and "ruff" in (err or ""):
|
|
106
|
+
log("ruff not installed in sandbox venv; skipping lint", "DEBUG")
|
|
107
|
+
else:
|
|
108
|
+
log(f"ruff reported issues (rc={rc}): {err}", "DEBUG")
|
|
109
|
+
|
|
110
|
+
changed_sandbox_files: set[str] = set()
|
|
111
|
+
|
|
112
|
+
for attempt in range(max_retries):
|
|
113
|
+
log(f"Run attempt #{attempt + 1}")
|
|
114
|
+
|
|
115
|
+
# Ensure local modules resolve inside sandbox
|
|
116
|
+
if run_cmd:
|
|
117
|
+
from auto_code_fixer.command_runner import run_command
|
|
118
|
+
|
|
119
|
+
retcode, stdout, stderr = run_command(
|
|
120
|
+
run_cmd,
|
|
121
|
+
timeout_s=timeout_s,
|
|
122
|
+
python_exe=venv_python,
|
|
123
|
+
cwd=sandbox_root,
|
|
124
|
+
extra_env={"PYTHONPATH": sandbox_root},
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
retcode, stdout, stderr = run_code(
|
|
128
|
+
sandbox_entry,
|
|
129
|
+
timeout_s=timeout_s,
|
|
130
|
+
python_exe=venv_python,
|
|
131
|
+
cwd=sandbox_root,
|
|
132
|
+
extra_env={"PYTHONPATH": sandbox_root},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if verbose:
|
|
136
|
+
if stdout:
|
|
137
|
+
log(f"STDOUT:\n{stdout}", "DEBUG")
|
|
138
|
+
if stderr:
|
|
139
|
+
log(f"STDERR:\n{stderr}", "DEBUG")
|
|
140
|
+
|
|
141
|
+
if retcode == 0:
|
|
142
|
+
log("Script executed successfully ✅")
|
|
143
|
+
|
|
144
|
+
# Apply sandbox changes back to project (only if we actually changed something)
|
|
145
|
+
if attempt > 0 and is_in_project(file_path, project_root) and changed_sandbox_files:
|
|
146
|
+
rel_changes = [os.path.relpath(p, sandbox_root) for p in sorted(changed_sandbox_files)]
|
|
147
|
+
|
|
148
|
+
if ask:
|
|
149
|
+
confirm = input(
|
|
150
|
+
"Overwrite original files with fixed versions?\n"
|
|
151
|
+
+ "\n".join(f"- {c}" for c in rel_changes)
|
|
152
|
+
+ "\n(y/n): "
|
|
153
|
+
).strip().lower()
|
|
154
|
+
|
|
155
|
+
if confirm != "y":
|
|
156
|
+
log("User declined overwrite", "WARN")
|
|
157
|
+
cleanup_sandbox()
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
if dry_run:
|
|
161
|
+
log("DRY RUN: would apply fixes:\n" + "\n".join(rel_changes), "WARN")
|
|
162
|
+
else:
|
|
163
|
+
sr = os.path.realpath(os.path.abspath(sandbox_root))
|
|
164
|
+
pr = os.path.realpath(os.path.abspath(project_root))
|
|
165
|
+
|
|
166
|
+
for p in sorted(changed_sandbox_files):
|
|
167
|
+
p_real = os.path.realpath(os.path.abspath(p))
|
|
168
|
+
|
|
169
|
+
# compute rel using real paths to avoid macOS /private path weirdness
|
|
170
|
+
rel = os.path.relpath(p_real, sr)
|
|
171
|
+
|
|
172
|
+
# Safety: never allow paths escaping the sandbox
|
|
173
|
+
if rel.startswith(".." + os.sep) or rel == "..":
|
|
174
|
+
log(f"Skipping suspicious path outside sandbox: {p}", "WARN")
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
dst = os.path.join(pr, rel)
|
|
178
|
+
dst_real = os.path.realpath(os.path.abspath(dst))
|
|
179
|
+
|
|
180
|
+
# Safety: never write outside the project root
|
|
181
|
+
if not (dst_real.startswith(pr + os.sep) or dst_real == pr):
|
|
182
|
+
log(f"Skipping suspicious destination outside project: {dst}", "WARN")
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Avoid shutil.SameFileError
|
|
186
|
+
try:
|
|
187
|
+
if os.path.exists(dst_real) and os.path.samefile(p_real, dst_real):
|
|
188
|
+
log(f"Skip copy (same file): {dst_real}", "DEBUG")
|
|
189
|
+
continue
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
if os.path.exists(dst_real):
|
|
194
|
+
bak = backup_file(dst_real)
|
|
195
|
+
log(f"Backup created: {bak}", "DEBUG")
|
|
196
|
+
|
|
197
|
+
os.makedirs(os.path.dirname(dst_real), exist_ok=True)
|
|
198
|
+
shutil.copy(p_real, dst_real)
|
|
199
|
+
log(f"File updated: {dst_real}")
|
|
200
|
+
|
|
201
|
+
cleanup_sandbox()
|
|
202
|
+
log(f"Fix completed in {attempt + 1} attempt(s) 🎉")
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
log("Error detected ❌", "ERROR")
|
|
206
|
+
print(stderr)
|
|
207
|
+
|
|
208
|
+
if check_and_install_missing_lib(stderr, python_exe=venv_python, project_root=sandbox_root):
|
|
209
|
+
log("Missing dependency installed (venv), retrying…")
|
|
210
|
+
time.sleep(1)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Pick the most relevant local file from the traceback (entry or imported file)
|
|
214
|
+
from auto_code_fixer.traceback_utils import pick_relevant_file
|
|
215
|
+
|
|
216
|
+
target_file = pick_relevant_file(stderr, sandbox_root=sandbox_root) or sandbox_entry
|
|
217
|
+
|
|
218
|
+
# Safety: ensure target_file is within sandbox
|
|
219
|
+
try:
|
|
220
|
+
sr = os.path.realpath(os.path.abspath(sandbox_root))
|
|
221
|
+
tf = os.path.realpath(os.path.abspath(target_file))
|
|
222
|
+
if not (tf.startswith(sr + os.sep) or tf == sr):
|
|
223
|
+
target_file = sandbox_entry
|
|
224
|
+
except Exception:
|
|
225
|
+
target_file = sandbox_entry
|
|
226
|
+
|
|
227
|
+
# Optional AI fix plan can override file selection
|
|
228
|
+
try:
|
|
229
|
+
from auto_code_fixer.plan import ask_ai_for_fix_plan
|
|
230
|
+
|
|
231
|
+
plan = ask_ai_for_fix_plan(
|
|
232
|
+
sandbox_root=sandbox_root,
|
|
233
|
+
stderr=stderr,
|
|
234
|
+
api_key=api_key,
|
|
235
|
+
model=model,
|
|
236
|
+
)
|
|
237
|
+
if plan and plan.target_files:
|
|
238
|
+
# Use the first suggested file that exists
|
|
239
|
+
for rel in plan.target_files:
|
|
240
|
+
cand = os.path.abspath(os.path.join(sandbox_root, rel))
|
|
241
|
+
if cand.startswith(os.path.abspath(sandbox_root)) and os.path.exists(cand):
|
|
242
|
+
target_file = cand
|
|
243
|
+
break
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
log(f"Sending {os.path.relpath(target_file, sandbox_root)} + error to GPT 🧠", "DEBUG")
|
|
248
|
+
|
|
249
|
+
applied_any = False
|
|
250
|
+
|
|
251
|
+
if patch_protocol:
|
|
252
|
+
try:
|
|
253
|
+
import difflib
|
|
254
|
+
|
|
255
|
+
from auto_code_fixer.fixer import fix_code_with_gpt_patch_protocol
|
|
256
|
+
from auto_code_fixer.patch_protocol import (
|
|
257
|
+
parse_patch_protocol_response,
|
|
258
|
+
validate_and_resolve_patch_files,
|
|
259
|
+
)
|
|
260
|
+
from auto_code_fixer.patcher import safe_read, atomic_write_verified_sha256
|
|
261
|
+
from auto_code_fixer.utils import find_imports
|
|
262
|
+
|
|
263
|
+
hint_paths = [os.path.relpath(target_file, sandbox_root)]
|
|
264
|
+
|
|
265
|
+
# Add a few related local files as read-only context to reduce back-and-forth.
|
|
266
|
+
ctx_pairs: list[tuple[str, str]] = []
|
|
267
|
+
if context_files and context_files > 0:
|
|
268
|
+
rels: list[str] = []
|
|
269
|
+
for abs_p in find_imports(target_file, sandbox_root):
|
|
270
|
+
try:
|
|
271
|
+
rels.append(os.path.relpath(abs_p, sandbox_root))
|
|
272
|
+
except Exception:
|
|
273
|
+
continue
|
|
274
|
+
# drop duplicates and the target itself
|
|
275
|
+
rels = [r for r in rels if r not in hint_paths]
|
|
276
|
+
for rel in rels[:context_files]:
|
|
277
|
+
abs_p = os.path.join(sandbox_root, rel)
|
|
278
|
+
if os.path.exists(abs_p):
|
|
279
|
+
try:
|
|
280
|
+
ctx_pairs.append((rel, safe_read(abs_p)))
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
raw = fix_code_with_gpt_patch_protocol(
|
|
285
|
+
sandbox_root=sandbox_root,
|
|
286
|
+
error_log=stderr,
|
|
287
|
+
api_key=api_key,
|
|
288
|
+
model=model,
|
|
289
|
+
hint_paths=hint_paths,
|
|
290
|
+
context_files=ctx_pairs,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
patch_files = parse_patch_protocol_response(raw)
|
|
294
|
+
|
|
295
|
+
if len(patch_files) > max_files_changed:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"Patch wants to change {len(patch_files)} files, exceeding --max-files-changed={max_files_changed}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
resolved = validate_and_resolve_patch_files(patch_files, sandbox_root=sandbox_root)
|
|
301
|
+
|
|
302
|
+
# Prepare diffs / approvals and apply atomically.
|
|
303
|
+
sha_by_rel = {pf.path: pf.sha256 for pf in patch_files}
|
|
304
|
+
|
|
305
|
+
planned: list[tuple[str, str, str, str]] = [] # (abs_path, rel, old, new)
|
|
306
|
+
for abs_path, new_content in resolved:
|
|
307
|
+
old = ""
|
|
308
|
+
if os.path.exists(abs_path):
|
|
309
|
+
old = safe_read(abs_path)
|
|
310
|
+
if new_content.strip() == (old or "").strip():
|
|
311
|
+
continue
|
|
312
|
+
rel = os.path.relpath(abs_path, sandbox_root)
|
|
313
|
+
planned.append((abs_path, rel, old, new_content))
|
|
314
|
+
|
|
315
|
+
if planned:
|
|
316
|
+
if len(planned) > max_files_changed:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"Patch would change {len(planned)} files, exceeding --max-files-changed={max_files_changed}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if approve:
|
|
322
|
+
print("\nPROPOSED CHANGES (patch protocol):")
|
|
323
|
+
for _, rel, old, new in planned:
|
|
324
|
+
diff = "".join(
|
|
325
|
+
difflib.unified_diff(
|
|
326
|
+
(old or "").splitlines(keepends=True),
|
|
327
|
+
(new or "").splitlines(keepends=True),
|
|
328
|
+
fromfile=rel + " (before)",
|
|
329
|
+
tofile=rel + " (after)",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
print(diff)
|
|
333
|
+
|
|
334
|
+
confirm = input("Apply these changes in the sandbox? (y/n): ").strip().lower()
|
|
335
|
+
if confirm != "y":
|
|
336
|
+
log("User declined patch application", "WARN")
|
|
337
|
+
planned = []
|
|
338
|
+
|
|
339
|
+
for abs_path, rel, old, new_content in planned:
|
|
340
|
+
expected_sha = sha_by_rel.get(rel)
|
|
341
|
+
if not expected_sha:
|
|
342
|
+
# Shouldn't happen; keep safe.
|
|
343
|
+
raise ValueError(f"Missing sha256 for {rel} in patch protocol payload")
|
|
344
|
+
|
|
345
|
+
atomic_write_verified_sha256(abs_path, new_content, expected_sha)
|
|
346
|
+
changed_sandbox_files.add(os.path.abspath(abs_path))
|
|
347
|
+
applied_any = True
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
log(f"Patch protocol failed ({e}); falling back to full-text mode", "WARN")
|
|
351
|
+
|
|
352
|
+
if not applied_any:
|
|
353
|
+
fixed_code = fix_code_with_gpt(
|
|
354
|
+
original_code=open(target_file, encoding="utf-8").read(),
|
|
355
|
+
error_log=stderr,
|
|
356
|
+
api_key=api_key,
|
|
357
|
+
model=model,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if fixed_code.strip() == open(target_file, encoding="utf-8").read().strip():
|
|
361
|
+
log("GPT returned no changes. Stopping.", "WARN")
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
if approve:
|
|
365
|
+
import difflib
|
|
366
|
+
|
|
367
|
+
old = open(target_file, encoding="utf-8").read()
|
|
368
|
+
diff = "".join(
|
|
369
|
+
difflib.unified_diff(
|
|
370
|
+
(old or "").splitlines(keepends=True),
|
|
371
|
+
(fixed_code or "").splitlines(keepends=True),
|
|
372
|
+
fromfile=os.path.relpath(target_file, sandbox_root) + " (before)",
|
|
373
|
+
tofile=os.path.relpath(target_file, sandbox_root) + " (after)",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
print("\nPROPOSED CHANGES (legacy mode):")
|
|
377
|
+
print(diff)
|
|
378
|
+
confirm = input("Apply this change in the sandbox? (y/n): ").strip().lower()
|
|
379
|
+
if confirm != "y":
|
|
380
|
+
log("User declined patch application", "WARN")
|
|
381
|
+
cleanup_sandbox()
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
from auto_code_fixer.patcher import safe_write
|
|
385
|
+
|
|
386
|
+
safe_write(target_file, fixed_code)
|
|
387
|
+
changed_sandbox_files.add(os.path.abspath(target_file))
|
|
388
|
+
applied_any = True
|
|
389
|
+
|
|
390
|
+
if applied_any:
|
|
391
|
+
_run_optional_formatters_and_linters()
|
|
392
|
+
log("Code updated by GPT ✏️")
|
|
393
|
+
time.sleep(1)
|
|
394
|
+
|
|
395
|
+
log("Failed to auto-fix file after max retries ❌", "ERROR")
|
|
396
|
+
cleanup_sandbox()
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main():
|
|
401
|
+
parser = argparse.ArgumentParser(
|
|
402
|
+
description="Auto-fix Python code using OpenAI (advanced sandbox + retry loop)"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
parser.add_argument(
|
|
406
|
+
"--version",
|
|
407
|
+
action="version",
|
|
408
|
+
version=f"%(prog)s {__version__}",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
parser.add_argument(
|
|
412
|
+
"entry_file",
|
|
413
|
+
nargs="?",
|
|
414
|
+
help="Path to the main Python file",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
parser.add_argument("--project-root", default=".")
|
|
418
|
+
parser.add_argument("--api-key")
|
|
419
|
+
parser.add_argument("--model", default=None, help="OpenAI model (default: AUTO_CODE_FIXER_MODEL or gpt-4.1-mini)")
|
|
420
|
+
parser.add_argument("--timeout", type=int, default=30, help="Execution timeout seconds (default: 30)")
|
|
421
|
+
parser.add_argument("--dry-run", action="store_true", help="Do not overwrite files; just report what would change")
|
|
422
|
+
parser.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES, help="Max fix attempts (default: 8)")
|
|
423
|
+
parser.add_argument(
|
|
424
|
+
"--run",
|
|
425
|
+
default=None,
|
|
426
|
+
help=(
|
|
427
|
+
"Optional command to run instead of `python entry.py`. Examples: 'pytest -q', 'python -m module'. "
|
|
428
|
+
"If set, it runs inside the sandbox venv."
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
parser.add_argument(
|
|
432
|
+
"--ai-plan",
|
|
433
|
+
action="store_true",
|
|
434
|
+
help="Optional: use AI to suggest which file to edit (AUTO_CODE_FIXER_AI_PLAN=1)",
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
parser.add_argument(
|
|
438
|
+
"--legacy-mode",
|
|
439
|
+
action="store_true",
|
|
440
|
+
help=(
|
|
441
|
+
"Disable the default JSON patch protocol and use legacy full-text edit mode only. "
|
|
442
|
+
"(Not recommended; patch protocol is safer and supports multi-file fixes.)"
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
parser.add_argument(
|
|
447
|
+
"--max-files-changed",
|
|
448
|
+
type=int,
|
|
449
|
+
default=20,
|
|
450
|
+
help="Safety guard: maximum number of files the model may change per attempt (default: 20)",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
parser.add_argument(
|
|
454
|
+
"--context-files",
|
|
455
|
+
type=int,
|
|
456
|
+
default=3,
|
|
457
|
+
help=(
|
|
458
|
+
"Include up to N related local files (imports) as read-only context in the LLM prompt "
|
|
459
|
+
"when using patch protocol (default: 3)"
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
parser.add_argument(
|
|
464
|
+
"--approve",
|
|
465
|
+
action="store_true",
|
|
466
|
+
help="Show diffs for proposed changes and ask for approval before applying them in the sandbox",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
parser.add_argument(
|
|
470
|
+
"--format",
|
|
471
|
+
default=None,
|
|
472
|
+
choices=["black"],
|
|
473
|
+
help="Optional: run formatter in sandbox (best-effort). Currently supported: black",
|
|
474
|
+
)
|
|
475
|
+
parser.add_argument(
|
|
476
|
+
"--lint",
|
|
477
|
+
default=None,
|
|
478
|
+
choices=["ruff"],
|
|
479
|
+
help="Optional: run linter in sandbox (best-effort). Currently supported: ruff",
|
|
480
|
+
)
|
|
481
|
+
parser.add_argument(
|
|
482
|
+
"--fix",
|
|
483
|
+
action="store_true",
|
|
484
|
+
help="If used with --lint ruff, apply fixes (ruff --fix) (best-effort)",
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# ✅ Proper boolean flags
|
|
488
|
+
ask_group = parser.add_mutually_exclusive_group()
|
|
489
|
+
ask_group.add_argument(
|
|
490
|
+
"--ask",
|
|
491
|
+
action="store_true",
|
|
492
|
+
help="Ask before overwriting files",
|
|
493
|
+
)
|
|
494
|
+
ask_group.add_argument(
|
|
495
|
+
"--no-ask",
|
|
496
|
+
action="store_true",
|
|
497
|
+
help="Do not ask before overwriting files",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
parser.add_argument(
|
|
501
|
+
"--verbose",
|
|
502
|
+
action="store_true",
|
|
503
|
+
help="Enable verbose/debug output",
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
args = parser.parse_args()
|
|
507
|
+
|
|
508
|
+
if not args.entry_file:
|
|
509
|
+
parser.error("the following arguments are required: entry_file")
|
|
510
|
+
|
|
511
|
+
# ENV defaults
|
|
512
|
+
env_ask = config("AUTO_CODE_FIXER_ASK", default=False, cast=bool)
|
|
513
|
+
env_verbose = config("AUTO_CODE_FIXER_VERBOSE", default=False, cast=bool)
|
|
514
|
+
|
|
515
|
+
# Final ask resolution (CLI overrides ENV)
|
|
516
|
+
if args.ask:
|
|
517
|
+
ask = True
|
|
518
|
+
elif args.no_ask:
|
|
519
|
+
ask = False
|
|
520
|
+
else:
|
|
521
|
+
ask = env_ask
|
|
522
|
+
|
|
523
|
+
verbose = args.verbose or env_verbose
|
|
524
|
+
set_verbose(verbose)
|
|
525
|
+
|
|
526
|
+
# Optional: enable AI planning helper
|
|
527
|
+
if args.ai_plan:
|
|
528
|
+
os.environ["AUTO_CODE_FIXER_AI_PLAN"] = "1"
|
|
529
|
+
|
|
530
|
+
ok = fix_file(
|
|
531
|
+
args.entry_file,
|
|
532
|
+
args.project_root,
|
|
533
|
+
args.api_key,
|
|
534
|
+
ask,
|
|
535
|
+
verbose,
|
|
536
|
+
dry_run=args.dry_run,
|
|
537
|
+
model=args.model,
|
|
538
|
+
timeout_s=args.timeout,
|
|
539
|
+
max_retries=args.max_retries,
|
|
540
|
+
run_cmd=args.run,
|
|
541
|
+
patch_protocol=not args.legacy_mode,
|
|
542
|
+
max_files_changed=args.max_files_changed,
|
|
543
|
+
context_files=args.context_files,
|
|
544
|
+
approve=args.approve,
|
|
545
|
+
fmt=args.format,
|
|
546
|
+
lint=args.lint,
|
|
547
|
+
lint_fix=args.fix,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
raise SystemExit(0 if ok else 2)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
main()
|