envfix 0.4.0__tar.gz → 0.5.0__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.
- {envfix-0.4.0 → envfix-0.5.0}/PKG-INFO +4 -1
- {envfix-0.4.0 → envfix-0.5.0}/envfix/__init__.py +1 -1
- {envfix-0.4.0 → envfix-0.5.0}/envfix/ai.py +3 -79
- {envfix-0.4.0 → envfix-0.5.0}/envfix/cache.py +19 -2
- envfix-0.5.0/envfix/config.py +66 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix/context.py +88 -0
- envfix-0.5.0/envfix/dependencies.py +102 -0
- envfix-0.5.0/envfix/embeddings.py +43 -0
- envfix-0.5.0/envfix/git_utils.py +63 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix/logger.py +7 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix/main.py +232 -7
- envfix-0.5.0/envfix/providers/__init__.py +44 -0
- envfix-0.5.0/envfix/providers/base.py +29 -0
- envfix-0.5.0/envfix/providers/gemini.py +40 -0
- envfix-0.5.0/envfix/providers/groq.py +42 -0
- envfix-0.5.0/envfix/providers/ollama.py +36 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/PKG-INFO +4 -1
- {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/SOURCES.txt +14 -1
- {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/requires.txt +4 -0
- {envfix-0.4.0 → envfix-0.5.0}/pyproject.toml +7 -1
- envfix-0.5.0/tests/test_config_yaml.py +92 -0
- envfix-0.5.0/tests/test_dependencies.py +77 -0
- envfix-0.5.0/tests/test_embeddings.py +48 -0
- envfix-0.5.0/tests/test_git_utils.py +62 -0
- {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase4.py +1 -1
- {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase6.py +59 -1
- envfix-0.5.0/tests/test_providers.py +86 -0
- envfix-0.4.0/envfix/config.py +0 -36
- {envfix-0.4.0 → envfix-0.5.0}/LICENSE +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/README.md +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix/preview.py +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix/runner.py +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/dependency_links.txt +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/entry_points.txt +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/top_level.txt +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/setup.cfg +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase1.py +0 -0
- {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase2.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: envfix
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: CLI tool that diagnoses and auto-fixes Python/ML environment errors using a local LLM.
|
|
5
5
|
License: Business Source License 1.1
|
|
6
6
|
|
|
@@ -116,6 +116,9 @@ Requires-Dist: groq>=0.9.0
|
|
|
116
116
|
Requires-Dist: google-genai>=0.1.0
|
|
117
117
|
Requires-Dist: tomli>=2.0.1; python_version < "3.11"
|
|
118
118
|
Requires-Dist: tomli-w>=1.0.0
|
|
119
|
+
Requires-Dist: pyyaml>=6.0
|
|
120
|
+
Provides-Extra: semantic
|
|
121
|
+
Requires-Dist: sentence-transformers>=3.0.0; extra == "semantic"
|
|
119
122
|
Dynamic: license-file
|
|
120
123
|
|
|
121
124
|
# envfix 🛠️
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
"""ai.py —
|
|
1
|
+
"""ai.py — Prompt building, response parsing, and provider dispatch for envfix."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import re
|
|
5
4
|
from dataclasses import dataclass
|
|
6
5
|
from typing import TYPE_CHECKING, Optional
|
|
@@ -8,21 +7,7 @@ from typing import TYPE_CHECKING, Optional
|
|
|
8
7
|
if TYPE_CHECKING:
|
|
9
8
|
from envfix.context import CodeContext
|
|
10
9
|
|
|
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
|
|
10
|
+
from envfix.providers import get_provider
|
|
26
11
|
|
|
27
12
|
|
|
28
13
|
PROMPT_TEMPLATE = (
|
|
@@ -125,68 +110,7 @@ def get_diagnosis(
|
|
|
125
110
|
else:
|
|
126
111
|
prompt = PROMPT_TEMPLATE.format(stderr=stderr, category=category)
|
|
127
112
|
|
|
128
|
-
|
|
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
|
-
|
|
113
|
+
raw = get_provider(provider, model).diagnose(prompt)
|
|
190
114
|
return _parse_response(raw)
|
|
191
115
|
|
|
192
116
|
|
|
@@ -7,6 +7,7 @@ from difflib import SequenceMatcher
|
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
9
|
from envfix.logger import get_log_file
|
|
10
|
+
from envfix.embeddings import get_embedding, cosine_similarity
|
|
10
11
|
|
|
11
12
|
# Minimum similarity ratio (0–1) to consider a log entry a match.
|
|
12
13
|
# 0.92 is deliberately conservative: we only surface cache hits when
|
|
@@ -15,6 +16,10 @@ from envfix.logger import get_log_file
|
|
|
15
16
|
# get matched to each other even when the missing package differs.
|
|
16
17
|
SIMILARITY_THRESHOLD = 0.92
|
|
17
18
|
|
|
19
|
+
# Threshold for semantic cosine similarity if sentence-transformers is installed.
|
|
20
|
+
# 0.80 is empirically chosen to match identical errors with different variable names.
|
|
21
|
+
SEMANTIC_THRESHOLD = 0.80
|
|
22
|
+
|
|
18
23
|
|
|
19
24
|
@dataclass
|
|
20
25
|
class CacheHit:
|
|
@@ -76,6 +81,8 @@ def find_cached_fix(
|
|
|
76
81
|
# We keep two best candidates: one that worked, one that was merely approved.
|
|
77
82
|
best_worked: Optional[tuple[float, CacheHit]] = None
|
|
78
83
|
best_approved: Optional[tuple[float, CacheHit]] = None
|
|
84
|
+
|
|
85
|
+
current_embedding = get_embedding(error_text)
|
|
79
86
|
|
|
80
87
|
for entry in log_data:
|
|
81
88
|
# ── Support both Phase 1 and Phase 2 schemas ─────────────────────
|
|
@@ -105,9 +112,19 @@ def find_cached_fix(
|
|
|
105
112
|
|
|
106
113
|
if not stored_error or not fix:
|
|
107
114
|
continue
|
|
115
|
+
|
|
116
|
+
stored_embedding = entry.get("embedding")
|
|
117
|
+
is_semantic = False
|
|
118
|
+
|
|
119
|
+
if current_embedding and stored_embedding:
|
|
120
|
+
score = cosine_similarity(current_embedding, stored_embedding)
|
|
121
|
+
is_semantic = True
|
|
122
|
+
hit_threshold = SEMANTIC_THRESHOLD
|
|
123
|
+
else:
|
|
124
|
+
score = SequenceMatcher(None, error_text, stored_error).ratio()
|
|
125
|
+
hit_threshold = SIMILARITY_THRESHOLD
|
|
108
126
|
|
|
109
|
-
score
|
|
110
|
-
if score < threshold:
|
|
127
|
+
if score < hit_threshold:
|
|
111
128
|
continue
|
|
112
129
|
|
|
113
130
|
hit = CacheHit(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
import yaml
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import tomllib
|
|
14
|
+
except ImportError:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
import tomli_w
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path.home() / ".envfix"
|
|
20
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
21
|
+
|
|
22
|
+
def load_config() -> Dict[str, Any]:
|
|
23
|
+
"""Load the persistent TOML configuration file."""
|
|
24
|
+
if not CONFIG_FILE.exists():
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
29
|
+
global_config = tomllib.load(f)
|
|
30
|
+
except Exception:
|
|
31
|
+
global_config = {}
|
|
32
|
+
|
|
33
|
+
project_config = load_project_config()
|
|
34
|
+
|
|
35
|
+
# Merge project config over global config
|
|
36
|
+
for k, v in project_config.items():
|
|
37
|
+
global_config[k] = v
|
|
38
|
+
|
|
39
|
+
return global_config
|
|
40
|
+
|
|
41
|
+
def load_project_config() -> Dict[str, Any]:
|
|
42
|
+
"""Load the project-specific YAML configuration file."""
|
|
43
|
+
yaml_file = Path.cwd() / ".envfix.yaml"
|
|
44
|
+
if not yaml_file.exists():
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with open(yaml_file, "r", encoding="utf-8") as f:
|
|
49
|
+
data = yaml.safe_load(f)
|
|
50
|
+
return data if isinstance(data, dict) else {}
|
|
51
|
+
except yaml.YAMLError as e:
|
|
52
|
+
console.print(f"\n[bold red]✗ Failed to parse .envfix.yaml:[/bold red]\n{e}")
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
except OSError:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
def save_config(data: Dict[str, Any]) -> None:
|
|
58
|
+
"""Save the configuration dict to TOML."""
|
|
59
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
with open(CONFIG_FILE, "wb") as f:
|
|
61
|
+
tomli_w.dump(data, f)
|
|
62
|
+
|
|
63
|
+
def reset_config() -> None:
|
|
64
|
+
"""Delete the configuration file."""
|
|
65
|
+
if CONFIG_FILE.exists():
|
|
66
|
+
CONFIG_FILE.unlink()
|
|
@@ -120,3 +120,91 @@ def _read_safe(filepath: str, lineno: int, root: Path) -> Optional[CodeContext]:
|
|
|
120
120
|
)
|
|
121
121
|
except (ValueError, OSError, UnicodeDecodeError):
|
|
122
122
|
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_external_path(path_str: str, root: Path) -> bool:
|
|
126
|
+
"""Check if a path indicates a framework/stdlib or is outside the project root."""
|
|
127
|
+
path_str = path_str.replace("\\", "/")
|
|
128
|
+
if any(marker in path_str for marker in ["site-packages", "node_modules", "lib/python", "vendor/"]):
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
path_obj = Path(path_str)
|
|
132
|
+
if path_obj.is_absolute():
|
|
133
|
+
try:
|
|
134
|
+
path_obj.resolve().relative_to(root)
|
|
135
|
+
except (ValueError, OSError):
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def trim_stack_trace(stderr: str, cwd: Optional[str] = None, ignore_patterns: Optional[list[str]] = None) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Trim external frames (site-packages, node_modules) from stack traces.
|
|
144
|
+
Strips any lines matching the provided regex patterns (ignore_patterns).
|
|
145
|
+
Caps the final text at ~12000 chars (approx 3000 tokens), keeping the bottom.
|
|
146
|
+
"""
|
|
147
|
+
root = Path(cwd or os.getcwd()).resolve()
|
|
148
|
+
lines = stderr.splitlines()
|
|
149
|
+
|
|
150
|
+
compiled_patterns = []
|
|
151
|
+
if ignore_patterns:
|
|
152
|
+
for p in ignore_patterns:
|
|
153
|
+
try:
|
|
154
|
+
compiled_patterns.append(re.compile(p))
|
|
155
|
+
except re.error:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
out_lines = []
|
|
159
|
+
hidden_count = 0
|
|
160
|
+
skip_next = False
|
|
161
|
+
|
|
162
|
+
for line in lines:
|
|
163
|
+
if compiled_patterns and any(p.search(line) for p in compiled_patterns):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if skip_next:
|
|
167
|
+
skip_next = False
|
|
168
|
+
# Python's code line under the frame is usually indented
|
|
169
|
+
if line.startswith(" ") or line.startswith("\t"):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Check Python frame
|
|
173
|
+
py_match = re.search(r'File "([^"]+)",\s*line \d+', line)
|
|
174
|
+
if py_match:
|
|
175
|
+
path = py_match.group(1)
|
|
176
|
+
if is_external_path(path, root):
|
|
177
|
+
hidden_count += 1
|
|
178
|
+
skip_next = True
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Check Node frame
|
|
182
|
+
node_match = re.search(r'at\s+(?:[^\s(]+\s+\()?([^()]+?\.(?:js|ts|jsx|tsx|mjs|cjs)):(\d+)', line)
|
|
183
|
+
if node_match:
|
|
184
|
+
path = node_match.group(1)
|
|
185
|
+
if is_external_path(path, root):
|
|
186
|
+
hidden_count += 1
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Keep this line, flush hidden marker if needed
|
|
190
|
+
if hidden_count > 0:
|
|
191
|
+
out_lines.append(f" [... {hidden_count} external frames hidden ...]")
|
|
192
|
+
hidden_count = 0
|
|
193
|
+
|
|
194
|
+
out_lines.append(line)
|
|
195
|
+
|
|
196
|
+
if hidden_count > 0:
|
|
197
|
+
out_lines.append(f" [... {hidden_count} external frames hidden ...]")
|
|
198
|
+
|
|
199
|
+
trimmed = "\n".join(out_lines)
|
|
200
|
+
|
|
201
|
+
# Fallback if trimming removed everything useful
|
|
202
|
+
if not re.search(r'[a-zA-Z]', trimmed):
|
|
203
|
+
trimmed = stderr
|
|
204
|
+
|
|
205
|
+
# Cap at ~12000 chars (keeping the bottom, which holds the actual error)
|
|
206
|
+
MAX_CHARS = 12000
|
|
207
|
+
if len(trimmed) > MAX_CHARS:
|
|
208
|
+
trimmed = "... [truncated] ...\n" + trimmed[-(MAX_CHARS - 25):]
|
|
209
|
+
|
|
210
|
+
return trimmed
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""dependencies.py — Auto-append fixed packages to requirements files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_package_name(fix_command: str) -> str | None:
|
|
8
|
+
"""
|
|
9
|
+
Extract the package name from a pip install command.
|
|
10
|
+
Example: 'python -m pip install -U torch' -> 'torch'
|
|
11
|
+
"""
|
|
12
|
+
# Matches 'pip install', optional flags like '-U' or '--upgrade',
|
|
13
|
+
# and then captures the package name.
|
|
14
|
+
# Note: this is a heuristic and will grab the first package if multiple are specified.
|
|
15
|
+
match = re.search(r"pip\s+install\s+(?:-[a-zA-Z-]+\s+)*([a-zA-Z0-9_.-]+)", fix_command, re.IGNORECASE)
|
|
16
|
+
if match:
|
|
17
|
+
pkg = match.group(1).strip()
|
|
18
|
+
# Ignore common flags that might have been caught if regex missed them
|
|
19
|
+
if not pkg.startswith("-") and not pkg.endswith(".txt"):
|
|
20
|
+
return pkg
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def update_requirements_txt(path: str, package: str) -> None:
|
|
25
|
+
"""Append the package to requirements.txt, ensuring a trailing newline."""
|
|
26
|
+
if not os.path.exists(path):
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
30
|
+
content = f.read()
|
|
31
|
+
|
|
32
|
+
# Avoid adding if it's already there
|
|
33
|
+
if re.search(rf"^{re.escape(package)}(?:[<>=!].*)?$", content, re.MULTILINE | re.IGNORECASE):
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
37
|
+
if content and not content.endswith("\n"):
|
|
38
|
+
f.write("\n")
|
|
39
|
+
f.write(f"{package}\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update_pyproject_toml(path: str, package: str) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Insert the package into the [project] dependencies array in pyproject.toml.
|
|
45
|
+
Uses string manipulation to preserve comments and formatting.
|
|
46
|
+
"""
|
|
47
|
+
if not os.path.exists(path):
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
51
|
+
content = f.read()
|
|
52
|
+
|
|
53
|
+
# Avoid adding if it's already there (rudimentary check)
|
|
54
|
+
if f'"{package}"' in content or f"'{package}'" in content:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
project_start = content.find("[project]")
|
|
58
|
+
if project_start == -1:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
next_section = content.find("\n[", project_start + 1)
|
|
62
|
+
if next_section == -1:
|
|
63
|
+
next_section = len(content)
|
|
64
|
+
|
|
65
|
+
project_block = content[project_start:next_section]
|
|
66
|
+
|
|
67
|
+
# Find the dependencies array
|
|
68
|
+
match = re.search(r"(dependencies\s*=\s*\[)(.*?)(\])", project_block, flags=re.DOTALL)
|
|
69
|
+
if not match:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
inner = match.group(2)
|
|
73
|
+
if "\n" in inner:
|
|
74
|
+
# Multi-line array: insert at the top to avoid trailing comma issues at the bottom
|
|
75
|
+
new_project_block = re.sub(
|
|
76
|
+
r"(dependencies\s*=\s*\[\s*\n)",
|
|
77
|
+
rf'\g<1> "{package}",\n',
|
|
78
|
+
project_block,
|
|
79
|
+
count=1
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
# Single-line array
|
|
83
|
+
inner_stripped = inner.strip()
|
|
84
|
+
if inner_stripped:
|
|
85
|
+
# Add at the beginning of the single line
|
|
86
|
+
new_inner = f'"{package}", ' + inner_stripped
|
|
87
|
+
else:
|
|
88
|
+
new_inner = f'"{package}"'
|
|
89
|
+
|
|
90
|
+
new_project_block = re.sub(
|
|
91
|
+
r"(dependencies\s*=\s*\[).*?(\])",
|
|
92
|
+
rf'\g<1>{new_inner}\g<2>',
|
|
93
|
+
project_block,
|
|
94
|
+
count=1,
|
|
95
|
+
flags=re.DOTALL
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Reconstruct the file
|
|
99
|
+
new_content = content[:project_start] + new_project_block + content[next_section:]
|
|
100
|
+
|
|
101
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
102
|
+
f.write(new_content)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""embeddings.py — Semantic similarity using sentence-transformers."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from sentence_transformers import SentenceTransformer, util
|
|
7
|
+
_HAS_SENTENCE_TRANSFORMERS = True
|
|
8
|
+
except ImportError:
|
|
9
|
+
_HAS_SENTENCE_TRANSFORMERS = False
|
|
10
|
+
SentenceTransformer = None
|
|
11
|
+
util = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_model = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_embedding(text: str) -> Optional[List[float]]:
|
|
18
|
+
"""
|
|
19
|
+
Generate an embedding for the given text using all-MiniLM-L6-v2.
|
|
20
|
+
Returns None if sentence-transformers is not installed.
|
|
21
|
+
"""
|
|
22
|
+
if not _HAS_SENTENCE_TRANSFORMERS:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
global _model
|
|
26
|
+
if _model is None:
|
|
27
|
+
_model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
28
|
+
|
|
29
|
+
# Encode returns a numpy array or tensor, convert to standard Python floats for JSON serialization
|
|
30
|
+
embedding = _model.encode(text)
|
|
31
|
+
return embedding.tolist()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
|
35
|
+
"""
|
|
36
|
+
Compute cosine similarity between two embedding vectors.
|
|
37
|
+
Returns 0.0 if sentence-transformers is not installed.
|
|
38
|
+
"""
|
|
39
|
+
if not _HAS_SENTENCE_TRANSFORMERS:
|
|
40
|
+
return 0.0
|
|
41
|
+
|
|
42
|
+
# util.cos_sim returns a 2D tensor (1x1), we extract the float item
|
|
43
|
+
return util.cos_sim(vec1, vec2).item()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""git_utils.py — Git safety backup utilities."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_in_git_repo(cwd: Optional[str] = None) -> bool:
|
|
9
|
+
"""Check if the current directory is inside a Git repository."""
|
|
10
|
+
try:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
13
|
+
cwd=cwd, capture_output=True, text=True, check=False
|
|
14
|
+
)
|
|
15
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def has_uncommitted_changes(cwd: Optional[str] = None) -> bool:
|
|
21
|
+
"""Check if there are any uncommitted changes in the repository."""
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["git", "status", "--porcelain"],
|
|
25
|
+
cwd=cwd, capture_output=True, text=True, check=False
|
|
26
|
+
)
|
|
27
|
+
return result.returncode == 0 and bool(result.stdout.strip())
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_safety_stash(cwd: Optional[str] = None) -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Create a non-destructive stash of the current working directory.
|
|
35
|
+
Uses 'git stash create' and 'git stash store' to avoid removing changes
|
|
36
|
+
from the working directory (unlike 'git stash push').
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Create stash object
|
|
40
|
+
create_result = subprocess.run(
|
|
41
|
+
["git", "stash", "create"],
|
|
42
|
+
cwd=cwd, capture_output=True, text=True, check=False
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if create_result.returncode != 0:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
commit_hash = create_result.stdout.strip()
|
|
49
|
+
if not commit_hash:
|
|
50
|
+
return False # Nothing to stash (e.g. only untracked files and no -u)
|
|
51
|
+
|
|
52
|
+
# Store it
|
|
53
|
+
timestamp = int(time.time())
|
|
54
|
+
msg = f"envfix-auto-backup-{timestamp}"
|
|
55
|
+
|
|
56
|
+
store_result = subprocess.run(
|
|
57
|
+
["git", "stash", "store", "-m", msg, commit_hash],
|
|
58
|
+
cwd=cwd, capture_output=True, text=True, check=False
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return store_result.returncode == 0
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return False
|
|
@@ -7,6 +7,8 @@ import re
|
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from typing import Any, Optional
|
|
9
9
|
|
|
10
|
+
from envfix.embeddings import get_embedding
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
def get_log_file() -> str:
|
|
12
14
|
"""
|
|
@@ -85,6 +87,11 @@ def log_attempt(
|
|
|
85
87
|
"context_included": context_included,
|
|
86
88
|
"provider": provider,
|
|
87
89
|
}
|
|
90
|
+
|
|
91
|
+
# Optionally compute and store a semantic embedding of the error text
|
|
92
|
+
embedding = get_embedding(error_text)
|
|
93
|
+
if embedding:
|
|
94
|
+
record["embedding"] = embedding
|
|
88
95
|
|
|
89
96
|
log_data = _load_log(get_log_file())
|
|
90
97
|
log_data.append(record)
|