auto-code-fixer 0.3.6__py3-none-any.whl → 0.3.7__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.
@@ -1 +1 @@
1
- __version__ = "0.3.6"
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
auto_code_fixer/cli.py CHANGED
@@ -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
- planned: list[tuple[str, str, str, str]] = [] # (abs_path, rel, old, new)
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((abs_path, rel, old, new_content))
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
- 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)
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
- confirm = input("Apply these changes in the sandbox? (y/n): ").strip().lower()
335
- if confirm != "y":
336
- log("User declined patch application", "WARN")
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 abs_path, rel, old, new_content in planned:
340
- expected_sha = sha_by_rel.get(rel)
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 {rel} in patch protocol payload")
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
- if approve:
365
- import difflib
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
- 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
- )
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
- 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":
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, fixed_code)
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="Show diffs for proposed changes and ask for approval before applying them in the sandbox",
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.6
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
- ### Optional structured patch protocol (JSON + sha256)
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 --patch-protocol
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
@@ -1,5 +1,6 @@
1
- auto_code_fixer/__init__.py,sha256=W_9dCm49nLvZulVAvvsafxLJjVBSKDBHz9K7szFZllo,22
2
- auto_code_fixer/cli.py,sha256=lFIGgrMQaqAoNtGRlkKXE1Lzk4l74XwsYjdxnrlF6lI,20304
1
+ auto_code_fixer/__init__.py,sha256=J0I0c7-a50EOnWXMryTu_E6xhXSYFBPjVpeYP_a3vRI,22
2
+ auto_code_fixer/approval.py,sha256=NPSLu54maAK2RAJF_t4fq7Bs_M8K026DhDwqBFJtiwQ,2832
3
+ auto_code_fixer/cli.py,sha256=Dq-tVpLMU6wEnOw7ox0MaKNyig8j0q0fdTJGN1D3HyM,24087
3
4
  auto_code_fixer/command_runner.py,sha256=6P8hGRavN5C39x-e03p02Vc805NnZH9U7e48ngb5jJI,1104
4
5
  auto_code_fixer/fixer.py,sha256=zcgw56pRTuOLvna09lTXatD0VWwjjzBVk0OyEKfgxDM,4691
5
6
  auto_code_fixer/installer.py,sha256=LC0jasSsPI7eHMeDxa622OoMCR1951HAXUZWp-kcmVY,1522
@@ -12,9 +13,9 @@ auto_code_fixer/sandbox.py,sha256=FWQcCxNDI4i7ckTKHuARSSIHCopBRqG16MVtx9s75R8,16
12
13
  auto_code_fixer/traceback_utils.py,sha256=sbSuLO-2UBk5QPJZYJunTK9WGOpEY8mxR6WRKbtCIoM,935
13
14
  auto_code_fixer/utils.py,sha256=YXCv3PcDo5NBM1odksBTWkHTEELRtEXfPDIORA5iYaM,3090
14
15
  auto_code_fixer/venv_manager.py,sha256=2ww8reYgLbLohh-moAD5YKM09qv_mC5yYzJRwm3XiXc,1202
15
- auto_code_fixer-0.3.6.dist-info/licenses/LICENSE,sha256=hgchJNa26tjXuLztwSUDbYQxNLnAPnLk6kDXNIkC8xc,1066
16
- auto_code_fixer-0.3.6.dist-info/METADATA,sha256=R6geFjh2cpLvAzuR1KQRcAJyryQgClYYB1wWmD1o2Qk,3870
17
- auto_code_fixer-0.3.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
18
- auto_code_fixer-0.3.6.dist-info/entry_points.txt,sha256=a-j2rkfwkrhXZ5Qbz_6_gwk6Bj7nijYR1DALjWp5Myk,61
19
- auto_code_fixer-0.3.6.dist-info/top_level.txt,sha256=qUk1qznb6Qxqmxy2A3z_5dpOZlmNKHwUiLuJwH-CrAk,16
20
- auto_code_fixer-0.3.6.dist-info/RECORD,,
16
+ auto_code_fixer-0.3.7.dist-info/licenses/LICENSE,sha256=hgchJNa26tjXuLztwSUDbYQxNLnAPnLk6kDXNIkC8xc,1066
17
+ auto_code_fixer-0.3.7.dist-info/METADATA,sha256=NzIeRiTntfsDkboP3rnY1VphzjJ6VDKQRFZW3yn-juw,4735
18
+ auto_code_fixer-0.3.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
+ auto_code_fixer-0.3.7.dist-info/entry_points.txt,sha256=a-j2rkfwkrhXZ5Qbz_6_gwk6Bj7nijYR1DALjWp5Myk,61
20
+ auto_code_fixer-0.3.7.dist-info/top_level.txt,sha256=qUk1qznb6Qxqmxy2A3z_5dpOZlmNKHwUiLuJwH-CrAk,16
21
+ auto_code_fixer-0.3.7.dist-info/RECORD,,