auto-code-fixer 0.3.5__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.5 → auto_code_fixer-0.3.6}/PKG-INFO +1 -1
- auto_code_fixer-0.3.6/auto_code_fixer/__init__.py +1 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/cli.py +124 -9
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/fixer.py +14 -1
- auto_code_fixer-0.3.6/auto_code_fixer/patcher.py +67 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/PKG-INFO +1 -1
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/SOURCES.txt +1 -0
- {auto_code_fixer-0.3.5 → 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.5/auto_code_fixer/__init__.py +0 -1
- auto_code_fixer-0.3.5/auto_code_fixer/patcher.py +0 -31
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/LICENSE +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/README.md +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/command_runner.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/installer.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/models.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/patch_protocol.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/plan.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/runner.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/sandbox.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/traceback_utils.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/utils.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer/venv_manager.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/dependency_links.txt +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/entry_points.txt +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/requires.txt +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/auto_code_fixer.egg-info/top_level.txt +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/setup.cfg +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/tests/test_fix_imported_file.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/tests/test_internal_imports.py +0 -0
- {auto_code_fixer-0.3.5 → auto_code_fixer-0.3.6}/tests/test_patch_protocol.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.6"
|
|
@@ -32,6 +32,9 @@ def fix_file(
|
|
|
32
32
|
max_retries: int,
|
|
33
33
|
run_cmd: str | None,
|
|
34
34
|
patch_protocol: bool,
|
|
35
|
+
max_files_changed: int,
|
|
36
|
+
context_files: int,
|
|
37
|
+
approve: bool,
|
|
35
38
|
fmt: str | None,
|
|
36
39
|
lint: str | None,
|
|
37
40
|
lint_fix: bool,
|
|
@@ -247,33 +250,99 @@ def fix_file(
|
|
|
247
250
|
|
|
248
251
|
if patch_protocol:
|
|
249
252
|
try:
|
|
253
|
+
import difflib
|
|
254
|
+
|
|
250
255
|
from auto_code_fixer.fixer import fix_code_with_gpt_patch_protocol
|
|
251
256
|
from auto_code_fixer.patch_protocol import (
|
|
252
257
|
parse_patch_protocol_response,
|
|
253
258
|
validate_and_resolve_patch_files,
|
|
254
259
|
)
|
|
255
|
-
from auto_code_fixer.patcher import safe_read,
|
|
260
|
+
from auto_code_fixer.patcher import safe_read, atomic_write_verified_sha256
|
|
261
|
+
from auto_code_fixer.utils import find_imports
|
|
256
262
|
|
|
257
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
|
+
|
|
258
284
|
raw = fix_code_with_gpt_patch_protocol(
|
|
259
285
|
sandbox_root=sandbox_root,
|
|
260
286
|
error_log=stderr,
|
|
261
287
|
api_key=api_key,
|
|
262
288
|
model=model,
|
|
263
289
|
hint_paths=hint_paths,
|
|
290
|
+
context_files=ctx_pairs,
|
|
264
291
|
)
|
|
265
292
|
|
|
266
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
|
+
|
|
267
300
|
resolved = validate_and_resolve_patch_files(patch_files, sandbox_root=sandbox_root)
|
|
268
301
|
|
|
269
|
-
#
|
|
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)
|
|
270
306
|
for abs_path, new_content in resolved:
|
|
271
307
|
old = ""
|
|
272
308
|
if os.path.exists(abs_path):
|
|
273
309
|
old = safe_read(abs_path)
|
|
274
310
|
if new_content.strip() == (old or "").strip():
|
|
275
311
|
continue
|
|
276
|
-
|
|
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)
|
|
277
346
|
changed_sandbox_files.add(os.path.abspath(abs_path))
|
|
278
347
|
applied_any = True
|
|
279
348
|
|
|
@@ -292,9 +361,29 @@ def fix_file(
|
|
|
292
361
|
log("GPT returned no changes. Stopping.", "WARN")
|
|
293
362
|
break
|
|
294
363
|
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
297
385
|
|
|
386
|
+
safe_write(target_file, fixed_code)
|
|
298
387
|
changed_sandbox_files.add(os.path.abspath(target_file))
|
|
299
388
|
applied_any = True
|
|
300
389
|
|
|
@@ -346,14 +435,37 @@ def main():
|
|
|
346
435
|
)
|
|
347
436
|
|
|
348
437
|
parser.add_argument(
|
|
349
|
-
"--
|
|
438
|
+
"--legacy-mode",
|
|
350
439
|
action="store_true",
|
|
351
440
|
help=(
|
|
352
|
-
"
|
|
353
|
-
"(
|
|
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.)"
|
|
354
443
|
),
|
|
355
444
|
)
|
|
356
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
|
+
|
|
357
469
|
parser.add_argument(
|
|
358
470
|
"--format",
|
|
359
471
|
default=None,
|
|
@@ -426,7 +538,10 @@ def main():
|
|
|
426
538
|
timeout_s=args.timeout,
|
|
427
539
|
max_retries=args.max_retries,
|
|
428
540
|
run_cmd=args.run,
|
|
429
|
-
patch_protocol=args.
|
|
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,
|
|
430
545
|
fmt=args.format,
|
|
431
546
|
lint=args.lint,
|
|
432
547
|
lint_fix=args.fix,
|
|
@@ -86,12 +86,13 @@ def fix_code_with_gpt_patch_protocol(
|
|
|
86
86
|
api_key: str | None = None,
|
|
87
87
|
model: str | None = None,
|
|
88
88
|
hint_paths: list[str] | None = None,
|
|
89
|
+
context_files: list[tuple[str, str]] | None = None,
|
|
89
90
|
) -> str:
|
|
90
91
|
"""Ask the model for structured edits (patch protocol).
|
|
91
92
|
|
|
92
93
|
Returns raw model text (expected JSON). Parsing/validation happens elsewhere.
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
context_files: optional list of (relative_path, content) to include as read-only context.
|
|
95
96
|
"""
|
|
96
97
|
|
|
97
98
|
client = get_openai_client(api_key)
|
|
@@ -113,11 +114,23 @@ def fix_code_with_gpt_patch_protocol(
|
|
|
113
114
|
f"- {p}" for p in hint_paths
|
|
114
115
|
)
|
|
115
116
|
|
|
117
|
+
ctx = ""
|
|
118
|
+
if context_files:
|
|
119
|
+
# Keep it readable and avoid enormous prompts.
|
|
120
|
+
blocks: list[str] = []
|
|
121
|
+
for rel, content in context_files:
|
|
122
|
+
snippet = content
|
|
123
|
+
if len(snippet) > 4000:
|
|
124
|
+
snippet = snippet[:4000] + "\n... (truncated)"
|
|
125
|
+
blocks.append(f"--- FILE: {rel} ---\n{snippet}\n")
|
|
126
|
+
ctx = "\n\nREAD-ONLY CONTEXT FILES:\n" + "\n".join(blocks)
|
|
127
|
+
|
|
116
128
|
prompt = (
|
|
117
129
|
"You are a senior Python engineer. Fix the project so it runs without errors.\n"
|
|
118
130
|
"Return ONLY valid JSON that matches this schema (no markdown, no commentary):\n"
|
|
119
131
|
+ json.dumps(schema)
|
|
120
132
|
+ hints
|
|
133
|
+
+ ctx
|
|
121
134
|
+ "\n\nSANDBOX ROOT:\n"
|
|
122
135
|
+ sandbox_root
|
|
123
136
|
+ "\n\nERROR LOG:\n"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from auto_code_fixer.patch_protocol import compute_sha256_utf8
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class FileEdit:
|
|
10
|
+
path: str
|
|
11
|
+
new_content: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _atomic_write(path: str, content: str) -> None:
|
|
15
|
+
"""Atomically write text content to path.
|
|
16
|
+
|
|
17
|
+
Writes to a temp file in the same directory then os.replace() to avoid partial writes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
dir_name = os.path.dirname(path) or "."
|
|
21
|
+
os.makedirs(dir_name, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
fd, tmp_path = tempfile.mkstemp(prefix=".acf_", suffix=".tmp", dir=dir_name)
|
|
24
|
+
try:
|
|
25
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
26
|
+
f.write(content)
|
|
27
|
+
f.flush()
|
|
28
|
+
os.fsync(f.fileno())
|
|
29
|
+
os.replace(tmp_path, path)
|
|
30
|
+
finally:
|
|
31
|
+
try:
|
|
32
|
+
if os.path.exists(tmp_path):
|
|
33
|
+
os.unlink(tmp_path)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atomic_write_verified_sha256(path: str, content: str, expected_sha256: str) -> None:
|
|
39
|
+
"""Atomically write text content, verifying sha256(content) before commit."""
|
|
40
|
+
|
|
41
|
+
got = compute_sha256_utf8(content)
|
|
42
|
+
if got != (expected_sha256 or "").strip().lower():
|
|
43
|
+
raise ValueError(f"sha256 mismatch for {path}: expected {expected_sha256}, got {got}")
|
|
44
|
+
|
|
45
|
+
_atomic_write(path, content)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def safe_write(path: str, content: str) -> None:
|
|
49
|
+
# Backwards-compatible name; now atomic.
|
|
50
|
+
_atomic_write(path, content)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def safe_read(path: str) -> str:
|
|
54
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
55
|
+
return f.read()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def backup_file(path: str) -> str:
|
|
59
|
+
bak = path + ".bak"
|
|
60
|
+
# Avoid overwriting an existing bak
|
|
61
|
+
i = 1
|
|
62
|
+
while os.path.exists(bak):
|
|
63
|
+
bak = f"{path}.bak{i}"
|
|
64
|
+
i += 1
|
|
65
|
+
with open(path, "rb") as src, open(bak, "wb") as dst:
|
|
66
|
+
dst.write(src.read())
|
|
67
|
+
return bak
|
|
@@ -21,6 +21,7 @@ auto_code_fixer.egg-info/dependency_links.txt
|
|
|
21
21
|
auto_code_fixer.egg-info/entry_points.txt
|
|
22
22
|
auto_code_fixer.egg-info/requires.txt
|
|
23
23
|
auto_code_fixer.egg-info/top_level.txt
|
|
24
|
+
tests/test_atomic_write.py
|
|
24
25
|
tests/test_fix_imported_file.py
|
|
25
26
|
tests/test_internal_imports.py
|
|
26
27
|
tests/test_patch_protocol.py
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from auto_code_fixer.patcher import atomic_write_verified_sha256, safe_read, safe_write
|
|
7
|
+
from auto_code_fixer.patch_protocol import compute_sha256_utf8
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_safe_write_is_atomic_and_writes_content(tmp_path: Path):
|
|
11
|
+
p = tmp_path / "a.txt"
|
|
12
|
+
safe_write(str(p), "hello")
|
|
13
|
+
assert safe_read(str(p)) == "hello"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_atomic_write_verified_sha256_rejects_mismatch_and_does_not_modify(tmp_path: Path):
|
|
17
|
+
p = tmp_path / "a.txt"
|
|
18
|
+
p.write_text("old", encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
with pytest.raises(ValueError, match="sha256 mismatch"):
|
|
21
|
+
atomic_write_verified_sha256(str(p), "new", expected_sha256="0" * 64)
|
|
22
|
+
|
|
23
|
+
assert p.read_text(encoding="utf-8") == "old"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_atomic_write_verified_sha256_accepts_and_modifies(tmp_path: Path):
|
|
27
|
+
p = tmp_path / "a.txt"
|
|
28
|
+
p.write_text("old", encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
content = "new content\n"
|
|
31
|
+
sha = compute_sha256_utf8(content)
|
|
32
|
+
atomic_write_verified_sha256(str(p), content, expected_sha256=sha)
|
|
33
|
+
|
|
34
|
+
assert p.read_text(encoding="utf-8") == content
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.5"
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
@dataclass
|
|
6
|
-
class FileEdit:
|
|
7
|
-
path: str
|
|
8
|
-
new_content: str
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def safe_write(path: str, content: str) -> None:
|
|
12
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
13
|
-
with open(path, "w", encoding="utf-8") as f:
|
|
14
|
-
f.write(content)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def safe_read(path: str) -> str:
|
|
18
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
19
|
-
return f.read()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def backup_file(path: str) -> str:
|
|
23
|
-
bak = path + ".bak"
|
|
24
|
-
# Avoid overwriting an existing bak
|
|
25
|
-
i = 1
|
|
26
|
-
while os.path.exists(bak):
|
|
27
|
-
bak = f"{path}.bak{i}"
|
|
28
|
-
i += 1
|
|
29
|
-
with open(path, "rb") as src, open(bak, "wb") as dst:
|
|
30
|
-
dst.write(src.read())
|
|
31
|
-
return bak
|
|
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.5 → auto_code_fixer-0.3.6}/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
|