django-explain-errors 0.8.0__tar.gz → 0.9.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.
Files changed (40) hide show
  1. {django_explain_errors-0.8.0/django_explain_errors.egg-info → django_explain_errors-0.9.1}/PKG-INFO +35 -36
  2. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/README.md +34 -35
  3. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1/django_explain_errors.egg-info}/PKG-INFO +35 -36
  4. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/debug_page.py +94 -5
  5. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/rag/indexer.py +14 -8
  6. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/setup.py +1 -1
  7. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_debug_page.py +62 -0
  8. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_rag.py +143 -0
  9. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/LICENSE +0 -0
  10. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/MANIFEST.in +0 -0
  11. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/django_explain_errors.egg-info/SOURCES.txt +0 -0
  12. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  13. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/django_explain_errors.egg-info/requires.txt +0 -0
  14. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/django_explain_errors.egg-info/top_level.txt +0 -0
  15. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/__init__.py +0 -0
  16. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/client.py +0 -0
  17. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/management/__init__.py +0 -0
  18. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/management/commands/__init__.py +0 -0
  19. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/management/commands/build_error_index.py +0 -0
  20. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/middleware.py +0 -0
  21. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/rag/__init__.py +0 -0
  22. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/rag/retriever.py +0 -0
  23. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/rag/store.py +0 -0
  24. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/sanitize.py +0 -0
  25. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/throttle.py +0 -0
  26. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/explain_errors/tracebacks.py +0 -0
  27. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/setup.cfg +0 -0
  28. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_build_error_index.py +0 -0
  29. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_client.py +0 -0
  30. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_eval_fixtures.py +0 -0
  31. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_eval_judge.py +0 -0
  32. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_eval_run.py +0 -0
  33. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_language.py +0 -0
  34. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_middleware.py +0 -0
  35. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_print_stdout.py +0 -0
  36. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_sanitize.py +0 -0
  37. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_signals.py +0 -0
  38. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_throttle.py +0 -0
  39. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/tests/test_tracebacks.py +0 -0
  40. {django_explain_errors-0.8.0 → django_explain_errors-0.9.1}/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.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
@@ -42,37 +42,31 @@ Dynamic: requires-dist
42
42
  Dynamic: requires-python
43
43
  Dynamic: summary
44
44
 
45
- # Django Explain Errors Middleware
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
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
55
- code off your machine.
56
-
57
- Enabling RAG is strongly recommended. With it, the middleware retrieves the
58
- relevant parts of your own project source from a local vector index, so
59
- explanations name the actual file and function that failed instead of
60
- guessing. In the package's eval harness, RAG-grounded explanations won 35
61
- of 43 blind comparisons, with the gap concentrated in pointing at the right
62
- fix location and not inventing details (see "Does RAG actually help?"
63
- below). It is off by default because it needs an optional extra and a
64
- one-time index build.
65
-
66
- The middleware supports both synchronous (WSGI) and asynchronous (ASGI)
67
- views. It auto-detects the view chain at startup and routes requests through
68
- the matching path, so no extra configuration is required for either server
69
- type. Tracebacks are sanitized before leaving the process, and API calls are
70
- rate limited.
71
-
72
- ![Django debug page with the explanation banner under the exception headline](https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png)
73
-
74
- *Real `gpt-4o-mini` output with RAG enabled, recorded by the eval harness
75
- on its `missing_fk` fixture.*
45
+ # Django Explain Errors
46
+
47
+ Explains Django exceptions on the debug page, pointing at the real fix in your own source code.
48
+
49
+ <p align="center">
50
+ <a href="https://pypi.org/project/django-explain-errors/"><img src="https://img.shields.io/pypi/v/django-explain-errors" alt="PyPI version"></a>
51
+ <a href="https://pypi.org/project/django-explain-errors/"><img src="https://img.shields.io/pypi/pyversions/django-explain-errors" alt="Python versions"></a>
52
+ <a href="https://github.com/topunix/django-explain-errors/actions/workflows/test.yml"><img src="https://github.com/topunix/django-explain-errors/actions/workflows/test.yml/badge.svg?branch=main" alt="Tests"></a>
53
+ <a href="https://github.com/topunix/django-explain-errors/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/django-explain-errors" alt="License"></a>
54
+ </p>
55
+
56
+ <p align="center">
57
+ <img src="https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png" width="800"
58
+ alt="Django debug page with the explanation banner under the exception headline">
59
+ </p>
60
+
61
+ *The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
62
+ failing function (`clone_latest_post`) and gives the one-line fix. Real `gpt-4o-mini` output from
63
+ the eval harness.*
64
+
65
+ - **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
66
+ - Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
67
+ - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with code and identifiers kept in English
68
+ - Sync and async views, traceback redaction, rate limiting
69
+ - Local development only: requires `DEBUG = True`
76
70
 
77
71
  ## Scope
78
72
 
@@ -85,19 +79,20 @@ If a coding agent is doing the debugging, it does not need this. Agents read tra
85
79
  and tools that expose live runtime state (debugger-over-MCP servers, `mcp-django`) serve that case
86
80
  better. This package is not trying to compete there.
87
81
 
88
- Local development only. It requires `DEBUG = True` and is inert otherwise.
89
-
90
82
  ## Features
91
83
 
92
84
  - Captures Django errors and exceptions
93
85
  - Shows the explanation on Django's debug page, directly under the
94
86
  exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
87
+ - Copy button on each code block in the explanation, so a suggested fix
88
+ copies in one click
95
89
  - Prints the explanation to stdout, for terminal workflows and logs
96
90
  (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
97
91
  - Optional JSON 500 response instead of the debug page
98
92
  (`EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False`)
99
- - Explains errors using OpenAI, Anthropic's Claude models, or any other
100
- OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`
93
+ - Explains errors using OpenAI out of the box, Anthropic's Claude models, or any other
94
+ OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`,
95
+ including fully local models, so no code has to leave your machine
101
96
  - Codebase-aware explanations via RAG over a local sqlite-vec index
102
97
  (recommended; see "Codebase-aware explanations" below)
103
98
  - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with exception names,
@@ -223,6 +218,10 @@ background) so it reads on Django's page. The explanation is HTML-escaped first,
223
218
  Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
224
219
  model is ever marked safe.
225
220
 
221
+ Each fenced code block gets a "Copy" button that copies the exact code (via
222
+ `navigator.clipboard` on secure contexts such as `localhost`, or a text-selection fallback
223
+ otherwise, for example when `runserver` is reached by IP over plain HTTP).
224
+
226
225
  **Fails open.** Any problem building or inserting the banner (a missing request, an
227
226
  unexpected debug page layout, anything else) logs one warning and leaves Django's normal
228
227
  debug page untouched. This code can never turn a working debug page into a broken one.
@@ -1,34 +1,28 @@
1
- # Django Explain Errors Middleware
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
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
11
- code off your machine.
12
-
13
- Enabling RAG is strongly recommended. With it, the middleware retrieves the
14
- relevant parts of your own project source from a local vector index, so
15
- explanations name the actual file and function that failed instead of
16
- guessing. In the package's eval harness, RAG-grounded explanations won 35
17
- of 43 blind comparisons, with the gap concentrated in pointing at the right
18
- fix location and not inventing details (see "Does RAG actually help?"
19
- below). It is off by default because it needs an optional extra and a
20
- one-time index build.
21
-
22
- The middleware supports both synchronous (WSGI) and asynchronous (ASGI)
23
- views. It auto-detects the view chain at startup and routes requests through
24
- the matching path, so no extra configuration is required for either server
25
- type. Tracebacks are sanitized before leaving the process, and API calls are
26
- rate limited.
27
-
28
- ![Django debug page with the explanation banner under the exception headline](https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png)
29
-
30
- *Real `gpt-4o-mini` output with RAG enabled, recorded by the eval harness
31
- on its `missing_fk` fixture.*
1
+ # Django Explain Errors
2
+
3
+ Explains Django exceptions on the debug page, pointing at the real fix in your own source code.
4
+
5
+ <p align="center">
6
+ <a href="https://pypi.org/project/django-explain-errors/"><img src="https://img.shields.io/pypi/v/django-explain-errors" alt="PyPI version"></a>
7
+ <a href="https://pypi.org/project/django-explain-errors/"><img src="https://img.shields.io/pypi/pyversions/django-explain-errors" alt="Python versions"></a>
8
+ <a href="https://github.com/topunix/django-explain-errors/actions/workflows/test.yml"><img src="https://github.com/topunix/django-explain-errors/actions/workflows/test.yml/badge.svg?branch=main" alt="Tests"></a>
9
+ <a href="https://github.com/topunix/django-explain-errors/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/django-explain-errors" alt="License"></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <img src="https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png" width="800"
14
+ alt="Django debug page with the explanation banner under the exception headline">
15
+ </p>
16
+
17
+ *The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
18
+ failing function (`clone_latest_post`) and gives the one-line fix. Real `gpt-4o-mini` output from
19
+ the eval harness.*
20
+
21
+ - **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
22
+ - Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
23
+ - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with code and identifiers kept in English
24
+ - Sync and async views, traceback redaction, rate limiting
25
+ - Local development only: requires `DEBUG = True`
32
26
 
33
27
  ## Scope
34
28
 
@@ -41,19 +35,20 @@ If a coding agent is doing the debugging, it does not need this. Agents read tra
41
35
  and tools that expose live runtime state (debugger-over-MCP servers, `mcp-django`) serve that case
42
36
  better. This package is not trying to compete there.
43
37
 
44
- Local development only. It requires `DEBUG = True` and is inert otherwise.
45
-
46
38
  ## Features
47
39
 
48
40
  - Captures Django errors and exceptions
49
41
  - Shows the explanation on Django's debug page, directly under the
50
42
  exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
43
+ - Copy button on each code block in the explanation, so a suggested fix
44
+ copies in one click
51
45
  - Prints the explanation to stdout, for terminal workflows and logs
52
46
  (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
53
47
  - Optional JSON 500 response instead of the debug page
54
48
  (`EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False`)
55
- - Explains errors using OpenAI, Anthropic's Claude models, or any other
56
- OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`
49
+ - Explains errors using OpenAI out of the box, Anthropic's Claude models, or any other
50
+ OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`,
51
+ including fully local models, so no code has to leave your machine
57
52
  - Codebase-aware explanations via RAG over a local sqlite-vec index
58
53
  (recommended; see "Codebase-aware explanations" below)
59
54
  - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with exception names,
@@ -179,6 +174,10 @@ background) so it reads on Django's page. The explanation is HTML-escaped first,
179
174
  Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
180
175
  model is ever marked safe.
181
176
 
177
+ Each fenced code block gets a "Copy" button that copies the exact code (via
178
+ `navigator.clipboard` on secure contexts such as `localhost`, or a text-selection fallback
179
+ otherwise, for example when `runserver` is reached by IP over plain HTTP).
180
+
182
181
  **Fails open.** Any problem building or inserting the banner (a missing request, an
183
182
  unexpected debug page layout, anything else) logs one warning and leaves Django's normal
184
183
  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.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
@@ -42,37 +42,31 @@ Dynamic: requires-dist
42
42
  Dynamic: requires-python
43
43
  Dynamic: summary
44
44
 
45
- # Django Explain Errors Middleware
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
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
55
- code off your machine.
56
-
57
- Enabling RAG is strongly recommended. With it, the middleware retrieves the
58
- relevant parts of your own project source from a local vector index, so
59
- explanations name the actual file and function that failed instead of
60
- guessing. In the package's eval harness, RAG-grounded explanations won 35
61
- of 43 blind comparisons, with the gap concentrated in pointing at the right
62
- fix location and not inventing details (see "Does RAG actually help?"
63
- below). It is off by default because it needs an optional extra and a
64
- one-time index build.
65
-
66
- The middleware supports both synchronous (WSGI) and asynchronous (ASGI)
67
- views. It auto-detects the view chain at startup and routes requests through
68
- the matching path, so no extra configuration is required for either server
69
- type. Tracebacks are sanitized before leaving the process, and API calls are
70
- rate limited.
71
-
72
- ![Django debug page with the explanation banner under the exception headline](https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png)
73
-
74
- *Real `gpt-4o-mini` output with RAG enabled, recorded by the eval harness
75
- on its `missing_fk` fixture.*
45
+ # Django Explain Errors
46
+
47
+ Explains Django exceptions on the debug page, pointing at the real fix in your own source code.
48
+
49
+ <p align="center">
50
+ <a href="https://pypi.org/project/django-explain-errors/"><img src="https://img.shields.io/pypi/v/django-explain-errors" alt="PyPI version"></a>
51
+ <a href="https://pypi.org/project/django-explain-errors/"><img src="https://img.shields.io/pypi/pyversions/django-explain-errors" alt="Python versions"></a>
52
+ <a href="https://github.com/topunix/django-explain-errors/actions/workflows/test.yml"><img src="https://github.com/topunix/django-explain-errors/actions/workflows/test.yml/badge.svg?branch=main" alt="Tests"></a>
53
+ <a href="https://github.com/topunix/django-explain-errors/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/django-explain-errors" alt="License"></a>
54
+ </p>
55
+
56
+ <p align="center">
57
+ <img src="https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png" width="800"
58
+ alt="Django debug page with the explanation banner under the exception headline">
59
+ </p>
60
+
61
+ *The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
62
+ failing function (`clone_latest_post`) and gives the one-line fix. Real `gpt-4o-mini` output from
63
+ the eval harness.*
64
+
65
+ - **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
66
+ - Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
67
+ - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with code and identifiers kept in English
68
+ - Sync and async views, traceback redaction, rate limiting
69
+ - Local development only: requires `DEBUG = True`
76
70
 
77
71
  ## Scope
78
72
 
@@ -85,19 +79,20 @@ If a coding agent is doing the debugging, it does not need this. Agents read tra
85
79
  and tools that expose live runtime state (debugger-over-MCP servers, `mcp-django`) serve that case
86
80
  better. This package is not trying to compete there.
87
81
 
88
- Local development only. It requires `DEBUG = True` and is inert otherwise.
89
-
90
82
  ## Features
91
83
 
92
84
  - Captures Django errors and exceptions
93
85
  - Shows the explanation on Django's debug page, directly under the
94
86
  exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
87
+ - Copy button on each code block in the explanation, so a suggested fix
88
+ copies in one click
95
89
  - Prints the explanation to stdout, for terminal workflows and logs
96
90
  (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
97
91
  - Optional JSON 500 response instead of the debug page
98
92
  (`EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False`)
99
- - Explains errors using OpenAI, Anthropic's Claude models, or any other
100
- OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`
93
+ - Explains errors using OpenAI out of the box, Anthropic's Claude models, or any other
94
+ OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`,
95
+ including fully local models, so no code has to leave your machine
101
96
  - Codebase-aware explanations via RAG over a local sqlite-vec index
102
97
  (recommended; see "Codebase-aware explanations" below)
103
98
  - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with exception names,
@@ -223,6 +218,10 @@ background) so it reads on Django's page. The explanation is HTML-escaped first,
223
218
  Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
224
219
  model is ever marked safe.
225
220
 
221
+ Each fenced code block gets a "Copy" button that copies the exact code (via
222
+ `navigator.clipboard` on secure contexts such as `localhost`, or a text-selection fallback
223
+ otherwise, for example when `runserver` is reached by IP over plain HTTP).
224
+
226
225
  **Fails open.** Any problem building or inserting the banner (a missing request, an
227
226
  unexpected debug page layout, anything else) logs one warning and leaves Django's normal
228
227
  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):
@@ -1,5 +1,6 @@
1
1
  """File discovery, chunking, and embedding for the RAG source index."""
2
2
  import ast
3
+ import contextlib
3
4
  import os
4
5
 
5
6
  from django.conf import settings
@@ -167,16 +168,21 @@ def build_index():
167
168
  os.remove(tmp_path)
168
169
 
169
170
  dimensions = len(embeddings[0]) if embeddings else 1536
170
- with VectorStore(tmp_path) as store:
171
- store.create(dimensions)
172
- store.add(
173
- (file_path, start_line, end_line, sanitized_text, embedding)
174
- for (file_path, start_line, end_line, _text), sanitized_text, embedding in zip(
175
- raw_chunks, sanitized_texts, embeddings
171
+ try:
172
+ with VectorStore(tmp_path) as store:
173
+ store.create(dimensions)
174
+ store.add(
175
+ (file_path, start_line, end_line, sanitized_text, embedding)
176
+ for (file_path, start_line, end_line, _text), sanitized_text, embedding in zip(
177
+ raw_chunks, sanitized_texts, embeddings
178
+ )
176
179
  )
177
- )
178
180
 
179
- os.replace(tmp_path, index_path)
181
+ os.replace(tmp_path, index_path)
182
+ except BaseException:
183
+ with contextlib.suppress(OSError):
184
+ os.remove(tmp_path)
185
+ raise
180
186
 
181
187
  return {
182
188
  "files_scanned": len(files),
@@ -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.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',
@@ -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")
@@ -174,6 +174,76 @@ class IndexerBuildTest(SimpleTestCase):
174
174
  ]
175
175
  self.assertEqual(leftovers, [])
176
176
 
177
+ def test_failed_rebuild_keeps_previous_index_and_removes_temp_file(self):
178
+ with tempfile.TemporaryDirectory() as tmp:
179
+ with open(os.path.join(tmp, "app.py"), "w") as f:
180
+ f.write(SAMPLE_MODULE)
181
+
182
+ index_path = os.path.join(tmp, "index.db")
183
+
184
+ with override_settings(
185
+ EXPLAIN_ERRORS_RAG_INCLUDE=[tmp],
186
+ EXPLAIN_ERRORS_RAG_INDEX_PATH=index_path,
187
+ ):
188
+ with patch(
189
+ "explain_errors.rag.indexer.get_openai_client",
190
+ return_value=_mock_embed_client([0.1, 0.2, 0.3]),
191
+ ):
192
+ build_index()
193
+
194
+ with open(index_path, "rb") as f:
195
+ original_bytes = f.read()
196
+ with VectorStore(index_path) as store:
197
+ original_count = store.count()
198
+ self.assertGreater(original_count, 0)
199
+
200
+ with patch.object(
201
+ VectorStore, "add", side_effect=RuntimeError("add failed")
202
+ ):
203
+ with self.assertRaisesRegex(RuntimeError, "add failed"):
204
+ build_index()
205
+
206
+ with open(index_path, "rb") as f:
207
+ self.assertEqual(f.read(), original_bytes)
208
+ with VectorStore(index_path) as store:
209
+ self.assertEqual(store.count(), original_count)
210
+
211
+ leftovers = [
212
+ name for name in os.listdir(tmp) if name.startswith("index.db.tmp-")
213
+ ]
214
+ self.assertEqual(leftovers, [])
215
+
216
+ def test_build_index_redacts_secrets_in_stored_chunk_text(self):
217
+ with tempfile.TemporaryDirectory() as tmp:
218
+ src = os.path.join(tmp, "src")
219
+ os.makedirs(src)
220
+ with open(os.path.join(src, "config.py"), "w") as f:
221
+ f.write(
222
+ "def load():\n"
223
+ " api_key = 'sk-index-secret-value'\n"
224
+ " return api_key\n"
225
+ )
226
+
227
+ index_path = os.path.join(tmp, "index.db")
228
+ embed_client = _mock_embed_client([0.1, 0.2, 0.3])
229
+
230
+ with override_settings(
231
+ EXPLAIN_ERRORS_RAG_INCLUDE=[src],
232
+ EXPLAIN_ERRORS_RAG_INDEX_PATH=index_path,
233
+ ):
234
+ with patch(
235
+ "explain_errors.rag.indexer.get_openai_client",
236
+ return_value=embed_client,
237
+ ):
238
+ build_index()
239
+
240
+ with VectorStore(index_path) as store:
241
+ rows = store.query([0.1, 0.2, 0.3], 10)
242
+
243
+ self.assertEqual(len(rows), 1)
244
+ self.assertNotIn("sk-index-secret-value", rows[0]["chunk_text"])
245
+ self.assertIn("[REDACTED]", rows[0]["chunk_text"])
246
+
177
247
 
178
248
  def _raise_json_error():
179
249
  return json.loads("{not valid json")
@@ -192,6 +262,35 @@ class RetrieverFrameExtractionTest(SimpleTestCase):
192
262
  self.assertTrue(any(name.endswith("test_rag.py") for name in filenames))
193
263
  self.assertFalse(any("json" in os.path.basename(name) for name in filenames))
194
264
 
265
+ def test_extract_project_frames_excludes_site_packages_frame(self):
266
+ with tempfile.TemporaryDirectory() as tmp:
267
+ site_dir = os.path.join(tmp, "venv-lib", "site-packages")
268
+ os.makedirs(site_dir)
269
+ lib_path = os.path.join(site_dir, "thirdparty.py")
270
+ app_path = os.path.join(tmp, "app.py")
271
+ with open(lib_path, "w") as f:
272
+ f.write("def boom():\n raise ValueError('lib failure')\n")
273
+ with open(app_path, "w") as f:
274
+ f.write("def call(fn):\n return fn()\n")
275
+
276
+ namespaces = {}
277
+ for path in (lib_path, app_path):
278
+ ns = {}
279
+ with open(path) as f:
280
+ exec(compile(f.read(), path, "exec"), ns)
281
+ namespaces[path] = ns
282
+
283
+ # site-packages sits inside the include dir, so only the
284
+ # site-packages rule can exclude it.
285
+ with override_settings(EXPLAIN_ERRORS_RAG_INCLUDE=[tmp]):
286
+ try:
287
+ namespaces[app_path]["call"](namespaces[lib_path]["boom"])
288
+ except ValueError as exc:
289
+ frames = extract_project_frames(exc)
290
+
291
+ filenames = [frame.filename for frame in frames]
292
+ self.assertEqual(filenames, [app_path])
293
+
195
294
  def test_extract_project_frames_empty_without_traceback(self):
196
295
  self.assertEqual(extract_project_frames(ValueError("no traceback")), [])
197
296
 
@@ -229,6 +328,50 @@ class RetrieverTopKTest(SimpleTestCase):
229
328
  self.assertEqual(retrieve_chunks(ValueError("boom")), [])
230
329
 
231
330
 
331
+ class RetrieverQuerySanitizationTest(SimpleTestCase):
332
+
333
+ @requires_sqlite_vec
334
+ def test_query_text_is_sanitized_before_embedding(self):
335
+ with tempfile.TemporaryDirectory() as tmp:
336
+ module_path = os.path.join(tmp, "leaky.py")
337
+ with open(module_path, "w") as f:
338
+ f.write(
339
+ "def fail():\n"
340
+ " password = 'hunter2-source-secret'; raise ValueError(\n"
341
+ " 'token=msg-secret-value')\n"
342
+ )
343
+
344
+ index_path = os.path.join(tmp, "index.db")
345
+ with VectorStore(index_path) as store:
346
+ store.create(3)
347
+ store.add([("leaky.py", 1, 3, "chunk", [1.0, 0.0, 0.0])])
348
+
349
+ namespace = {}
350
+ exec(compile(open(module_path).read(), module_path, "exec"), namespace)
351
+ try:
352
+ namespace["fail"]()
353
+ except ValueError as exc:
354
+ captured = exc
355
+
356
+ embed_client = _mock_embed_client([1.0, 0.0, 0.0])
357
+ with override_settings(
358
+ EXPLAIN_ERRORS_RAG_ENABLED=True,
359
+ EXPLAIN_ERRORS_RAG_INDEX_PATH=index_path,
360
+ EXPLAIN_ERRORS_RAG_INCLUDE=[tmp],
361
+ ):
362
+ with patch(
363
+ "explain_errors.rag.retriever.get_openai_client",
364
+ return_value=embed_client,
365
+ ):
366
+ retrieve_chunks(captured)
367
+
368
+ embed_client.embeddings.create.assert_called_once()
369
+ (query_text,) = embed_client.embeddings.create.call_args.kwargs["input"]
370
+ self.assertNotIn("msg-secret-value", query_text)
371
+ self.assertNotIn("hunter2-source-secret", query_text)
372
+ self.assertIn("[REDACTED]", query_text)
373
+
374
+
232
375
  @override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
233
376
  class MiddlewareRagIntegrationTest(SimpleTestCase):
234
377