django-explain-errors 0.9.3__tar.gz → 0.10.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.
Files changed (42) hide show
  1. {django_explain_errors-0.9.3/django_explain_errors.egg-info → django_explain_errors-0.10.0}/PKG-INFO +2 -1
  2. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/README.md +1 -0
  3. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0/django_explain_errors.egg-info}/PKG-INFO +2 -1
  4. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/django_explain_errors.egg-info/SOURCES.txt +2 -0
  5. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/middleware.py +126 -63
  6. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/tracebacks.py +35 -0
  7. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/setup.py +1 -1
  8. django_explain_errors-0.10.0/tests/test_dedup.py +271 -0
  9. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_middleware.py +15 -1
  10. django_explain_errors-0.10.0/tests/test_process_exception_split.py +156 -0
  11. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/LICENSE +0 -0
  12. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/MANIFEST.in +0 -0
  13. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  14. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/django_explain_errors.egg-info/requires.txt +0 -0
  15. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  16. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/__init__.py +0 -0
  17. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/client.py +0 -0
  18. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/debug_page.py +0 -0
  19. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/management/__init__.py +0 -0
  20. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/management/commands/__init__.py +0 -0
  21. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/management/commands/build_error_index.py +0 -0
  22. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/rag/__init__.py +0 -0
  23. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/rag/indexer.py +0 -0
  24. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/rag/retriever.py +0 -0
  25. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/rag/store.py +0 -0
  26. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/sanitize.py +0 -0
  27. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/explain_errors/throttle.py +0 -0
  28. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/setup.cfg +0 -0
  29. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_build_error_index.py +0 -0
  30. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_client.py +0 -0
  31. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_debug_page.py +0 -0
  32. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_eval_fixtures.py +0 -0
  33. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_eval_judge.py +0 -0
  34. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_eval_run.py +0 -0
  35. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_language.py +0 -0
  36. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_print_stdout.py +0 -0
  37. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_rag.py +0 -0
  38. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_sanitize.py +0 -0
  39. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_signals.py +0 -0
  40. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_throttle.py +0 -0
  41. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_tracebacks.py +0 -0
  42. {django_explain_errors-0.9.3 → django_explain_errors-0.10.0}/tests/test_truncation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.9.3
3
+ Version: 0.10.0
4
4
  Summary: Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.
5
5
  Home-page: https://github.com/topunix/django-explain-errors
6
6
  Author: topunix
@@ -289,6 +289,7 @@ developer is actually investigating.
289
289
  | `EXPLAIN_ERRORS_REDACT_DISABLE_DEFAULTS` | No | When `True`, skips the built-in secret/token/email redaction patterns entirely and redacts only what `EXPLAIN_ERRORS_REDACT_PATTERNS` specifies. Turning this on removes the default protection against leaking secrets and PII in tracebacks. Defaults to `False`. |
290
290
  | `EXPLAIN_ERRORS_REDACT_REPLACEMENT` | No | Replacement string substituted for anything matched by the redaction patterns. Defaults to `"[REDACTED]"`. |
291
291
  | `EXPLAIN_ERRORS_LANGUAGE` | No | Language the explanation prose is written in, as a plain name or code (for example `"Spanish"` or `"es"`). Defaults to `None`, meaning English. Exception names, identifiers, code, file paths, and tracebacks always stay in English regardless of this setting. |
292
+ | `EXPLAIN_ERRORS_DEDUP` | No | When `True` (the default), a repeat of an error already explained in this process replays the earlier explanation instead of calling the model or using a throttle slot. Two errors are identical when they have the same exception type, the same exception message, and the same innermost project line (file, line number, and that line's source text); editing the line makes it a new error. Keeps the last 128 distinct errors per worker (least recently used entries are evicted first) and entries never expire on a timer. Set to `False` to explain every error. |
292
293
 
293
294
  ## Using local models (Ollama)
294
295
 
@@ -239,6 +239,7 @@ developer is actually investigating.
239
239
  | `EXPLAIN_ERRORS_REDACT_DISABLE_DEFAULTS` | No | When `True`, skips the built-in secret/token/email redaction patterns entirely and redacts only what `EXPLAIN_ERRORS_REDACT_PATTERNS` specifies. Turning this on removes the default protection against leaking secrets and PII in tracebacks. Defaults to `False`. |
240
240
  | `EXPLAIN_ERRORS_REDACT_REPLACEMENT` | No | Replacement string substituted for anything matched by the redaction patterns. Defaults to `"[REDACTED]"`. |
241
241
  | `EXPLAIN_ERRORS_LANGUAGE` | No | Language the explanation prose is written in, as a plain name or code (for example `"Spanish"` or `"es"`). Defaults to `None`, meaning English. Exception names, identifiers, code, file paths, and tracebacks always stay in English regardless of this setting. |
242
+ | `EXPLAIN_ERRORS_DEDUP` | No | When `True` (the default), a repeat of an error already explained in this process replays the earlier explanation instead of calling the model or using a throttle slot. Two errors are identical when they have the same exception type, the same exception message, and the same innermost project line (file, line number, and that line's source text); editing the line makes it a new error. Keeps the last 128 distinct errors per worker (least recently used entries are evicted first) and entries never expire on a timer. Set to `False` to explain every error. |
242
243
 
243
244
  ## Using local models (Ollama)
244
245
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.9.3
3
+ Version: 0.10.0
4
4
  Summary: Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.
5
5
  Home-page: https://github.com/topunix/django-explain-errors
6
6
  Author: topunix
@@ -289,6 +289,7 @@ developer is actually investigating.
289
289
  | `EXPLAIN_ERRORS_REDACT_DISABLE_DEFAULTS` | No | When `True`, skips the built-in secret/token/email redaction patterns entirely and redacts only what `EXPLAIN_ERRORS_REDACT_PATTERNS` specifies. Turning this on removes the default protection against leaking secrets and PII in tracebacks. Defaults to `False`. |
290
290
  | `EXPLAIN_ERRORS_REDACT_REPLACEMENT` | No | Replacement string substituted for anything matched by the redaction patterns. Defaults to `"[REDACTED]"`. |
291
291
  | `EXPLAIN_ERRORS_LANGUAGE` | No | Language the explanation prose is written in, as a plain name or code (for example `"Spanish"` or `"es"`). Defaults to `None`, meaning English. Exception names, identifiers, code, file paths, and tracebacks always stay in English regardless of this setting. |
292
+ | `EXPLAIN_ERRORS_DEDUP` | No | When `True` (the default), a repeat of an error already explained in this process replays the earlier explanation instead of calling the model or using a throttle slot. Two errors are identical when they have the same exception type, the same exception message, and the same innermost project line (file, line number, and that line's source text); editing the line makes it a new error. Keeps the last 128 distinct errors per worker (least recently used entries are evicted first) and entries never expire on a timer. Set to `False` to explain every error. |
292
293
 
293
294
  ## Using local models (Ollama)
294
295
 
@@ -24,12 +24,14 @@ explain_errors/rag/store.py
24
24
  tests/test_build_error_index.py
25
25
  tests/test_client.py
26
26
  tests/test_debug_page.py
27
+ tests/test_dedup.py
27
28
  tests/test_eval_fixtures.py
28
29
  tests/test_eval_judge.py
29
30
  tests/test_eval_run.py
30
31
  tests/test_language.py
31
32
  tests/test_middleware.py
32
33
  tests/test_print_stdout.py
34
+ tests/test_process_exception_split.py
33
35
  tests/test_rag.py
34
36
  tests/test_sanitize.py
35
37
  tests/test_signals.py
@@ -1,5 +1,7 @@
1
1
  import asyncio
2
2
  import logging
3
+ import threading
4
+ from collections import OrderedDict
3
5
 
4
6
  from django.conf import settings
5
7
  from django.http import JsonResponse
@@ -9,13 +11,16 @@ from .client import get_openai_client
9
11
  from .debug_page import ExplainErrorsExceptionReporter
10
12
  from .sanitize import sanitize_traceback
11
13
  from .throttle import SlidingWindowThrottle
12
- from .tracebacks import format_traceback
14
+ from .tracebacks import exception_dedup_key, format_traceback
13
15
  from .rag.retriever import format_chunks_for_prompt, retrieve_chunks
14
16
 
15
17
  DEFAULT_EXCEPTION_REPORTER_PATH = "django.views.debug.ExceptionReporter"
16
18
 
17
19
  logger = logging.getLogger(__name__)
18
20
 
21
+ # Max distinct errors whose explanations are kept for replay (LRU, no TTL).
22
+ DEDUP_CACHE_SIZE = 128
23
+
19
24
  EXPLANATION_WORD_BUDGET = 200
20
25
  # Generous ceiling, not a target. Billing follows tokens generated, so unused
21
26
  # headroom is free. The prompt controls length; this only stops runaway
@@ -68,8 +73,12 @@ class ExplainErrorsMiddleware:
68
73
  self._is_async = asyncio.iscoroutinefunction(get_response)
69
74
  self.openai_client = None
70
75
  self.throttle = None
76
+ self.dedup_enabled = False
77
+ self._explanation_cache = OrderedDict()
78
+ self._explanation_cache_lock = threading.Lock()
71
79
 
72
80
  if settings.DEBUG:
81
+ self.dedup_enabled = getattr(settings, "EXPLAIN_ERRORS_DEDUP", True)
73
82
  max_calls = getattr(settings, "EXPLAIN_ERRORS_MAX_CALLS", 5)
74
83
  window_seconds = getattr(settings, "EXPLAIN_ERRORS_WINDOW_SECONDS", 60)
75
84
  self.throttle = SlidingWindowThrottle(max_calls, window_seconds)
@@ -129,6 +138,10 @@ class ExplainErrorsMiddleware:
129
138
  try:
130
139
  response = await self.get_response(request)
131
140
  except Exception as exception:
141
+ # Skip the thread-pool round-trip entirely when inert in production.
142
+ # process_exception keeps its own guard for the sync path.
143
+ if not settings.DEBUG:
144
+ raise
132
145
  # process_exception performs blocking model API I/O, so run it in
133
146
  # a thread to keep the event loop free.
134
147
  response = await sync_to_async(self.process_exception)(request, exception)
@@ -142,70 +155,22 @@ class ExplainErrorsMiddleware:
142
155
  return None
143
156
 
144
157
  explanation = None
145
- if self.throttle.allow():
146
- # Get the exception traceback, keeping application frames over
147
- # library internals to cap token usage and stay within the
148
- # model's context window.
149
- max_tb_chars = getattr(settings, "OPENAI_MAX_TRACEBACK_CHARS", 3000)
150
- tb = format_traceback(exception, max_tb_chars)
151
- # Sanitize the exact payload that ships, after truncation so we
152
- # don't waste work redacting frames that get discarded.
153
- tb = sanitize_traceback(tb)
154
-
155
- # Construct the prompt
156
- prompt = f"Explain the following Django error in simple terms:\n\n{tb}"
157
-
158
- # RAG: ground the explanation in the user's own project source.
159
- # Opt-in and must never break the traceback-only path, so any
160
- # failure here is logged and swallowed.
161
- if getattr(settings, "EXPLAIN_ERRORS_RAG_ENABLED", False):
162
- try:
163
- chunks = retrieve_chunks(exception)
164
- max_prompt_chars = getattr(
165
- settings, "EXPLAIN_ERRORS_RAG_MAX_PROMPT_CHARS", 6000
166
- )
167
- remaining_chars = max(max_prompt_chars - len(tb), 0)
168
- rag_section = format_chunks_for_prompt(chunks, remaining_chars)
169
- if rag_section:
170
- prompt += f"\n\n{rag_section}"
171
- except Exception as exc:
172
- logger.warning(
173
- "explain_errors: RAG retrieval failed, falling back to "
174
- "traceback-only prompt: %s",
175
- exc,
176
- )
177
-
178
- try:
179
- # Call model API
180
- response = self.openai_client.chat.completions.create(
181
- model=self.model,
182
- messages=[
183
- {"role": "system", "content": self.system_prompt},
184
- {"role": "user", "content": prompt},
185
- ],
186
- max_tokens=self.max_tokens,
187
- )
188
- explanation = response.choices[0].message.content
189
- usage = getattr(response, "usage", None)
190
- logger.debug(
191
- "explain_errors: token usage prompt_tokens=%s completion_tokens=%s",
192
- getattr(usage, "prompt_tokens", None),
193
- getattr(usage, "completion_tokens", None),
194
- )
195
- if response.choices[0].finish_reason == "length":
196
- logger.warning(
197
- "explain_errors: explanation truncated by OPENAI_MAX_TOKENS=%s",
198
- self.max_tokens,
199
- )
200
-
201
- if getattr(settings, "EXPLAIN_ERRORS_PRINT_STDOUT", True):
202
- print(f"Error explanation ({self.model}):\n", explanation)
203
- except Exception as e:
204
- # If the model API call fails, surface the failure but still
205
- # return a 500 so the request lifecycle completes cleanly.
206
- print("Failed to get an explanation from the model API:", e)
158
+ key = self._dedup_key(exception)
159
+ cached = self._cache_get(key) if key is not None else None
160
+ if cached is not None:
161
+ # Repeat of an error already explained: no throttle slot, no
162
+ # model call. Still shown on the debug page.
163
+ explanation = cached
164
+ if getattr(settings, "EXPLAIN_ERRORS_PRINT_STDOUT", True):
165
+ print("Error explanation: same error as an earlier one, replaying it.")
166
+ self._inject_debug_page(request, explanation)
167
+ elif self.throttle.allow():
168
+ prompt = self._build_prompt(exception)
169
+ explanation = self._generate_explanation(prompt)
207
170
 
208
171
  if explanation is not None:
172
+ if key is not None:
173
+ self._cache_put(key, explanation)
209
174
  self._inject_debug_page(request, explanation)
210
175
 
211
176
  if getattr(settings, "EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE", True):
@@ -215,6 +180,104 @@ class ExplainErrorsMiddleware:
215
180
  {"error": "An error occurred.", "message": explanation}, status=500
216
181
  )
217
182
 
183
+ def _dedup_key(self, exception):
184
+ """Key for the replay cache, or None (a miss). Fails open."""
185
+ if not self.dedup_enabled:
186
+ return None
187
+ try:
188
+ return exception_dedup_key(exception)
189
+ except Exception:
190
+ logger.debug("explain_errors: dedup key computation failed", exc_info=True)
191
+ return None
192
+
193
+ def _cache_get(self, key):
194
+ with self._explanation_cache_lock:
195
+ explanation = self._explanation_cache.get(key)
196
+ if explanation is not None:
197
+ self._explanation_cache.move_to_end(key)
198
+ return explanation
199
+
200
+ def _cache_put(self, key, explanation):
201
+ with self._explanation_cache_lock:
202
+ self._explanation_cache[key] = explanation
203
+ self._explanation_cache.move_to_end(key)
204
+ while len(self._explanation_cache) > DEDUP_CACHE_SIZE:
205
+ self._explanation_cache.popitem(last=False)
206
+
207
+ def _build_prompt(self, exception):
208
+ """Build the user prompt: sanitized traceback, plus RAG context if enabled."""
209
+ # Get the exception traceback, keeping application frames over
210
+ # library internals to cap token usage and stay within the
211
+ # model's context window.
212
+ max_tb_chars = getattr(settings, "OPENAI_MAX_TRACEBACK_CHARS", 3000)
213
+ tb = format_traceback(exception, max_tb_chars)
214
+ # Sanitize the exact payload that ships, after truncation so we
215
+ # don't waste work redacting frames that get discarded.
216
+ tb = sanitize_traceback(tb)
217
+
218
+ # Construct the prompt
219
+ prompt = f"Explain the following Django error in simple terms:\n\n{tb}"
220
+
221
+ # RAG: ground the explanation in the user's own project source.
222
+ # Opt-in and must never break the traceback-only path, so any
223
+ # failure here is logged and swallowed.
224
+ if getattr(settings, "EXPLAIN_ERRORS_RAG_ENABLED", False):
225
+ try:
226
+ chunks = retrieve_chunks(exception)
227
+ max_prompt_chars = getattr(
228
+ settings, "EXPLAIN_ERRORS_RAG_MAX_PROMPT_CHARS", 6000
229
+ )
230
+ remaining_chars = max(max_prompt_chars - len(tb), 0)
231
+ rag_section = format_chunks_for_prompt(chunks, remaining_chars)
232
+ if rag_section:
233
+ prompt += f"\n\n{rag_section}"
234
+ except Exception as exc:
235
+ logger.warning(
236
+ "explain_errors: RAG retrieval failed, falling back to "
237
+ "traceback-only prompt: %s",
238
+ exc,
239
+ )
240
+
241
+ return prompt
242
+
243
+ def _generate_explanation(self, prompt):
244
+ """Call the model and return the explanation text, or None on failure.
245
+
246
+ Never raises: a failed or malformed response is reported on stdout and
247
+ yields None (or the text already extracted, if a later step failed).
248
+ """
249
+ explanation = None
250
+ try:
251
+ # Call model API
252
+ response = self.openai_client.chat.completions.create(
253
+ model=self.model,
254
+ messages=[
255
+ {"role": "system", "content": self.system_prompt},
256
+ {"role": "user", "content": prompt},
257
+ ],
258
+ max_tokens=self.max_tokens,
259
+ )
260
+ explanation = response.choices[0].message.content
261
+ usage = getattr(response, "usage", None)
262
+ logger.debug(
263
+ "explain_errors: token usage prompt_tokens=%s completion_tokens=%s",
264
+ getattr(usage, "prompt_tokens", None),
265
+ getattr(usage, "completion_tokens", None),
266
+ )
267
+ if response.choices[0].finish_reason == "length":
268
+ logger.warning(
269
+ "explain_errors: explanation truncated by OPENAI_MAX_TOKENS=%s",
270
+ self.max_tokens,
271
+ )
272
+
273
+ if getattr(settings, "EXPLAIN_ERRORS_PRINT_STDOUT", True):
274
+ print(f"Error explanation ({self.model}):\n", explanation)
275
+ except Exception as e:
276
+ # If the model API call fails, surface the failure but still
277
+ # return a 500 so the request lifecycle completes cleanly.
278
+ print("Failed to get an explanation from the model API:", e)
279
+ return explanation
280
+
218
281
  def _inject_debug_page(self, request, explanation):
219
282
  """Wire the explanation into the debug page, if all conditions hold.
220
283
 
@@ -5,6 +5,7 @@ a character budget, instead of slicing the last N characters of the raw
5
5
  traceback.
6
6
  """
7
7
  import functools
8
+ import hashlib
8
9
  import os
9
10
  import sysconfig
10
11
  import traceback
@@ -103,3 +104,37 @@ def format_traceback(exc: BaseException, max_chars: int) -> str:
103
104
  break
104
105
 
105
106
  return best
107
+
108
+
109
+ def exception_dedup_key(exc: BaseException):
110
+ """Return a stable hash identifying "the same error" for replay purposes,
111
+ or None when the exception carries no traceback to key on.
112
+
113
+ The key covers the exception type's qualified name, the exception
114
+ message, and the innermost project frame's filename, line number and
115
+ stripped source text. The message is included, so "id 41 not found" and
116
+ "id 42 not found" at one site are different errors. With no project frames the
117
+ innermost frame is used. traceback.extract_tb refreshes linecache, so
118
+ editing the offending line yields a new key. Built from the raw exception,
119
+ not the sanitized traceback.
120
+ """
121
+ tb = exc.__traceback__
122
+ if tb is None:
123
+ return None
124
+ frames = traceback.extract_tb(tb)
125
+ if not frames:
126
+ return None
127
+ frame = frames[-1]
128
+ for candidate in reversed(frames):
129
+ if not _is_library_frame(candidate.filename):
130
+ frame = candidate
131
+ break
132
+ exc_type = type(exc)
133
+ parts = (
134
+ f"{exc_type.__module__}.{exc_type.__qualname__}",
135
+ str(exc),
136
+ frame.filename,
137
+ str(frame.lineno),
138
+ (frame.line or "").strip(),
139
+ )
140
+ return hashlib.sha256("\0".join(parts).encode("utf-8", "replace")).hexdigest()
@@ -10,7 +10,7 @@ with open('README.md', encoding='utf-8') as f:
10
10
 
11
11
  setup(
12
12
  name='django-explain-errors',
13
- version='0.9.3',
13
+ version='0.10.0',
14
14
  packages=find_packages(exclude=['tests', 'tests.*', 'evals', 'evals.*']),
15
15
  description='Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.',
16
16
  long_description_content_type='text/markdown',
@@ -0,0 +1,271 @@
1
+ import os
2
+ import tempfile
3
+ from io import StringIO
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from django.test import RequestFactory, SimpleTestCase, override_settings
7
+
8
+ from explain_errors.middleware import ExplainErrorsMiddleware
9
+ from explain_errors.tracebacks import exception_dedup_key
10
+
11
+
12
+ def _raise_value(message="boom"):
13
+ raise ValueError(message)
14
+
15
+
16
+ def _raise_value_elsewhere(message="boom"):
17
+ raise ValueError(message)
18
+
19
+
20
+ def _raise_key(message="boom"):
21
+ raise KeyError(message)
22
+
23
+
24
+ def _raise_two_lines(message="boom"):
25
+ x = 1
26
+ raise ValueError(message + str(x))
27
+
28
+
29
+ def _caught(fn, *args):
30
+ try:
31
+ fn(*args)
32
+ except Exception as exc:
33
+ return exc
34
+
35
+
36
+ def _response(content="Cached explanation."):
37
+ choice = MagicMock(finish_reason="stop")
38
+ choice.message.content = content
39
+ return MagicMock(choices=[choice])
40
+
41
+
42
+ def _setup(test, **kwargs):
43
+ """Build a middleware with a mocked OpenAI client; return (mw, create)."""
44
+ with patch("openai.OpenAI") as mock_cls:
45
+ client = MagicMock()
46
+ client.chat.completions.create.return_value = _response()
47
+ mock_cls.return_value = client
48
+ mw = ExplainErrorsMiddleware(lambda r: None)
49
+ return mw, client.chat.completions.create
50
+
51
+
52
+ @override_settings(
53
+ DEBUG=True,
54
+ OPENAI_API_KEY="test-key",
55
+ EXPLAIN_ERRORS_PRINT_STDOUT=False,
56
+ )
57
+ class DedupTest(SimpleTestCase):
58
+ def setUp(self):
59
+ self.factory = RequestFactory()
60
+ self.mw, self.create = _setup(self)
61
+
62
+ def _handle(self, exc):
63
+ request = self.factory.get("/")
64
+ result = self.mw.process_exception(request, exc)
65
+ return request, result
66
+
67
+ def test_repeat_skips_model_and_throttle_and_injects(self):
68
+ _, _ = self._handle(_caught(_raise_value))
69
+ self.assertEqual(self.create.call_count, 1)
70
+
71
+ with patch.object(self.mw.throttle, "allow") as allow:
72
+ request, result = self._handle(_caught(_raise_value))
73
+ allow.assert_not_called()
74
+ self.assertEqual(self.create.call_count, 1)
75
+ self.assertIsNone(result)
76
+ self.assertEqual(request._explain_errors_explanation, "Cached explanation.")
77
+ self.assertIsNotNone(request.exception_reporter_class)
78
+
79
+ def test_repeat_does_not_consume_throttle(self):
80
+ for _ in range(10):
81
+ self._handle(_caught(_raise_value))
82
+ self.assertEqual(len(self.mw.throttle._calls), 1)
83
+
84
+ def test_different_exception_type_is_miss(self):
85
+ self._handle(_caught(_raise_value))
86
+ self._handle(_caught(_raise_key))
87
+ self.assertEqual(self.create.call_count, 2)
88
+
89
+ def test_different_frame_is_miss(self):
90
+ self._handle(_caught(_raise_value))
91
+ self._handle(_caught(_raise_value_elsewhere))
92
+ self.assertEqual(self.create.call_count, 2)
93
+
94
+ def test_different_line_text_is_miss(self):
95
+ with tempfile.TemporaryDirectory() as tmp:
96
+ path = os.path.join(tmp, "dedup_edit_target.py")
97
+
98
+ def run(source):
99
+ with open(path, "w") as f:
100
+ f.write(source)
101
+ namespace = {}
102
+ exec(compile(source, path, "exec"), namespace)
103
+ return _caught(namespace["go"])
104
+
105
+ first = run("def go():\n raise ValueError('a')\n")
106
+ first_key = exception_dedup_key(first)
107
+ self._handle(first)
108
+ second = run("def go():\n raise ValueError('a') # edited\n")
109
+ second_key = exception_dedup_key(second)
110
+ self._handle(second)
111
+ self.assertNotEqual(first_key, second_key)
112
+ self.assertEqual(self.create.call_count, 2)
113
+
114
+ def test_different_line_number_is_miss(self):
115
+ self.assertNotEqual(
116
+ exception_dedup_key(_caught(_raise_value)),
117
+ exception_dedup_key(_caught(_raise_two_lines)),
118
+ )
119
+
120
+ def test_different_message_same_line_is_miss(self):
121
+ self._handle(_caught(_raise_value, "first"))
122
+ self._handle(_caught(_raise_value, "second"))
123
+ self.assertEqual(self.create.call_count, 2)
124
+
125
+ def test_same_message_same_line_is_hit(self):
126
+ self._handle(_caught(_raise_value, "same"))
127
+ self._handle(_caught(_raise_value, "same"))
128
+ self.assertEqual(self.create.call_count, 1)
129
+
130
+ def test_failed_generation_is_not_cached(self):
131
+ self.create.side_effect = RuntimeError("api down")
132
+ with patch("builtins.print"):
133
+ self._handle(_caught(_raise_value))
134
+ self.assertEqual(len(self.mw._explanation_cache), 0)
135
+
136
+ self.create.side_effect = None
137
+ self.create.return_value = _response("Recovered.")
138
+ request, _ = self._handle(_caught(_raise_value))
139
+ self.assertEqual(self.create.call_count, 2)
140
+ self.assertEqual(request._explain_errors_explanation, "Recovered.")
141
+
142
+ def test_eviction_at_capacity(self):
143
+ with patch("explain_errors.middleware.DEDUP_CACHE_SIZE", 2):
144
+ self._handle(_caught(_raise_value)) # A
145
+ self._handle(_caught(_raise_key)) # B
146
+ self._handle(_caught(_raise_value)) # A hit, A now most recent
147
+ self.assertEqual(self.create.call_count, 2)
148
+ self._handle(_caught(_raise_value_elsewhere)) # C evicts B
149
+ self.assertEqual(self.create.call_count, 3)
150
+ self.assertEqual(len(self.mw._explanation_cache), 2)
151
+ self._handle(_caught(_raise_value)) # A still cached
152
+ self.assertEqual(self.create.call_count, 3)
153
+ self._handle(_caught(_raise_key)) # B was evicted
154
+ self.assertEqual(self.create.call_count, 4)
155
+
156
+ def test_key_failure_falls_through_to_miss(self):
157
+ with patch(
158
+ "explain_errors.middleware.exception_dedup_key",
159
+ side_effect=RuntimeError("bad key"),
160
+ ):
161
+ _, result = self._handle(_caught(_raise_value))
162
+ self._handle(_caught(_raise_value))
163
+ self.assertIsNone(result)
164
+ self.assertEqual(self.create.call_count, 2)
165
+ self.assertEqual(len(self.mw._explanation_cache), 0)
166
+
167
+ def test_exception_without_traceback_is_miss(self):
168
+ self._handle(ValueError("never raised"))
169
+ self._handle(ValueError("never raised"))
170
+ self.assertEqual(self.create.call_count, 2)
171
+
172
+ def test_cached_error_replays_after_throttle_exhausted(self):
173
+ self._handle(_caught(_raise_value))
174
+ self.mw.throttle.max_calls = 1 # window is now full
175
+ self.assertFalse(self.mw.throttle.allow())
176
+
177
+ self._handle(_caught(_raise_key)) # new error: denied by throttle
178
+ self.assertEqual(self.create.call_count, 1)
179
+
180
+ request, _ = self._handle(_caught(_raise_value)) # cached: replays
181
+ self.assertEqual(self.create.call_count, 1)
182
+ self.assertEqual(request._explain_errors_explanation, "Cached explanation.")
183
+
184
+ def test_json_500_path_returns_cached_explanation(self):
185
+ with override_settings(EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False):
186
+ self._handle(_caught(_raise_value))
187
+ _, response = self._handle(_caught(_raise_value))
188
+ self.assertEqual(response.status_code, 500)
189
+ self.assertIn(b"Cached explanation.", response.content)
190
+ self.assertEqual(self.create.call_count, 1)
191
+
192
+
193
+ @override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
194
+ class DedupStdoutTest(SimpleTestCase):
195
+ def test_hit_prints_one_line_not_full_reprint(self):
196
+ mw, create = _setup(self)
197
+ request = RequestFactory().get("/")
198
+ with patch("sys.stdout", new_callable=StringIO) as out:
199
+ mw.process_exception(request, _caught(_raise_value))
200
+ first = out.getvalue()
201
+ out.truncate(0)
202
+ out.seek(0)
203
+ mw.process_exception(request, _caught(_raise_value))
204
+ second = out.getvalue()
205
+ self.assertIn("Cached explanation.", first)
206
+ self.assertNotIn("Cached explanation.", second)
207
+ self.assertEqual(len(second.strip().splitlines()), 1)
208
+
209
+ @override_settings(EXPLAIN_ERRORS_PRINT_STDOUT=False)
210
+ def test_hit_prints_nothing_when_stdout_disabled(self):
211
+ mw, create = _setup(self)
212
+ request = RequestFactory().get("/")
213
+ mw.process_exception(request, _caught(_raise_value))
214
+ with patch("sys.stdout", new_callable=StringIO) as out:
215
+ mw.process_exception(request, _caught(_raise_value))
216
+ self.assertEqual(out.getvalue(), "")
217
+
218
+
219
+ @override_settings(
220
+ DEBUG=True,
221
+ OPENAI_API_KEY="test-key",
222
+ EXPLAIN_ERRORS_PRINT_STDOUT=False,
223
+ EXPLAIN_ERRORS_DEDUP=False,
224
+ )
225
+ class DedupDisabledTest(SimpleTestCase):
226
+ def test_disabled_always_calls_model(self):
227
+ mw, create = _setup(self)
228
+ request = RequestFactory().get("/")
229
+ with patch("explain_errors.middleware.exception_dedup_key") as key:
230
+ for _ in range(3):
231
+ mw.process_exception(request, _caught(_raise_value))
232
+ key.assert_not_called()
233
+ self.assertEqual(create.call_count, 3)
234
+ self.assertEqual(len(mw._explanation_cache), 0)
235
+
236
+
237
+ @override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
238
+ class DedupDebugOffTest(SimpleTestCase):
239
+ def test_debug_false_computes_nothing(self):
240
+ mw, create = _setup(self)
241
+ with override_settings(DEBUG=False), patch(
242
+ "explain_errors.middleware.exception_dedup_key"
243
+ ) as key:
244
+ result = mw.process_exception(RequestFactory().get("/"), _caught(_raise_value))
245
+ self.assertIsNone(result)
246
+ key.assert_not_called()
247
+ create.assert_not_called()
248
+ self.assertEqual(len(mw._explanation_cache), 0)
249
+
250
+
251
+ @override_settings(
252
+ DEBUG=True,
253
+ OPENAI_API_KEY="test-key",
254
+ EXPLAIN_ERRORS_PRINT_STDOUT=False,
255
+ )
256
+ class DedupAsyncTest(SimpleTestCase):
257
+ async def test_async_path_replays(self):
258
+ async def failing(request):
259
+ _raise_value()
260
+
261
+ with patch("openai.OpenAI") as mock_cls:
262
+ client = MagicMock()
263
+ client.chat.completions.create.return_value = _response()
264
+ mock_cls.return_value = client
265
+ mw = ExplainErrorsMiddleware(failing)
266
+
267
+ for _ in range(3):
268
+ with self.assertRaises(ValueError):
269
+ await mw(RequestFactory().get("/"))
270
+ self.assertEqual(client.chat.completions.create.call_count, 1)
271
+ self.assertEqual(len(mw.throttle._calls), 1)
@@ -192,7 +192,11 @@ class ExplainErrorsMiddlewareAsyncTest(SimpleTestCase):
192
192
  with self.assertRaises(ValueError):
193
193
  await mw(self.factory.get("/"))
194
194
 
195
- @override_settings(EXPLAIN_ERRORS_MAX_CALLS=1, EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False)
195
+ @override_settings(
196
+ EXPLAIN_ERRORS_MAX_CALLS=1,
197
+ EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False,
198
+ EXPLAIN_ERRORS_DEDUP=False,
199
+ )
196
200
  async def test_async_path_respects_throttle(self):
197
201
  async def boom(r):
198
202
  raise ValueError("async boom")
@@ -216,6 +220,16 @@ class ExplainErrorsMiddlewareDebugOffTest(SimpleTestCase):
216
220
  mw = ExplainErrorsMiddleware(lambda r: None)
217
221
  self.assertIsNone(mw.process_exception(factory.get("/"), Exception("x")))
218
222
 
223
+ async def test_async_debug_false_does_not_dispatch_to_thread(self):
224
+ async def boom(r):
225
+ raise ValueError("async boom")
226
+
227
+ mw = ExplainErrorsMiddleware(boom)
228
+ with patch("explain_errors.middleware.sync_to_async") as mock_sync_to_async:
229
+ with self.assertRaises(ValueError):
230
+ await mw(AsyncRequestFactory().get("/"))
231
+ mock_sync_to_async.assert_not_called()
232
+
219
233
  def test_debug_false_handler_reraises(self):
220
234
  factory = RequestFactory()
221
235
 
@@ -0,0 +1,156 @@
1
+ from io import StringIO
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from django.test import SimpleTestCase, override_settings
5
+
6
+ from explain_errors.middleware import ExplainErrorsMiddleware
7
+
8
+
9
+ def _exception():
10
+ try:
11
+ raise ValueError("split boom")
12
+ except ValueError as exc:
13
+ return exc
14
+
15
+
16
+ def _middleware():
17
+ with patch("openai.OpenAI"):
18
+ return ExplainErrorsMiddleware(lambda r: None)
19
+
20
+
21
+ def _response(content="Mocked explanation.", finish_reason="stop"):
22
+ choice = MagicMock(finish_reason=finish_reason)
23
+ choice.message.content = content
24
+ return MagicMock(choices=[choice])
25
+
26
+
27
+ @override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
28
+ class BuildPromptTest(SimpleTestCase):
29
+
30
+ @override_settings(EXPLAIN_ERRORS_RAG_ENABLED=False)
31
+ def test_rag_off_is_traceback_only_and_skips_retrieval(self):
32
+ mw = _middleware()
33
+ with patch("explain_errors.middleware.retrieve_chunks") as retrieve:
34
+ prompt = mw._build_prompt(_exception())
35
+ retrieve.assert_not_called()
36
+ self.assertTrue(
37
+ prompt.startswith("Explain the following Django error in simple terms:\n\n")
38
+ )
39
+ self.assertIn("split boom", prompt)
40
+
41
+ @override_settings(EXPLAIN_ERRORS_RAG_ENABLED=False)
42
+ def test_prompt_uses_module_level_sanitize_traceback(self):
43
+ mw = _middleware()
44
+ with patch(
45
+ "explain_errors.middleware.sanitize_traceback", return_value="SANITIZED"
46
+ ) as sanitize:
47
+ prompt = mw._build_prompt(_exception())
48
+ sanitize.assert_called_once()
49
+ self.assertTrue(prompt.endswith("\n\nSANITIZED"))
50
+
51
+ @override_settings(EXPLAIN_ERRORS_RAG_ENABLED=True)
52
+ def test_rag_on_appends_section(self):
53
+ mw = _middleware()
54
+ with patch(
55
+ "explain_errors.middleware.retrieve_chunks", return_value=["chunk"]
56
+ ), patch(
57
+ "explain_errors.middleware.format_chunks_for_prompt",
58
+ return_value="RAG SECTION",
59
+ ):
60
+ prompt = mw._build_prompt(_exception())
61
+ self.assertIn("split boom", prompt)
62
+ self.assertTrue(prompt.endswith("\n\nRAG SECTION"))
63
+
64
+ @override_settings(EXPLAIN_ERRORS_RAG_ENABLED=True)
65
+ def test_rag_empty_section_leaves_prompt_unchanged(self):
66
+ mw = _middleware()
67
+ exc = _exception()
68
+ with patch("explain_errors.middleware.retrieve_chunks", return_value=[]), patch(
69
+ "explain_errors.middleware.format_chunks_for_prompt", return_value=""
70
+ ):
71
+ with_rag = mw._build_prompt(exc)
72
+ with override_settings(EXPLAIN_ERRORS_RAG_ENABLED=False):
73
+ without_rag = mw._build_prompt(exc)
74
+ self.assertEqual(with_rag, without_rag)
75
+
76
+ @override_settings(EXPLAIN_ERRORS_RAG_ENABLED=True)
77
+ def test_rag_failure_degrades_to_traceback_only(self):
78
+ mw = _middleware()
79
+ exc = _exception()
80
+ with override_settings(EXPLAIN_ERRORS_RAG_ENABLED=False):
81
+ expected = mw._build_prompt(exc)
82
+ with patch(
83
+ "explain_errors.middleware.retrieve_chunks", side_effect=RuntimeError("no index")
84
+ ), patch("explain_errors.middleware.logger.warning") as warning:
85
+ prompt = mw._build_prompt(exc)
86
+ self.assertEqual(prompt, expected)
87
+ warning.assert_called_once()
88
+ self.assertIn("RAG retrieval failed", warning.call_args[0][0])
89
+
90
+
91
+ @override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
92
+ class GenerateExplanationTest(SimpleTestCase):
93
+
94
+ def _generate(self, mw, prompt="p"):
95
+ with patch("sys.stdout", new_callable=StringIO) as out:
96
+ result = mw._generate_explanation(prompt)
97
+ return result, out.getvalue()
98
+
99
+ def test_success_returns_text_and_prints(self):
100
+ mw = _middleware()
101
+ mw.openai_client.chat.completions.create.return_value = _response("Fix it.")
102
+ result, out = self._generate(mw, "my prompt")
103
+ self.assertEqual(result, "Fix it.")
104
+ self.assertIn("Fix it.", out)
105
+ kwargs = mw.openai_client.chat.completions.create.call_args.kwargs
106
+ self.assertEqual(kwargs["messages"][1], {"role": "user", "content": "my prompt"})
107
+ self.assertEqual(kwargs["messages"][0]["content"], mw.system_prompt)
108
+
109
+ @override_settings(EXPLAIN_ERRORS_PRINT_STDOUT=False)
110
+ def test_stdout_suppressed_when_print_disabled(self):
111
+ mw = _middleware()
112
+ mw.openai_client.chat.completions.create.return_value = _response("Fix it.")
113
+ result, out = self._generate(mw)
114
+ self.assertEqual(result, "Fix it.")
115
+ self.assertEqual(out, "")
116
+
117
+ def test_api_error_returns_none_and_prints_failure(self):
118
+ mw = _middleware()
119
+ mw.openai_client.chat.completions.create.side_effect = RuntimeError("api down")
120
+ result, out = self._generate(mw)
121
+ self.assertIsNone(result)
122
+ self.assertIn("Failed to get an explanation from the model API:", out)
123
+ self.assertIn("api down", out)
124
+
125
+ @override_settings(EXPLAIN_ERRORS_PRINT_STDOUT=False)
126
+ def test_api_error_message_prints_even_with_stdout_disabled(self):
127
+ mw = _middleware()
128
+ mw.openai_client.chat.completions.create.side_effect = RuntimeError("api down")
129
+ result, out = self._generate(mw)
130
+ self.assertIsNone(result)
131
+ self.assertIn("api down", out)
132
+
133
+ def test_truncation_warns_and_still_returns_text(self):
134
+ mw = _middleware()
135
+ mw.openai_client.chat.completions.create.return_value = _response(
136
+ "Cut off", finish_reason="length"
137
+ )
138
+ with patch("explain_errors.middleware.logger.warning") as warning:
139
+ result, _ = self._generate(mw)
140
+ self.assertEqual(result, "Cut off")
141
+ warning.assert_called_once()
142
+ self.assertIn("truncated", warning.call_args[0][0])
143
+
144
+ def test_non_length_finish_reason_does_not_warn(self):
145
+ mw = _middleware()
146
+ mw.openai_client.chat.completions.create.return_value = _response("ok")
147
+ with patch("explain_errors.middleware.logger.warning") as warning:
148
+ self._generate(mw)
149
+ warning.assert_not_called()
150
+
151
+ def test_empty_choices_fails_open(self):
152
+ mw = _middleware()
153
+ mw.openai_client.chat.completions.create.return_value = MagicMock(choices=[])
154
+ result, out = self._generate(mw)
155
+ self.assertIsNone(result)
156
+ self.assertIn("Failed to get an explanation from the model API:", out)