django-explain-errors 0.3.1__tar.gz → 0.4.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.
- {django_explain_errors-0.3.1/django_explain_errors.egg-info → django_explain_errors-0.4.0}/PKG-INFO +19 -3
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/README.md +17 -1
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0/django_explain_errors.egg-info}/PKG-INFO +19 -3
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/SOURCES.txt +2 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/requires.txt +1 -1
- django_explain_errors-0.4.0/explain_errors/client.py +37 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/middleware.py +13 -18
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/indexer.py +1 -14
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/setup.py +2 -2
- django_explain_errors-0.4.0/tests/test_client.py +114 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_middleware.py +77 -3
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_rag.py +8 -10
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/LICENSE +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/MANIFEST.in +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/top_level.txt +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/__init__.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/__init__.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/commands/__init__.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/commands/build_error_index.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/__init__.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/retriever.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/store.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/sanitize.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/throttle.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/setup.cfg +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_build_error_index.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_sanitize.py +0 -0
- {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_throttle.py +0 -0
{django_explain_errors-0.3.1/django_explain_errors.egg-info → django_explain_errors-0.4.0}/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.4.0
|
|
4
4
|
Summary: Django middleware that captures errors and exceptions, sends them to OpenAI for a detailed explanation, and prints the explanation to stdout when debug mode is enabled. Supports both sync and async views.
|
|
5
5
|
Home-page: https://github.com/topunix/django-explain-errors
|
|
6
6
|
Author: topunix
|
|
@@ -27,7 +27,7 @@ Requires-Python: >=3.9
|
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: Django>=4.2
|
|
30
|
-
Requires-Dist: openai
|
|
30
|
+
Requires-Dist: openai<4.0,>=1.0
|
|
31
31
|
Requires-Dist: python-dotenv>=1.0
|
|
32
32
|
Requires-Dist: asgiref>=3.6
|
|
33
33
|
Provides-Extra: rag
|
|
@@ -99,7 +99,7 @@ pip install django-explain-errors
|
|
|
99
99
|
|
|
100
100
|
2. **Trigger an error in your Django application**:
|
|
101
101
|
|
|
102
|
-
The middleware will capture the error, send it to OpenAI for explanation, and print the explanation to stdout. When an exception is caught, it returns a JSON `500` response containing the error message and the explanation.
|
|
102
|
+
The middleware will capture the error, send it to OpenAI for explanation, and print the explanation to stdout. When an exception is caught, it returns a JSON `500` response containing the error message and the explanation. Set `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = True` to keep the stdout explanation while letting Django render its standard debug page instead.
|
|
103
103
|
|
|
104
104
|
## Async Support
|
|
105
105
|
|
|
@@ -120,6 +120,22 @@ No additional settings are needed. Place the middleware last in `MIDDLEWARE` for
|
|
|
120
120
|
| `OPENAI_MAX_TOKENS` | No | Maximum tokens in the explanation. Defaults to `150`. |
|
|
121
121
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for the OpenAI client. Defaults to `10`. |
|
|
122
122
|
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Traceback is trimmed to its last N characters before being sent. Defaults to `3000`. |
|
|
123
|
+
| `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` | No | When True, the middleware prints the explanation to stdout and re-raises the exception so Django renders its standard debug page instead of a JSON 500. Defaults to False. |
|
|
124
|
+
| `OPENAI_BASE_URL` (env or settings) | No | Base URL for any OpenAI-compatible API (for example Ollama at `http://localhost:11434/v1`). When set, a missing API key is replaced with a placeholder since local servers do not require one. |
|
|
125
|
+
|
|
126
|
+
## Using local models (Ollama)
|
|
127
|
+
|
|
128
|
+
Point `OPENAI_BASE_URL` at any OpenAI-compatible server to run explanations
|
|
129
|
+
against a local model instead of the OpenAI API:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
OPENAI_BASE_URL = "http://localhost:11434/v1"
|
|
133
|
+
OPENAI_MODEL = "llama3.1"
|
|
134
|
+
EXPLAIN_ERRORS_RAG_EMBED_MODEL = "nomic-embed-text"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If you use the RAG layer, rebuild the index after changing the embedding
|
|
138
|
+
model or provider. Stored vectors are model-specific.
|
|
123
139
|
|
|
124
140
|
## Codebase-aware explanations (RAG)
|
|
125
141
|
|
|
@@ -52,7 +52,7 @@ pip install django-explain-errors
|
|
|
52
52
|
|
|
53
53
|
2. **Trigger an error in your Django application**:
|
|
54
54
|
|
|
55
|
-
The middleware will capture the error, send it to OpenAI for explanation, and print the explanation to stdout. When an exception is caught, it returns a JSON `500` response containing the error message and the explanation.
|
|
55
|
+
The middleware will capture the error, send it to OpenAI for explanation, and print the explanation to stdout. When an exception is caught, it returns a JSON `500` response containing the error message and the explanation. Set `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = True` to keep the stdout explanation while letting Django render its standard debug page instead.
|
|
56
56
|
|
|
57
57
|
## Async Support
|
|
58
58
|
|
|
@@ -73,6 +73,22 @@ No additional settings are needed. Place the middleware last in `MIDDLEWARE` for
|
|
|
73
73
|
| `OPENAI_MAX_TOKENS` | No | Maximum tokens in the explanation. Defaults to `150`. |
|
|
74
74
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for the OpenAI client. Defaults to `10`. |
|
|
75
75
|
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Traceback is trimmed to its last N characters before being sent. Defaults to `3000`. |
|
|
76
|
+
| `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` | No | When True, the middleware prints the explanation to stdout and re-raises the exception so Django renders its standard debug page instead of a JSON 500. Defaults to False. |
|
|
77
|
+
| `OPENAI_BASE_URL` (env or settings) | No | Base URL for any OpenAI-compatible API (for example Ollama at `http://localhost:11434/v1`). When set, a missing API key is replaced with a placeholder since local servers do not require one. |
|
|
78
|
+
|
|
79
|
+
## Using local models (Ollama)
|
|
80
|
+
|
|
81
|
+
Point `OPENAI_BASE_URL` at any OpenAI-compatible server to run explanations
|
|
82
|
+
against a local model instead of the OpenAI API:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
OPENAI_BASE_URL = "http://localhost:11434/v1"
|
|
86
|
+
OPENAI_MODEL = "llama3.1"
|
|
87
|
+
EXPLAIN_ERRORS_RAG_EMBED_MODEL = "nomic-embed-text"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If you use the RAG layer, rebuild the index after changing the embedding
|
|
91
|
+
model or provider. Stored vectors are model-specific.
|
|
76
92
|
|
|
77
93
|
## Codebase-aware explanations (RAG)
|
|
78
94
|
|
{django_explain_errors-0.3.1 → django_explain_errors-0.4.0/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.4.0
|
|
4
4
|
Summary: Django middleware that captures errors and exceptions, sends them to OpenAI for a detailed explanation, and prints the explanation to stdout when debug mode is enabled. Supports both sync and async views.
|
|
5
5
|
Home-page: https://github.com/topunix/django-explain-errors
|
|
6
6
|
Author: topunix
|
|
@@ -27,7 +27,7 @@ Requires-Python: >=3.9
|
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: Django>=4.2
|
|
30
|
-
Requires-Dist: openai
|
|
30
|
+
Requires-Dist: openai<4.0,>=1.0
|
|
31
31
|
Requires-Dist: python-dotenv>=1.0
|
|
32
32
|
Requires-Dist: asgiref>=3.6
|
|
33
33
|
Provides-Extra: rag
|
|
@@ -99,7 +99,7 @@ pip install django-explain-errors
|
|
|
99
99
|
|
|
100
100
|
2. **Trigger an error in your Django application**:
|
|
101
101
|
|
|
102
|
-
The middleware will capture the error, send it to OpenAI for explanation, and print the explanation to stdout. When an exception is caught, it returns a JSON `500` response containing the error message and the explanation.
|
|
102
|
+
The middleware will capture the error, send it to OpenAI for explanation, and print the explanation to stdout. When an exception is caught, it returns a JSON `500` response containing the error message and the explanation. Set `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = True` to keep the stdout explanation while letting Django render its standard debug page instead.
|
|
103
103
|
|
|
104
104
|
## Async Support
|
|
105
105
|
|
|
@@ -120,6 +120,22 @@ No additional settings are needed. Place the middleware last in `MIDDLEWARE` for
|
|
|
120
120
|
| `OPENAI_MAX_TOKENS` | No | Maximum tokens in the explanation. Defaults to `150`. |
|
|
121
121
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for the OpenAI client. Defaults to `10`. |
|
|
122
122
|
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Traceback is trimmed to its last N characters before being sent. Defaults to `3000`. |
|
|
123
|
+
| `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` | No | When True, the middleware prints the explanation to stdout and re-raises the exception so Django renders its standard debug page instead of a JSON 500. Defaults to False. |
|
|
124
|
+
| `OPENAI_BASE_URL` (env or settings) | No | Base URL for any OpenAI-compatible API (for example Ollama at `http://localhost:11434/v1`). When set, a missing API key is replaced with a placeholder since local servers do not require one. |
|
|
125
|
+
|
|
126
|
+
## Using local models (Ollama)
|
|
127
|
+
|
|
128
|
+
Point `OPENAI_BASE_URL` at any OpenAI-compatible server to run explanations
|
|
129
|
+
against a local model instead of the OpenAI API:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
OPENAI_BASE_URL = "http://localhost:11434/v1"
|
|
133
|
+
OPENAI_MODEL = "llama3.1"
|
|
134
|
+
EXPLAIN_ERRORS_RAG_EMBED_MODEL = "nomic-embed-text"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If you use the RAG layer, rebuild the index after changing the embedding
|
|
138
|
+
model or provider. Stored vectors are model-specific.
|
|
123
139
|
|
|
124
140
|
## Codebase-aware explanations (RAG)
|
|
125
141
|
|
|
@@ -8,6 +8,7 @@ django_explain_errors.egg-info/dependency_links.txt
|
|
|
8
8
|
django_explain_errors.egg-info/requires.txt
|
|
9
9
|
django_explain_errors.egg-info/top_level.txt
|
|
10
10
|
explain_errors/__init__.py
|
|
11
|
+
explain_errors/client.py
|
|
11
12
|
explain_errors/middleware.py
|
|
12
13
|
explain_errors/sanitize.py
|
|
13
14
|
explain_errors/throttle.py
|
|
@@ -19,6 +20,7 @@ explain_errors/rag/indexer.py
|
|
|
19
20
|
explain_errors/rag/retriever.py
|
|
20
21
|
explain_errors/rag/store.py
|
|
21
22
|
tests/test_build_error_index.py
|
|
23
|
+
tests/test_client.py
|
|
22
24
|
tests/test_middleware.py
|
|
23
25
|
tests/test_rag.py
|
|
24
26
|
tests/test_sanitize.py
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from dotenv import load_dotenv, find_dotenv
|
|
5
|
+
|
|
6
|
+
PLACEHOLDER_API_KEY = "sk-no-key-required"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_openai_client(timeout=None):
|
|
10
|
+
"""Build the OpenAI client used by the middleware and the
|
|
11
|
+
RAG indexer. Honors OPENAI_BASE_URL for OpenAI-compatible
|
|
12
|
+
servers (Ollama, LM Studio, gateways)."""
|
|
13
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
|
|
16
|
+
base_url = os.getenv(
|
|
17
|
+
"OPENAI_BASE_URL", getattr(settings, "OPENAI_BASE_URL", None)
|
|
18
|
+
)
|
|
19
|
+
api_key = os.getenv(
|
|
20
|
+
"OPENAI_API_KEY", getattr(settings, "OPENAI_API_KEY", None)
|
|
21
|
+
)
|
|
22
|
+
if not api_key:
|
|
23
|
+
if base_url:
|
|
24
|
+
# Local OpenAI-compatible servers ignore the key but
|
|
25
|
+
# the SDK requires one.
|
|
26
|
+
api_key = PLACEHOLDER_API_KEY
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"OpenAI API key not found. Please set the "
|
|
30
|
+
"OPENAI_API_KEY environment variable."
|
|
31
|
+
)
|
|
32
|
+
kwargs = {"api_key": api_key}
|
|
33
|
+
if base_url:
|
|
34
|
+
kwargs["base_url"] = base_url
|
|
35
|
+
if timeout is not None:
|
|
36
|
+
kwargs["timeout"] = timeout
|
|
37
|
+
return OpenAI(**kwargs)
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import asyncio
|
|
3
2
|
import logging
|
|
4
3
|
import traceback
|
|
5
4
|
|
|
6
|
-
from openai import OpenAI
|
|
7
|
-
from dotenv import load_dotenv, find_dotenv
|
|
8
5
|
from django.conf import settings
|
|
9
6
|
from django.http import JsonResponse
|
|
10
7
|
from asgiref.sync import sync_to_async
|
|
11
8
|
|
|
9
|
+
from .client import get_openai_client
|
|
12
10
|
from .sanitize import sanitize_traceback
|
|
13
11
|
from .throttle import SlidingWindowThrottle
|
|
14
12
|
from .rag.retriever import format_chunks_for_prompt, retrieve_chunks
|
|
@@ -37,24 +35,12 @@ class ExplainErrorsMiddleware:
|
|
|
37
35
|
window_seconds = getattr(settings, "EXPLAIN_ERRORS_WINDOW_SECONDS", 60)
|
|
38
36
|
self.throttle = SlidingWindowThrottle(max_calls, window_seconds)
|
|
39
37
|
|
|
40
|
-
# Load environment variables from .env file
|
|
41
|
-
load_dotenv(find_dotenv(usecwd=True))
|
|
42
|
-
# Get the OpenAI API key from environment variable (or settings)
|
|
43
|
-
openai_api_key = os.getenv(
|
|
44
|
-
"OPENAI_API_KEY", getattr(settings, "OPENAI_API_KEY", None)
|
|
45
|
-
)
|
|
46
|
-
if not openai_api_key:
|
|
47
|
-
raise ValueError(
|
|
48
|
-
"OpenAI API key not found. Please set the OPENAI_API_KEY "
|
|
49
|
-
"environment variable."
|
|
50
|
-
)
|
|
51
|
-
|
|
52
38
|
# Configurable via settings, with sensible defaults.
|
|
53
39
|
self.model = getattr(settings, "OPENAI_MODEL", "gpt-4o-mini")
|
|
54
40
|
self.max_tokens = getattr(settings, "OPENAI_MAX_TOKENS", 150)
|
|
55
41
|
timeout = getattr(settings, "OPENAI_TIMEOUT", 10)
|
|
56
42
|
|
|
57
|
-
self.openai_client =
|
|
43
|
+
self.openai_client = get_openai_client(timeout=timeout)
|
|
58
44
|
|
|
59
45
|
def __call__(self, request):
|
|
60
46
|
# Delegate to the async path when wrapped around an async view chain.
|
|
@@ -67,7 +53,10 @@ class ExplainErrorsMiddleware:
|
|
|
67
53
|
try:
|
|
68
54
|
response = self.get_response(request)
|
|
69
55
|
except Exception as exception:
|
|
70
|
-
|
|
56
|
+
response = self.process_exception(request, exception)
|
|
57
|
+
if response is None:
|
|
58
|
+
raise
|
|
59
|
+
return response
|
|
71
60
|
return response
|
|
72
61
|
|
|
73
62
|
# --------- Async path ----------
|
|
@@ -77,7 +66,10 @@ class ExplainErrorsMiddleware:
|
|
|
77
66
|
except Exception as exception:
|
|
78
67
|
# process_exception performs blocking OpenAI I/O, so run it in a
|
|
79
68
|
# thread to keep the event loop free.
|
|
80
|
-
|
|
69
|
+
response = await sync_to_async(self.process_exception)(request, exception)
|
|
70
|
+
if response is None:
|
|
71
|
+
raise
|
|
72
|
+
return response
|
|
81
73
|
return response
|
|
82
74
|
|
|
83
75
|
def process_exception(self, request, exception):
|
|
@@ -138,6 +130,9 @@ class ExplainErrorsMiddleware:
|
|
|
138
130
|
# a 500 so the request lifecycle completes cleanly.
|
|
139
131
|
print("Failed to get an explanation from OpenAI:", e)
|
|
140
132
|
|
|
133
|
+
if getattr(settings, "EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE", False):
|
|
134
|
+
return None
|
|
135
|
+
|
|
141
136
|
return JsonResponse(
|
|
142
137
|
{"error": "An error occurred.", "message": explanation}, status=500
|
|
143
138
|
)
|
|
@@ -3,8 +3,8 @@ import ast
|
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
5
|
from django.conf import settings
|
|
6
|
-
from dotenv import load_dotenv, find_dotenv
|
|
7
6
|
|
|
7
|
+
from ..client import get_openai_client
|
|
8
8
|
from ..sanitize import sanitize_traceback
|
|
9
9
|
from .store import VectorStore
|
|
10
10
|
|
|
@@ -62,19 +62,6 @@ def get_embed_model():
|
|
|
62
62
|
return getattr(settings, "EXPLAIN_ERRORS_RAG_EMBED_MODEL", "text-embedding-3-small")
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def get_openai_client():
|
|
66
|
-
load_dotenv(find_dotenv(usecwd=True))
|
|
67
|
-
from openai import OpenAI
|
|
68
|
-
|
|
69
|
-
api_key = os.getenv("OPENAI_API_KEY", getattr(settings, "OPENAI_API_KEY", None))
|
|
70
|
-
if not api_key:
|
|
71
|
-
raise ValueError(
|
|
72
|
-
"OpenAI API key not found. Please set the OPENAI_API_KEY "
|
|
73
|
-
"environment variable."
|
|
74
|
-
)
|
|
75
|
-
return OpenAI(api_key=api_key)
|
|
76
|
-
|
|
77
|
-
|
|
78
65
|
def discover_files(include_dirs, exclude_dirs):
|
|
79
66
|
"""Walk include_dirs, pruning excluded directory names, keep chunkable files."""
|
|
80
67
|
exclude_set = set(exclude_dirs)
|
|
@@ -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.4.0',
|
|
14
14
|
packages=find_packages(exclude=['tests', 'tests.*']),
|
|
15
15
|
description='Django middleware that captures errors and exceptions, sends them to OpenAI for a detailed explanation, and prints the explanation to stdout when debug mode is enabled. Supports both sync and async views.',
|
|
16
16
|
long_description_content_type='text/markdown',
|
|
@@ -18,7 +18,7 @@ setup(
|
|
|
18
18
|
python_requires='>=3.9',
|
|
19
19
|
install_requires=[
|
|
20
20
|
'Django>=4.2',
|
|
21
|
-
'openai>=1.0',
|
|
21
|
+
'openai>=1.0,<4.0',
|
|
22
22
|
'python-dotenv>=1.0',
|
|
23
23
|
'asgiref>=3.6',
|
|
24
24
|
],
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from django.test import SimpleTestCase, override_settings
|
|
5
|
+
|
|
6
|
+
from explain_errors.client import PLACEHOLDER_API_KEY, get_openai_client
|
|
7
|
+
from explain_errors.middleware import ExplainErrorsMiddleware
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GetOpenAIClientTest(SimpleTestCase):
|
|
11
|
+
|
|
12
|
+
def _clear_env(self):
|
|
13
|
+
"""Pop the env vars the factory reads; patch.dict restores the
|
|
14
|
+
original environment on exit regardless of these mutations."""
|
|
15
|
+
os.environ.pop("OPENAI_API_KEY", None)
|
|
16
|
+
os.environ.pop("OPENAI_BASE_URL", None)
|
|
17
|
+
|
|
18
|
+
@override_settings(OPENAI_API_KEY="test-key")
|
|
19
|
+
def test_default_no_base_url_kwarg(self):
|
|
20
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
21
|
+
self._clear_env()
|
|
22
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
23
|
+
get_openai_client()
|
|
24
|
+
|
|
25
|
+
mock_cls.assert_called_once()
|
|
26
|
+
kwargs = mock_cls.call_args.kwargs
|
|
27
|
+
self.assertEqual(kwargs["api_key"], "test-key")
|
|
28
|
+
self.assertNotIn("base_url", kwargs)
|
|
29
|
+
|
|
30
|
+
@override_settings(
|
|
31
|
+
OPENAI_API_KEY="test-key",
|
|
32
|
+
OPENAI_BASE_URL="http://localhost:11434/v1",
|
|
33
|
+
)
|
|
34
|
+
def test_base_url_from_settings_passed_to_client(self):
|
|
35
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
36
|
+
self._clear_env()
|
|
37
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
38
|
+
get_openai_client()
|
|
39
|
+
|
|
40
|
+
kwargs = mock_cls.call_args.kwargs
|
|
41
|
+
self.assertEqual(kwargs["base_url"], "http://localhost:11434/v1")
|
|
42
|
+
|
|
43
|
+
@override_settings(
|
|
44
|
+
OPENAI_API_KEY="test-key",
|
|
45
|
+
OPENAI_BASE_URL="http://settings-host/v1",
|
|
46
|
+
)
|
|
47
|
+
def test_base_url_env_var_takes_precedence_over_settings(self):
|
|
48
|
+
with patch.dict(os.environ, {"OPENAI_BASE_URL": "http://env-host/v1"}):
|
|
49
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
50
|
+
get_openai_client()
|
|
51
|
+
|
|
52
|
+
kwargs = mock_cls.call_args.kwargs
|
|
53
|
+
self.assertEqual(kwargs["base_url"], "http://env-host/v1")
|
|
54
|
+
|
|
55
|
+
@override_settings(OPENAI_BASE_URL="http://localhost:11434/v1")
|
|
56
|
+
def test_missing_key_with_base_url_uses_placeholder(self):
|
|
57
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
58
|
+
self._clear_env()
|
|
59
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
60
|
+
get_openai_client()
|
|
61
|
+
|
|
62
|
+
kwargs = mock_cls.call_args.kwargs
|
|
63
|
+
self.assertEqual(kwargs["api_key"], PLACEHOLDER_API_KEY)
|
|
64
|
+
|
|
65
|
+
def test_missing_key_without_base_url_raises(self):
|
|
66
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
67
|
+
self._clear_env()
|
|
68
|
+
with self.assertRaises(ValueError) as ctx:
|
|
69
|
+
get_openai_client()
|
|
70
|
+
|
|
71
|
+
self.assertEqual(
|
|
72
|
+
str(ctx.exception),
|
|
73
|
+
"OpenAI API key not found. Please set the "
|
|
74
|
+
"OPENAI_API_KEY environment variable.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@override_settings(OPENAI_API_KEY="test-key")
|
|
78
|
+
def test_timeout_forwarded_when_provided(self):
|
|
79
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
80
|
+
self._clear_env()
|
|
81
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
82
|
+
get_openai_client(timeout=15)
|
|
83
|
+
|
|
84
|
+
self.assertEqual(mock_cls.call_args.kwargs["timeout"], 15)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MiddlewareUsesFactoryTest(SimpleTestCase):
|
|
88
|
+
|
|
89
|
+
@override_settings(DEBUG=True, OPENAI_API_KEY="test-key", OPENAI_TIMEOUT=7)
|
|
90
|
+
def test_middleware_uses_factory(self):
|
|
91
|
+
with patch("explain_errors.middleware.get_openai_client") as mock_factory:
|
|
92
|
+
ExplainErrorsMiddleware(lambda r: None)
|
|
93
|
+
|
|
94
|
+
mock_factory.assert_called_once_with(timeout=7)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class IndexerUsesFactoryTest(SimpleTestCase):
|
|
98
|
+
|
|
99
|
+
@override_settings(
|
|
100
|
+
OPENAI_API_KEY="test-key",
|
|
101
|
+
OPENAI_BASE_URL="http://localhost:11434/v1",
|
|
102
|
+
)
|
|
103
|
+
def test_indexer_uses_factory(self):
|
|
104
|
+
from explain_errors.rag.indexer import get_openai_client as indexer_get_client
|
|
105
|
+
|
|
106
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
107
|
+
os.environ.pop("OPENAI_API_KEY", None)
|
|
108
|
+
os.environ.pop("OPENAI_BASE_URL", None)
|
|
109
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
110
|
+
indexer_get_client()
|
|
111
|
+
|
|
112
|
+
kwargs = mock_cls.call_args.kwargs
|
|
113
|
+
self.assertEqual(kwargs["api_key"], "test-key")
|
|
114
|
+
self.assertEqual(kwargs["base_url"], "http://localhost:11434/v1")
|
|
@@ -8,8 +8,14 @@ from explain_errors.middleware import ExplainErrorsMiddleware
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def _mock_openai():
|
|
11
|
-
"""Return a patch context for OpenAI plus a configured fake client.
|
|
12
|
-
|
|
11
|
+
"""Return a patch context for OpenAI plus a configured fake client.
|
|
12
|
+
|
|
13
|
+
Patches openai.OpenAI at its source (rather than
|
|
14
|
+
explain_errors.middleware.OpenAI) because the client is now built by
|
|
15
|
+
explain_errors.client.get_openai_client(), which imports OpenAI
|
|
16
|
+
locally inside its function body.
|
|
17
|
+
"""
|
|
18
|
+
patcher = patch("openai.OpenAI")
|
|
13
19
|
mock_cls = patcher.start()
|
|
14
20
|
client = MagicMock()
|
|
15
21
|
client.chat.completions.create.return_value = MagicMock(
|
|
@@ -148,6 +154,74 @@ class ExplainErrorsMiddlewareDebugOffTest(SimpleTestCase):
|
|
|
148
154
|
mw = ExplainErrorsMiddleware(lambda r: None)
|
|
149
155
|
self.assertIsNone(mw.process_exception(factory.get("/"), Exception("x")))
|
|
150
156
|
|
|
157
|
+
def test_debug_false_handler_reraises(self):
|
|
158
|
+
factory = RequestFactory()
|
|
159
|
+
|
|
160
|
+
def boom(r):
|
|
161
|
+
raise ValueError("boom")
|
|
162
|
+
|
|
163
|
+
mw = ExplainErrorsMiddleware(boom)
|
|
164
|
+
with self.assertRaises(ValueError):
|
|
165
|
+
mw(factory.get("/"))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
|
|
169
|
+
class ExplainErrorsMiddlewarePreserveDebugPageTest(SimpleTestCase):
|
|
170
|
+
|
|
171
|
+
def setUp(self):
|
|
172
|
+
self.factory = RequestFactory()
|
|
173
|
+
self.patcher, self.client = _mock_openai()
|
|
174
|
+
self.addCleanup(self.patcher.stop)
|
|
175
|
+
|
|
176
|
+
@override_settings(EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True)
|
|
177
|
+
def test_preserve_flag_reraises_original_exception_sync(self):
|
|
178
|
+
def boom(r):
|
|
179
|
+
raise ValueError("boom")
|
|
180
|
+
|
|
181
|
+
mw = ExplainErrorsMiddleware(boom)
|
|
182
|
+
with self.assertRaises(ValueError) as ctx:
|
|
183
|
+
mw(self.factory.get("/"))
|
|
184
|
+
self.assertEqual(str(ctx.exception), "boom")
|
|
185
|
+
|
|
186
|
+
@override_settings(EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True)
|
|
187
|
+
def test_preserve_flag_still_prints_explanation(self):
|
|
188
|
+
def boom(r):
|
|
189
|
+
raise ValueError("boom")
|
|
190
|
+
|
|
191
|
+
mw = ExplainErrorsMiddleware(boom)
|
|
192
|
+
with patch("builtins.print") as mock_print:
|
|
193
|
+
with self.assertRaises(ValueError):
|
|
194
|
+
mw(self.factory.get("/"))
|
|
195
|
+
mock_print.assert_any_call("Error Explanation by OpenAI:\n", "Mocked explanation.")
|
|
196
|
+
|
|
197
|
+
def test_preserve_flag_default_off_returns_json_500(self):
|
|
198
|
+
def boom(r):
|
|
199
|
+
raise ValueError("boom")
|
|
200
|
+
|
|
201
|
+
mw = ExplainErrorsMiddleware(boom)
|
|
202
|
+
resp = mw(self.factory.get("/"))
|
|
203
|
+
self.assertIsInstance(resp, JsonResponse)
|
|
204
|
+
self.assertEqual(resp.status_code, 500)
|
|
205
|
+
self.assertIn("error", json.loads(resp.content))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@override_settings(DEBUG=True, OPENAI_API_KEY="test-key", EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True)
|
|
209
|
+
class ExplainErrorsMiddlewarePreserveDebugPageAsyncTest(SimpleTestCase):
|
|
210
|
+
|
|
211
|
+
def setUp(self):
|
|
212
|
+
self.factory = AsyncRequestFactory()
|
|
213
|
+
self.patcher, self.client = _mock_openai()
|
|
214
|
+
self.addCleanup(self.patcher.stop)
|
|
215
|
+
|
|
216
|
+
async def test_preserve_flag_reraises_original_exception_async(self):
|
|
217
|
+
async def boom(r):
|
|
218
|
+
raise ValueError("async boom")
|
|
219
|
+
|
|
220
|
+
mw = ExplainErrorsMiddleware(boom)
|
|
221
|
+
with self.assertRaises(ValueError) as ctx:
|
|
222
|
+
await mw(self.factory.get("/"))
|
|
223
|
+
self.assertEqual(str(ctx.exception), "async boom")
|
|
224
|
+
|
|
151
225
|
|
|
152
226
|
@override_settings(DEBUG=True, OPENAI_API_KEY="test-key")
|
|
153
227
|
class OpenAICallConfigTest(SimpleTestCase):
|
|
@@ -185,6 +259,6 @@ class OpenAICallConfigTest(SimpleTestCase):
|
|
|
185
259
|
|
|
186
260
|
def test_timeout_passed_to_client(self):
|
|
187
261
|
with override_settings(OPENAI_TIMEOUT=7):
|
|
188
|
-
with patch("
|
|
262
|
+
with patch("openai.OpenAI") as mock_cls:
|
|
189
263
|
ExplainErrorsMiddleware(lambda r: None)
|
|
190
264
|
self.assertEqual(mock_cls.call_args.kwargs["timeout"], 7)
|
|
@@ -39,17 +39,15 @@ requires_sqlite_vec = unittest.skipUnless(
|
|
|
39
39
|
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
def test_get_openai_client_loads_dotenv(self):
|
|
44
|
-
with patch("explain_errors.rag.indexer.load_dotenv") as mock_load:
|
|
45
|
-
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}):
|
|
46
|
-
get_openai_client()
|
|
47
|
-
mock_load.assert_called_once()
|
|
48
|
-
|
|
49
|
-
|
|
50
42
|
def _mock_openai():
|
|
51
|
-
"""Patch the chat-completion OpenAI client used by the middleware.
|
|
52
|
-
|
|
43
|
+
"""Patch the chat-completion OpenAI client used by the middleware.
|
|
44
|
+
|
|
45
|
+
Patches openai.OpenAI at its source (rather than
|
|
46
|
+
explain_errors.middleware.OpenAI) because the client is now built by
|
|
47
|
+
explain_errors.client.get_openai_client(), which imports OpenAI
|
|
48
|
+
locally inside its function body.
|
|
49
|
+
"""
|
|
50
|
+
patcher = patch("openai.OpenAI")
|
|
53
51
|
mock_cls = patcher.start()
|
|
54
52
|
client = MagicMock()
|
|
55
53
|
client.chat.completions.create.return_value = MagicMock(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/__init__.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
|