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.
- shadow_debugger/__init__.py +293 -0
- shadow_debugger/_version.py +1 -0
- shadow_debugger/analyzer.py +108 -0
- shadow_debugger/config.py +56 -0
- shadow_debugger/core.py +244 -0
- shadow_debugger/inspector.py +176 -0
- shadow_debugger/integrations/__init__.py +224 -0
- shadow_debugger/patch_check.py +57 -0
- shadow_debugger/providers/__init__.py +23 -0
- shadow_debugger/providers/anthropic.py +54 -0
- shadow_debugger/providers/base.py +92 -0
- shadow_debugger/providers/gemini.py +66 -0
- shadow_debugger/providers/ollama.py +65 -0
- shadow_debugger/providers/openai.py +63 -0
- shadow_debugger/py.typed +1 -0
- shadow_debugger/redaction.py +190 -0
- shadow_debugger/reporter.py +128 -0
- shadow_debugger/throttler.py +64 -0
- shadow_debugger-0.1.0.dist-info/METADATA +292 -0
- shadow_debugger-0.1.0.dist-info/RECORD +23 -0
- shadow_debugger-0.1.0.dist-info/WHEEL +5 -0
- shadow_debugger-0.1.0.dist-info/licenses/LICENSE +21 -0
- shadow_debugger-0.1.0.dist-info/top_level.txt +1 -0
shadow_debugger/core.py
ADDED
|
@@ -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
|
+
)
|