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 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