django-explain-errors 0.8.0__tar.gz → 0.9.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 (40) hide show
  1. {django_explain_errors-0.8.0/django_explain_errors.egg-info → django_explain_errors-0.9.0}/PKG-INFO +14 -8
  2. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/README.md +13 -7
  3. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0/django_explain_errors.egg-info}/PKG-INFO +14 -8
  4. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/debug_page.py +94 -5
  5. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/setup.py +1 -1
  6. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_debug_page.py +62 -0
  7. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/LICENSE +0 -0
  8. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/MANIFEST.in +0 -0
  9. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/django_explain_errors.egg-info/SOURCES.txt +0 -0
  10. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  11. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/django_explain_errors.egg-info/requires.txt +0 -0
  12. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  13. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/__init__.py +0 -0
  14. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/client.py +0 -0
  15. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/management/__init__.py +0 -0
  16. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/management/commands/__init__.py +0 -0
  17. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/management/commands/build_error_index.py +0 -0
  18. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/middleware.py +0 -0
  19. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/rag/__init__.py +0 -0
  20. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/rag/indexer.py +0 -0
  21. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/rag/retriever.py +0 -0
  22. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/rag/store.py +0 -0
  23. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/sanitize.py +0 -0
  24. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/throttle.py +0 -0
  25. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/explain_errors/tracebacks.py +0 -0
  26. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/setup.cfg +0 -0
  27. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_build_error_index.py +0 -0
  28. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_client.py +0 -0
  29. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_eval_fixtures.py +0 -0
  30. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_eval_judge.py +0 -0
  31. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_eval_run.py +0 -0
  32. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_language.py +0 -0
  33. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_middleware.py +0 -0
  34. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_print_stdout.py +0 -0
  35. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_rag.py +0 -0
  36. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_sanitize.py +0 -0
  37. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_signals.py +0 -0
  38. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_throttle.py +0 -0
  39. {django_explain_errors-0.8.0 → django_explain_errors-0.9.0}/tests/test_tracebacks.py +0 -0
  40. {django_explain_errors-0.8.0 → django_explain_errors-0.9.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.8.0
3
+ Version: 0.9.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
@@ -44,14 +44,14 @@ Dynamic: summary
44
44
 
45
45
  # Django Explain Errors Middleware
46
46
 
47
- This Django middleware captures unhandled errors and exceptions, sends them
48
- to a language model for explanation, and, when debug mode is enabled, shows
49
- the explanation on Django's debug page directly under the exception
50
- headline, as well as printing it to stdout. It works with the OpenAI API
51
- out of the box, with Anthropic's Claude models through Anthropic's
47
+ When debug mode is on, this Django middleware sends each unhandled exception
48
+ to a language model and shows its explanation of what went wrong and how to
49
+ fix it on Django's debug page, directly under the exception headline. The
50
+ explanation is also printed to stdout. The middleware works with the OpenAI
51
+ API out of the box, with Anthropic's Claude models through Anthropic's
52
52
  OpenAI-compatible endpoint, and with any other OpenAI-compatible endpoint
53
- (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL,
54
- so explanations can run entirely on a local model if you prefer not to send
53
+ (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL, so
54
+ explanations can run entirely on a local model if you prefer not to send
55
55
  code off your machine.
56
56
 
57
57
  Enabling RAG is strongly recommended. With it, the middleware retrieves the
@@ -92,6 +92,8 @@ Local development only. It requires `DEBUG = True` and is inert otherwise.
92
92
  - Captures Django errors and exceptions
93
93
  - Shows the explanation on Django's debug page, directly under the
94
94
  exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
95
+ - Copy button on each code block in the explanation, so a suggested fix
96
+ copies in one click
95
97
  - Prints the explanation to stdout, for terminal workflows and logs
96
98
  (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
97
99
  - Optional JSON 500 response instead of the debug page
@@ -223,6 +225,10 @@ background) so it reads on Django's page. The explanation is HTML-escaped first,
223
225
  Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
224
226
  model is ever marked safe.
225
227
 
228
+ Each fenced code block gets a "Copy" button that copies the exact code (via
229
+ `navigator.clipboard` on secure contexts such as `localhost`, or a text-selection fallback
230
+ otherwise, for example when `runserver` is reached by IP over plain HTTP).
231
+
226
232
  **Fails open.** Any problem building or inserting the banner (a missing request, an
227
233
  unexpected debug page layout, anything else) logs one warning and leaves Django's normal
228
234
  debug page untouched. This code can never turn a working debug page into a broken one.
@@ -1,13 +1,13 @@
1
1
  # Django Explain Errors Middleware
2
2
 
3
- This Django middleware captures unhandled errors and exceptions, sends them
4
- to a language model for explanation, and, when debug mode is enabled, shows
5
- the explanation on Django's debug page directly under the exception
6
- headline, as well as printing it to stdout. It works with the OpenAI API
7
- out of the box, with Anthropic's Claude models through Anthropic's
3
+ When debug mode is on, this Django middleware sends each unhandled exception
4
+ to a language model and shows its explanation of what went wrong and how to
5
+ fix it on Django's debug page, directly under the exception headline. The
6
+ explanation is also printed to stdout. The middleware works with the OpenAI
7
+ API out of the box, with Anthropic's Claude models through Anthropic's
8
8
  OpenAI-compatible endpoint, and with any other OpenAI-compatible endpoint
9
- (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL,
10
- so explanations can run entirely on a local model if you prefer not to send
9
+ (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL, so
10
+ explanations can run entirely on a local model if you prefer not to send
11
11
  code off your machine.
12
12
 
13
13
  Enabling RAG is strongly recommended. With it, the middleware retrieves the
@@ -48,6 +48,8 @@ Local development only. It requires `DEBUG = True` and is inert otherwise.
48
48
  - Captures Django errors and exceptions
49
49
  - Shows the explanation on Django's debug page, directly under the
50
50
  exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
51
+ - Copy button on each code block in the explanation, so a suggested fix
52
+ copies in one click
51
53
  - Prints the explanation to stdout, for terminal workflows and logs
52
54
  (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
53
55
  - Optional JSON 500 response instead of the debug page
@@ -179,6 +181,10 @@ background) so it reads on Django's page. The explanation is HTML-escaped first,
179
181
  Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
180
182
  model is ever marked safe.
181
183
 
184
+ Each fenced code block gets a "Copy" button that copies the exact code (via
185
+ `navigator.clipboard` on secure contexts such as `localhost`, or a text-selection fallback
186
+ otherwise, for example when `runserver` is reached by IP over plain HTTP).
187
+
182
188
  **Fails open.** Any problem building or inserting the banner (a missing request, an
183
189
  unexpected debug page layout, anything else) logs one warning and leaves Django's normal
184
190
  debug page untouched. This code can never turn a working debug page into a broken one.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -44,14 +44,14 @@ Dynamic: summary
44
44
 
45
45
  # Django Explain Errors Middleware
46
46
 
47
- This Django middleware captures unhandled errors and exceptions, sends them
48
- to a language model for explanation, and, when debug mode is enabled, shows
49
- the explanation on Django's debug page directly under the exception
50
- headline, as well as printing it to stdout. It works with the OpenAI API
51
- out of the box, with Anthropic's Claude models through Anthropic's
47
+ When debug mode is on, this Django middleware sends each unhandled exception
48
+ to a language model and shows its explanation of what went wrong and how to
49
+ fix it on Django's debug page, directly under the exception headline. The
50
+ explanation is also printed to stdout. The middleware works with the OpenAI
51
+ API out of the box, with Anthropic's Claude models through Anthropic's
52
52
  OpenAI-compatible endpoint, and with any other OpenAI-compatible endpoint
53
- (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL,
54
- so explanations can run entirely on a local model if you prefer not to send
53
+ (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL, so
54
+ explanations can run entirely on a local model if you prefer not to send
55
55
  code off your machine.
56
56
 
57
57
  Enabling RAG is strongly recommended. With it, the middleware retrieves the
@@ -92,6 +92,8 @@ Local development only. It requires `DEBUG = True` and is inert otherwise.
92
92
  - Captures Django errors and exceptions
93
93
  - Shows the explanation on Django's debug page, directly under the
94
94
  exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
95
+ - Copy button on each code block in the explanation, so a suggested fix
96
+ copies in one click
95
97
  - Prints the explanation to stdout, for terminal workflows and logs
96
98
  (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
97
99
  - Optional JSON 500 response instead of the debug page
@@ -223,6 +225,10 @@ background) so it reads on Django's page. The explanation is HTML-escaped first,
223
225
  Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
224
226
  model is ever marked safe.
225
227
 
228
+ Each fenced code block gets a "Copy" button that copies the exact code (via
229
+ `navigator.clipboard` on secure contexts such as `localhost`, or a text-selection fallback
230
+ otherwise, for example when `runserver` is reached by IP over plain HTTP).
231
+
226
232
  **Fails open.** Any problem building or inserting the banner (a missing request, an
227
233
  unexpected debug page layout, anything else) logs one warning and leaves Django's normal
228
234
  debug page untouched. This code can never turn a working debug page into a broken one.
@@ -21,10 +21,78 @@ _PRE_STYLE = (
21
21
  "font-family: monospace; background: #eee; padding: 8px; "
22
22
  "overflow-x: auto; white-space: pre-wrap; margin: 4px 0;"
23
23
  )
24
+ _PRE_WITH_BUTTON_STYLE = (
25
+ "font-family: monospace; background: #eee; padding: 8px 60px 8px 8px; "
26
+ "overflow-x: auto; white-space: pre-wrap; margin: 0;"
27
+ )
24
28
  _P_STYLE = "margin: 6px 0;"
25
29
  _UL_STYLE = "margin: 6px 0; padding-inline-start: 1.5em; list-style: disc;"
26
30
  _OL_STYLE = "margin: 6px 0; padding-inline-start: 1.5em; list-style: decimal;"
27
31
  _LI_STYLE = "margin: 2px 0;"
32
+ _CODE_WRAPPER_STYLE = "position: relative; margin: 4px 0;"
33
+ _COPY_BUTTON_STYLE = (
34
+ "position: absolute; top: 4px; right: 4px; font-family: sans-serif; "
35
+ "font-size: 11px; line-height: 1; padding: 3px 7px; cursor: pointer; "
36
+ "background: #fff; border: 1px solid #999; border-radius: 3px; color: #333;"
37
+ )
38
+ _COPY_BUTTON_CLASS = "explain-errors-copy-btn"
39
+ _CODE_WRAPPER_CLASS = "explain-errors-code-wrapper"
40
+
41
+ _COPY_SCRIPT = """
42
+ <script>
43
+ (function () {{
44
+ var container = document.currentScript && document.currentScript.closest("#explain-errors");
45
+ if (!container) {{
46
+ return;
47
+ }}
48
+ container.addEventListener("click", function (event) {{
49
+ var btn = event.target.closest("button[data-copy]");
50
+ if (!btn) {{
51
+ return;
52
+ }}
53
+ var wrapper = btn.closest(".{wrapper_class}");
54
+ var code = wrapper && wrapper.querySelector("code");
55
+ if (!code) {{
56
+ return;
57
+ }}
58
+ var text = code.textContent;
59
+ var showCopied = function () {{
60
+ if (btn._explainErrorsTimer) {{
61
+ clearTimeout(btn._explainErrorsTimer);
62
+ }}
63
+ btn.textContent = "Copied";
64
+ btn._explainErrorsTimer = setTimeout(function () {{
65
+ btn.textContent = "Copy";
66
+ btn._explainErrorsTimer = null;
67
+ }}, 1500);
68
+ }};
69
+ var fallbackCopy = function () {{
70
+ try {{
71
+ var range = document.createRange();
72
+ range.selectNodeContents(code);
73
+ var selection = window.getSelection();
74
+ selection.removeAllRanges();
75
+ selection.addRange(range);
76
+ var ok = document.execCommand("copy");
77
+ selection.removeAllRanges();
78
+ if (ok) {{
79
+ showCopied();
80
+ }}
81
+ }} catch (err) {{
82
+ /* both copy paths failed; do nothing */
83
+ }}
84
+ }};
85
+ if (navigator.clipboard && navigator.clipboard.writeText) {{
86
+ navigator.clipboard.writeText(text).then(showCopied, fallbackCopy);
87
+ }} else {{
88
+ fallbackCopy();
89
+ }}
90
+ }});
91
+ }})();
92
+ </script>
93
+ """.format(
94
+ wrapper_class=_CODE_WRAPPER_CLASS
95
+ )
28
96
 
29
97
  _LANG_TAG_RE = re.compile(r"^[A-Za-z0-9_+-]*$")
30
98
  _INLINE_CODE_RE = re.compile(r"(`[^`\n]*`)")
@@ -55,12 +123,18 @@ def render_explanation_html(text):
55
123
  def _render(text):
56
124
  parts = text.split("```")
57
125
  rendered = []
126
+ has_copy_button = False
58
127
  for index, part in enumerate(parts):
59
128
  if index % 2 == 1:
60
- rendered.append(_render_code_block(part))
129
+ block_html, has_button = _render_code_block(part)
130
+ rendered.append(block_html)
131
+ has_copy_button = has_copy_button or has_button
61
132
  else:
62
133
  rendered.append(_render_prose(escape(part)))
63
- return "".join(rendered)
134
+ html = "".join(rendered)
135
+ if has_copy_button:
136
+ html += _COPY_SCRIPT
137
+ return html
64
138
 
65
139
 
66
140
  def _render_code_block(segment):
@@ -71,9 +145,24 @@ def _render_code_block(segment):
71
145
  code = rest
72
146
  if code.endswith("\n"):
73
147
  code = code[:-1]
74
- return '<pre dir="ltr" style="{style}"><code dir="ltr">{code}</code></pre>'.format(
75
- style=_PRE_STYLE, code=escape(code)
76
- )
148
+ if not code.strip():
149
+ return (
150
+ '<pre dir="ltr" style="{pre_style}"><code dir="ltr">{code}</code></pre>'
151
+ ).format(pre_style=_PRE_STYLE, code=escape(code)), False
152
+ return (
153
+ '<div class="{wrapper_class}" style="{wrapper_style}">'
154
+ '<button type="button" class="{btn_class}" data-copy '
155
+ 'aria-label="Copy code" style="{btn_style}">Copy</button>'
156
+ '<pre dir="ltr" style="{pre_style}"><code dir="ltr">{code}</code></pre>'
157
+ "</div>"
158
+ ).format(
159
+ wrapper_class=_CODE_WRAPPER_CLASS,
160
+ wrapper_style=_CODE_WRAPPER_STYLE,
161
+ btn_class=_COPY_BUTTON_CLASS,
162
+ btn_style=_COPY_BUTTON_STYLE,
163
+ pre_style=_PRE_WITH_BUTTON_STYLE,
164
+ code=escape(code),
165
+ ), True
77
166
 
78
167
 
79
168
  def _render_prose(escaped_text):
@@ -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.8.0',
13
+ version='0.9.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',
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ import html as html_module
2
3
  from unittest.mock import MagicMock, patch
3
4
 
4
5
  from django.test import (
@@ -357,6 +358,67 @@ class RenderExplanationHtmlTest(SimpleTestCase):
357
358
  self.assertNotIn("<a ", html)
358
359
  self.assertIn("[text](url)", html)
359
360
 
361
+ def test_single_code_block_emits_one_copy_button_and_script(self):
362
+ html = render_explanation_html("```\nx = 1\n```")
363
+
364
+ self.assertEqual(html.count("<button"), 1)
365
+ self.assertEqual(html.count("<script>"), 1)
366
+
367
+ def test_multiple_code_blocks_emit_one_button_each_and_exactly_one_script(self):
368
+ html = render_explanation_html("```\nx = 1\n```\ntext\n```\ny = 2\n```")
369
+
370
+ self.assertEqual(html.count("<button"), 2)
371
+ self.assertEqual(html.count("<script>"), 1)
372
+
373
+ def test_copy_button_has_type_button_and_aria_label(self):
374
+ html = render_explanation_html("```\nx = 1\n```")
375
+
376
+ self.assertIn('type="button"', html)
377
+ self.assertIn('aria-label="Copy code"', html)
378
+
379
+ def test_no_code_blocks_emit_no_button_and_no_script(self):
380
+ html = render_explanation_html("Just prose, no code here.")
381
+
382
+ self.assertNotIn("data-copy", html)
383
+ self.assertNotIn("explain-errors-copy-btn", html)
384
+ self.assertNotIn("<script>", html)
385
+
386
+ def test_copy_source_round_trips_html_special_characters(self):
387
+ raw_code = """<script>alert("x & 'y'")</script>"""
388
+ html = render_explanation_html("```\n{}\n```".format(raw_code))
389
+
390
+ start = html.index("<code dir=\"ltr\">") + len('<code dir="ltr">')
391
+ end = html.index("</code>", start)
392
+ escaped_code = html[start:end]
393
+
394
+ self.assertEqual(html_module.unescape(escaped_code), raw_code)
395
+
396
+ def test_empty_code_block_emits_pre_without_button(self):
397
+ html = render_explanation_html("```\n\n```")
398
+
399
+ self.assertIn("<pre", html)
400
+ self.assertNotIn("<button", html)
401
+ self.assertNotIn("explain-errors-code-wrapper", html)
402
+
403
+ def test_whitespace_only_code_block_emits_pre_without_button(self):
404
+ html = render_explanation_html("```\n \n```")
405
+
406
+ self.assertIn("<pre", html)
407
+ self.assertNotIn("<button", html)
408
+ self.assertNotIn("explain-errors-code-wrapper", html)
409
+
410
+ def test_all_empty_code_blocks_emit_no_script(self):
411
+ html = render_explanation_html("```\n\n```\ntext\n```\n \n```")
412
+
413
+ self.assertNotIn("<button", html)
414
+ self.assertNotIn("<script>", html)
415
+
416
+ def test_mix_of_empty_and_nonempty_blocks_emits_button_only_for_nonempty(self):
417
+ html = render_explanation_html("```\n\n```\ntext\n```\nx = 1\n```")
418
+
419
+ self.assertEqual(html.count("<button"), 1)
420
+ self.assertEqual(html.count("<script>"), 1)
421
+
360
422
  def test_raising_transform_falls_back_to_escaped_plain_text_and_logs(self):
361
423
  with patch(
362
424
  "explain_errors.debug_page._render", side_effect=RuntimeError("boom")