auto-code-fixer 0.3.6__tar.gz → 0.3.7__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.6 → auto_code_fixer-0.3.7}/PKG-INFO +33 -6
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/README.md +32 -5
- auto_code_fixer-0.3.7/auto_code_fixer/__init__.py +1 -0
- auto_code_fixer-0.3.7/auto_code_fixer/approval.py +114 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/cli.py +153 -38
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/PKG-INFO +33 -6
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/SOURCES.txt +2 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/pyproject.toml +1 -1
- auto_code_fixer-0.3.7/tests/test_approval_and_guards.py +55 -0
- auto_code_fixer-0.3.6/auto_code_fixer/__init__.py +0 -1
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/LICENSE +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/command_runner.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/fixer.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/installer.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/models.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/patch_protocol.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/patcher.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/plan.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/runner.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/sandbox.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/traceback_utils.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/utils.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer/venv_manager.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/dependency_links.txt +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/entry_points.txt +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/requires.txt +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/top_level.txt +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/setup.cfg +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/tests/test_atomic_write.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/tests/test_fix_imported_file.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/tests/test_internal_imports.py +0 -0
- {auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/tests/test_patch_protocol.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.7
|
|
4
4
|
Summary: Automatically fix Python code using ChatGPT
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -87,6 +87,18 @@ pip install requests
|
|
|
87
87
|
Before overwriting any file, it creates a backup:
|
|
88
88
|
- `file.py.bak` (or `.bak1`, `.bak2`, ...)
|
|
89
89
|
|
|
90
|
+
### Approval mode (diff review)
|
|
91
|
+
```bash
|
|
92
|
+
auto-code-fixer path/to/main.py --project-root . --approve
|
|
93
|
+
```
|
|
94
|
+
In patch-protocol mode, approvals are **file-by-file** (apply/skip).
|
|
95
|
+
|
|
96
|
+
### Diff / size guards
|
|
97
|
+
To prevent huge edits from being applied accidentally:
|
|
98
|
+
- `--max-diff-lines` limits unified-diff size per file
|
|
99
|
+
- `--max-file-bytes` limits the proposed new content size per file
|
|
100
|
+
- `--max-total-bytes` limits total proposed new content across all files
|
|
101
|
+
|
|
90
102
|
### Dry run
|
|
91
103
|
```bash
|
|
92
104
|
auto-code-fixer path/to/main.py --project-root . --dry-run
|
|
@@ -103,6 +115,16 @@ Instead of `python main.py`, run tests:
|
|
|
103
115
|
auto-code-fixer . --project-root . --run "pytest -q" --no-ask
|
|
104
116
|
```
|
|
105
117
|
|
|
118
|
+
When you use `--run`, the tool (by default) also performs a **post-apply check**:
|
|
119
|
+
after copying fixes back to your project, it re-runs the same command against the real project files
|
|
120
|
+
(using the sandbox venv for dependencies).
|
|
121
|
+
|
|
122
|
+
You can disable that extra check with:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
auto-code-fixer . --project-root . --run "pytest -q" --no-post-apply-check
|
|
126
|
+
```
|
|
127
|
+
|
|
106
128
|
### Model selection
|
|
107
129
|
```bash
|
|
108
130
|
export AUTO_CODE_FIXER_MODEL=gpt-4.1-mini
|
|
@@ -121,13 +143,18 @@ auto-code-fixer main.py --ai-plan
|
|
|
121
143
|
```
|
|
122
144
|
This enables a helper that can suggest which local file to edit. It is best-effort.
|
|
123
145
|
|
|
124
|
-
###
|
|
146
|
+
### Structured patch protocol (JSON + sha256) (default)
|
|
147
|
+
By default, Auto Code Fixer uses a structured **patch protocol** where the model returns strict JSON:
|
|
148
|
+
|
|
149
|
+
`{ "files": [ {"path": "...", "new_content": "...", "sha256": "..."}, ... ] }`
|
|
150
|
+
|
|
151
|
+
The tool verifies the SHA-256 hash of `new_content` before applying edits.
|
|
152
|
+
|
|
153
|
+
To disable this and use legacy full-text mode only:
|
|
154
|
+
|
|
125
155
|
```bash
|
|
126
|
-
auto-code-fixer main.py --
|
|
156
|
+
auto-code-fixer main.py --legacy-mode
|
|
127
157
|
```
|
|
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
158
|
|
|
132
159
|
### Optional formatting / linting (best-effort)
|
|
133
160
|
```bash
|
|
@@ -66,6 +66,18 @@ pip install requests
|
|
|
66
66
|
Before overwriting any file, it creates a backup:
|
|
67
67
|
- `file.py.bak` (or `.bak1`, `.bak2`, ...)
|
|
68
68
|
|
|
69
|
+
### Approval mode (diff review)
|
|
70
|
+
```bash
|
|
71
|
+
auto-code-fixer path/to/main.py --project-root . --approve
|
|
72
|
+
```
|
|
73
|
+
In patch-protocol mode, approvals are **file-by-file** (apply/skip).
|
|
74
|
+
|
|
75
|
+
### Diff / size guards
|
|
76
|
+
To prevent huge edits from being applied accidentally:
|
|
77
|
+
- `--max-diff-lines` limits unified-diff size per file
|
|
78
|
+
- `--max-file-bytes` limits the proposed new content size per file
|
|
79
|
+
- `--max-total-bytes` limits total proposed new content across all files
|
|
80
|
+
|
|
69
81
|
### Dry run
|
|
70
82
|
```bash
|
|
71
83
|
auto-code-fixer path/to/main.py --project-root . --dry-run
|
|
@@ -82,6 +94,16 @@ Instead of `python main.py`, run tests:
|
|
|
82
94
|
auto-code-fixer . --project-root . --run "pytest -q" --no-ask
|
|
83
95
|
```
|
|
84
96
|
|
|
97
|
+
When you use `--run`, the tool (by default) also performs a **post-apply check**:
|
|
98
|
+
after copying fixes back to your project, it re-runs the same command against the real project files
|
|
99
|
+
(using the sandbox venv for dependencies).
|
|
100
|
+
|
|
101
|
+
You can disable that extra check with:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
auto-code-fixer . --project-root . --run "pytest -q" --no-post-apply-check
|
|
105
|
+
```
|
|
106
|
+
|
|
85
107
|
### Model selection
|
|
86
108
|
```bash
|
|
87
109
|
export AUTO_CODE_FIXER_MODEL=gpt-4.1-mini
|
|
@@ -100,13 +122,18 @@ auto-code-fixer main.py --ai-plan
|
|
|
100
122
|
```
|
|
101
123
|
This enables a helper that can suggest which local file to edit. It is best-effort.
|
|
102
124
|
|
|
103
|
-
###
|
|
125
|
+
### Structured patch protocol (JSON + sha256) (default)
|
|
126
|
+
By default, Auto Code Fixer uses a structured **patch protocol** where the model returns strict JSON:
|
|
127
|
+
|
|
128
|
+
`{ "files": [ {"path": "...", "new_content": "...", "sha256": "..."}, ... ] }`
|
|
129
|
+
|
|
130
|
+
The tool verifies the SHA-256 hash of `new_content` before applying edits.
|
|
131
|
+
|
|
132
|
+
To disable this and use legacy full-text mode only:
|
|
133
|
+
|
|
104
134
|
```bash
|
|
105
|
-
auto-code-fixer main.py --
|
|
135
|
+
auto-code-fixer main.py --legacy-mode
|
|
106
136
|
```
|
|
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
137
|
|
|
111
138
|
### Optional formatting / linting (best-effort)
|
|
112
139
|
```bash
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.7"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class PlannedChange:
|
|
7
|
+
abs_path: str
|
|
8
|
+
rel_path: str
|
|
9
|
+
old_content: str
|
|
10
|
+
new_content: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserAbort(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def unified_diff_text(rel_path: str, old: str, new: str) -> str:
|
|
18
|
+
return "".join(
|
|
19
|
+
difflib.unified_diff(
|
|
20
|
+
(old or "").splitlines(keepends=True),
|
|
21
|
+
(new or "").splitlines(keepends=True),
|
|
22
|
+
fromfile=rel_path + " (before)",
|
|
23
|
+
tofile=rel_path + " (after)",
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def guard_planned_changes(
|
|
29
|
+
planned: list[PlannedChange],
|
|
30
|
+
*,
|
|
31
|
+
max_file_bytes: int,
|
|
32
|
+
max_total_bytes: int,
|
|
33
|
+
max_diff_lines: int,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Safety guards against huge edits.
|
|
36
|
+
|
|
37
|
+
Raises ValueError when limits are exceeded.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
if max_file_bytes <= 0 or max_total_bytes <= 0 or max_diff_lines <= 0:
|
|
41
|
+
raise ValueError("Guard limits must be positive")
|
|
42
|
+
|
|
43
|
+
total = 0
|
|
44
|
+
|
|
45
|
+
for ch in planned:
|
|
46
|
+
b = len((ch.new_content or "").encode("utf-8"))
|
|
47
|
+
total += b
|
|
48
|
+
if b > max_file_bytes:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Proposed content for {ch.rel_path} is {b} bytes, exceeding --max-file-bytes={max_file_bytes}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
diff = unified_diff_text(ch.rel_path, ch.old_content, ch.new_content)
|
|
54
|
+
diff_lines = len(diff.splitlines())
|
|
55
|
+
if diff_lines > max_diff_lines:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Diff for {ch.rel_path} is {diff_lines} lines, exceeding --max-diff-lines={max_diff_lines}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if total > max_total_bytes:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Total proposed content is {total} bytes, exceeding --max-total-bytes={max_total_bytes}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def prompt_approve_file_by_file(
|
|
67
|
+
planned: list[PlannedChange],
|
|
68
|
+
*,
|
|
69
|
+
input_fn=input,
|
|
70
|
+
print_fn=print,
|
|
71
|
+
) -> list[PlannedChange]:
|
|
72
|
+
"""Interactive approval per file.
|
|
73
|
+
|
|
74
|
+
Commands:
|
|
75
|
+
y = apply this change
|
|
76
|
+
n = skip this change
|
|
77
|
+
a = apply this and all remaining
|
|
78
|
+
q = abort
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
if not planned:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
out: list[PlannedChange] = []
|
|
85
|
+
apply_all = False
|
|
86
|
+
|
|
87
|
+
for ch in planned:
|
|
88
|
+
diff = unified_diff_text(ch.rel_path, ch.old_content, ch.new_content)
|
|
89
|
+
print_fn("\nPROPOSED CHANGE:")
|
|
90
|
+
print_fn(diff)
|
|
91
|
+
|
|
92
|
+
if apply_all:
|
|
93
|
+
out.append(ch)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
ans = (
|
|
97
|
+
input_fn(f"Apply change for {ch.rel_path}? (y/n/a/q): ")
|
|
98
|
+
.strip()
|
|
99
|
+
.lower()
|
|
100
|
+
)
|
|
101
|
+
if ans == "y":
|
|
102
|
+
out.append(ch)
|
|
103
|
+
elif ans == "n":
|
|
104
|
+
continue
|
|
105
|
+
elif ans == "a":
|
|
106
|
+
out.append(ch)
|
|
107
|
+
apply_all = True
|
|
108
|
+
elif ans == "q":
|
|
109
|
+
raise UserAbort("User aborted approvals")
|
|
110
|
+
else:
|
|
111
|
+
# default: be safe
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
return out
|
|
@@ -35,6 +35,10 @@ def fix_file(
|
|
|
35
35
|
max_files_changed: int,
|
|
36
36
|
context_files: int,
|
|
37
37
|
approve: bool,
|
|
38
|
+
max_diff_lines: int,
|
|
39
|
+
max_file_bytes: int,
|
|
40
|
+
max_total_bytes: int,
|
|
41
|
+
post_apply_check: bool,
|
|
38
42
|
fmt: str | None,
|
|
39
43
|
lint: str | None,
|
|
40
44
|
lint_fix: bool,
|
|
@@ -163,6 +167,8 @@ def fix_file(
|
|
|
163
167
|
sr = os.path.realpath(os.path.abspath(sandbox_root))
|
|
164
168
|
pr = os.path.realpath(os.path.abspath(project_root))
|
|
165
169
|
|
|
170
|
+
backups: dict[str, str] = {}
|
|
171
|
+
|
|
166
172
|
for p in sorted(changed_sandbox_files):
|
|
167
173
|
p_real = os.path.realpath(os.path.abspath(p))
|
|
168
174
|
|
|
@@ -192,12 +198,44 @@ def fix_file(
|
|
|
192
198
|
|
|
193
199
|
if os.path.exists(dst_real):
|
|
194
200
|
bak = backup_file(dst_real)
|
|
201
|
+
backups[dst_real] = bak
|
|
195
202
|
log(f"Backup created: {bak}", "DEBUG")
|
|
196
203
|
|
|
197
204
|
os.makedirs(os.path.dirname(dst_real), exist_ok=True)
|
|
198
205
|
shutil.copy(p_real, dst_real)
|
|
199
206
|
log(f"File updated: {dst_real}")
|
|
200
207
|
|
|
208
|
+
# Optional post-apply verification: run the same command against the real project files,
|
|
209
|
+
# using the sandbox venv for dependencies.
|
|
210
|
+
if post_apply_check and run_cmd and changed_sandbox_files:
|
|
211
|
+
from auto_code_fixer.command_runner import run_command
|
|
212
|
+
|
|
213
|
+
rc2, out2, err2 = run_command(
|
|
214
|
+
run_cmd,
|
|
215
|
+
timeout_s=timeout_s,
|
|
216
|
+
python_exe=venv_python,
|
|
217
|
+
cwd=project_root,
|
|
218
|
+
extra_env={"PYTHONPATH": project_root},
|
|
219
|
+
)
|
|
220
|
+
if verbose:
|
|
221
|
+
if out2:
|
|
222
|
+
log(f"POST-APPLY STDOUT:\n{out2}", "DEBUG")
|
|
223
|
+
if err2:
|
|
224
|
+
log(f"POST-APPLY STDERR:\n{err2}", "DEBUG")
|
|
225
|
+
|
|
226
|
+
if rc2 != 0:
|
|
227
|
+
log(
|
|
228
|
+
"Post-apply command failed; restoring backups (best-effort).",
|
|
229
|
+
"ERROR",
|
|
230
|
+
)
|
|
231
|
+
for dst_real, bak in backups.items():
|
|
232
|
+
try:
|
|
233
|
+
shutil.copy(bak, dst_real)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
log(f"WARN: failed to restore {dst_real} from {bak}: {e}")
|
|
236
|
+
cleanup_sandbox()
|
|
237
|
+
return False
|
|
238
|
+
|
|
201
239
|
cleanup_sandbox()
|
|
202
240
|
log(f"Fix completed in {attempt + 1} attempt(s) 🎉")
|
|
203
241
|
return True
|
|
@@ -302,7 +340,14 @@ def fix_file(
|
|
|
302
340
|
# Prepare diffs / approvals and apply atomically.
|
|
303
341
|
sha_by_rel = {pf.path: pf.sha256 for pf in patch_files}
|
|
304
342
|
|
|
305
|
-
|
|
343
|
+
from auto_code_fixer.approval import (
|
|
344
|
+
PlannedChange,
|
|
345
|
+
UserAbort,
|
|
346
|
+
guard_planned_changes,
|
|
347
|
+
prompt_approve_file_by_file,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
planned: list[PlannedChange] = []
|
|
306
351
|
for abs_path, new_content in resolved:
|
|
307
352
|
old = ""
|
|
308
353
|
if os.path.exists(abs_path):
|
|
@@ -310,7 +355,14 @@ def fix_file(
|
|
|
310
355
|
if new_content.strip() == (old or "").strip():
|
|
311
356
|
continue
|
|
312
357
|
rel = os.path.relpath(abs_path, sandbox_root)
|
|
313
|
-
planned.append(
|
|
358
|
+
planned.append(
|
|
359
|
+
PlannedChange(
|
|
360
|
+
abs_path=abs_path,
|
|
361
|
+
rel_path=rel,
|
|
362
|
+
old_content=old,
|
|
363
|
+
new_content=new_content,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
314
366
|
|
|
315
367
|
if planned:
|
|
316
368
|
if len(planned) > max_files_changed:
|
|
@@ -318,32 +370,28 @@ def fix_file(
|
|
|
318
370
|
f"Patch would change {len(planned)} files, exceeding --max-files-changed={max_files_changed}"
|
|
319
371
|
)
|
|
320
372
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
(new or "").splitlines(keepends=True),
|
|
328
|
-
fromfile=rel + " (before)",
|
|
329
|
-
tofile=rel + " (after)",
|
|
330
|
-
)
|
|
331
|
-
)
|
|
332
|
-
print(diff)
|
|
373
|
+
guard_planned_changes(
|
|
374
|
+
planned,
|
|
375
|
+
max_file_bytes=max_file_bytes,
|
|
376
|
+
max_total_bytes=max_total_bytes,
|
|
377
|
+
max_diff_lines=max_diff_lines,
|
|
378
|
+
)
|
|
333
379
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
380
|
+
if approve:
|
|
381
|
+
try:
|
|
382
|
+
planned = prompt_approve_file_by_file(planned)
|
|
383
|
+
except UserAbort:
|
|
384
|
+
log("User aborted patch application", "WARN")
|
|
337
385
|
planned = []
|
|
338
386
|
|
|
339
|
-
for
|
|
340
|
-
expected_sha = sha_by_rel.get(
|
|
387
|
+
for ch in planned:
|
|
388
|
+
expected_sha = sha_by_rel.get(ch.rel_path)
|
|
341
389
|
if not expected_sha:
|
|
342
390
|
# Shouldn't happen; keep safe.
|
|
343
|
-
raise ValueError(f"Missing sha256 for {
|
|
391
|
+
raise ValueError(f"Missing sha256 for {ch.rel_path} in patch protocol payload")
|
|
344
392
|
|
|
345
|
-
atomic_write_verified_sha256(abs_path, new_content, expected_sha)
|
|
346
|
-
changed_sandbox_files.add(os.path.abspath(abs_path))
|
|
393
|
+
atomic_write_verified_sha256(ch.abs_path, ch.new_content, expected_sha)
|
|
394
|
+
changed_sandbox_files.add(os.path.abspath(ch.abs_path))
|
|
347
395
|
applied_any = True
|
|
348
396
|
|
|
349
397
|
except Exception as e:
|
|
@@ -361,29 +409,48 @@ def fix_file(
|
|
|
361
409
|
log("GPT returned no changes. Stopping.", "WARN")
|
|
362
410
|
break
|
|
363
411
|
|
|
364
|
-
|
|
365
|
-
|
|
412
|
+
from auto_code_fixer.approval import (
|
|
413
|
+
PlannedChange,
|
|
414
|
+
UserAbort,
|
|
415
|
+
guard_planned_changes,
|
|
416
|
+
prompt_approve_file_by_file,
|
|
417
|
+
)
|
|
366
418
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
419
|
+
old = open(target_file, encoding="utf-8").read()
|
|
420
|
+
rel = os.path.relpath(target_file, sandbox_root)
|
|
421
|
+
|
|
422
|
+
planned = [
|
|
423
|
+
PlannedChange(
|
|
424
|
+
abs_path=target_file,
|
|
425
|
+
rel_path=rel,
|
|
426
|
+
old_content=old,
|
|
427
|
+
new_content=fixed_code,
|
|
375
428
|
)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
429
|
+
]
|
|
430
|
+
|
|
431
|
+
guard_planned_changes(
|
|
432
|
+
planned,
|
|
433
|
+
max_file_bytes=max_file_bytes,
|
|
434
|
+
max_total_bytes=max_total_bytes,
|
|
435
|
+
max_diff_lines=max_diff_lines,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if approve:
|
|
439
|
+
try:
|
|
440
|
+
planned = prompt_approve_file_by_file(planned)
|
|
441
|
+
except UserAbort:
|
|
380
442
|
log("User declined patch application", "WARN")
|
|
381
443
|
cleanup_sandbox()
|
|
382
444
|
return False
|
|
383
445
|
|
|
446
|
+
if not planned:
|
|
447
|
+
log("No legacy changes approved", "WARN")
|
|
448
|
+
cleanup_sandbox()
|
|
449
|
+
return False
|
|
450
|
+
|
|
384
451
|
from auto_code_fixer.patcher import safe_write
|
|
385
452
|
|
|
386
|
-
safe_write(target_file,
|
|
453
|
+
safe_write(target_file, planned[0].new_content)
|
|
387
454
|
changed_sandbox_files.add(os.path.abspath(target_file))
|
|
388
455
|
applied_any = True
|
|
389
456
|
|
|
@@ -463,7 +530,44 @@ def main():
|
|
|
463
530
|
parser.add_argument(
|
|
464
531
|
"--approve",
|
|
465
532
|
action="store_true",
|
|
466
|
-
help=
|
|
533
|
+
help=(
|
|
534
|
+
"Show diffs for proposed changes and ask for approval before applying them in the sandbox. "
|
|
535
|
+
"In patch-protocol mode, approvals are file-by-file."
|
|
536
|
+
),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
parser.add_argument(
|
|
540
|
+
"--max-diff-lines",
|
|
541
|
+
type=int,
|
|
542
|
+
default=4000,
|
|
543
|
+
help="Safety guard: maximum unified-diff lines to display/apply per file (default: 4000)",
|
|
544
|
+
)
|
|
545
|
+
parser.add_argument(
|
|
546
|
+
"--max-file-bytes",
|
|
547
|
+
type=int,
|
|
548
|
+
default=200_000,
|
|
549
|
+
help="Safety guard: maximum size (bytes) of proposed new content per file (default: 200000)",
|
|
550
|
+
)
|
|
551
|
+
parser.add_argument(
|
|
552
|
+
"--max-total-bytes",
|
|
553
|
+
type=int,
|
|
554
|
+
default=1_000_000,
|
|
555
|
+
help="Safety guard: maximum total bytes of proposed new content across all files (default: 1000000)",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
post_apply_group = parser.add_mutually_exclusive_group()
|
|
559
|
+
post_apply_group.add_argument(
|
|
560
|
+
"--post-apply-check",
|
|
561
|
+
action="store_true",
|
|
562
|
+
help=(
|
|
563
|
+
"If using --run, re-run the command against the real project files after applying fixes "
|
|
564
|
+
"(uses sandbox venv for deps)."
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
post_apply_group.add_argument(
|
|
568
|
+
"--no-post-apply-check",
|
|
569
|
+
action="store_true",
|
|
570
|
+
help="Disable the post-apply re-run check (even if --run is set).",
|
|
467
571
|
)
|
|
468
572
|
|
|
469
573
|
parser.add_argument(
|
|
@@ -527,6 +631,13 @@ def main():
|
|
|
527
631
|
if args.ai_plan:
|
|
528
632
|
os.environ["AUTO_CODE_FIXER_AI_PLAN"] = "1"
|
|
529
633
|
|
|
634
|
+
if args.no_post_apply_check:
|
|
635
|
+
post_apply_check = False
|
|
636
|
+
elif args.post_apply_check:
|
|
637
|
+
post_apply_check = True
|
|
638
|
+
else:
|
|
639
|
+
post_apply_check = bool(args.run)
|
|
640
|
+
|
|
530
641
|
ok = fix_file(
|
|
531
642
|
args.entry_file,
|
|
532
643
|
args.project_root,
|
|
@@ -542,6 +653,10 @@ def main():
|
|
|
542
653
|
max_files_changed=args.max_files_changed,
|
|
543
654
|
context_files=args.context_files,
|
|
544
655
|
approve=args.approve,
|
|
656
|
+
max_diff_lines=args.max_diff_lines,
|
|
657
|
+
max_file_bytes=args.max_file_bytes,
|
|
658
|
+
max_total_bytes=args.max_total_bytes,
|
|
659
|
+
post_apply_check=post_apply_check,
|
|
545
660
|
fmt=args.format,
|
|
546
661
|
lint=args.lint,
|
|
547
662
|
lint_fix=args.fix,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auto-code-fixer
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.7
|
|
4
4
|
Summary: Automatically fix Python code using ChatGPT
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -87,6 +87,18 @@ pip install requests
|
|
|
87
87
|
Before overwriting any file, it creates a backup:
|
|
88
88
|
- `file.py.bak` (or `.bak1`, `.bak2`, ...)
|
|
89
89
|
|
|
90
|
+
### Approval mode (diff review)
|
|
91
|
+
```bash
|
|
92
|
+
auto-code-fixer path/to/main.py --project-root . --approve
|
|
93
|
+
```
|
|
94
|
+
In patch-protocol mode, approvals are **file-by-file** (apply/skip).
|
|
95
|
+
|
|
96
|
+
### Diff / size guards
|
|
97
|
+
To prevent huge edits from being applied accidentally:
|
|
98
|
+
- `--max-diff-lines` limits unified-diff size per file
|
|
99
|
+
- `--max-file-bytes` limits the proposed new content size per file
|
|
100
|
+
- `--max-total-bytes` limits total proposed new content across all files
|
|
101
|
+
|
|
90
102
|
### Dry run
|
|
91
103
|
```bash
|
|
92
104
|
auto-code-fixer path/to/main.py --project-root . --dry-run
|
|
@@ -103,6 +115,16 @@ Instead of `python main.py`, run tests:
|
|
|
103
115
|
auto-code-fixer . --project-root . --run "pytest -q" --no-ask
|
|
104
116
|
```
|
|
105
117
|
|
|
118
|
+
When you use `--run`, the tool (by default) also performs a **post-apply check**:
|
|
119
|
+
after copying fixes back to your project, it re-runs the same command against the real project files
|
|
120
|
+
(using the sandbox venv for dependencies).
|
|
121
|
+
|
|
122
|
+
You can disable that extra check with:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
auto-code-fixer . --project-root . --run "pytest -q" --no-post-apply-check
|
|
126
|
+
```
|
|
127
|
+
|
|
106
128
|
### Model selection
|
|
107
129
|
```bash
|
|
108
130
|
export AUTO_CODE_FIXER_MODEL=gpt-4.1-mini
|
|
@@ -121,13 +143,18 @@ auto-code-fixer main.py --ai-plan
|
|
|
121
143
|
```
|
|
122
144
|
This enables a helper that can suggest which local file to edit. It is best-effort.
|
|
123
145
|
|
|
124
|
-
###
|
|
146
|
+
### Structured patch protocol (JSON + sha256) (default)
|
|
147
|
+
By default, Auto Code Fixer uses a structured **patch protocol** where the model returns strict JSON:
|
|
148
|
+
|
|
149
|
+
`{ "files": [ {"path": "...", "new_content": "...", "sha256": "..."}, ... ] }`
|
|
150
|
+
|
|
151
|
+
The tool verifies the SHA-256 hash of `new_content` before applying edits.
|
|
152
|
+
|
|
153
|
+
To disable this and use legacy full-text mode only:
|
|
154
|
+
|
|
125
155
|
```bash
|
|
126
|
-
auto-code-fixer main.py --
|
|
156
|
+
auto-code-fixer main.py --legacy-mode
|
|
127
157
|
```
|
|
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
158
|
|
|
132
159
|
### Optional formatting / linting (best-effort)
|
|
133
160
|
```bash
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
auto_code_fixer/__init__.py
|
|
5
|
+
auto_code_fixer/approval.py
|
|
5
6
|
auto_code_fixer/cli.py
|
|
6
7
|
auto_code_fixer/command_runner.py
|
|
7
8
|
auto_code_fixer/fixer.py
|
|
@@ -21,6 +22,7 @@ auto_code_fixer.egg-info/dependency_links.txt
|
|
|
21
22
|
auto_code_fixer.egg-info/entry_points.txt
|
|
22
23
|
auto_code_fixer.egg-info/requires.txt
|
|
23
24
|
auto_code_fixer.egg-info/top_level.txt
|
|
25
|
+
tests/test_approval_and_guards.py
|
|
24
26
|
tests/test_atomic_write.py
|
|
25
27
|
tests/test_fix_imported_file.py
|
|
26
28
|
tests/test_internal_imports.py
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from auto_code_fixer.approval import (
|
|
4
|
+
PlannedChange,
|
|
5
|
+
UserAbort,
|
|
6
|
+
guard_planned_changes,
|
|
7
|
+
prompt_approve_file_by_file,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_guard_planned_changes_rejects_big_file():
|
|
12
|
+
planned = [
|
|
13
|
+
PlannedChange(
|
|
14
|
+
abs_path="/tmp/a.py",
|
|
15
|
+
rel_path="a.py",
|
|
16
|
+
old_content="",
|
|
17
|
+
new_content="x" * 11,
|
|
18
|
+
)
|
|
19
|
+
]
|
|
20
|
+
with pytest.raises(ValueError, match="max-file-bytes"):
|
|
21
|
+
guard_planned_changes(planned, max_file_bytes=10, max_total_bytes=100, max_diff_lines=100)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_guard_planned_changes_rejects_total_bytes():
|
|
25
|
+
planned = [
|
|
26
|
+
PlannedChange("/tmp/a.py", "a.py", "", "x" * 6),
|
|
27
|
+
PlannedChange("/tmp/b.py", "b.py", "", "x" * 6),
|
|
28
|
+
]
|
|
29
|
+
with pytest.raises(ValueError, match="max-total-bytes"):
|
|
30
|
+
guard_planned_changes(planned, max_file_bytes=100, max_total_bytes=10, max_diff_lines=100)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_prompt_approve_file_by_file_apply_skip():
|
|
34
|
+
planned = [
|
|
35
|
+
PlannedChange("/tmp/a.py", "a.py", "print(1)\n", "print(2)\n"),
|
|
36
|
+
PlannedChange("/tmp/b.py", "b.py", "print(1)\n", "print(3)\n"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
answers = iter(["y", "n"])
|
|
40
|
+
|
|
41
|
+
def _input(_prompt: str) -> str:
|
|
42
|
+
return next(answers)
|
|
43
|
+
|
|
44
|
+
chosen = prompt_approve_file_by_file(planned, input_fn=_input, print_fn=lambda *_: None)
|
|
45
|
+
assert [c.rel_path for c in chosen] == ["a.py"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_prompt_approve_file_by_file_abort():
|
|
49
|
+
planned = [PlannedChange("/tmp/a.py", "a.py", "", "x")]
|
|
50
|
+
|
|
51
|
+
def _input(_prompt: str) -> str:
|
|
52
|
+
return "q"
|
|
53
|
+
|
|
54
|
+
with pytest.raises(UserAbort):
|
|
55
|
+
prompt_approve_file_by_file(planned, input_fn=_input, print_fn=lambda *_: None)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.6"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{auto_code_fixer-0.3.6 → auto_code_fixer-0.3.7}/auto_code_fixer.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|