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.
Files changed (29) hide show
  1. {django_explain_errors-0.3.1/django_explain_errors.egg-info → django_explain_errors-0.4.0}/PKG-INFO +19 -3
  2. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/README.md +17 -1
  3. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0/django_explain_errors.egg-info}/PKG-INFO +19 -3
  4. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/SOURCES.txt +2 -0
  5. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/requires.txt +1 -1
  6. django_explain_errors-0.4.0/explain_errors/client.py +37 -0
  7. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/middleware.py +13 -18
  8. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/indexer.py +1 -14
  9. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/setup.py +2 -2
  10. django_explain_errors-0.4.0/tests/test_client.py +114 -0
  11. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_middleware.py +77 -3
  12. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_rag.py +8 -10
  13. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/LICENSE +0 -0
  14. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/MANIFEST.in +0 -0
  15. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  16. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  17. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/__init__.py +0 -0
  18. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/__init__.py +0 -0
  19. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/commands/__init__.py +0 -0
  20. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/management/commands/build_error_index.py +0 -0
  21. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/__init__.py +0 -0
  22. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/retriever.py +0 -0
  23. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/rag/store.py +0 -0
  24. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/sanitize.py +0 -0
  25. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/explain_errors/throttle.py +0 -0
  26. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/setup.cfg +0 -0
  27. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_build_error_index.py +0 -0
  28. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_sanitize.py +0 -0
  29. {django_explain_errors-0.3.1 → django_explain_errors-0.4.0}/tests/test_throttle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.3.1
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>=1.0
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.3.1
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>=1.0
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
@@ -1,5 +1,5 @@
1
1
  Django>=4.2
2
- openai>=1.0
2
+ openai<4.0,>=1.0
3
3
  python-dotenv>=1.0
4
4
  asgiref>=3.6
5
5
 
@@ -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 = OpenAI(api_key=openai_api_key, timeout=timeout)
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
- return self.process_exception(request, exception)
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
- return await sync_to_async(self.process_exception)(request, exception)
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.3.1',
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
- patcher = patch("explain_errors.middleware.OpenAI")
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("explain_errors.middleware.OpenAI") as mock_cls:
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
- patcher = patch("explain_errors.middleware.OpenAI")
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(