auto-code-fixer 0.2.5__py3-none-any.whl → 0.3.0__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,2 +1 @@
1
- __version__ = "0.2.5"
2
-
1
+ __version__ = "0.3.0"
auto_code_fixer/cli.py CHANGED
@@ -1,8 +1,8 @@
1
1
  import argparse
2
+ import os
2
3
  import shutil
3
4
  import tempfile
4
5
  import time
5
- import os
6
6
  from decouple import config
7
7
 
8
8
  from auto_code_fixer.runner import run_code
@@ -14,32 +14,51 @@ from auto_code_fixer.utils import (
14
14
  log,
15
15
  set_verbose,
16
16
  )
17
- from auto_code_fixer.guard import import_hijacked
18
17
  from auto_code_fixer import __version__
19
18
 
20
- MAX_RETRIES = 5
19
+ DEFAULT_MAX_RETRIES = 8 # can be overridden via CLI
20
+
21
21
 
22
+ def fix_file(file_path, project_root, api_key, ask, verbose, *, dry_run: bool, model: str | None, timeout_s: int, max_retries: int, run_cmd: str | None) -> bool:
23
+ log(f"Processing entry file: {file_path}")
22
24
 
23
- def fix_file(file_path, project_root, api_key, ask, verbose):
24
- log(f"Processing file: {file_path}")
25
+ project_root = os.path.abspath(project_root)
26
+ file_path = os.path.abspath(file_path)
25
27
 
26
- with open(file_path) as f:
27
- current_code = f.read() # 🔑 SOURCE OF TRUTH
28
+ # Build sandbox with entry + imported local files
29
+ from auto_code_fixer.sandbox import make_sandbox
30
+ sandbox_root, sandbox_entry = make_sandbox(entry_file=file_path, project_root=project_root)
28
31
 
29
- temp_dir = tempfile.mkdtemp(prefix="codefix_")
30
- temp_file = os.path.join(temp_dir, "temp.py")
32
+ # Create isolated venv in sandbox
33
+ from auto_code_fixer.venv_manager import create_venv
34
+ from auto_code_fixer.patcher import backup_file
31
35
 
32
- # write initial code to temp
33
- with open(temp_file, "w") as f:
34
- f.write(current_code)
36
+ venv_python = create_venv(sandbox_root)
35
37
 
36
- for attempt in range(MAX_RETRIES):
38
+ changed_sandbox_files: set[str] = set()
39
+
40
+ for attempt in range(max_retries):
37
41
  log(f"Run attempt #{attempt + 1}")
38
42
 
39
- retcode, stdout, stderr = run_code(
40
- temp_file,
41
- project_root=project_root,
42
- )
43
+ # Ensure local modules resolve inside sandbox
44
+ if run_cmd:
45
+ from auto_code_fixer.command_runner import run_command
46
+
47
+ retcode, stdout, stderr = run_command(
48
+ run_cmd,
49
+ timeout_s=timeout_s,
50
+ python_exe=venv_python,
51
+ cwd=sandbox_root,
52
+ extra_env={"PYTHONPATH": sandbox_root},
53
+ )
54
+ else:
55
+ retcode, stdout, stderr = run_code(
56
+ sandbox_entry,
57
+ timeout_s=timeout_s,
58
+ python_exe=venv_python,
59
+ cwd=sandbox_root,
60
+ extra_env={"PYTHONPATH": sandbox_root},
61
+ )
43
62
 
44
63
  if verbose:
45
64
  if stdout:
@@ -50,90 +69,101 @@ def fix_file(file_path, project_root, api_key, ask, verbose):
50
69
  if retcode == 0:
51
70
  log("Script executed successfully ✅")
52
71
 
53
- if attempt > 0 and is_in_project(file_path, project_root):
72
+ # Apply sandbox changes back to project (only if we actually changed something)
73
+ if attempt > 0 and is_in_project(file_path, project_root) and changed_sandbox_files:
74
+ rel_changes = [os.path.relpath(p, sandbox_root) for p in sorted(changed_sandbox_files)]
75
+
54
76
  if ask:
55
77
  confirm = input(
56
- f"Overwrite original file '{file_path}' with fixed version? (y/n): "
78
+ "Overwrite original files with fixed versions?\n"
79
+ + "\n".join(f"- {c}" for c in rel_changes)
80
+ + "\n(y/n): "
57
81
  ).strip().lower()
82
+
58
83
  if confirm != "y":
59
84
  log("User declined overwrite", "WARN")
60
- shutil.rmtree(temp_dir)
61
- return
62
-
63
- # ✅ WRITE FROM MEMORY, NOT TEMP FILE
64
- with open(file_path, "w") as f:
65
- f.write(current_code)
66
-
67
- log(f"File updated: {file_path}")
68
-
69
- shutil.rmtree(temp_dir)
85
+ shutil.rmtree(sandbox_root)
86
+ return False
87
+
88
+ if dry_run:
89
+ log("DRY RUN: would apply fixes:\n" + "\n".join(rel_changes), "WARN")
90
+ else:
91
+ for p in sorted(changed_sandbox_files):
92
+ rel = os.path.relpath(p, sandbox_root)
93
+ dst = os.path.join(project_root, rel)
94
+ if os.path.exists(dst):
95
+ bak = backup_file(dst)
96
+ log(f"Backup created: {bak}", "DEBUG")
97
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
98
+ shutil.copy(p, dst)
99
+ log(f"File updated: {dst}")
100
+
101
+ shutil.rmtree(sandbox_root)
70
102
  log(f"Fix completed in {attempt + 1} attempt(s) 🎉")
71
- return
103
+ return True
72
104
 
73
105
  log("Error detected ❌", "ERROR")
74
106
  print(stderr)
75
107
 
76
- if check_and_install_missing_lib(stderr, project_root):
77
- log("Missing dependency installed, retrying…")
108
+ if check_and_install_missing_lib(stderr, python_exe=venv_python, project_root=sandbox_root):
109
+ log("Missing dependency installed (venv), retrying…")
78
110
  time.sleep(1)
79
111
  continue
80
112
 
81
- log("Sending code & error to GPT 🧠", "DEBUG")
82
-
83
- fixed_code = fix_code_with_gpt(
84
- current_code,
85
- stderr,
86
- api_key,
87
- )
88
-
89
- if import_hijacked(current_code, fixed_code):
90
- log(
91
- "GPT attempted to hijack local imports. Rejecting fix.",
92
- "ERROR",
93
- )
94
- break
95
-
96
- if fixed_code.strip() == current_code.strip():
97
- log("GPT returned no changes. Stopping.", "WARN")
98
- break
113
+ # Pick the most relevant local file from the traceback (entry or imported file)
114
+ from auto_code_fixer.traceback_utils import pick_relevant_file
99
115
 
100
- import ast
116
+ target_file = pick_relevant_file(stderr, sandbox_root=sandbox_root) or sandbox_entry
101
117
 
102
- # Validate GPT output BEFORE accepting it
118
+ # Optional AI fix plan can override file selection
103
119
  try:
104
- ast.parse(fixed_code)
105
- except SyntaxError:
106
- log(
107
- "GPT returned invalid or empty Python code. Rejecting fix.",
108
- "ERROR",
109
- )
110
- break
120
+ from auto_code_fixer.plan import ask_ai_for_fix_plan
111
121
 
112
- if not fixed_code.strip():
113
- log(
114
- "GPT returned empty code. Rejecting fix.",
115
- "ERROR",
122
+ plan = ask_ai_for_fix_plan(
123
+ sandbox_root=sandbox_root,
124
+ stderr=stderr,
125
+ api_key=api_key,
126
+ model=model,
116
127
  )
128
+ if plan and plan.target_files:
129
+ # Use the first suggested file that exists
130
+ for rel in plan.target_files:
131
+ cand = os.path.abspath(os.path.join(sandbox_root, rel))
132
+ if cand.startswith(os.path.abspath(sandbox_root)) and os.path.exists(cand):
133
+ target_file = cand
134
+ break
135
+ except Exception:
136
+ pass
137
+
138
+ log(f"Sending {os.path.relpath(target_file, sandbox_root)} + error to GPT 🧠", "DEBUG")
139
+
140
+ fixed_code = fix_code_with_gpt(
141
+ original_code=open(target_file, encoding="utf-8").read(),
142
+ error_log=stderr,
143
+ api_key=api_key,
144
+ model=model,
145
+ )
146
+
147
+ if fixed_code.strip() == open(target_file, encoding="utf-8").read().strip():
148
+ log("GPT returned no changes. Stopping.", "WARN")
117
149
  break
118
150
 
119
- # SAFE: update memory only after validation
120
- current_code = fixed_code
151
+ with open(target_file, "w", encoding="utf-8") as f:
152
+ f.write(fixed_code)
121
153
 
122
- # then update temp file for next execution
123
- with open(temp_file, "w") as f:
124
- f.write(current_code)
154
+ changed_sandbox_files.add(os.path.abspath(target_file))
125
155
 
126
156
  log("Code updated by GPT ✏️")
127
157
  time.sleep(1)
128
158
 
129
159
  log("Failed to auto-fix file after max retries ❌", "ERROR")
130
- shutil.rmtree(temp_dir)
131
-
160
+ shutil.rmtree(sandbox_root)
161
+ return False
132
162
 
133
163
 
134
164
  def main():
135
165
  parser = argparse.ArgumentParser(
136
- description="Auto-fix Python code using ChatGPT"
166
+ description="Auto-fix Python code using OpenAI (advanced sandbox + retry loop)"
137
167
  )
138
168
 
139
169
  parser.add_argument(
@@ -150,24 +180,53 @@ def main():
150
180
 
151
181
  parser.add_argument("--project-root", default=".")
152
182
  parser.add_argument("--api-key")
183
+ parser.add_argument("--model", default=None, help="OpenAI model (default: AUTO_CODE_FIXER_MODEL or gpt-4.1-mini)")
184
+ parser.add_argument("--timeout", type=int, default=30, help="Execution timeout seconds (default: 30)")
185
+ parser.add_argument("--dry-run", action="store_true", help="Do not overwrite files; just report what would change")
186
+ parser.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES, help="Max fix attempts (default: 8)")
187
+ parser.add_argument(
188
+ "--run",
189
+ default=None,
190
+ help=(
191
+ "Optional command to run instead of `python entry.py`. Examples: 'pytest -q', 'python -m module'. "
192
+ "If set, it runs inside the sandbox venv."
193
+ ),
194
+ )
195
+ parser.add_argument(
196
+ "--ai-plan",
197
+ action="store_true",
198
+ help="Optional: use AI to suggest which file to edit (AUTO_CODE_FIXER_AI_PLAN=1)",
199
+ )
153
200
 
201
+ # ✅ Proper boolean flags
154
202
  ask_group = parser.add_mutually_exclusive_group()
155
- ask_group.add_argument("--ask", action="store_true")
156
- ask_group.add_argument("--no-ask", action="store_true")
203
+ ask_group.add_argument(
204
+ "--ask",
205
+ action="store_true",
206
+ help="Ask before overwriting files",
207
+ )
208
+ ask_group.add_argument(
209
+ "--no-ask",
210
+ action="store_true",
211
+ help="Do not ask before overwriting files",
212
+ )
157
213
 
158
- parser.add_argument("--verbose", action="store_true")
214
+ parser.add_argument(
215
+ "--verbose",
216
+ action="store_true",
217
+ help="Enable verbose/debug output",
218
+ )
159
219
 
160
220
  args = parser.parse_args()
161
221
 
162
222
  if not args.entry_file:
163
223
  parser.error("the following arguments are required: entry_file")
164
224
 
165
- # 🔑 NORMALIZE ROOT PATH ONCE
166
- project_root = os.path.abspath(args.project_root)
167
-
225
+ # ENV defaults
168
226
  env_ask = config("AUTO_CODE_FIXER_ASK", default=False, cast=bool)
169
227
  env_verbose = config("AUTO_CODE_FIXER_VERBOSE", default=False, cast=bool)
170
228
 
229
+ # Final ask resolution (CLI overrides ENV)
171
230
  if args.ask:
172
231
  ask = True
173
232
  elif args.no_ask:
@@ -178,6 +237,25 @@ def main():
178
237
  verbose = args.verbose or env_verbose
179
238
  set_verbose(verbose)
180
239
 
181
- files = discover_all_files(args.entry_file)
182
- for file in files:
183
- fix_file(file, project_root, args.api_key, ask, verbose)
240
+ # Optional: enable AI planning helper
241
+ if args.ai_plan:
242
+ os.environ["AUTO_CODE_FIXER_AI_PLAN"] = "1"
243
+
244
+ ok = fix_file(
245
+ args.entry_file,
246
+ args.project_root,
247
+ args.api_key,
248
+ ask,
249
+ verbose,
250
+ dry_run=args.dry_run,
251
+ model=args.model,
252
+ timeout_s=args.timeout,
253
+ max_retries=args.max_retries,
254
+ run_cmd=args.run,
255
+ )
256
+
257
+ raise SystemExit(0 if ok else 2)
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
@@ -0,0 +1,45 @@
1
+ import os
2
+ import shlex
3
+ import subprocess
4
+
5
+
6
+ def run_command(
7
+ cmd: str,
8
+ *,
9
+ timeout_s: int,
10
+ python_exe: str,
11
+ cwd: str,
12
+ extra_env: dict | None = None,
13
+ ):
14
+ """Run an arbitrary command inside the sandbox.
15
+
16
+ If cmd starts with 'python', replace with the sandbox venv python.
17
+ If cmd starts with 'pytest', run as `python -m pytest ...`.
18
+ """
19
+
20
+ parts = shlex.split(cmd)
21
+ if not parts:
22
+ return -1, "", "Empty command"
23
+
24
+ if parts[0] == "python" or parts[0] == "python3":
25
+ parts[0] = python_exe
26
+
27
+ if parts[0] == "pytest":
28
+ parts = [python_exe, "-m", "pytest", *parts[1:]]
29
+
30
+ env = {**os.environ}
31
+ if extra_env:
32
+ env.update({k: str(v) for k, v in extra_env.items()})
33
+
34
+ try:
35
+ r = subprocess.run(
36
+ parts,
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=timeout_s,
40
+ cwd=cwd,
41
+ env=env,
42
+ )
43
+ return r.returncode, r.stdout, r.stderr
44
+ except subprocess.TimeoutExpired:
45
+ return -1, "", f"TimeoutExpired: Command took too long to run ({timeout_s}s)."
auto_code_fixer/fixer.py CHANGED
@@ -1,60 +1,79 @@
1
- from openai import OpenAI
1
+ import json
2
+ import os
2
3
  from decouple import config
4
+ from openai import OpenAI
3
5
 
4
- client = OpenAI(api_key=config("chat_gpt_secret_key"))
5
6
 
7
+ def get_openai_client(api_key: str | None = None) -> OpenAI:
8
+ key = api_key or os.getenv("OPENAI_API_KEY") or config("OPENAI_API_KEY", default=None)
9
+ if not key:
10
+ raise RuntimeError(
11
+ "OpenAI API key not found. Set OPENAI_API_KEY env var or .env file or pass --api-key"
12
+ )
13
+ return OpenAI(api_key=key)
6
14
 
7
- def fix_code_with_gpt(original_code: str, error_log: str, api_key: str | None):
8
- """
9
- Send code + error to GPT and return fixed code.
10
- Enforces strict rules to prevent semantic-breaking fixes.
15
+
16
+ def _strip_code_fences(text: str) -> str:
17
+ text = (text or "").strip()
18
+ if text.startswith("```"):
19
+ text = "\n".join(text.split("\n")[1:])
20
+ if text.endswith("```"):
21
+ text = "\n".join(text.split("\n")[:-1])
22
+ return text.strip()
23
+
24
+
25
+ def fix_code_with_gpt(
26
+ *,
27
+ original_code: str,
28
+ error_log: str,
29
+ api_key: str | None = None,
30
+ model: str | None = None,
31
+ ) -> str:
32
+ """Fix code using OpenAI. Returns full corrected code.
33
+
34
+ Model can be set via:
35
+ - --model
36
+ - AUTO_CODE_FIXER_MODEL env
37
+ - default fallback
11
38
  """
12
39
 
13
- system_prompt = """
14
- You are a senior Python engineer fixing code.
15
-
16
- STRICT RULES (DO NOT VIOLATE):
17
- - NEVER replace or wrap local imports with try/except.
18
- - NEVER inline, duplicate, or redefine code from other local files.
19
- - NEVER change import statements for local modules.
20
- - Assume all local imports are valid and must remain unchanged.
21
- - If an import fails, assume environment configuration is incorrect.
22
- - Only fix syntax errors or runtime errors in the current file.
23
- - Return ONLY valid Python code. No markdown. No explanation.
24
- """
25
-
26
- user_prompt = f"""
27
- The following Python code has an error.
28
-
29
- CODE:
30
- {original_code}
31
-
32
- ERROR:
33
- {error_log}
34
-
35
- Fix the code according to the rules.
36
- """
37
-
38
- try:
39
- response = client.chat.completions.create(
40
- model="gpt-5",
41
- messages=[
42
- {"role": "system", "content": system_prompt},
43
- {"role": "user", "content": user_prompt},
44
- ],
45
- max_completion_tokens=1500,
46
- )
40
+ client = get_openai_client(api_key)
41
+ model = model or os.getenv("AUTO_CODE_FIXER_MODEL") or "gpt-4.1-mini"
42
+
43
+ instructions = {
44
+ "task": "Fix Python code to run without errors.",
45
+ "requirements": [
46
+ "Preserve existing behavior unless required to fix the crash.",
47
+ "Do not remove functionality.",
48
+ "Return ONLY the full corrected Python source code (no markdown).",
49
+ ],
50
+ }
47
51
 
48
- fixed_code = response.choices[0].message.content.strip()
52
+ prompt = (
53
+ "You are a senior Python engineer. Fix the following Python code so it runs without errors.\n\n"
54
+ "INSTRUCTIONS (JSON):\n"
55
+ + json.dumps(instructions)
56
+ + "\n\nCODE:\n"
57
+ + original_code
58
+ + "\n\nERROR LOG:\n"
59
+ + error_log
60
+ )
49
61
 
50
- # strip accidental markdown fences
51
- if fixed_code.startswith("```"):
52
- fixed_code = "\n".join(fixed_code.splitlines()[1:])
53
- if fixed_code.endswith("```"):
54
- fixed_code = "\n".join(fixed_code.splitlines()[:-1])
62
+ # Use Responses API (openai>=1) for forward compatibility
63
+ resp = client.responses.create(
64
+ model=model,
65
+ input=[
66
+ {"role": "system", "content": "You fix broken Python code."},
67
+ {"role": "user", "content": prompt},
68
+ ],
69
+ temperature=0.2,
70
+ max_output_tokens=2000,
71
+ )
55
72
 
56
- return fixed_code.strip()
73
+ text = ""
74
+ for item in resp.output or []:
75
+ for c in item.content or []:
76
+ if getattr(c, "type", None) in ("output_text", "text"):
77
+ text += getattr(c, "text", "") or ""
57
78
 
58
- except Exception as e:
59
- print(f"Error calling OpenAI API: {e}")
60
- return original_code
79
+ return _strip_code_fences(text)
@@ -1,25 +1,47 @@
1
- import os
2
1
  import re
3
- import sys
4
2
  import subprocess
5
3
 
6
4
 
7
- def check_and_install_missing_lib(stderr, project_root):
8
- match = re.search(r"No module named '([^']+)'", stderr)
9
- if not match:
10
- return False
5
+ _MISSING_RE = re.compile(r"No module named '([^']+)'")
11
6
 
12
- missing = match.group(1)
13
7
 
14
- # 🔒 CRITICAL: skip local project modules
15
- local_file = os.path.join(project_root, f"{missing}.py")
16
- if os.path.exists(local_file):
17
- return False
8
+ def parse_missing_module(stderr: str) -> str | None:
9
+ m = _MISSING_RE.search(stderr or "")
10
+ if not m:
11
+ return None
12
+ return m.group(1)
13
+
18
14
 
15
+ def install_package(package: str, *, python_exe: str) -> bool:
19
16
  try:
20
- subprocess.check_call(
21
- [sys.executable, "-m", "pip", "install", missing]
22
- )
17
+ subprocess.check_call([python_exe, "-m", "pip", "install", package])
23
18
  return True
24
19
  except subprocess.CalledProcessError:
25
20
  return False
21
+
22
+
23
+ def is_local_module(module: str, *, project_root: str) -> bool:
24
+ """Return True if `module` can be resolved to a local file/package under project_root."""
25
+ import os
26
+ from pathlib import Path
27
+
28
+ base = Path(project_root)
29
+ parts = module.split(".")
30
+ candidates = [
31
+ base.joinpath(*parts).with_suffix(".py"),
32
+ base.joinpath(*parts, "__init__.py"),
33
+ ]
34
+ return any(os.path.isfile(str(p)) for p in candidates)
35
+
36
+
37
+ def check_and_install_missing_lib(stderr: str, *, python_exe: str, project_root: str) -> bool:
38
+ lib = parse_missing_module(stderr)
39
+ if not lib:
40
+ return False
41
+
42
+ # ✅ Important: do NOT pip install if it's a local module.
43
+ if is_local_module(lib, project_root=project_root):
44
+ return False
45
+
46
+ # naive mapping; user can always fix the requirement name manually.
47
+ return install_package(lib, python_exe=python_exe)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class RunResult:
8
+ returncode: int
9
+ stdout: str
10
+ stderr: str
11
+
12
+
13
+ @dataclass
14
+ class FixResult:
15
+ success: bool
16
+ attempts: int
17
+ changed_files: list[str]
18
+ last_error: str | None = None
19
+ sandbox_root: str | None = None
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,61 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+
5
+ from auto_code_fixer.fixer import get_openai_client
6
+
7
+
8
+ @dataclass
9
+ class FixPlan:
10
+ target_files: list[str]
11
+ reason: str
12
+
13
+
14
+ def ask_ai_for_fix_plan(*, sandbox_root: str, stderr: str, api_key: str | None, model: str | None) -> FixPlan | None:
15
+ """Optional: let AI propose which file to edit.
16
+
17
+ This is a best-effort helper; errors should be ignored by caller.
18
+ """
19
+
20
+ if os.getenv("AUTO_CODE_FIXER_AI_PLAN", "0").lower() not in {"1", "true", "yes"}:
21
+ return None
22
+
23
+ client = get_openai_client(api_key)
24
+ model = model or os.getenv("AUTO_CODE_FIXER_MODEL") or "gpt-4.1-mini"
25
+
26
+ prompt = (
27
+ "Given this Python traceback, decide which local file(s) should be edited to fix it.\n"
28
+ "Return ONLY JSON with keys: target_files (list of relative paths), reason (string).\n"
29
+ f"Sandbox root: {sandbox_root}\n\n"
30
+ "Traceback:\n" + (stderr or "")
31
+ )
32
+
33
+ resp = client.responses.create(
34
+ model=model,
35
+ input=[{"role": "user", "content": prompt}],
36
+ temperature=0.0,
37
+ max_output_tokens=600,
38
+ )
39
+
40
+ text = ""
41
+ for item in resp.output or []:
42
+ for c in item.content or []:
43
+ if getattr(c, "type", None) in ("output_text", "text"):
44
+ text += getattr(c, "text", "") or ""
45
+
46
+ text = (text or "").strip()
47
+ if not text:
48
+ return None
49
+
50
+ data = json.loads(text)
51
+ if not isinstance(data, dict):
52
+ return None
53
+
54
+ target_files = data.get("target_files") or []
55
+ if not isinstance(target_files, list):
56
+ return None
57
+
58
+ target_files = [str(x) for x in target_files if x]
59
+ reason = str(data.get("reason") or "")
60
+
61
+ return FixPlan(target_files=target_files, reason=reason)
auto_code_fixer/runner.py CHANGED
@@ -1,30 +1,38 @@
1
- import subprocess
2
1
  import os
2
+ import subprocess
3
3
  import sys
4
4
 
5
5
 
6
- def run_code(script_path, project_root):
7
- env = os.environ.copy()
8
-
9
- script_dir = os.path.dirname(script_path)
6
+ def run_code(
7
+ path_to_code: str,
8
+ *,
9
+ timeout_s: int = 30,
10
+ python_exe: str | None = None,
11
+ cwd: str | None = None,
12
+ extra_env: dict | None = None,
13
+ ):
14
+ """Run a python file and capture output.
10
15
 
11
- # 🔑 CRITICAL: include BOTH project root and script dir
12
- paths = [project_root, script_dir]
16
+ python_exe: allow running inside a venv interpreter.
17
+ cwd: working directory (important for relative imports/files)
18
+ extra_env: extra environment variables (merged)
19
+ """
13
20
 
14
- existing = env.get("PYTHONPATH", "")
15
- if existing:
16
- paths.append(existing)
17
-
18
- env["PYTHONPATH"] = os.pathsep.join(paths)
21
+ exe = python_exe or sys.executable
22
+ env = {**os.environ}
23
+ if extra_env:
24
+ env.update({k: str(v) for k, v in extra_env.items()})
19
25
 
20
26
  try:
21
27
  result = subprocess.run(
22
- [sys.executable, script_path],
28
+ [exe, path_to_code],
23
29
  capture_output=True,
24
30
  text=True,
25
- timeout=15,
31
+ timeout=timeout_s,
26
32
  env=env,
33
+ cwd=cwd,
27
34
  )
28
35
  return result.returncode, result.stdout, result.stderr
29
36
  except subprocess.TimeoutExpired:
30
37
  return -1, "", "TimeoutExpired: Code took too long to run."
38
+
@@ -0,0 +1,49 @@
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+
7
+ def make_sandbox(*, entry_file: str, project_root: str) -> tuple[str, str]:
8
+ """Create a sandbox directory containing entry_file and discovered local imports.
9
+
10
+ Returns (sandbox_root, sandbox_entry_path).
11
+
12
+ Files are copied preserving relative paths from project_root.
13
+ """
14
+
15
+ from auto_code_fixer.utils import discover_all_files
16
+
17
+ project_root = os.path.abspath(project_root)
18
+ entry_file = os.path.abspath(entry_file)
19
+
20
+ sandbox_root = tempfile.mkdtemp(prefix="codefix_sandbox_")
21
+
22
+ files = discover_all_files(entry_file)
23
+
24
+ for src in files:
25
+ src = os.path.abspath(src)
26
+ if not src.startswith(project_root):
27
+ # Skip anything outside the project
28
+ continue
29
+ rel = os.path.relpath(src, project_root)
30
+ dst = os.path.join(sandbox_root, rel)
31
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
32
+ shutil.copy(src, dst)
33
+
34
+ sandbox_entry = os.path.join(sandbox_root, os.path.relpath(entry_file, project_root))
35
+ return sandbox_root, sandbox_entry
36
+
37
+
38
+ def apply_sandbox_back(*, sandbox_root: str, project_root: str, changed_paths: list[str]) -> None:
39
+ """Copy changed sandbox files back into project_root."""
40
+
41
+ project_root = os.path.abspath(project_root)
42
+ for p in changed_paths:
43
+ p = os.path.abspath(p)
44
+ if not p.startswith(os.path.abspath(sandbox_root)):
45
+ continue
46
+ rel = os.path.relpath(p, sandbox_root)
47
+ dst = os.path.join(project_root, rel)
48
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
49
+ shutil.copy(p, dst)
@@ -0,0 +1,27 @@
1
+ import os
2
+ import re
3
+
4
+ _FILE_RE = re.compile(r"File \"([^\"]+)\", line (\d+)")
5
+
6
+
7
+ def pick_relevant_file(stderr: str, *, sandbox_root: str) -> str | None:
8
+ """Pick the most relevant python file from a traceback.
9
+
10
+ Strategy: return the last file path that lives inside sandbox_root.
11
+ """
12
+
13
+ if not stderr:
14
+ return None
15
+
16
+ sandbox_root = os.path.abspath(sandbox_root)
17
+ matches = _FILE_RE.findall(stderr)
18
+ if not matches:
19
+ return None
20
+
21
+ chosen = None
22
+ for path, _lineno in matches:
23
+ ap = os.path.abspath(path)
24
+ if ap.startswith(sandbox_root):
25
+ chosen = ap
26
+
27
+ return chosen
auto_code_fixer/utils.py CHANGED
@@ -1,6 +1,8 @@
1
+ import ast
1
2
  import os
2
3
  import re
3
4
  from datetime import datetime
5
+ from pathlib import Path
4
6
 
5
7
 
6
8
  VERBOSE = False
@@ -19,26 +21,73 @@ def log(message, level="INFO"):
19
21
  print(f"[{timestamp}] {level}: {message}")
20
22
 
21
23
 
22
- def find_imports(file_path, project_root):
23
- import_pattern = re.compile(r'^\s*(?:from|import)\s+([a-zA-Z0-9_\.]+)')
24
- found = set()
24
+ def _module_to_candidate_paths(project_root: str, module: str) -> list[str]:
25
+ """Resolve a module name to potential local file paths."""
26
+ base = Path(project_root)
27
+ parts = module.split(".")
28
+
29
+ # module.py
30
+ p1 = base.joinpath(*parts).with_suffix(".py")
31
+ # module/__init__.py
32
+ p2 = base.joinpath(*parts, "__init__.py")
33
+ return [str(p1), str(p2)]
34
+
35
+
36
+ def find_imports(file_path: str, project_root: str) -> list[str]:
37
+ """AST-based import discovery for local files.
38
+
39
+ This is more accurate than regex scanning and supports:
40
+ - import x
41
+ - import x.y
42
+ - from x import y
43
+ - from x.y import z
44
+
45
+ Only resolves modules that map to a file under project_root.
46
+ """
47
+
48
+ found: set[str] = set()
49
+
50
+ try:
51
+ src = Path(file_path).read_text(encoding="utf-8")
52
+ tree = ast.parse(src)
53
+ except Exception:
54
+ # Fallback to regex if parsing fails
55
+ import_pattern = re.compile(r"^\s*(?:from|import)\s+([a-zA-Z0-9_\.]+)")
56
+ for line in src.splitlines() if 'src' in locals() else Path(file_path).read_text(encoding='utf-8').splitlines():
57
+ m = import_pattern.match(line)
58
+ if not m:
59
+ continue
60
+ mod = m.group(1)
61
+ for c in _module_to_candidate_paths(project_root, mod):
62
+ if os.path.isfile(c):
63
+ found.add(os.path.abspath(c))
64
+ return sorted(found)
65
+
66
+ for node in ast.walk(tree):
67
+ if isinstance(node, ast.Import):
68
+ for alias in node.names:
69
+ mod = alias.name
70
+ for c in _module_to_candidate_paths(project_root, mod):
71
+ if os.path.isfile(c):
72
+ found.add(os.path.abspath(c))
73
+
74
+ if isinstance(node, ast.ImportFrom):
75
+ if not node.module:
76
+ continue
77
+ mod = node.module
78
+ for c in _module_to_candidate_paths(project_root, mod):
79
+ if os.path.isfile(c):
80
+ found.add(os.path.abspath(c))
81
+
82
+ return sorted(found)
83
+
84
+
85
+ def discover_all_files(entry_file: str) -> list[str]:
86
+ """Discover all local python files imported from entry_file."""
25
87
 
26
- with open(file_path) as f:
27
- for line in f:
28
- match = import_pattern.match(line)
29
- if match:
30
- module = match.group(1).split('.')[0]
31
- candidate = os.path.join(project_root, module + ".py")
32
- if os.path.isfile(candidate):
33
- found.add(os.path.abspath(candidate))
34
-
35
- return list(found)
36
-
37
-
38
- def discover_all_files(entry_file):
39
88
  project_root = os.path.dirname(os.path.abspath(entry_file))
40
89
  stack = [os.path.abspath(entry_file)]
41
- discovered = set()
90
+ discovered: set[str] = set()
42
91
 
43
92
  while stack:
44
93
  current = stack.pop()
@@ -50,8 +99,8 @@ def discover_all_files(entry_file):
50
99
  if f not in discovered:
51
100
  stack.append(f)
52
101
 
53
- return list(discovered)
102
+ return sorted(discovered)
54
103
 
55
104
 
56
- def is_in_project(file_path, project_root):
105
+ def is_in_project(file_path: str, project_root: str) -> bool:
57
106
  return os.path.abspath(file_path).startswith(os.path.abspath(project_root))
@@ -0,0 +1,33 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ def create_venv(dir_path: str) -> str:
8
+ """Create a venv and return path to its python executable."""
9
+ venv_dir = Path(dir_path) / ".venv"
10
+ if not venv_dir.exists():
11
+ subprocess.check_call([sys.executable, "-m", "venv", str(venv_dir)])
12
+
13
+ # Linux/mac
14
+ py = venv_dir / "bin" / "python"
15
+ if py.exists():
16
+ # ensure pip exists
17
+ subprocess.check_call([str(py), "-m", "pip", "install", "-U", "pip"], stdout=subprocess.DEVNULL)
18
+ return str(py)
19
+
20
+ # Windows fallback
21
+ py = venv_dir / "Scripts" / "python.exe"
22
+ if py.exists():
23
+ subprocess.check_call([str(py), "-m", "pip", "install", "-U", "pip"], stdout=subprocess.DEVNULL)
24
+ return str(py)
25
+
26
+ raise RuntimeError(f"Could not locate venv python in {venv_dir}")
27
+
28
+
29
+ def install_editable(project_root: str, *, python_exe: str) -> None:
30
+ """Install project (editable) into venv for proper imports."""
31
+ # Avoid user site-packages contamination
32
+ env = {**os.environ, "PIP_DISABLE_PIP_VERSION_CHECK": "1"}
33
+ subprocess.check_call([python_exe, "-m", "pip", "install", "-e", project_root], env=env, stdout=subprocess.DEVNULL)
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: auto-code-fixer
3
+ Version: 0.3.0
4
+ Summary: Automatically fix Python code using ChatGPT
5
+ Author-email: Arif Shah <ashah7775@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/auto-code-fixer/
8
+ Project-URL: Source, https://bitbucket.org/arif_automation/auto_code_fixer
9
+ Project-URL: Issues, https://bitbucket.org/arif_automation/auto_code_fixer/issues
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: openai>=1.0.0
19
+ Requires-Dist: python-decouple
20
+ Dynamic: license-file
21
+
22
+ # Auto Code Fixer
23
+
24
+ Auto Code Fixer is a CLI tool that **detects runtime failures** and automatically fixes Python code using OpenAI.
25
+
26
+ It is designed for real projects where an entry script imports multiple local modules. The tool runs your code in an **isolated sandbox + venv**, installs missing external dependencies, and applies fixes only after the code executes successfully.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install auto-code-fixer
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ auto-code-fixer path/to/main.py --project-root . --ask
42
+ ```
43
+
44
+ If you want it to overwrite without asking:
45
+
46
+ ```bash
47
+ auto-code-fixer path/to/main.py --project-root . --no-ask
48
+ ```
49
+
50
+ ---
51
+
52
+ ## What it fixes (core behavior)
53
+
54
+ ### ✅ Local/internal imports are treated as project code
55
+ If your entry file imports something like:
56
+
57
+ ```py
58
+ from mylib import add
59
+ ```
60
+
61
+ …and `mylib.py` exists inside the project, Auto Code Fixer will:
62
+ - copy `main.py` + `mylib.py` into a sandbox
63
+ - execute inside the sandbox
64
+ - if the traceback points to `mylib.py`, it will fix `mylib.py`
65
+ - then apply the fix back to your repo (with backups)
66
+
67
+ ### ✅ External imports are auto-installed
68
+ If execution fails with:
69
+
70
+ ```
71
+ ModuleNotFoundError: No module named 'requests'
72
+ ```
73
+
74
+ …it will run:
75
+
76
+ ```bash
77
+ pip install requests
78
+ ```
79
+
80
+ …but only inside the sandbox venv (so your system env isn’t polluted).
81
+
82
+ ---
83
+
84
+ ## Safety
85
+
86
+ ### Backups
87
+ Before overwriting any file, it creates a backup:
88
+ - `file.py.bak` (or `.bak1`, `.bak2`, ...)
89
+
90
+ ### Dry run
91
+ ```bash
92
+ auto-code-fixer path/to/main.py --project-root . --dry-run
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Advanced options
98
+
99
+ ### Run a custom command (pytest, etc.)
100
+ Instead of `python main.py`, run tests:
101
+
102
+ ```bash
103
+ auto-code-fixer . --project-root . --run "pytest -q" --no-ask
104
+ ```
105
+
106
+ ### Model selection
107
+ ```bash
108
+ export AUTO_CODE_FIXER_MODEL=gpt-4.1-mini
109
+ # or
110
+ auto-code-fixer main.py --model gpt-4.1-mini
111
+ ```
112
+
113
+ ### Max retries / timeout
114
+ ```bash
115
+ auto-code-fixer main.py --max-retries 8 --timeout 30
116
+ ```
117
+
118
+ ### Optional AI planning (which file to edit)
119
+ ```bash
120
+ auto-code-fixer main.py --ai-plan
121
+ ```
122
+ This enables a helper that can suggest which local file to edit. It is best-effort.
123
+
124
+ ---
125
+
126
+ ## Environment variables
127
+
128
+ - `OPENAI_API_KEY` (required unless you always pass `--api-key`)
129
+ - `AUTO_CODE_FIXER_MODEL` (default model)
130
+ - `AUTO_CODE_FIXER_ASK=true|false`
131
+ - `AUTO_CODE_FIXER_VERBOSE=true|false`
132
+
133
+ ---
134
+
135
+ ## Notes
136
+
137
+ - This tool edits code. Use it on a git repo so you can review diffs.
138
+ - For maximum safety, run with `--ask` and/or `--dry-run`.
@@ -0,0 +1,19 @@
1
+ auto_code_fixer/__init__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,22
2
+ auto_code_fixer/cli.py,sha256=tQeFO9ou5JVqM6twFYmG2WQGqI_6ohFRb2qarUpQY1A,8735
3
+ auto_code_fixer/command_runner.py,sha256=6P8hGRavN5C39x-e03p02Vc805NnZH9U7e48ngb5jJI,1104
4
+ auto_code_fixer/fixer.py,sha256=DlAOVbw9AImEKJy8llisVCtss0tXKHMnioYeFBQGXlE,2293
5
+ auto_code_fixer/installer.py,sha256=x5aE-tUGTNoDUCAjtKcv-kKXsExaO7w8L7WoM8bxz9k,1358
6
+ auto_code_fixer/models.py,sha256=JLBJutOoiOjjlT_RMPUPhWlmm1yc_nGcQqv5tY72Al0,317
7
+ auto_code_fixer/patcher.py,sha256=WDYrkl12Dm3fpWppxWRszDGyD0-Sty3ud6mIZhjAMBU,686
8
+ auto_code_fixer/plan.py,sha256=jrZdG-f1RDxVB0tBLlTwKbCSEiOYI_RMetdzfBcyE4s,1762
9
+ auto_code_fixer/runner.py,sha256=BvQm3CrwkQEDOw0tpiamSTcdu3OjbOgA801xW2zWdP8,970
10
+ auto_code_fixer/sandbox.py,sha256=FWQcCxNDI4i7ckTKHuARSSIHCopBRqG16MVtx9s75R8,1628
11
+ auto_code_fixer/traceback_utils.py,sha256=caxexcBuNj19P0RG-3MtkT4XQeMcFkkgxIzzpG6SGCU,633
12
+ auto_code_fixer/utils.py,sha256=YXCv3PcDo5NBM1odksBTWkHTEELRtEXfPDIORA5iYaM,3090
13
+ auto_code_fixer/venv_manager.py,sha256=2ww8reYgLbLohh-moAD5YKM09qv_mC5yYzJRwm3XiXc,1202
14
+ auto_code_fixer-0.3.0.dist-info/licenses/LICENSE,sha256=hgchJNa26tjXuLztwSUDbYQxNLnAPnLk6kDXNIkC8xc,1066
15
+ auto_code_fixer-0.3.0.dist-info/METADATA,sha256=SUMX84ekb--wwYeNTqL0vw--yyLLRXu5njKNBX189V4,3290
16
+ auto_code_fixer-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
+ auto_code_fixer-0.3.0.dist-info/entry_points.txt,sha256=a-j2rkfwkrhXZ5Qbz_6_gwk6Bj7nijYR1DALjWp5Myk,61
18
+ auto_code_fixer-0.3.0.dist-info/top_level.txt,sha256=qUk1qznb6Qxqmxy2A3z_5dpOZlmNKHwUiLuJwH-CrAk,16
19
+ auto_code_fixer-0.3.0.dist-info/RECORD,,
auto_code_fixer/guard.py DELETED
@@ -1,19 +0,0 @@
1
- def import_hijacked(original_code: str, fixed_code: str) -> bool:
2
- """
3
- Detects GPT cheating by replacing imports with try/except fallbacks
4
- or inlining local module logic.
5
- """
6
-
7
- if "from " not in original_code:
8
- return False
9
-
10
- # common cheating patterns
11
- bad_patterns = [
12
- "try:",
13
- "except ImportError",
14
- "except ModuleNotFoundError",
15
- "def greet(",
16
- "def add(",
17
- ]
18
-
19
- return any(p in fixed_code for p in bad_patterns)
@@ -1,51 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: auto-code-fixer
3
- Version: 0.2.5
4
- Summary: Automatically fix Python code using ChatGPT
5
- Author-email: Arif Shah <ashah7775@gmail.com>
6
- License: MIT
7
- Project-URL: Homepage, https://pypi.org/project/auto-code-fixer/
8
- Project-URL: Source, https://bitbucket.org/arif_automation/auto_code_fixer
9
- Project-URL: Issues, https://bitbucket.org/arif_automation/auto_code_fixer/issues
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.9
13
- Classifier: Programming Language :: Python :: 3.10
14
- Classifier: Programming Language :: Python :: 3.11
15
- Requires-Python: >=3.9
16
- Description-Content-Type: text/markdown
17
- License-File: LICENSE
18
- Requires-Dist: openai>=1.0.0
19
- Requires-Dist: python-decouple
20
- Dynamic: license-file
21
-
22
- # Auto Code Fixer 🛠️🤖
23
-
24
- Auto Code Fixer is a CLI tool that automatically detects runtime errors in Python code and fixes them using ChatGPT.
25
-
26
- It:
27
- - Recursively discovers imported local Python files
28
- - Runs code in an isolated temp environment
29
- - Auto-installs missing dependencies
30
- - Iteratively fixes errors using GPT
31
- - Safely updates files only after successful execution
32
-
33
- ---
34
-
35
- ## ✨ Features
36
-
37
- - 🔍 Recursive import discovery
38
- - 🧠 GPT-powered auto-fixing
39
- - 📦 Auto-install missing Python libraries
40
- - 🔁 Retry & fix loop
41
- - 🛠 CLI-based (works from anywhere)
42
- - 🔐 Safe overwrite handling
43
-
44
- ---
45
-
46
- ## 📦 Installation
47
-
48
- ### ✅ Recommended (Install from PyPI)
49
-
50
- ```bash
51
- pip install auto-code-fixer
@@ -1,13 +0,0 @@
1
- auto_code_fixer/__init__.py,sha256=S5Nlc-a2E4VkueiTguk-_xKzhUnSyTj5fGeoME1VKts,23
2
- auto_code_fixer/cli.py,sha256=cg86GiEuihOHnzqlP0ocEdmpgFtDigQ20pY7oazH-_c,5049
3
- auto_code_fixer/fixer.py,sha256=Lqb9IQiSvXIo168wv9cE3uyM3F_FVmu681PnnAmCFLI,1767
4
- auto_code_fixer/guard.py,sha256=Qfyqj2H1VeqBHFQxBkMy-L7Rz6ULXMV3uPAz7IMyTRs,489
5
- auto_code_fixer/installer.py,sha256=FYPD2r3zJ24MOPDlB_HWOTr2lTXKFLc73vMiQIChsCk,599
6
- auto_code_fixer/runner.py,sha256=EOQERj9lspNHpDd5QOvoB0XqbCRCed1vu1EAb6nJZsE,763
7
- auto_code_fixer/utils.py,sha256=ovWyNf3B7xMVq8qjylrPzNhrmLFaHKFF1G0vIp_GGSI,1425
8
- auto_code_fixer-0.2.5.dist-info/licenses/LICENSE,sha256=hgchJNa26tjXuLztwSUDbYQxNLnAPnLk6kDXNIkC8xc,1066
9
- auto_code_fixer-0.2.5.dist-info/METADATA,sha256=fJDgSRlKDTuBPzlNwpK3BIMwk1l7_uSwnTujsv3ex9c,1522
10
- auto_code_fixer-0.2.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
- auto_code_fixer-0.2.5.dist-info/entry_points.txt,sha256=a-j2rkfwkrhXZ5Qbz_6_gwk6Bj7nijYR1DALjWp5Myk,61
12
- auto_code_fixer-0.2.5.dist-info/top_level.txt,sha256=qUk1qznb6Qxqmxy2A3z_5dpOZlmNKHwUiLuJwH-CrAk,16
13
- auto_code_fixer-0.2.5.dist-info/RECORD,,