shadow-debugger 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,244 @@
1
+ """Background engine: bounded queue + worker threads that never block the host application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import dataclasses
7
+ import logging
8
+ import queue
9
+ import threading
10
+ import time
11
+ from types import TracebackType
12
+ from typing import Any, Dict, List, Optional, Type
13
+
14
+ from .analyzer import AIAnalyzer
15
+ from .config import ShadowConfig
16
+ from .inspector import capture_exception_context, compute_fingerprint, fingerprint_exception
17
+ from .patch_check import check_patch_syntax
18
+ from .providers import ProviderError
19
+ from .redaction import Redactor
20
+ from .reporter import ReportManager
21
+ from .throttler import ErrorThrottler
22
+
23
+ logger = logging.getLogger("shadow_debugger")
24
+
25
+ _CAPTURED_FLAG = "__shadow_debugger_captured__"
26
+
27
+
28
+ class ShadowCore:
29
+ """Owns the analysis pipeline. Usually created through :func:`shadow_debugger.init`.
30
+
31
+ Accepts either a :class:`ShadowConfig` or the same keyword arguments::
32
+
33
+ ShadowCore(provider="ollama", max_queue_size=10)
34
+ """
35
+
36
+ def __init__(self, config: Optional[ShadowConfig] = None, **overrides: Any) -> None:
37
+ if "verify_patches" in overrides:
38
+ vp = overrides.pop("verify_patches")
39
+ overrides.setdefault("check_patch_syntax", bool(vp))
40
+ if config is None:
41
+ config = ShadowConfig(**overrides)
42
+ elif overrides:
43
+ config = dataclasses.replace(config, **overrides)
44
+ self.config = config
45
+
46
+ self.redactor = Redactor(config.redact_keys, config.redact_patterns)
47
+ self.analyzer = AIAnalyzer(
48
+ provider=config.provider,
49
+ model=config.model,
50
+ api_key=config.api_key,
51
+ base_url=config.base_url,
52
+ timeout=config.request_timeout,
53
+ fallback_models=config.fallback_models,
54
+ )
55
+ self.reporter = ReportManager(output_dir=config.output_dir, print_to_console=config.print_to_console)
56
+ self.throttler = ErrorThrottler(config.cooldown_seconds, config.max_tracked_errors)
57
+
58
+ # Bounded queue: an error flood can never exhaust memory.
59
+ self.queue: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=config.max_queue_size)
60
+ self._lock = threading.Lock()
61
+ self._dropped = 0
62
+ self._closed = False
63
+ self._stop = threading.Event()
64
+ self._threads: List[threading.Thread] = []
65
+ for i in range(config.max_workers):
66
+ t = threading.Thread(target=self._worker_loop, name=f"ShadowDebugger-{i}", daemon=True)
67
+ t.start()
68
+ self._threads.append(t)
69
+
70
+ atexit.register(self._atexit)
71
+
72
+ @property
73
+ def dropped_incidents(self) -> int:
74
+ with self._lock:
75
+ return self._dropped
76
+
77
+ @property
78
+ def _dropped_incidents_count(self) -> int:
79
+ return self.dropped_incidents
80
+
81
+ @property
82
+ def is_closed(self) -> bool:
83
+ return self._closed
84
+
85
+ def capture(
86
+ self,
87
+ exc_type: Type[BaseException],
88
+ exc_value: BaseException,
89
+ exc_tb: Optional[TracebackType],
90
+ extra_metadata: Optional[Dict[str, Any]] = None,
91
+ ) -> Optional[str]:
92
+ """Hot-path entry point. Returns the error fingerprint, or None if ignored.
93
+
94
+ Never raises: a bug in the debugger must never break the host application.
95
+ """
96
+ try:
97
+ if self._closed:
98
+ return None
99
+ if getattr(exc_value, _CAPTURED_FLAG, False):
100
+ return None # already captured by an inner layer (decorator + middleware)
101
+ try:
102
+ setattr(exc_value, _CAPTURED_FLAG, True)
103
+ except Exception:
104
+ pass
105
+
106
+ # Throttle BEFORE the expensive part (locals serialization, source reading).
107
+ fingerprint = fingerprint_exception(exc_type, exc_tb)
108
+ should_analyze, count = self.throttler.hit(fingerprint)
109
+ if not should_analyze:
110
+ logger.debug("Throttled duplicate %s (seen %d times, %s)", exc_type.__name__, count, fingerprint)
111
+ return fingerprint
112
+
113
+ context = capture_exception_context(
114
+ exc_type,
115
+ exc_value,
116
+ exc_tb,
117
+ extra_metadata,
118
+ redactor=self.redactor,
119
+ context_lines=self.config.context_lines,
120
+ include_source=self.config.send_source_code,
121
+ include_locals=self.config.send_locals,
122
+ )
123
+ context["occurrence_count"] = count
124
+ self._enqueue(context)
125
+ return fingerprint
126
+ except Exception: # pragma: no cover - defensive
127
+ logger.exception("Shadow Debugger failed to capture an exception")
128
+ return None
129
+
130
+ def capture_exception(
131
+ self, exc: BaseException, extra_metadata: Optional[Dict[str, Any]] = None
132
+ ) -> Optional[str]:
133
+ return self.capture(type(exc), exc, exc.__traceback__, extra_metadata)
134
+
135
+ def submit_incident(self, error_context: Dict[str, Any]) -> None:
136
+ """Queue a pre-built incident dict (advanced use). Applies throttling."""
137
+ fingerprint = error_context.get("fingerprint") or compute_fingerprint(
138
+ type(str(error_context.get("exception_type", ""))), # type: ignore[arg-type]
139
+ str(error_context.get("file", "")),
140
+ int(error_context.get("line", -1) or -1),
141
+ )
142
+ should_analyze, count = self.throttler.hit(fingerprint)
143
+ if not should_analyze:
144
+ return
145
+ error_context = {**error_context, "fingerprint": fingerprint, "occurrence_count": count}
146
+ self._enqueue(error_context)
147
+
148
+ def flush(self, timeout: Optional[float] = None) -> bool:
149
+ """Block until all queued incidents are processed. Returns False on timeout."""
150
+ deadline = None if timeout is None else time.monotonic() + timeout
151
+ with self.queue.all_tasks_done:
152
+ while self.queue.unfinished_tasks:
153
+ if deadline is None:
154
+ self.queue.all_tasks_done.wait()
155
+ continue
156
+ remaining = deadline - time.monotonic()
157
+ if remaining <= 0:
158
+ return False
159
+ self.queue.all_tasks_done.wait(remaining)
160
+ return True
161
+
162
+ def shutdown(self, timeout: Optional[float] = None, wait: Optional[bool] = None) -> bool:
163
+ """Stop accepting incidents, wait up to ``timeout`` s for pending ones, stop workers.
164
+
165
+ ``timeout=None`` uses ``config.shutdown_timeout``. Returns True if everything finished.
166
+ """
167
+ if self._closed:
168
+ return True
169
+ self._closed = True
170
+ if wait is False:
171
+ timeout = 0.0
172
+ elif timeout is None:
173
+ timeout = self.config.shutdown_timeout
174
+ finished = self.flush(timeout) if timeout > 0 else (self.queue.unfinished_tasks == 0)
175
+ if not finished:
176
+ logger.warning(
177
+ "Shadow Debugger shutdown: %d incident(s) were still being analyzed after %.1fs and were abandoned.",
178
+ self.queue.unfinished_tasks,
179
+ timeout,
180
+ )
181
+ self._stop.set()
182
+ for t in self._threads:
183
+ t.join(timeout=1.0)
184
+ try:
185
+ atexit.unregister(self._atexit)
186
+ except Exception: # pragma: no cover
187
+ pass
188
+ return finished
189
+
190
+ def _atexit(self) -> None:
191
+ pending = self.queue.unfinished_tasks
192
+ if pending:
193
+ logger.warning(
194
+ "Shadow Debugger: waiting up to %.0fs for %d pending diagnosis(es) before exit...",
195
+ self.config.shutdown_timeout,
196
+ pending,
197
+ )
198
+ self.shutdown()
199
+
200
+ def _enqueue(self, context: Dict[str, Any]) -> None:
201
+ try:
202
+ self.queue.put_nowait(context)
203
+ except queue.Full:
204
+ with self._lock:
205
+ self._dropped += 1
206
+ dropped = self._dropped
207
+ if dropped == 1 or dropped % 100 == 0:
208
+ logger.warning(
209
+ "Shadow Debugger queue is full (%d). Dropped incident %s; total dropped: %d",
210
+ self.queue.maxsize,
211
+ context.get("exception_type", "?"),
212
+ dropped,
213
+ )
214
+
215
+ def _worker_loop(self) -> None:
216
+ while not self._stop.is_set():
217
+ try:
218
+ context = self.queue.get(timeout=0.25)
219
+ except queue.Empty:
220
+ continue
221
+ try:
222
+ self._process(context)
223
+ except Exception: # pragma: no cover - defensive
224
+ logger.exception("Shadow Debugger worker crashed while processing an incident")
225
+ finally:
226
+ self.queue.task_done()
227
+
228
+ def _process(self, context: Dict[str, Any]) -> None:
229
+ try:
230
+ analysis = self.analyzer.analyze(context)
231
+ except ProviderError as exc:
232
+ logger.error("Shadow Debugger could not analyze %s: %s", context.get("exception_type"), exc)
233
+ return
234
+ except Exception:
235
+ logger.exception("Shadow Debugger: unexpected provider failure")
236
+ return
237
+
238
+ context["model"] = self.analyzer.model
239
+ if self.config.check_patch_syntax:
240
+ context["patch_check"] = check_patch_syntax(analysis).to_dict()
241
+ try:
242
+ self.reporter.report(context, analysis)
243
+ except Exception:
244
+ logger.exception("Shadow Debugger failed to write the report")
@@ -0,0 +1,176 @@
1
+ """Collects a redacted snapshot of an exception: location, source, locals, traceback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import linecache
7
+ import os
8
+ import platform
9
+ import sys
10
+ import sysconfig
11
+ import traceback
12
+ from types import FrameType, TracebackType
13
+ from typing import Any, Dict, List, Optional, Tuple, Type
14
+
15
+ from .redaction import Redactor
16
+
17
+ MAX_TRACEBACK_CHARS = 8000
18
+ MAX_MESSAGE_CHARS = 2000
19
+
20
+ _DEFAULT_REDACTOR = Redactor()
21
+
22
+
23
+ def redact_sensitive_data(key: str, value: Any, max_depth: int = 4) -> Any:
24
+ """Convenience wrapper around :class:`Redactor` for single-value sanitization."""
25
+ redactor = _DEFAULT_REDACTOR if max_depth == 4 else Redactor(max_depth=max_depth)
26
+ return redactor.sanitize(key, value)
27
+
28
+
29
+ _PACKAGE_DIR = os.path.normcase(os.path.dirname(os.path.abspath(__file__)))
30
+
31
+
32
+ def _library_prefixes() -> Tuple[str, ...]:
33
+ prefixes = set()
34
+ for name in ("stdlib", "platstdlib", "purelib", "platlib"):
35
+ path = sysconfig.get_paths().get(name)
36
+ if path:
37
+ prefixes.add(os.path.normcase(os.path.abspath(path)))
38
+ return tuple(prefixes)
39
+
40
+
41
+ _LIBRARY_PREFIXES = _library_prefixes()
42
+
43
+
44
+ def is_user_code(filename: str) -> bool:
45
+ """True if the file belongs to the application rather than stdlib/site-packages/us."""
46
+ if not filename or filename.startswith("<"):
47
+ return False
48
+ path = os.path.normcase(os.path.abspath(filename))
49
+ if path.startswith(_PACKAGE_DIR):
50
+ return False
51
+ if "site-packages" in path or "dist-packages" in path:
52
+ return False
53
+ return not any(path.startswith(prefix) for prefix in _LIBRARY_PREFIXES)
54
+
55
+
56
+ def _walk(tb: Optional[TracebackType]) -> List[Tuple[FrameType, int]]:
57
+ frames = []
58
+ while tb is not None:
59
+ frames.append((tb.tb_frame, tb.tb_lineno))
60
+ tb = tb.tb_next
61
+ return frames
62
+
63
+
64
+ def select_frame(tb: Optional[TracebackType]) -> Optional[Tuple[FrameType, int]]:
65
+ """The most relevant frame: the deepest one in user code, else the deepest overall."""
66
+ frames = _walk(tb)
67
+ if not frames:
68
+ return None
69
+ for frame, lineno in reversed(frames):
70
+ if is_user_code(frame.f_code.co_filename):
71
+ return frame, lineno
72
+ return frames[-1]
73
+
74
+
75
+ def compute_fingerprint(exc_type: Type[BaseException], filename: str, lineno: int) -> str:
76
+ raw = f"{getattr(exc_type, '__qualname__', exc_type)}:{filename}:{lineno}"
77
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
78
+
79
+
80
+ def fingerprint_exception(exc_type: Type[BaseException], exc_tb: Optional[TracebackType]) -> str:
81
+ """Cheap fingerprint (no locals, no source) used for throttling before full capture."""
82
+ selected = select_frame(exc_tb)
83
+ if selected is None:
84
+ return compute_fingerprint(exc_type, "", -1)
85
+ frame, lineno = selected
86
+ return compute_fingerprint(exc_type, frame.f_code.co_filename, lineno)
87
+
88
+
89
+ def extract_code_context(filepath: str, lineno: int, context_lines: int = 5) -> str:
90
+ if not filepath or lineno <= 0:
91
+ return ""
92
+ start = max(1, lineno - context_lines)
93
+ lines = []
94
+ for i in range(start, lineno + context_lines + 1):
95
+ line = linecache.getline(filepath, i)
96
+ if not line and i > lineno:
97
+ break
98
+ marker = ">> " if i == lineno else " "
99
+ lines.append(f"{marker}{i:4d}: {line.rstrip()}")
100
+ return "\n".join(lines)
101
+
102
+
103
+ def capture_exception_context(
104
+ exc_type: Type[BaseException],
105
+ exc_value: BaseException,
106
+ exc_tb: Optional[TracebackType],
107
+ extra_metadata: Optional[Dict[str, Any]] = None,
108
+ redactor: Optional[Redactor] = None,
109
+ context_lines: int = 5,
110
+ include_source: bool = True,
111
+ include_locals: bool = True,
112
+ ) -> Dict[str, Any]:
113
+ """Build the (redacted) incident payload. Never raises."""
114
+ redactor = redactor or Redactor()
115
+
116
+ try:
117
+ formatted_tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
118
+ except Exception: # pragma: no cover - defensive
119
+ formatted_tb = f"{exc_type.__name__}: <traceback unavailable>"
120
+ if len(formatted_tb) > MAX_TRACEBACK_CHARS:
121
+ # Keep the tail: that's where the actual error is.
122
+ formatted_tb = "... (truncated)\n" + formatted_tb[-MAX_TRACEBACK_CHARS:]
123
+
124
+ try:
125
+ message = str(exc_value)
126
+ except Exception:
127
+ message = "<unprintable exception message>"
128
+
129
+ error_file, error_line, function = "", -1, ""
130
+ code_context = ""
131
+ locals_dict: Dict[str, Any] = {}
132
+
133
+ selected = select_frame(exc_tb)
134
+ if selected is not None:
135
+ frame, error_line = selected
136
+ error_file = frame.f_code.co_filename
137
+ function = frame.f_code.co_name
138
+ if include_source:
139
+ code_context = redactor.redact_text(extract_code_context(error_file, error_line, context_lines))
140
+ if include_locals:
141
+ for name, value in list(frame.f_locals.items()):
142
+ try:
143
+ locals_dict[name] = redactor.sanitize(name, value)
144
+ except Exception:
145
+ locals_dict[name] = f"<{type(value).__name__} (unserializable)>"
146
+
147
+ deepest = _walk(exc_tb)[-1] if exc_tb is not None else None
148
+ raised_in = None
149
+ if deepest is not None and selected is not None and deepest[0] is not selected[0]:
150
+ raised_in = f"{deepest[0].f_code.co_filename}:{deepest[1]} in {deepest[0].f_code.co_name}"
151
+
152
+ metadata: Dict[str, Any] = {}
153
+ for key, value in (extra_metadata or {}).items():
154
+ try:
155
+ metadata[str(key)] = redactor.sanitize(str(key), value)
156
+ except Exception:
157
+ metadata[str(key)] = "<unserializable>"
158
+
159
+ return {
160
+ "exception_type": getattr(exc_type, "__name__", str(exc_type)),
161
+ "exception_message": redactor.redact_text(message, limit=MAX_MESSAGE_CHARS),
162
+ "traceback": redactor.redact_text(formatted_tb),
163
+ "file": error_file,
164
+ "line": error_line,
165
+ "function": function,
166
+ "raised_in": raised_in,
167
+ "code_context": code_context,
168
+ "locals": locals_dict,
169
+ "extra_metadata": metadata,
170
+ "fingerprint": compute_fingerprint(exc_type, error_file, error_line),
171
+ "runtime": {
172
+ "python": platform.python_version(),
173
+ "implementation": platform.python_implementation(),
174
+ "platform": sys.platform,
175
+ },
176
+ }
@@ -0,0 +1,224 @@
1
+ """Web framework integrations: FastAPI / Starlette (pure ASGI), Flask, and Django.
2
+
3
+ All integrations attach sanitized HTTP request metadata to the incident and re-raise the
4
+ exception so the framework's normal error handling (status code, response body, logging)
5
+ is preserved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import Any, Callable, Dict, Optional
12
+ from urllib.parse import parse_qsl, urlencode
13
+
14
+ from ..core import ShadowCore
15
+
16
+ _SENSITIVE_HEADER_NAMES = {
17
+ "authorization",
18
+ "proxy-authorization",
19
+ "cookie",
20
+ "set-cookie",
21
+ "x-api-key",
22
+ "x-auth-token",
23
+ "x-csrf-token",
24
+ "x-xsrf-token",
25
+ }
26
+
27
+
28
+ def _resolve_core(core: Optional[ShadowCore]) -> Optional[ShadowCore]:
29
+ if core is not None:
30
+ return core
31
+ from .. import get_core
32
+
33
+ return get_core()
34
+
35
+
36
+ def _sanitize_headers(raw_headers: Any) -> Dict[str, str]:
37
+ headers: Dict[str, str] = {}
38
+ try:
39
+ items = raw_headers.items() if hasattr(raw_headers, "items") else raw_headers
40
+ for k, v in items:
41
+ key = k.decode("latin-1") if isinstance(k, (bytes, bytearray)) else str(k)
42
+ val = v.decode("latin-1") if isinstance(v, (bytes, bytearray)) else str(v)
43
+ if key.lower() in _SENSITIVE_HEADER_NAMES:
44
+ headers[key] = "[REDACTED]"
45
+ else:
46
+ headers[key] = val[:200]
47
+ except Exception:
48
+ pass
49
+ return headers
50
+
51
+
52
+ class ASGIWatcherMiddleware:
53
+ """Pure ASGI middleware compatible with FastAPI, Starlette, Litestar, Quart, etc.
54
+
55
+ Unlike Starlette's ``BaseHTTPMiddleware``, a pure ASGI middleware does not buffer
56
+ streaming responses, break ``contextvars``, or interfere with background tasks.
57
+ """
58
+
59
+ def __init__(self, app: Any, core: Optional[ShadowCore] = None) -> None:
60
+ self.app = app
61
+ self.core = core
62
+
63
+ async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
64
+ if scope.get("type") != "http":
65
+ await self.app(scope, receive, send)
66
+ return
67
+
68
+ try:
69
+ await self.app(scope, receive, send)
70
+ except Exception as exc:
71
+ active_core = _resolve_core(self.core)
72
+ if active_core is not None:
73
+ http_meta = _extract_asgi_metadata(scope)
74
+ exc_type, exc_value, exc_tb = sys.exc_info()
75
+ if exc_type is not None and exc_value is not None:
76
+ active_core.capture(
77
+ exc_type,
78
+ exc_value,
79
+ exc_tb,
80
+ extra_metadata={"http_request": http_meta},
81
+ )
82
+ else:
83
+ active_core.capture_exception(exc, extra_metadata={"http_request": http_meta})
84
+ raise
85
+
86
+
87
+ # Alias kept for backward compatibility and clarity in FastAPI apps.
88
+ FastAPIWatcherMiddleware = ASGIWatcherMiddleware
89
+
90
+
91
+ def _extract_asgi_metadata(scope: Dict[str, Any]) -> Dict[str, Any]:
92
+ method = scope.get("method", "UNKNOWN")
93
+ scheme = scope.get("scheme", "http")
94
+ path = scope.get("path", "")
95
+ raw_qs = scope.get("query_string", b"")
96
+ qs = raw_qs.decode("latin-1", errors="replace") if isinstance(raw_qs, (bytes, bytearray)) else str(raw_qs)
97
+
98
+ headers = _sanitize_headers(scope.get("headers") or [])
99
+ host = headers.get("host") or headers.get("Host") or "localhost"
100
+ url = f"{scheme}://{host}{path}" + (f"?{qs}" if qs else "")
101
+
102
+ client = scope.get("client")
103
+ client_ip = client[0] if isinstance(client, (list, tuple)) and client else "unknown"
104
+ query_params = dict(parse_qsl(qs, keep_blank_values=True)) if qs else {}
105
+
106
+ return {
107
+ "framework": "ASGI/FastAPI",
108
+ "http_method": method,
109
+ "url": url,
110
+ "path": path,
111
+ "client_ip": client_ip,
112
+ "query_params": query_params,
113
+ "headers": headers,
114
+ }
115
+
116
+
117
+ def init_flask(app: Any, core: Optional[ShadowCore] = None) -> None:
118
+ """Attach Shadow Debugger to a Flask app without breaking 404/4xx HTTPExceptions.
119
+
120
+ Usage::
121
+
122
+ app = Flask(__name__)
123
+ shadow_debugger.init_flask(app)
124
+ """
125
+ try:
126
+ from flask import got_request_exception, request
127
+ except ImportError as exc:
128
+ raise ImportError("Flask is not installed. Run: pip install 'shadow-debugger[flask]'") from exc
129
+
130
+ def _on_request_exception(sender: Any, exception: BaseException, **extra: Any) -> None:
131
+ # Ignore standard 4xx Werkzeug HTTPExceptions (404 Not Found, 405 Method Not Allowed, etc.)
132
+ status_code = getattr(exception, "code", None)
133
+ if isinstance(status_code, int) and status_code < 500:
134
+ return
135
+
136
+ active_core = _resolve_core(core)
137
+ if active_core is None:
138
+ return
139
+
140
+ try:
141
+ http_meta = {
142
+ "framework": "Flask",
143
+ "http_method": getattr(request, "method", "UNKNOWN"),
144
+ "url": getattr(request, "url", ""),
145
+ "path": getattr(request, "path", ""),
146
+ "endpoint": getattr(request, "endpoint", None),
147
+ "client_ip": getattr(request, "remote_addr", "unknown"),
148
+ "query_params": dict(getattr(request, "args", {}) or {}),
149
+ "headers": _sanitize_headers(getattr(request, "headers", {}) or {}),
150
+ }
151
+ except Exception:
152
+ http_meta = {"framework": "Flask"}
153
+
154
+ active_core.capture(
155
+ type(exception),
156
+ exception,
157
+ exception.__traceback__,
158
+ extra_metadata={"http_request": http_meta},
159
+ )
160
+
161
+ got_request_exception.connect(_on_request_exception, app, weak=False)
162
+
163
+
164
+ class DjangoWatcherMiddleware:
165
+ """Django middleware that captures unhandled view exceptions (HTTP 500).
166
+
167
+ Add to ``MIDDLEWARE`` in ``settings.py``::
168
+
169
+ MIDDLEWARE = [
170
+ "shadow_debugger.integrations.DjangoWatcherMiddleware",
171
+ ...
172
+ ]
173
+ """
174
+
175
+ sync_capable = True
176
+ async_capable = False
177
+
178
+ def __init__(self, get_response: Callable, core: Optional[ShadowCore] = None) -> None:
179
+ self.get_response = get_response
180
+ self.core = core
181
+
182
+ def __call__(self, request: Any) -> Any:
183
+ return self.get_response(request)
184
+
185
+ def process_exception(self, request: Any, exception: BaseException) -> None:
186
+ # Skip Django's Http404 / PermissionDenied
187
+ exc_name = type(exception).__name__
188
+ if exc_name in ("Http404", "PermissionDenied", "SuspiciousOperation"):
189
+ return None
190
+
191
+ active_core = _resolve_core(self.core)
192
+ if active_core is None:
193
+ return None
194
+
195
+ try:
196
+ query_params = dict(getattr(request, "GET", {}) or {})
197
+ path = getattr(request, "path", "")
198
+ qs = urlencode(query_params, doseq=True) if query_params else ""
199
+ http_meta = {
200
+ "framework": "Django",
201
+ "http_method": getattr(request, "method", "UNKNOWN"),
202
+ "path": path,
203
+ "url": f"{path}?{qs}" if qs else path,
204
+ "query_params": query_params,
205
+ "user": str(getattr(request, "user", "anonymous")),
206
+ }
207
+ except Exception:
208
+ http_meta = {"framework": "Django"}
209
+
210
+ active_core.capture(
211
+ type(exception),
212
+ exception,
213
+ exception.__traceback__,
214
+ extra_metadata={"http_request": http_meta},
215
+ )
216
+ return None
217
+
218
+
219
+ __all__ = [
220
+ "ASGIWatcherMiddleware",
221
+ "FastAPIWatcherMiddleware",
222
+ "init_flask",
223
+ "DjangoWatcherMiddleware",
224
+ ]
@@ -0,0 +1,57 @@
1
+ """Syntax check for code snippets suggested by the model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ import textwrap
8
+ import warnings
9
+ from dataclasses import asdict, dataclass
10
+ from typing import Any, Dict, List
11
+
12
+ _CODE_BLOCK = re.compile(r"```(?:python|py)[ \t]*\r?\n(.*?)```", re.DOTALL | re.IGNORECASE)
13
+ _LINE_NUMBER_PREFIX = re.compile(r"^(?:>>)?\s*\d+:\s?")
14
+
15
+
16
+ @dataclass
17
+ class PatchCheckResult:
18
+ attempted: bool
19
+ success: bool
20
+ message: str
21
+
22
+ def to_dict(self) -> Dict[str, Any]:
23
+ return asdict(self)
24
+
25
+
26
+ def extract_python_blocks(markdown: str) -> List[str]:
27
+ return [m.strip("\n") for m in _CODE_BLOCK.findall(markdown or "") if m.strip()]
28
+
29
+
30
+ def _clean(snippet: str) -> str:
31
+ lines = snippet.splitlines()
32
+ if lines and all(_LINE_NUMBER_PREFIX.match(line) for line in lines if line.strip()):
33
+ lines = [_LINE_NUMBER_PREFIX.sub("", line, count=1) for line in lines]
34
+ return textwrap.dedent("\n".join(lines))
35
+
36
+
37
+ def check_patch_syntax(ai_analysis: str) -> PatchCheckResult:
38
+ blocks = extract_python_blocks(ai_analysis)
39
+ if not blocks:
40
+ return PatchCheckResult(False, False, "No ```python block found in the analysis.")
41
+
42
+ errors: List[str] = []
43
+ for index, block in enumerate(blocks, start=1):
44
+ source = _clean(block)
45
+ try:
46
+ with warnings.catch_warnings():
47
+ warnings.simplefilter("ignore", SyntaxWarning)
48
+ ast.parse(source)
49
+ except SyntaxError as exc:
50
+ errors.append(f"block {index}, line {exc.lineno}: {exc.msg}")
51
+
52
+ if errors:
53
+ return PatchCheckResult(True, False, "Proposed code has syntax errors: " + "; ".join(errors))
54
+ noun = "snippet" if len(blocks) == 1 else "snippets"
55
+ return PatchCheckResult(
56
+ True, True, f"{len(blocks)} proposed {noun} parsed cleanly."
57
+ )