django-explain-errors 0.9.3__tar.gz → 0.10.1__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.
- {django_explain_errors-0.9.3/django_explain_errors.egg-info → django_explain_errors-0.10.1}/PKG-INFO +2 -1
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/README.md +1 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1/django_explain_errors.egg-info}/PKG-INFO +2 -1
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/django_explain_errors.egg-info/SOURCES.txt +3 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/middleware.py +131 -64
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/tracebacks.py +35 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/setup.py +1 -1
- django_explain_errors-0.10.1/tests/test_async_marking.py +82 -0
- django_explain_errors-0.10.1/tests/test_dedup.py +271 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_middleware.py +15 -1
- django_explain_errors-0.10.1/tests/test_process_exception_split.py +156 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/LICENSE +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/MANIFEST.in +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/django_explain_errors.egg-info/dependency_links.txt +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/django_explain_errors.egg-info/requires.txt +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/django_explain_errors.egg-info/top_level.txt +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/__init__.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/client.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/debug_page.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/management/__init__.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/management/commands/__init__.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/management/commands/build_error_index.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/rag/__init__.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/rag/indexer.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/rag/retriever.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/rag/store.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/sanitize.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/throttle.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/setup.cfg +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_build_error_index.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_client.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_debug_page.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_eval_fixtures.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_eval_judge.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_eval_run.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_language.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_print_stdout.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_rag.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_sanitize.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_signals.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_throttle.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_tracebacks.py +0 -0
- {django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_truncation.py +0 -0
{django_explain_errors-0.9.3/django_explain_errors.egg-info → django_explain_errors-0.10.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-explain-errors
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
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
|
|
{django_explain_errors-0.9.3 → django_explain_errors-0.10.1/django_explain_errors.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-explain-errors
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
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
|
|
|
@@ -21,15 +21,18 @@ explain_errors/rag/__init__.py
|
|
|
21
21
|
explain_errors/rag/indexer.py
|
|
22
22
|
explain_errors/rag/retriever.py
|
|
23
23
|
explain_errors/rag/store.py
|
|
24
|
+
tests/test_async_marking.py
|
|
24
25
|
tests/test_build_error_index.py
|
|
25
26
|
tests/test_client.py
|
|
26
27
|
tests/test_debug_page.py
|
|
28
|
+
tests/test_dedup.py
|
|
27
29
|
tests/test_eval_fixtures.py
|
|
28
30
|
tests/test_eval_judge.py
|
|
29
31
|
tests/test_eval_run.py
|
|
30
32
|
tests/test_language.py
|
|
31
33
|
tests/test_middleware.py
|
|
32
34
|
tests/test_print_stdout.py
|
|
35
|
+
tests/test_process_exception_split.py
|
|
33
36
|
tests/test_rag.py
|
|
34
37
|
tests/test_sanitize.py
|
|
35
38
|
tests/test_signals.py
|
|
@@ -1,21 +1,26 @@
|
|
|
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
|
|
6
|
-
from asgiref.sync import sync_to_async
|
|
8
|
+
from asgiref.sync import markcoroutinefunction, sync_to_async
|
|
7
9
|
|
|
8
10
|
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
|
|
@@ -66,10 +71,18 @@ class ExplainErrorsMiddleware:
|
|
|
66
71
|
def __init__(self, get_response):
|
|
67
72
|
self.get_response = get_response
|
|
68
73
|
self._is_async = asyncio.iscoroutinefunction(get_response)
|
|
74
|
+
if self._is_async:
|
|
75
|
+
# Without this, iscoroutinefunction(self) is False and middleware
|
|
76
|
+
# above this one runs in sync mode and receives a coroutine.
|
|
77
|
+
markcoroutinefunction(self)
|
|
69
78
|
self.openai_client = None
|
|
70
79
|
self.throttle = None
|
|
80
|
+
self.dedup_enabled = False
|
|
81
|
+
self._explanation_cache = OrderedDict()
|
|
82
|
+
self._explanation_cache_lock = threading.Lock()
|
|
71
83
|
|
|
72
84
|
if settings.DEBUG:
|
|
85
|
+
self.dedup_enabled = getattr(settings, "EXPLAIN_ERRORS_DEDUP", True)
|
|
73
86
|
max_calls = getattr(settings, "EXPLAIN_ERRORS_MAX_CALLS", 5)
|
|
74
87
|
window_seconds = getattr(settings, "EXPLAIN_ERRORS_WINDOW_SECONDS", 60)
|
|
75
88
|
self.throttle = SlidingWindowThrottle(max_calls, window_seconds)
|
|
@@ -129,6 +142,10 @@ class ExplainErrorsMiddleware:
|
|
|
129
142
|
try:
|
|
130
143
|
response = await self.get_response(request)
|
|
131
144
|
except Exception as exception:
|
|
145
|
+
# Skip the thread-pool round-trip entirely when inert in production.
|
|
146
|
+
# process_exception keeps its own guard for the sync path.
|
|
147
|
+
if not settings.DEBUG:
|
|
148
|
+
raise
|
|
132
149
|
# process_exception performs blocking model API I/O, so run it in
|
|
133
150
|
# a thread to keep the event loop free.
|
|
134
151
|
response = await sync_to_async(self.process_exception)(request, exception)
|
|
@@ -142,70 +159,22 @@ class ExplainErrorsMiddleware:
|
|
|
142
159
|
return None
|
|
143
160
|
|
|
144
161
|
explanation = None
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)
|
|
162
|
+
key = self._dedup_key(exception)
|
|
163
|
+
cached = self._cache_get(key) if key is not None else None
|
|
164
|
+
if cached is not None:
|
|
165
|
+
# Repeat of an error already explained: no throttle slot, no
|
|
166
|
+
# model call. Still shown on the debug page.
|
|
167
|
+
explanation = cached
|
|
168
|
+
if getattr(settings, "EXPLAIN_ERRORS_PRINT_STDOUT", True):
|
|
169
|
+
print("Error explanation: same error as an earlier one, replaying it.")
|
|
170
|
+
self._inject_debug_page(request, explanation)
|
|
171
|
+
elif self.throttle.allow():
|
|
172
|
+
prompt = self._build_prompt(exception)
|
|
173
|
+
explanation = self._generate_explanation(prompt)
|
|
207
174
|
|
|
208
175
|
if explanation is not None:
|
|
176
|
+
if key is not None:
|
|
177
|
+
self._cache_put(key, explanation)
|
|
209
178
|
self._inject_debug_page(request, explanation)
|
|
210
179
|
|
|
211
180
|
if getattr(settings, "EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE", True):
|
|
@@ -215,6 +184,104 @@ class ExplainErrorsMiddleware:
|
|
|
215
184
|
{"error": "An error occurred.", "message": explanation}, status=500
|
|
216
185
|
)
|
|
217
186
|
|
|
187
|
+
def _dedup_key(self, exception):
|
|
188
|
+
"""Key for the replay cache, or None (a miss). Fails open."""
|
|
189
|
+
if not self.dedup_enabled:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
return exception_dedup_key(exception)
|
|
193
|
+
except Exception:
|
|
194
|
+
logger.debug("explain_errors: dedup key computation failed", exc_info=True)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def _cache_get(self, key):
|
|
198
|
+
with self._explanation_cache_lock:
|
|
199
|
+
explanation = self._explanation_cache.get(key)
|
|
200
|
+
if explanation is not None:
|
|
201
|
+
self._explanation_cache.move_to_end(key)
|
|
202
|
+
return explanation
|
|
203
|
+
|
|
204
|
+
def _cache_put(self, key, explanation):
|
|
205
|
+
with self._explanation_cache_lock:
|
|
206
|
+
self._explanation_cache[key] = explanation
|
|
207
|
+
self._explanation_cache.move_to_end(key)
|
|
208
|
+
while len(self._explanation_cache) > DEDUP_CACHE_SIZE:
|
|
209
|
+
self._explanation_cache.popitem(last=False)
|
|
210
|
+
|
|
211
|
+
def _build_prompt(self, exception):
|
|
212
|
+
"""Build the user prompt: sanitized traceback, plus RAG context if enabled."""
|
|
213
|
+
# Get the exception traceback, keeping application frames over
|
|
214
|
+
# library internals to cap token usage and stay within the
|
|
215
|
+
# model's context window.
|
|
216
|
+
max_tb_chars = getattr(settings, "OPENAI_MAX_TRACEBACK_CHARS", 3000)
|
|
217
|
+
tb = format_traceback(exception, max_tb_chars)
|
|
218
|
+
# Sanitize the exact payload that ships, after truncation so we
|
|
219
|
+
# don't waste work redacting frames that get discarded.
|
|
220
|
+
tb = sanitize_traceback(tb)
|
|
221
|
+
|
|
222
|
+
# Construct the prompt
|
|
223
|
+
prompt = f"Explain the following Django error in simple terms:\n\n{tb}"
|
|
224
|
+
|
|
225
|
+
# RAG: ground the explanation in the user's own project source.
|
|
226
|
+
# Opt-in and must never break the traceback-only path, so any
|
|
227
|
+
# failure here is logged and swallowed.
|
|
228
|
+
if getattr(settings, "EXPLAIN_ERRORS_RAG_ENABLED", False):
|
|
229
|
+
try:
|
|
230
|
+
chunks = retrieve_chunks(exception)
|
|
231
|
+
max_prompt_chars = getattr(
|
|
232
|
+
settings, "EXPLAIN_ERRORS_RAG_MAX_PROMPT_CHARS", 6000
|
|
233
|
+
)
|
|
234
|
+
remaining_chars = max(max_prompt_chars - len(tb), 0)
|
|
235
|
+
rag_section = format_chunks_for_prompt(chunks, remaining_chars)
|
|
236
|
+
if rag_section:
|
|
237
|
+
prompt += f"\n\n{rag_section}"
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
logger.warning(
|
|
240
|
+
"explain_errors: RAG retrieval failed, falling back to "
|
|
241
|
+
"traceback-only prompt: %s",
|
|
242
|
+
exc,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return prompt
|
|
246
|
+
|
|
247
|
+
def _generate_explanation(self, prompt):
|
|
248
|
+
"""Call the model and return the explanation text, or None on failure.
|
|
249
|
+
|
|
250
|
+
Never raises: a failed or malformed response is reported on stdout and
|
|
251
|
+
yields None (or the text already extracted, if a later step failed).
|
|
252
|
+
"""
|
|
253
|
+
explanation = None
|
|
254
|
+
try:
|
|
255
|
+
# Call model API
|
|
256
|
+
response = self.openai_client.chat.completions.create(
|
|
257
|
+
model=self.model,
|
|
258
|
+
messages=[
|
|
259
|
+
{"role": "system", "content": self.system_prompt},
|
|
260
|
+
{"role": "user", "content": prompt},
|
|
261
|
+
],
|
|
262
|
+
max_tokens=self.max_tokens,
|
|
263
|
+
)
|
|
264
|
+
explanation = response.choices[0].message.content
|
|
265
|
+
usage = getattr(response, "usage", None)
|
|
266
|
+
logger.debug(
|
|
267
|
+
"explain_errors: token usage prompt_tokens=%s completion_tokens=%s",
|
|
268
|
+
getattr(usage, "prompt_tokens", None),
|
|
269
|
+
getattr(usage, "completion_tokens", None),
|
|
270
|
+
)
|
|
271
|
+
if response.choices[0].finish_reason == "length":
|
|
272
|
+
logger.warning(
|
|
273
|
+
"explain_errors: explanation truncated by OPENAI_MAX_TOKENS=%s",
|
|
274
|
+
self.max_tokens,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if getattr(settings, "EXPLAIN_ERRORS_PRINT_STDOUT", True):
|
|
278
|
+
print(f"Error explanation ({self.model}):\n", explanation)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
# If the model API call fails, surface the failure but still
|
|
281
|
+
# return a 500 so the request lifecycle completes cleanly.
|
|
282
|
+
print("Failed to get an explanation from the model API:", e)
|
|
283
|
+
return explanation
|
|
284
|
+
|
|
218
285
|
def _inject_debug_page(self, request, explanation):
|
|
219
286
|
"""Wire the explanation into the debug page, if all conditions hold.
|
|
220
287
|
|
|
@@ -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.
|
|
13
|
+
version='0.10.1',
|
|
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,82 @@
|
|
|
1
|
+
from unittest.mock import MagicMock, patch
|
|
2
|
+
|
|
3
|
+
from asgiref.sync import iscoroutinefunction
|
|
4
|
+
from django.http import HttpResponse
|
|
5
|
+
from django.test import AsyncClient, Client, SimpleTestCase, override_settings
|
|
6
|
+
|
|
7
|
+
from explain_errors.middleware import ExplainErrorsMiddleware
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _mock_openai():
|
|
11
|
+
patcher = patch("openai.OpenAI")
|
|
12
|
+
mock_cls = patcher.start()
|
|
13
|
+
client = MagicMock()
|
|
14
|
+
client.chat.completions.create.return_value = MagicMock(
|
|
15
|
+
choices=[MagicMock(message=MagicMock(content="Mocked explanation."))]
|
|
16
|
+
)
|
|
17
|
+
mock_cls.return_value = client
|
|
18
|
+
return patcher
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@override_settings(
|
|
22
|
+
DEBUG=True,
|
|
23
|
+
OPENAI_API_KEY="test-key",
|
|
24
|
+
ROOT_URLCONF="tests.urls_debug_page",
|
|
25
|
+
MIDDLEWARE=[
|
|
26
|
+
"django.middleware.common.CommonMiddleware",
|
|
27
|
+
"explain_errors.middleware.ExplainErrorsMiddleware",
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
class AsyncCoroutineMarkingRequestCycleTest(SimpleTestCase):
|
|
31
|
+
"""Through the real handler chain, with an async-capable middleware
|
|
32
|
+
above ExplainErrorsMiddleware, as in a default project."""
|
|
33
|
+
|
|
34
|
+
def setUp(self):
|
|
35
|
+
patcher = _mock_openai()
|
|
36
|
+
self.addCleanup(patcher.stop)
|
|
37
|
+
|
|
38
|
+
def _assert_banner_500(self, response):
|
|
39
|
+
self.assertEqual(response.status_code, 500)
|
|
40
|
+
content = response.content.decode()
|
|
41
|
+
self.assertIn('id="explain-errors"', content)
|
|
42
|
+
self.assertIn("Mocked explanation.", content)
|
|
43
|
+
|
|
44
|
+
async def test_async_client_sync_view(self):
|
|
45
|
+
response = await AsyncClient(raise_request_exception=False).get("/boom/")
|
|
46
|
+
self._assert_banner_500(response)
|
|
47
|
+
|
|
48
|
+
async def test_async_client_async_view(self):
|
|
49
|
+
response = await AsyncClient(raise_request_exception=False).get("/boom-async/")
|
|
50
|
+
self._assert_banner_500(response)
|
|
51
|
+
|
|
52
|
+
async def test_async_client_async_view_no_exception(self):
|
|
53
|
+
response = await AsyncClient().get("/ok-async/")
|
|
54
|
+
self.assertEqual(response.status_code, 200)
|
|
55
|
+
self.assertEqual(response.content, b"ok")
|
|
56
|
+
|
|
57
|
+
def test_sync_client_sync_view(self):
|
|
58
|
+
response = Client(raise_request_exception=False).get("/boom/")
|
|
59
|
+
self._assert_banner_500(response)
|
|
60
|
+
|
|
61
|
+
def test_sync_client_async_view(self):
|
|
62
|
+
response = Client(raise_request_exception=False).get("/boom-async/")
|
|
63
|
+
self._assert_banner_500(response)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
|
|
67
|
+
class AsyncCoroutineMarkingInstanceTest(SimpleTestCase):
|
|
68
|
+
def setUp(self):
|
|
69
|
+
patcher = _mock_openai()
|
|
70
|
+
self.addCleanup(patcher.stop)
|
|
71
|
+
|
|
72
|
+
def test_marked_as_coroutine_function_when_get_response_is_async(self):
|
|
73
|
+
async def get_response(request):
|
|
74
|
+
return HttpResponse("ok")
|
|
75
|
+
|
|
76
|
+
self.assertTrue(iscoroutinefunction(ExplainErrorsMiddleware(get_response)))
|
|
77
|
+
|
|
78
|
+
def test_not_marked_when_get_response_is_sync(self):
|
|
79
|
+
def get_response(request):
|
|
80
|
+
return HttpResponse("ok")
|
|
81
|
+
|
|
82
|
+
self.assertFalse(iscoroutinefunction(ExplainErrorsMiddleware(get_response)))
|
|
@@ -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(
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/management/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/explain_errors/rag/retriever.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_explain_errors-0.9.3 → django_explain_errors-0.10.1}/tests/test_build_error_index.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|