butwhy 0.1.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.
- butwhy/__init__.py +337 -0
- butwhy/__main__.py +27 -0
- butwhy/cache.py +105 -0
- butwhy/cli.py +88 -0
- butwhy/config.py +134 -0
- butwhy/context.py +131 -0
- butwhy/explainer.py +259 -0
- butwhy/formatter.py +190 -0
- butwhy/hook.py +165 -0
- butwhy/patterns.py +508 -0
- butwhy/providers/__init__.py +30 -0
- butwhy/providers/anthropic.py +52 -0
- butwhy/providers/ollama.py +69 -0
- butwhy/providers/openai.py +59 -0
- butwhy-0.1.0.dist-info/METADATA +367 -0
- butwhy-0.1.0.dist-info/RECORD +20 -0
- butwhy-0.1.0.dist-info/WHEEL +5 -0
- butwhy-0.1.0.dist-info/entry_points.txt +2 -0
- butwhy-0.1.0.dist-info/licenses/LICENSE +21 -0
- butwhy-0.1.0.dist-info/top_level.txt +1 -0
butwhy/__init__.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
why — Stop guessing. Start understanding.
|
|
3
|
+
|
|
4
|
+
import butwhy
|
|
5
|
+
|
|
6
|
+
That's it. Your Python errors now explain themselves.
|
|
7
|
+
|
|
8
|
+
Three ways to use:
|
|
9
|
+
|
|
10
|
+
1. Global (recommended):
|
|
11
|
+
import butwhy
|
|
12
|
+
1 / 0 # boom — why explains it
|
|
13
|
+
|
|
14
|
+
2. Context manager:
|
|
15
|
+
with butwhy.trace():
|
|
16
|
+
1 / 0
|
|
17
|
+
|
|
18
|
+
3. Decorator:
|
|
19
|
+
@butwhy.explain
|
|
20
|
+
def divide(a, b):
|
|
21
|
+
return a / b
|
|
22
|
+
|
|
23
|
+
Set OPENAI_API_KEY or ANTHROPIC_API_KEY for AI-powered explanations.
|
|
24
|
+
No key? why falls back to built-in pattern matching. Still useful.
|
|
25
|
+
|
|
26
|
+
>>> import butwhy
|
|
27
|
+
>>> butwhy.this
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
__author__ = "why contributors"
|
|
37
|
+
__license__ = "MIT"
|
|
38
|
+
|
|
39
|
+
# ── Re-export public API ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
from .config import get_config, set_config, WhyConfig
|
|
42
|
+
from .explainer import explain_error
|
|
43
|
+
from .hook import install, uninstall, is_active, trace, explain
|
|
44
|
+
from .cache import clear_cache
|
|
45
|
+
|
|
46
|
+
# ── Auto-install ──────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
if os.environ.get("BUTWHY_AUTOSTART", "1") != "0":
|
|
49
|
+
install()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── butwhy.this — The Zen of Why ────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
_ZEN_OF_WHY = """\
|
|
55
|
+
The Zen of Why, by why
|
|
56
|
+
|
|
57
|
+
Errors are not failures, they are teachers.
|
|
58
|
+
The traceback tells you what; why tells you why.
|
|
59
|
+
A good message answers the question before you ask it.
|
|
60
|
+
Read the variables, not just the line numbers.
|
|
61
|
+
If the error is obvious, the fix should be too.
|
|
62
|
+
Names matter: 'NoneType has no attribute' should mean something.
|
|
63
|
+
Context is king: a line number without variables is half a story.
|
|
64
|
+
The best debug is the one you didn't have to Google.
|
|
65
|
+
Simple errors deserve simple words.
|
|
66
|
+
Automate the explanation, not the understanding.
|
|
67
|
+
Now is better than never.
|
|
68
|
+
Although never is often better than *right* now.
|
|
69
|
+
If the implementation is hard to explain, it's a bad implementation.
|
|
70
|
+
If the implementation is easy to explain, it may be good.
|
|
71
|
+
Errors should never pass silently.
|
|
72
|
+
Unless explicitly silenced.
|
|
73
|
+
In the face of ambiguity, refuse the temptation to guess.
|
|
74
|
+
There should be one-- and preferably only one --obvious way to fix it.
|
|
75
|
+
Although that way may not be obvious at first unless you're why.
|
|
76
|
+
If why is not enough, read the source, Luke."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _This:
|
|
80
|
+
"""Printing this shows the Zen of Why. Like `import this`."""
|
|
81
|
+
|
|
82
|
+
def __repr__(self) -> str:
|
|
83
|
+
return _ZEN_OF_WHY
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
return _ZEN_OF_WHY
|
|
87
|
+
|
|
88
|
+
def _print(self):
|
|
89
|
+
print(_ZEN_OF_WHY)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
this = _This()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── butwhy.fix() — Interactive auto-fix ─────────────────────────────
|
|
96
|
+
|
|
97
|
+
def fix(filepath: str | None = None, *, auto: bool = False) -> bool:
|
|
98
|
+
"""Attempt to auto-fix the most recent error's source file.
|
|
99
|
+
|
|
100
|
+
After an error occurs, call butwhy.fix() to:
|
|
101
|
+
1. Get an AI-generated code patch for the failing line
|
|
102
|
+
2. Preview the diff
|
|
103
|
+
3. Apply the fix (with confirmation unless auto=True)
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
filepath: Path to the file to fix. If None, uses the last
|
|
107
|
+
error's file.
|
|
108
|
+
auto: If True, skip confirmation prompt.
|
|
109
|
+
|
|
110
|
+
Returns True if a fix was applied, False otherwise.
|
|
111
|
+
|
|
112
|
+
Note: Requires an AI provider (set OPENAI_API_KEY or similar).
|
|
113
|
+
Pattern-matching mode cannot generate code patches.
|
|
114
|
+
"""
|
|
115
|
+
from .explainer import _get_provider
|
|
116
|
+
from .config import get_config
|
|
117
|
+
|
|
118
|
+
cfg = get_config()
|
|
119
|
+
provider = _get_provider()
|
|
120
|
+
|
|
121
|
+
if not provider or not provider.is_available():
|
|
122
|
+
print(
|
|
123
|
+
"butwhy.fix() requires an AI provider.\n"
|
|
124
|
+
"Set OPENAI_API_KEY or ANTHROPIC_API_KEY to use this feature.",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
# We need the last error context — stored by the excepthook
|
|
130
|
+
last_error = getattr(sys, "_butwhy_last_error", None)
|
|
131
|
+
if last_error is None:
|
|
132
|
+
print(
|
|
133
|
+
"No recent error to fix. Run some code that crashes first.",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
exc = last_error["exc"]
|
|
139
|
+
frame_info = last_error["frame_info"]
|
|
140
|
+
|
|
141
|
+
target_file = filepath or frame_info.get("file", "")
|
|
142
|
+
lineno = frame_info.get("lineno", 0)
|
|
143
|
+
source_line = frame_info.get("source_line", "")
|
|
144
|
+
|
|
145
|
+
if not target_file or not os.path.exists(target_file):
|
|
146
|
+
print(f"Cannot find file to fix: {target_file}", file=sys.stderr)
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# Read the file
|
|
150
|
+
with open(target_file, "r", encoding="utf-8") as f:
|
|
151
|
+
file_lines = f.readlines()
|
|
152
|
+
|
|
153
|
+
# Build fix prompt
|
|
154
|
+
context_str = "\n".join(frame_info.get("source_context", []))
|
|
155
|
+
|
|
156
|
+
if cfg.language == "zh":
|
|
157
|
+
prompt = f"""请修复以下 Python 代码中的错误。
|
|
158
|
+
|
|
159
|
+
文件: {target_file}
|
|
160
|
+
错误行 ({lineno}): {source_line}
|
|
161
|
+
错误: {type(exc).__name__}: {exc}
|
|
162
|
+
|
|
163
|
+
代码上下文:
|
|
164
|
+
{context_str}
|
|
165
|
+
|
|
166
|
+
请只返回修复后的那一行代码(不要返回整个文件,不要加markdown标记)。
|
|
167
|
+
如果需要多行修复,返回替换代码块,用 --- 分隔旧行和新行。"""
|
|
168
|
+
else:
|
|
169
|
+
prompt = f"""Fix the error in this Python code.
|
|
170
|
+
|
|
171
|
+
File: {target_file}
|
|
172
|
+
Failing line ({lineno}): {source_line}
|
|
173
|
+
Error: {type(exc).__name__}: {exc}
|
|
174
|
+
|
|
175
|
+
Code context:
|
|
176
|
+
{context_str}
|
|
177
|
+
|
|
178
|
+
Return ONLY the fixed line(s) of code. No markdown fences, no explanation.
|
|
179
|
+
If multiple lines need changing, return them as a code block."""
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
fix_code = provider.explain(prompt, timeout=cfg.timeout)
|
|
183
|
+
except Exception as e:
|
|
184
|
+
print(f"Failed to generate fix: {e}", file=sys.stderr)
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
# Clean up the response
|
|
188
|
+
fix_code = fix_code.strip()
|
|
189
|
+
if fix_code.startswith("```"):
|
|
190
|
+
lines = fix_code.split("\n")
|
|
191
|
+
lines = [l for l in lines if not l.strip().startswith("```")]
|
|
192
|
+
fix_code = "\n".join(lines).strip()
|
|
193
|
+
|
|
194
|
+
# Show diff
|
|
195
|
+
print(f"\n Suggested fix for {target_file}:{lineno}\n", file=sys.stderr)
|
|
196
|
+
print(f" {'-' * 50}", file=sys.stderr)
|
|
197
|
+
print(f" - {source_line}", file=sys.stderr)
|
|
198
|
+
print(f" + {fix_code}", file=sys.stderr)
|
|
199
|
+
print(f" {'-' * 50}\n", file=sys.stderr)
|
|
200
|
+
|
|
201
|
+
# Confirm
|
|
202
|
+
if not auto:
|
|
203
|
+
try:
|
|
204
|
+
response = input(" Apply this fix? [y/N] ").strip().lower()
|
|
205
|
+
if response not in ("y", "yes"):
|
|
206
|
+
print(" Fix not applied.", file=sys.stderr)
|
|
207
|
+
return False
|
|
208
|
+
except (EOFError, KeyboardInterrupt):
|
|
209
|
+
print("\n Fix not applied.", file=sys.stderr)
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
# Apply fix — replace the line
|
|
213
|
+
if 0 < lineno <= len(file_lines):
|
|
214
|
+
new_lines = fix_code.split("\n")
|
|
215
|
+
file_lines[lineno - 1: lineno] = [l + "\n" for l in new_lines]
|
|
216
|
+
|
|
217
|
+
with open(target_file, "w", encoding="utf-8") as f:
|
|
218
|
+
f.writelines(file_lines)
|
|
219
|
+
|
|
220
|
+
print(f" Applied fix to {target_file}:{lineno}", file=sys.stderr)
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── butwhy.report() — Error statistics ──────────────────────────────
|
|
227
|
+
|
|
228
|
+
_error_stats: list[dict] = []
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _record_error(exc: BaseException, frame_info: dict):
|
|
232
|
+
"""Record an error for statistics (called by the hook)."""
|
|
233
|
+
_error_stats.append({
|
|
234
|
+
"type": type(exc).__name__,
|
|
235
|
+
"message": str(exc),
|
|
236
|
+
"file": frame_info.get("file", ""),
|
|
237
|
+
"func": frame_info.get("func", ""),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def report(top: int = 5) -> str:
|
|
242
|
+
"""Show a summary of errors encountered in this session.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
top: Number of most common error types to show.
|
|
246
|
+
|
|
247
|
+
Returns a formatted string showing error frequency.
|
|
248
|
+
"""
|
|
249
|
+
if not _error_stats:
|
|
250
|
+
return "No errors recorded in this session."
|
|
251
|
+
|
|
252
|
+
from collections import Counter
|
|
253
|
+
|
|
254
|
+
type_counts = Counter(e["type"] for e in _error_stats)
|
|
255
|
+
msg_counts = Counter(
|
|
256
|
+
f"{e['type']}: {e['message'][:60]}" for e in _error_stats
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
lines = [f"\n butwhy.report() — {len(_error_stats)} error(s) this session\n"]
|
|
260
|
+
|
|
261
|
+
lines.append(f" Top {top} error types:")
|
|
262
|
+
for etype, count in type_counts.most_common(top):
|
|
263
|
+
bar = "#" * count
|
|
264
|
+
lines.append(f" {etype:25s} {count:3d} {bar}")
|
|
265
|
+
|
|
266
|
+
lines.append(f"\n Top {top} recurring errors:")
|
|
267
|
+
for msg, count in msg_counts.most_common(top):
|
|
268
|
+
lines.append(f" [{count}x] {msg}")
|
|
269
|
+
|
|
270
|
+
lines.append("")
|
|
271
|
+
|
|
272
|
+
return "\n".join(lines)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ── Jupyter / IPython magic ───────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
def _load_ipython_extension(ipython):
|
|
278
|
+
"""Register %why magic for Jupyter/IPython.
|
|
279
|
+
|
|
280
|
+
Usage in a notebook:
|
|
281
|
+
%load_ext why
|
|
282
|
+
# or
|
|
283
|
+
import butwhy # auto-registers
|
|
284
|
+
|
|
285
|
+
Then all cell errors are explained automatically.
|
|
286
|
+
"""
|
|
287
|
+
from IPython.core.magic import Magics, magics_class, line_magic
|
|
288
|
+
|
|
289
|
+
@magics_class
|
|
290
|
+
class WhyMagics(Magics):
|
|
291
|
+
@line_magic
|
|
292
|
+
def why(self, line):
|
|
293
|
+
"""Toggle why error explanations in this notebook."""
|
|
294
|
+
if line.strip() == "off":
|
|
295
|
+
uninstall()
|
|
296
|
+
print("why: explanations disabled")
|
|
297
|
+
elif line.strip() == "report":
|
|
298
|
+
print(report())
|
|
299
|
+
else:
|
|
300
|
+
install()
|
|
301
|
+
print("why: explanations enabled — errors will be explained")
|
|
302
|
+
|
|
303
|
+
ipython.register_magics(WhyMagics)
|
|
304
|
+
|
|
305
|
+
# Also install the global hook
|
|
306
|
+
install()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# Auto-register with IPython if available
|
|
310
|
+
try:
|
|
311
|
+
from IPython import get_ipython
|
|
312
|
+
ip = get_ipython()
|
|
313
|
+
if ip is not None:
|
|
314
|
+
_load_ipython_extension(ip)
|
|
315
|
+
except ImportError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ── __all__ ───────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
__all__ = [
|
|
322
|
+
"install",
|
|
323
|
+
"uninstall",
|
|
324
|
+
"is_active",
|
|
325
|
+
"trace",
|
|
326
|
+
"explain",
|
|
327
|
+
"explain_error",
|
|
328
|
+
"this",
|
|
329
|
+
"fix",
|
|
330
|
+
"report",
|
|
331
|
+
"get_config",
|
|
332
|
+
"set_config",
|
|
333
|
+
"WhyConfig",
|
|
334
|
+
"clear_cache",
|
|
335
|
+
"cli",
|
|
336
|
+
"__version__",
|
|
337
|
+
]
|
butwhy/__main__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Support `python -m butwhy` — prints version and Zen of Why.
|
|
3
|
+
|
|
4
|
+
python -m butwhy # prints version + this
|
|
5
|
+
python -m butwhy --version
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
from . import __version__, this
|
|
13
|
+
|
|
14
|
+
if "--version" in sys.argv or "-V" in sys.argv:
|
|
15
|
+
print(f"butwhy {__version__}")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
print(f"butwhy {__version__}")
|
|
19
|
+
print()
|
|
20
|
+
print(this)
|
|
21
|
+
print()
|
|
22
|
+
print("Usage: import butwhy — your errors will be explained.")
|
|
23
|
+
print(" set OPENAI_API_KEY=sk-... for AI-powered explanations.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
butwhy/cache.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
On-disk caching for AI explanations.
|
|
3
|
+
|
|
4
|
+
Caches by a hash of (exception_type + message + source_line + local_vars).
|
|
5
|
+
This avoids redundant API calls when the same error keeps firing
|
|
6
|
+
(e.g. in a loop or across repeated runs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cache_key(exc_type: str, message: str, source_line: str, local_vars: dict) -> str:
|
|
19
|
+
"""Generate a stable cache key from the error signature."""
|
|
20
|
+
# Include a compact representation of local var types+values
|
|
21
|
+
var_sig = json.dumps(
|
|
22
|
+
{k: {"t": v["type"], "v": v["value"][:50]} for k, v in local_vars.items()},
|
|
23
|
+
sort_keys=True,
|
|
24
|
+
)
|
|
25
|
+
raw = f"{exc_type}|{message}|{source_line}|{var_sig}"
|
|
26
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cache_path(cache_dir: str, key: str) -> str:
|
|
30
|
+
return os.path.join(cache_dir, f"why_{key}.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_cached(
|
|
34
|
+
exc_type: str,
|
|
35
|
+
message: str,
|
|
36
|
+
source_line: str,
|
|
37
|
+
local_vars: dict,
|
|
38
|
+
cache_dir: str,
|
|
39
|
+
ttl: int = 86400 * 7, # 7 days
|
|
40
|
+
) -> Optional[str]:
|
|
41
|
+
"""Retrieve a cached explanation if it exists and hasn't expired."""
|
|
42
|
+
if not cache_dir:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
key = _cache_key(exc_type, message, source_line, local_vars)
|
|
46
|
+
path = _cache_path(cache_dir, key)
|
|
47
|
+
|
|
48
|
+
if not os.path.exists(path):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
if time.time() - data.get("ts", 0) > ttl:
|
|
55
|
+
return None
|
|
56
|
+
return data.get("explanation")
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_cached(
|
|
62
|
+
exc_type: str,
|
|
63
|
+
message: str,
|
|
64
|
+
source_line: str,
|
|
65
|
+
local_vars: dict,
|
|
66
|
+
explanation: str,
|
|
67
|
+
cache_dir: str,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Store an explanation in the cache."""
|
|
70
|
+
if not cache_dir:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
75
|
+
except OSError:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
key = _cache_key(exc_type, message, source_line, local_vars)
|
|
79
|
+
path = _cache_path(cache_dir, key)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
83
|
+
json.dump(
|
|
84
|
+
{"ts": time.time(), "explanation": explanation, "key": key},
|
|
85
|
+
f,
|
|
86
|
+
ensure_ascii=False,
|
|
87
|
+
)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clear_cache(cache_dir: str) -> int:
|
|
93
|
+
"""Clear all cached explanations. Returns the number of files removed."""
|
|
94
|
+
if not cache_dir or not os.path.isdir(cache_dir):
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
count = 0
|
|
98
|
+
for fname in os.listdir(cache_dir):
|
|
99
|
+
if fname.startswith("why_") and fname.endswith(".json"):
|
|
100
|
+
try:
|
|
101
|
+
os.remove(os.path.join(cache_dir, fname))
|
|
102
|
+
count += 1
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
return count
|
butwhy/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point — `butwhy` command line tool.
|
|
3
|
+
|
|
4
|
+
butwhy # show version + Zen of Why
|
|
5
|
+
butwhy --version # version only
|
|
6
|
+
butwhy --explain file.py # run a script and explain any errors
|
|
7
|
+
butwhy --clear-cache # clear explanation cache
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="butwhy",
|
|
17
|
+
description="Stop guessing. Start understanding. Your Python errors, explained.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"-V", "--version",
|
|
21
|
+
action="store_true",
|
|
22
|
+
help="Print version and exit",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--explain",
|
|
26
|
+
metavar="FILE",
|
|
27
|
+
help="Run a Python file and explain any errors that occur",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--clear-cache",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="Clear the explanation cache",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--language",
|
|
36
|
+
choices=["en", "zh"],
|
|
37
|
+
default=None,
|
|
38
|
+
help="Set explanation language (en or zh)",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args()
|
|
42
|
+
|
|
43
|
+
if args.version:
|
|
44
|
+
from butwhy import __version__
|
|
45
|
+
print(f"butwhy {__version__}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
if args.clear_cache:
|
|
49
|
+
from butwhy.cache import clear_cache
|
|
50
|
+
import tempfile
|
|
51
|
+
import os
|
|
52
|
+
cache_dir = os.path.join(tempfile.gettempdir(), "butwhy_cache")
|
|
53
|
+
count = clear_cache(cache_dir)
|
|
54
|
+
print(f"Cleared {count} cached explanation(s).")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
if args.language:
|
|
58
|
+
import os
|
|
59
|
+
os.environ["BUTWHY_LANGUAGE"] = args.language
|
|
60
|
+
|
|
61
|
+
if args.explain:
|
|
62
|
+
script = args.explain
|
|
63
|
+
code = f"import butwhy; exec(open('{script}', encoding='utf-8').read())"
|
|
64
|
+
try:
|
|
65
|
+
exec(compile(code, script, "exec"), {"__name__": "__main__"})
|
|
66
|
+
except SystemExit:
|
|
67
|
+
pass
|
|
68
|
+
except BaseException:
|
|
69
|
+
pass
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
# Default: show version + Zen
|
|
73
|
+
from butwhy import __version__, this
|
|
74
|
+
print(f"butwhy {__version__}")
|
|
75
|
+
print()
|
|
76
|
+
print(this)
|
|
77
|
+
print()
|
|
78
|
+
print("Usage:")
|
|
79
|
+
print(" import butwhy # enable error explanations")
|
|
80
|
+
print(" butwhy --explain file.py # run a file with explanations")
|
|
81
|
+
print(" butwhy --clear-cache # clear cached explanations")
|
|
82
|
+
print()
|
|
83
|
+
print("Set OPENAI_API_KEY for AI-powered deep explanations.")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
butwhy/config.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration and provider auto-detection for why.
|
|
3
|
+
|
|
4
|
+
Why has zero required configuration. It auto-detects available
|
|
5
|
+
LLM providers from environment variables and falls back to
|
|
6
|
+
built-in pattern matching when no provider is available.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class WhyConfig:
|
|
18
|
+
"""Runtime configuration for why.
|
|
19
|
+
|
|
20
|
+
All fields have sensible defaults. Users can override via
|
|
21
|
+
environment variables or by constructing a custom config
|
|
22
|
+
and passing it to ``why.init()``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Provider settings
|
|
26
|
+
provider: str = "auto" # auto | openai | anthropic | ollama | patterns
|
|
27
|
+
|
|
28
|
+
# OpenAI
|
|
29
|
+
openai_api_key: Optional[str] = None
|
|
30
|
+
openai_model: str = "gpt-4o-mini"
|
|
31
|
+
openai_base_url: str = "https://api.openai.com/v1"
|
|
32
|
+
|
|
33
|
+
# Anthropic
|
|
34
|
+
anthropic_api_key: Optional[str] = None
|
|
35
|
+
anthropic_model: str = "claude-3-5-sonnet-20241022"
|
|
36
|
+
anthropic_base_url: str = "https://api.anthropic.com"
|
|
37
|
+
|
|
38
|
+
# Ollama (local)
|
|
39
|
+
ollama_url: str = "http://localhost:11434"
|
|
40
|
+
ollama_model: str = "qwen2.5-coder:7b"
|
|
41
|
+
|
|
42
|
+
# Behavior
|
|
43
|
+
language: str = "en" # en | zh
|
|
44
|
+
cache: bool = True
|
|
45
|
+
cache_dir: str = ""
|
|
46
|
+
show_source: bool = True
|
|
47
|
+
show_vars: bool = True
|
|
48
|
+
show_fix: bool = True
|
|
49
|
+
context_lines: int = 5
|
|
50
|
+
max_var_len: int = 200
|
|
51
|
+
timeout: int = 30
|
|
52
|
+
|
|
53
|
+
# Internal
|
|
54
|
+
_active_provider: str = field(default="", init=False, repr=False)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env(cls) -> "WhyConfig":
|
|
58
|
+
"""Build config from environment variables."""
|
|
59
|
+
cfg = cls()
|
|
60
|
+
|
|
61
|
+
cfg.openai_api_key = os.environ.get("OPENAI_API_KEY")
|
|
62
|
+
cfg.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
63
|
+
|
|
64
|
+
if v := os.environ.get("BUTWHY_PROVIDER"):
|
|
65
|
+
cfg.provider = v
|
|
66
|
+
if v := os.environ.get("BUTWHY_LANGUAGE"):
|
|
67
|
+
cfg.language = v
|
|
68
|
+
if v := os.environ.get("BUTWHY_MODEL"):
|
|
69
|
+
# Set model for whichever provider is active
|
|
70
|
+
if cfg.provider == "openai":
|
|
71
|
+
cfg.openai_model = v
|
|
72
|
+
elif cfg.provider == "anthropic":
|
|
73
|
+
cfg.anthropic_model = v
|
|
74
|
+
elif cfg.provider == "ollama":
|
|
75
|
+
cfg.ollama_model = v
|
|
76
|
+
if v := os.environ.get("BUTWHY_CACHE_DIR"):
|
|
77
|
+
cfg.cache_dir = v
|
|
78
|
+
if v := os.environ.get("OPENAI_BASE_URL"):
|
|
79
|
+
cfg.openai_base_url = v
|
|
80
|
+
if v := os.environ.get("OLLAMA_URL"):
|
|
81
|
+
cfg.ollama_url = v
|
|
82
|
+
if v := os.environ.get("OLLAMA_MODEL"):
|
|
83
|
+
cfg.ollama_model = v
|
|
84
|
+
|
|
85
|
+
if v := os.environ.get("BUTWHY_TIMEOUT"):
|
|
86
|
+
try:
|
|
87
|
+
cfg.timeout = int(v)
|
|
88
|
+
except ValueError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
return cfg
|
|
92
|
+
|
|
93
|
+
def resolve_provider(self) -> str:
|
|
94
|
+
"""Determine which provider to actually use.
|
|
95
|
+
|
|
96
|
+
Resolution order when provider == 'auto':
|
|
97
|
+
1. OpenAI (if OPENAI_API_KEY is set)
|
|
98
|
+
2. Anthropic (if ANTHROPIC_API_KEY is set)
|
|
99
|
+
3. Ollama (if local server is reachable — checked lazily)
|
|
100
|
+
4. patterns (built-in, always available)
|
|
101
|
+
"""
|
|
102
|
+
if self.provider != "auto":
|
|
103
|
+
self._active_provider = self.provider
|
|
104
|
+
return self.provider
|
|
105
|
+
|
|
106
|
+
if self.openai_api_key:
|
|
107
|
+
self._active_provider = "openai"
|
|
108
|
+
elif self.anthropic_api_key:
|
|
109
|
+
self._active_provider = "anthropic"
|
|
110
|
+
else:
|
|
111
|
+
# Ollama is checked lazily at call time; default to patterns
|
|
112
|
+
# so we never block on a network request during import.
|
|
113
|
+
self._active_provider = "patterns"
|
|
114
|
+
|
|
115
|
+
return self._active_provider
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Global singleton ──────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
_config: Optional[WhyConfig] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_config() -> WhyConfig:
|
|
124
|
+
"""Return the active global config, creating it from env if needed."""
|
|
125
|
+
global _config
|
|
126
|
+
if _config is None:
|
|
127
|
+
_config = WhyConfig.from_env()
|
|
128
|
+
return _config
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def set_config(cfg: WhyConfig) -> None:
|
|
132
|
+
"""Override the global config."""
|
|
133
|
+
global _config
|
|
134
|
+
_config = cfg
|