envfix 0.4.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.
envfix/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """envfix — Automatic Python/ML environment error fixer."""
2
+
3
+ __version__ = "0.4.0"
envfix/ai.py ADDED
@@ -0,0 +1,261 @@
1
+ """ai.py — Calls local or cloud AI models and parses their responses."""
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Optional
7
+
8
+ if TYPE_CHECKING:
9
+ from envfix.context import CodeContext
10
+
11
+
12
+ try:
13
+ import ollama
14
+ except ImportError: # pragma: no cover
15
+ ollama = None # type: ignore[assignment]
16
+
17
+ try:
18
+ import groq
19
+ except ImportError:
20
+ groq = None
21
+
22
+ try:
23
+ from google import genai
24
+ except ImportError:
25
+ genai = None
26
+
27
+
28
+ PROMPT_TEMPLATE = (
29
+ "You are diagnosing a development environment error on a Windows machine. "
30
+ "The user has specified this error is related to the '{category}' ecosystem "
31
+ "(it could be Python, Node.js, package managers, build tools, permissions, or general shell errors).\n"
32
+ "Here is the error output:\n{stderr}\n\n"
33
+ "Give a short diagnosis (1-2 sentences) of the root cause, "
34
+ "then give exactly ONE shell command that would likely fix it. "
35
+ "Respond in this exact format:\n"
36
+ "DIAGNOSIS: <text>\n"
37
+ "FIX: <command>\n\n"
38
+ "IMPORTANT rules for the FIX command:\n"
39
+ "- If it's a Python pip error, use 'python -m pip install ...' instead of 'pip install ...'\n"
40
+ "- NO backticks, NO markdown formatting, NO surrounding quotes around the command\n"
41
+ "- Give a single runnable shell command only\n"
42
+ "Example correct format:\n"
43
+ "DIAGNOSIS: The torch package is not installed.\n"
44
+ "FIX: python -m pip install torch"
45
+ )
46
+
47
+ # Used when a code snippet from the failing file is available.
48
+ # Gives the model much richer context than the raw error text alone.
49
+ PROMPT_TEMPLATE_WITH_CONTEXT = (
50
+ "You are diagnosing a development environment error on a Windows machine. "
51
+ "The user has specified this error is related to the '{category}' ecosystem.\n\n"
52
+ "Here is the error output:\n{stderr}\n\n"
53
+ "Here is the relevant code from {filepath}, lines {start}-{end}:\n"
54
+ "```\n{snippet}\n```\n\n"
55
+ "Using both the error and the code above, give a precise diagnosis (1-2 sentences) "
56
+ "that references the actual code where possible, "
57
+ "then give exactly ONE shell command that would likely fix it. "
58
+ "Respond in this exact format:\n"
59
+ "DIAGNOSIS: <text>\n"
60
+ "FIX: <command>\n\n"
61
+ "IMPORTANT rules for the FIX command:\n"
62
+ "- If it's a Python pip error, use 'python -m pip install ...' instead of 'pip install ...'\n"
63
+ "- NO backticks, NO markdown formatting, NO surrounding quotes around the command\n"
64
+ "- Give a single runnable shell command only\n"
65
+ "Example correct format:\n"
66
+ "DIAGNOSIS: Line 42 calls `train()` before the model is loaded.\n"
67
+ "FIX: python -m pip install torch"
68
+ )
69
+
70
+ DEFAULT_MODEL = "llama3.1:8b"
71
+
72
+
73
+ def get_actual_model(model: str, provider: str) -> str:
74
+ """Resolve the actual model string if the user left it as the Ollama default."""
75
+ if model != DEFAULT_MODEL:
76
+ return model
77
+
78
+ if provider == "groq":
79
+ return "llama-3.3-70b-versatile"
80
+ elif provider == "gemini":
81
+ return "gemini-3.5-flash"
82
+
83
+ return DEFAULT_MODEL
84
+
85
+
86
+ @dataclass
87
+ class DiagnosisResult:
88
+ """Structured result from the AI model."""
89
+
90
+ diagnosis: str
91
+ fix: str
92
+ raw_response: str
93
+ parsed_ok: bool
94
+
95
+
96
+ def get_diagnosis(
97
+ stderr: str,
98
+ model: str = DEFAULT_MODEL,
99
+ category: str = "general",
100
+ code_context: "Optional[CodeContext]" = None,
101
+ provider: str = "ollama",
102
+ ) -> DiagnosisResult:
103
+ """
104
+ Send stderr (and optional code context) to the chosen AI provider.
105
+
106
+ Args:
107
+ stderr: The captured error text from the failed command.
108
+ model: Model tag to use (default: llama3.1:8b for ollama).
109
+ category: The ecosystem category.
110
+ code_context: Optional in-project code snippet extracted from the traceback.
111
+ provider: Which AI service to use ("ollama", "groq", or "gemini").
112
+
113
+ Returns:
114
+ A DiagnosisResult with diagnosis, fix, raw_response, and parsed_ok.
115
+ """
116
+ if code_context is not None:
117
+ prompt = PROMPT_TEMPLATE_WITH_CONTEXT.format(
118
+ stderr=stderr,
119
+ category=category,
120
+ filepath=code_context.filepath,
121
+ start=code_context.start_line,
122
+ end=code_context.end_line,
123
+ snippet=code_context.snippet,
124
+ )
125
+ else:
126
+ prompt = PROMPT_TEMPLATE.format(stderr=stderr, category=category)
127
+
128
+ if provider == "ollama":
129
+ if ollama is None:
130
+ raise RuntimeError(
131
+ "The 'ollama' Python package is not installed. "
132
+ "Run: pip install ollama"
133
+ )
134
+ try:
135
+ response = ollama.chat(
136
+ model=model,
137
+ messages=[{"role": "user", "content": prompt}],
138
+ )
139
+ raw = response["message"]["content"].strip()
140
+ except Exception as exc:
141
+ raise RuntimeError(
142
+ f"Ollama request failed: {exc}\n"
143
+ "Make sure the Ollama service is running (`ollama serve`) "
144
+ f"and the model '{model}' is pulled (`ollama pull {model}`)."
145
+ ) from exc
146
+
147
+ elif provider == "groq":
148
+ if groq is None:
149
+ raise RuntimeError("The 'groq' Python package is not installed.")
150
+ api_key = os.getenv("GROQ_API_KEY")
151
+ if not api_key:
152
+ raise RuntimeError(
153
+ "GROQ_API_KEY environment variable is not set. "
154
+ "Please set it to your Groq API key."
155
+ )
156
+ try:
157
+ client = groq.Groq(api_key=api_key)
158
+ actual_model = get_actual_model(model, provider)
159
+ chat_completion = client.chat.completions.create(
160
+ messages=[{"role": "user", "content": prompt}],
161
+ model=actual_model,
162
+ )
163
+ raw = chat_completion.choices[0].message.content.strip()
164
+ except Exception as exc:
165
+ raise RuntimeError(f"Groq request failed: {exc}") from exc
166
+
167
+ elif provider == "gemini":
168
+ if genai is None:
169
+ raise RuntimeError("The 'google-genai' Python package is not installed.")
170
+ api_key = os.getenv("GEMINI_API_KEY")
171
+ if not api_key:
172
+ raise RuntimeError(
173
+ "GEMINI_API_KEY environment variable is not set. "
174
+ "Please set it to your Gemini API key."
175
+ )
176
+ try:
177
+ client = genai.Client(api_key=api_key)
178
+ actual_model = get_actual_model(model, provider)
179
+ response = client.models.generate_content(
180
+ model=actual_model,
181
+ contents=prompt
182
+ )
183
+ raw = response.text.strip()
184
+ except Exception as exc:
185
+ raise RuntimeError(f"Gemini API call failed: {exc}") from exc
186
+
187
+ else:
188
+ raise ValueError(f"Unknown provider: {provider}")
189
+
190
+ return _parse_response(raw)
191
+
192
+
193
+ def _parse_response(raw: str) -> DiagnosisResult:
194
+ """
195
+ Extract DIAGNOSIS and FIX from the model's raw text.
196
+
197
+ Tries a strict regex first, then falls back to a lenient line scan.
198
+ If neither works, marks parsed_ok=False and stuffs the raw text into
199
+ both fields so the UI can still display something useful.
200
+ """
201
+ # --- Strict parse: both keys on their own lines (case-insensitive) ---
202
+ strict = re.search(
203
+ r"DIAGNOSIS\s*:\s*(.+?)\s*FIX\s*:\s*(.+)",
204
+ raw,
205
+ re.IGNORECASE | re.DOTALL,
206
+ )
207
+ if strict:
208
+ diagnosis = strict.group(1).strip()
209
+ fix = _clean_fix(strict.group(2).strip().splitlines()[0].strip())
210
+ return DiagnosisResult(
211
+ diagnosis=diagnosis, fix=fix, raw_response=raw, parsed_ok=True
212
+ )
213
+
214
+ # --- Lenient parse: scan line-by-line ---
215
+ diagnosis: Optional[str] = None
216
+ fix: Optional[str] = None
217
+ for line in raw.splitlines():
218
+ stripped = line.strip()
219
+ if diagnosis is None and re.match(r"DIAGNOSIS\s*:", stripped, re.IGNORECASE):
220
+ diagnosis = re.sub(r"^DIAGNOSIS\s*:\s*", "", stripped, flags=re.IGNORECASE)
221
+ elif fix is None and re.match(r"FIX\s*:", stripped, re.IGNORECASE):
222
+ fix = re.sub(r"^FIX\s*:\s*", "", stripped, flags=re.IGNORECASE)
223
+
224
+ if diagnosis and fix:
225
+ return DiagnosisResult(
226
+ diagnosis=diagnosis, fix=_clean_fix(fix), raw_response=raw, parsed_ok=True
227
+ )
228
+
229
+ # --- Fallback: show raw output, don't crash ---
230
+ return DiagnosisResult(
231
+ diagnosis=raw,
232
+ fix="(could not parse a fix command — see diagnosis above)",
233
+ raw_response=raw,
234
+ parsed_ok=False,
235
+ )
236
+
237
+
238
+ def _clean_fix(fix: str) -> str:
239
+ """
240
+ Strip markdown/shell formatting artefacts from the fix command and
241
+ normalise Windows-incompatible patterns.
242
+
243
+ LLMs often wrap commands in backticks (`` `cmd` ``) or fenced code blocks.
244
+ Running `` `pip install torch` `` on Windows will fail because the backtick
245
+ is not a valid shell character there.
246
+
247
+ Also replaces bare 'pip install' with 'python -m pip install' because pip
248
+ is often not on the Windows PATH even when python is.
249
+ """
250
+ # Strip surrounding backticks: `cmd` → cmd or ```cmd``` → cmd
251
+ fix = fix.strip()
252
+ fix = re.sub(r'^`+|`+$', '', fix).strip()
253
+ # Strip surrounding single or double quotes added by the model
254
+ if (fix.startswith('"') and fix.endswith('"')) or \
255
+ (fix.startswith("'") and fix.endswith("'")):
256
+ fix = fix[1:-1].strip()
257
+ # Strip markdown emphasis like *cmd* or **cmd**
258
+ fix = re.sub(r'^\*+|\*+$', '', fix).strip()
259
+ # Replace bare 'pip' with 'python -m pip' so it works when pip is not on PATH
260
+ fix = re.sub(r'^pip\b', 'python -m pip', fix)
261
+ return fix
envfix/cache.py ADDED
@@ -0,0 +1,134 @@
1
+ """cache.py — Known-fix cache: fuzzy-match current errors against past verified fixes."""
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from difflib import SequenceMatcher
7
+ from typing import Optional
8
+
9
+ from envfix.logger import get_log_file
10
+
11
+ # Minimum similarity ratio (0–1) to consider a log entry a match.
12
+ # 0.92 is deliberately conservative: we only surface cache hits when
13
+ # the error text is nearly identical (same module name, same error type).
14
+ # Too low and structurally-similar errors (e.g. two different ModuleNotFoundErrors)
15
+ # get matched to each other even when the missing package differs.
16
+ SIMILARITY_THRESHOLD = 0.92
17
+
18
+
19
+ @dataclass
20
+ class CacheHit:
21
+ """A previously verified fix that closely matches the current error."""
22
+
23
+ fix: str
24
+ diagnosis: str
25
+ score: float # 0.0 – 1.0 similarity ratio
26
+ original_command: str # the command that originally triggered the error
27
+ previously_worked: bool # True if fix_worked=True in the log entry
28
+ category: str # The ecosystem category of the error
29
+
30
+
31
+ def find_cached_fix(
32
+ error_text: str,
33
+ log_file: str = "", # empty string → resolved at call-time
34
+ threshold: float = SIMILARITY_THRESHOLD,
35
+ category: str = "general",
36
+ ) -> Optional[CacheHit]:
37
+ """
38
+ Search the user's log for a previously attempted fix for a similar error.
39
+
40
+ log_file defaults to the current user's personal log file if not supplied.
41
+ Passing an explicit path is still supported (used in tests).
42
+
43
+ Two tiers:
44
+ - Prefers entries where the fix is CONFIRMED to have worked (fix_worked=True).
45
+ - Falls back to entries where the user APPROVED the fix (user_approved=True)
46
+ even if fix_worked=False/None — this surfaces "we've already tried this"
47
+ so Ollama is not called again for the same problem.
48
+
49
+ Uses difflib.SequenceMatcher for lightweight fuzzy string similarity.
50
+ Supports both Phase 1 (worked/fix/stderr) and Phase 2 schemas transparently.
51
+
52
+ Args:
53
+ error_text: The current stderr text to match against.
54
+ log_file: Path to the JSON log file (defaults to cwd).
55
+ threshold: Minimum SequenceMatcher ratio to accept as a hit.
56
+ category: The ecosystem category. Only matches logs with the same category.
57
+
58
+ Returns:
59
+ A CacheHit with the best-matching fix, or None if no good match found.
60
+ """
61
+ if not log_file:
62
+ log_file = get_log_file()
63
+
64
+ if not os.path.exists(log_file):
65
+ return None
66
+
67
+ try:
68
+ with open(log_file, "r", encoding="utf-8") as f:
69
+ log_data = json.load(f)
70
+ except (json.JSONDecodeError, OSError):
71
+ return None
72
+
73
+ if not isinstance(log_data, list) or not log_data:
74
+ return None
75
+
76
+ # We keep two best candidates: one that worked, one that was merely approved.
77
+ best_worked: Optional[tuple[float, CacheHit]] = None
78
+ best_approved: Optional[tuple[float, CacheHit]] = None
79
+
80
+ for entry in log_data:
81
+ # ── Support both Phase 1 and Phase 2 schemas ─────────────────────
82
+ fix_worked = (
83
+ entry.get("fix_worked")
84
+ if "fix_worked" in entry
85
+ else entry.get("worked")
86
+ )
87
+ user_approved = (
88
+ entry.get("user_approved")
89
+ if "user_approved" in entry
90
+ else entry.get("approved", False)
91
+ )
92
+
93
+ # Only use entries the user actually approved (ignore "n" declines)
94
+ if not user_approved:
95
+ continue
96
+
97
+ entry_category = entry.get("category", "general")
98
+ if entry_category != category:
99
+ continue
100
+
101
+ stored_error: str = entry.get("error_text") or entry.get("stderr", "")
102
+ fix: str = entry.get("fix_command") or entry.get("fix", "")
103
+ diagnosis: str = entry.get("diagnosis", "")
104
+ original_cmd: str = entry.get("original_command") or entry.get("command", "")
105
+
106
+ if not stored_error or not fix:
107
+ continue
108
+
109
+ score = SequenceMatcher(None, error_text, stored_error).ratio()
110
+ if score < threshold:
111
+ continue
112
+
113
+ hit = CacheHit(
114
+ fix=fix,
115
+ diagnosis=diagnosis,
116
+ score=score,
117
+ original_command=original_cmd,
118
+ previously_worked=bool(fix_worked),
119
+ category=entry_category,
120
+ )
121
+
122
+ if fix_worked:
123
+ if best_worked is None or score > best_worked[0]:
124
+ best_worked = (score, hit)
125
+ else:
126
+ if best_approved is None or score > best_approved[0]:
127
+ best_approved = (score, hit)
128
+
129
+ # Prefer a confirmed-working hit over a merely-approved one
130
+ if best_worked:
131
+ return best_worked[1]
132
+ if best_approved:
133
+ return best_approved[1]
134
+ return None
envfix/config.py ADDED
@@ -0,0 +1,36 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Dict, Any
5
+
6
+ try:
7
+ import tomllib
8
+ except ImportError:
9
+ import tomli as tomllib
10
+
11
+ import tomli_w
12
+
13
+ CONFIG_DIR = Path.home() / ".envfix"
14
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
15
+
16
+ def load_config() -> Dict[str, Any]:
17
+ """Load the persistent TOML configuration file."""
18
+ if not CONFIG_FILE.exists():
19
+ return {}
20
+
21
+ try:
22
+ with open(CONFIG_FILE, "rb") as f:
23
+ return tomllib.load(f)
24
+ except Exception:
25
+ return {}
26
+
27
+ def save_config(data: Dict[str, Any]) -> None:
28
+ """Save the configuration dict to TOML."""
29
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
30
+ with open(CONFIG_FILE, "wb") as f:
31
+ tomli_w.dump(data, f)
32
+
33
+ def reset_config() -> None:
34
+ """Delete the configuration file."""
35
+ if CONFIG_FILE.exists():
36
+ CONFIG_FILE.unlink()
envfix/context.py ADDED
@@ -0,0 +1,122 @@
1
+ """context.py — Extract code snippets from stack traces for richer Ollama prompts."""
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ # Lines to include above and below the failing line
10
+ CONTEXT_WINDOW = 10
11
+
12
+ # ── Stack-trace patterns ──────────────────────────────────────────────────────
13
+
14
+ # Python: File "main.py", line 42
15
+ _PYTHON_RE = re.compile(r'File "([^"]+)",\s*line (\d+)')
16
+
17
+ # Node.js: at Object.<anonymous> (main.js:42:10) OR at main.js:42:10
18
+ _NODE_RE = re.compile(
19
+ r'at\s+(?:[^\s(]+\s+\()?([^()]+?\.(?:js|ts|jsx|tsx|mjs|cjs)):(\d+)'
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class CodeContext:
25
+ """A code snippet extracted from the failing file."""
26
+
27
+ filepath: str # path relative to cwd — safe to show the user
28
+ line_number: int # the line the stack trace pointed at
29
+ start_line: int # first line of the snippet (1-indexed, clamped)
30
+ end_line: int # last line of the snippet (1-indexed, clamped)
31
+ snippet: str # numbered source lines ready to embed in a prompt
32
+
33
+
34
+ def extract_context(stderr: str, cwd: Optional[str] = None) -> Optional[CodeContext]:
35
+ """
36
+ Parse *stderr* for file paths and line numbers in common stack trace formats.
37
+
38
+ Only files that live inside *cwd* (or its subdirectories) are read.
39
+ System paths, site-packages, and anything outside the project root are
40
+ silently skipped.
41
+
42
+ For Python tracebacks the *last* in-project file is used (that's where
43
+ the user's own code triggered the error). For Node.js the first match
44
+ is used (the top of the call stack).
45
+
46
+ Args:
47
+ stderr: The captured error output from the failed command.
48
+ cwd: Project root to restrict file access to. Defaults to os.getcwd().
49
+
50
+ Returns:
51
+ A CodeContext if a safe, readable file is found; None otherwise.
52
+ """
53
+ root = Path(cwd or os.getcwd()).resolve()
54
+
55
+ python_hits: list[tuple[str, int]] = [
56
+ (m.group(1), int(m.group(2))) for m in _PYTHON_RE.finditer(stderr)
57
+ ]
58
+ node_hits: list[tuple[str, int]] = [
59
+ (m.group(1), int(m.group(2))) for m in _NODE_RE.finditer(stderr)
60
+ ]
61
+
62
+ # Python: try last in-project file first (closest to the actual error)
63
+ for filepath, lineno in reversed(python_hits):
64
+ ctx = _read_safe(filepath, lineno, root)
65
+ if ctx:
66
+ return ctx
67
+
68
+ # Node.js: try first match first (top of call stack = user's code)
69
+ for filepath, lineno in node_hits:
70
+ ctx = _read_safe(filepath, lineno, root)
71
+ if ctx:
72
+ return ctx
73
+
74
+ return None
75
+
76
+
77
+ def _read_safe(filepath: str, lineno: int, root: Path) -> Optional[CodeContext]:
78
+ """
79
+ Read a ±CONTEXT_WINDOW line snippet from *filepath* around *lineno*.
80
+
81
+ Returns None if:
82
+ - the path resolves outside *root* (safety boundary)
83
+ - the file doesn't exist or can't be read
84
+ - the line number is out of range
85
+ """
86
+ try:
87
+ if os.path.isabs(filepath):
88
+ resolved = Path(filepath).resolve()
89
+ else:
90
+ resolved = (root / filepath).resolve()
91
+
92
+ # ── Safety check: must be inside the project root ─────────────────
93
+ resolved.relative_to(root) # raises ValueError if outside
94
+
95
+ if not resolved.is_file():
96
+ return None
97
+
98
+ lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines()
99
+ total = len(lines)
100
+
101
+ if lineno < 1 or lineno > total:
102
+ return None
103
+
104
+ # Clamp snippet window to file bounds (1-indexed)
105
+ start = max(1, lineno - CONTEXT_WINDOW)
106
+ end = min(total, lineno + CONTEXT_WINDOW)
107
+
108
+ # Build a numbered snippet so the model can reference line numbers
109
+ snippet = "\n".join(
110
+ f"{start + i:4d} | {line}"
111
+ for i, line in enumerate(lines[start - 1 : end])
112
+ )
113
+
114
+ return CodeContext(
115
+ filepath=str(resolved.relative_to(root)),
116
+ line_number=lineno,
117
+ start_line=start,
118
+ end_line=end,
119
+ snippet=snippet,
120
+ )
121
+ except (ValueError, OSError, UnicodeDecodeError):
122
+ return None