flexlock 0.8.2__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.
- flexlock/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
flexlock/debug.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Debugging utilities for FlexLock."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Any, Optional
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from . import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _is_notebook() -> bool:
|
|
13
|
+
"""Check if running in a Jupyter notebook or IPython environment."""
|
|
14
|
+
try:
|
|
15
|
+
from IPython import get_ipython
|
|
16
|
+
|
|
17
|
+
ipython = get_ipython()
|
|
18
|
+
if ipython is None:
|
|
19
|
+
return False
|
|
20
|
+
# Check if we're in a notebook (not just IPython terminal)
|
|
21
|
+
if "IPKernelApp" in ipython.config:
|
|
22
|
+
return True
|
|
23
|
+
# Also check for Jupyter console
|
|
24
|
+
return hasattr(ipython, "kernel")
|
|
25
|
+
except (ImportError, AttributeError):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_interactive_shell() -> bool:
|
|
30
|
+
"""Check if running in interactive Python shell (not notebook)."""
|
|
31
|
+
try:
|
|
32
|
+
from IPython import get_ipython
|
|
33
|
+
|
|
34
|
+
ipython = get_ipython()
|
|
35
|
+
if ipython is None:
|
|
36
|
+
return False
|
|
37
|
+
# IPython terminal (not notebook)
|
|
38
|
+
return "IPKernelApp" not in ipython.config
|
|
39
|
+
except (ImportError, AttributeError):
|
|
40
|
+
# Check for regular python -i
|
|
41
|
+
return hasattr(sys, "ps1")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_boring_frame(filename: str) -> bool:
|
|
45
|
+
"""Check if frame is from stdlib, site-packages, or flexlock internals."""
|
|
46
|
+
if not filename or filename == "<string>":
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
p = Path(filename).resolve()
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
path_str = str(p)
|
|
55
|
+
|
|
56
|
+
# Skip stdlib
|
|
57
|
+
if path_str.startswith(sys.prefix):
|
|
58
|
+
# But don't skip if it's in site-packages (that's checked next)
|
|
59
|
+
if "site-packages" not in path_str:
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
# Skip site-packages
|
|
63
|
+
if "site-packages" in p.parts:
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
# Skip flexlock itself (but not test code)
|
|
67
|
+
if "flexlock" in p.parts and "test" not in path_str:
|
|
68
|
+
# Check if it's actually the flexlock package
|
|
69
|
+
for part in p.parts:
|
|
70
|
+
if (
|
|
71
|
+
part == "flexlock"
|
|
72
|
+
and p.parts[p.parts.index(part) - 1] != "test_project"
|
|
73
|
+
):
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_project_frame(filename: str) -> bool:
|
|
80
|
+
"""Check if frame is from the current project (working directory)."""
|
|
81
|
+
if not filename or filename == "<string>":
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
p = Path(filename).resolve()
|
|
86
|
+
cwd = Path.cwd().resolve()
|
|
87
|
+
return p.is_relative_to(cwd)
|
|
88
|
+
except (OSError, ValueError, RuntimeError):
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _score_frame(frame_info: Dict[str, Any]) -> int:
|
|
93
|
+
"""
|
|
94
|
+
Score frame by 'interestingness'.
|
|
95
|
+
|
|
96
|
+
Higher score = more interesting = more likely to be where you want to debug.
|
|
97
|
+
"""
|
|
98
|
+
score = 0
|
|
99
|
+
frame = frame_info["frame"]
|
|
100
|
+
filename = frame_info["filename"]
|
|
101
|
+
locals_dict = frame_info["locals"]
|
|
102
|
+
|
|
103
|
+
# Project frames are much more interesting
|
|
104
|
+
if _is_project_frame(filename):
|
|
105
|
+
score += 1000
|
|
106
|
+
|
|
107
|
+
# More locals = more interesting (but cap to avoid explosion)
|
|
108
|
+
num_locals = len(locals_dict)
|
|
109
|
+
score += min(num_locals * 10, 200)
|
|
110
|
+
|
|
111
|
+
# Frames with data structures are more interesting
|
|
112
|
+
for val in locals_dict.values():
|
|
113
|
+
if isinstance(val, (list, dict, set)):
|
|
114
|
+
score += 20
|
|
115
|
+
elif isinstance(val, tuple) and len(val) > 2:
|
|
116
|
+
score += 10
|
|
117
|
+
|
|
118
|
+
# Frames with non-trivial local names (not just 'self', 'cls', '_')
|
|
119
|
+
interesting_names = [
|
|
120
|
+
name
|
|
121
|
+
for name in locals_dict.keys()
|
|
122
|
+
if not name.startswith("_") and name not in ("self", "cls")
|
|
123
|
+
]
|
|
124
|
+
score += len(interesting_names) * 5
|
|
125
|
+
|
|
126
|
+
# Penalize frames with very few locals (likely thin wrappers)
|
|
127
|
+
if num_locals < 2:
|
|
128
|
+
score -= 100
|
|
129
|
+
|
|
130
|
+
return score
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_frames(exc_info) -> List[Dict[str, Any]]:
|
|
134
|
+
"""
|
|
135
|
+
Extract all frames from exception traceback, with metadata.
|
|
136
|
+
|
|
137
|
+
Returns list of dicts with:
|
|
138
|
+
- frame: The actual frame object
|
|
139
|
+
- locals: Copy of frame locals
|
|
140
|
+
- filename: Source file
|
|
141
|
+
- function: Function name
|
|
142
|
+
- lineno: Line number
|
|
143
|
+
- score: Interest score
|
|
144
|
+
- is_project: Whether it's in project code
|
|
145
|
+
- is_boring: Whether it's stdlib/site-packages
|
|
146
|
+
"""
|
|
147
|
+
tb = exc_info[2]
|
|
148
|
+
frames = []
|
|
149
|
+
|
|
150
|
+
while tb is not None:
|
|
151
|
+
frame = tb.tb_frame
|
|
152
|
+
|
|
153
|
+
# Get frame metadata
|
|
154
|
+
filename = frame.f_code.co_filename
|
|
155
|
+
function = frame.f_code.co_name
|
|
156
|
+
lineno = tb.tb_lineno
|
|
157
|
+
|
|
158
|
+
# Handle C extensions and frames without proper locals
|
|
159
|
+
try:
|
|
160
|
+
locals_dict = dict(frame.f_locals) # Make a copy
|
|
161
|
+
except (AttributeError, RuntimeError):
|
|
162
|
+
# C extension or broken frame
|
|
163
|
+
locals_dict = {}
|
|
164
|
+
|
|
165
|
+
is_boring = _is_boring_frame(filename)
|
|
166
|
+
is_project = _is_project_frame(filename)
|
|
167
|
+
|
|
168
|
+
frame_info = {
|
|
169
|
+
"frame": frame,
|
|
170
|
+
"locals": locals_dict,
|
|
171
|
+
"filename": filename,
|
|
172
|
+
"function": function,
|
|
173
|
+
"lineno": lineno,
|
|
174
|
+
"is_project": is_project,
|
|
175
|
+
"is_boring": is_boring,
|
|
176
|
+
"score": 0, # Will be scored later
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Score the frame
|
|
180
|
+
frame_info["score"] = _score_frame(frame_info)
|
|
181
|
+
|
|
182
|
+
frames.append(frame_info)
|
|
183
|
+
tb = tb.tb_next
|
|
184
|
+
|
|
185
|
+
return frames
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _select_default_frame(frames: List[Dict[str, Any]]) -> int:
|
|
189
|
+
"""
|
|
190
|
+
Select the default frame to show.
|
|
191
|
+
|
|
192
|
+
Strategy:
|
|
193
|
+
1. If exception frame is in project → use it
|
|
194
|
+
2. Else: Find deepest (last) project frame
|
|
195
|
+
3. If no project frames: Use exception frame anyway
|
|
196
|
+
|
|
197
|
+
Returns: Index into frames list
|
|
198
|
+
"""
|
|
199
|
+
if not frames:
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
# Check if last frame (exception site) is in project
|
|
203
|
+
last_idx = len(frames) - 1
|
|
204
|
+
if frames[last_idx]["is_project"]:
|
|
205
|
+
return last_idx
|
|
206
|
+
|
|
207
|
+
# Find deepest project frame (iterate backwards)
|
|
208
|
+
for i in range(len(frames) - 1, -1, -1):
|
|
209
|
+
if frames[i]["is_project"]:
|
|
210
|
+
return i
|
|
211
|
+
|
|
212
|
+
# No project frames found - use exception frame
|
|
213
|
+
logger.warning(
|
|
214
|
+
"No project frames found in traceback. "
|
|
215
|
+
"Exception may be in C extension or library code."
|
|
216
|
+
)
|
|
217
|
+
return last_idx
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _inject_notebook_debug(frames: List[Dict[str, Any]], default_idx: int):
|
|
221
|
+
"""
|
|
222
|
+
Inject debug information into IPython/Jupyter notebook namespace.
|
|
223
|
+
|
|
224
|
+
Provides:
|
|
225
|
+
- Direct injection of default frame's locals
|
|
226
|
+
- _debug_frames: All frames
|
|
227
|
+
- _debug_current: Current frame index
|
|
228
|
+
- _debug_up(), _debug_down(), _debug_goto(n): Navigation
|
|
229
|
+
- _debug_show(): Show all frames
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
from IPython import get_ipython
|
|
233
|
+
|
|
234
|
+
ipython = get_ipython()
|
|
235
|
+
if ipython is None:
|
|
236
|
+
logger.warning("Could not get IPython instance for debug injection")
|
|
237
|
+
return
|
|
238
|
+
except ImportError:
|
|
239
|
+
logger.warning("IPython not available for debug injection")
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# State for navigation
|
|
243
|
+
state = {"current_idx": default_idx}
|
|
244
|
+
|
|
245
|
+
def show_frames():
|
|
246
|
+
"""Show all available frames."""
|
|
247
|
+
print("\n" + "=" * 70)
|
|
248
|
+
print("Available frames (most recent call last):")
|
|
249
|
+
print("=" * 70)
|
|
250
|
+
for i, f in enumerate(frames):
|
|
251
|
+
marker = "→" if i == state["current_idx"] else " "
|
|
252
|
+
project_marker = "📁" if f["is_project"] else " "
|
|
253
|
+
boring_marker = "⚙️" if f["is_boring"] else " "
|
|
254
|
+
|
|
255
|
+
print(
|
|
256
|
+
f"{marker} [{i:2d}] {project_marker}{boring_marker} "
|
|
257
|
+
f"{f['function']}() at {Path(f['filename']).name}:{f['lineno']}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if i == state["current_idx"]:
|
|
261
|
+
# Show locals preview
|
|
262
|
+
local_names = [k for k in f["locals"].keys() if not k.startswith("_")]
|
|
263
|
+
if local_names:
|
|
264
|
+
preview = ", ".join(local_names[:5])
|
|
265
|
+
if len(local_names) > 5:
|
|
266
|
+
preview += f", ... ({len(local_names)} total)"
|
|
267
|
+
print(f" Locals: {preview}")
|
|
268
|
+
|
|
269
|
+
print("=" * 70)
|
|
270
|
+
print("Legend: 📁=project code, ⚙️=library/stdlib, →=current frame")
|
|
271
|
+
print("\nNavigation: _debug_up(), _debug_down(), _debug_goto(n), _debug_show()")
|
|
272
|
+
print("=" * 70 + "\n")
|
|
273
|
+
|
|
274
|
+
def inject_frame(idx: int):
|
|
275
|
+
"""Inject a specific frame's locals."""
|
|
276
|
+
if not (0 <= idx < len(frames)):
|
|
277
|
+
print(f"Invalid frame index: {idx} (valid: 0-{len(frames) - 1})")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
state["current_idx"] = idx
|
|
281
|
+
frame_info = frames[idx]
|
|
282
|
+
|
|
283
|
+
# Inject locals into namespace
|
|
284
|
+
ipython.user_ns.update(frame_info["locals"])
|
|
285
|
+
|
|
286
|
+
# Update debug metadata
|
|
287
|
+
ipython.user_ns["_debug_current"] = idx
|
|
288
|
+
ipython.user_ns["_debug_frame_info"] = frame_info
|
|
289
|
+
|
|
290
|
+
print(
|
|
291
|
+
f"\n🔍 Injected frame [{idx}]: {frame_info['function']}() "
|
|
292
|
+
f"at {Path(frame_info['filename']).name}:{frame_info['lineno']}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
local_names = [k for k in frame_info["locals"].keys() if not k.startswith("_")]
|
|
296
|
+
if local_names:
|
|
297
|
+
print(f" Available: {', '.join(local_names[:10])}")
|
|
298
|
+
if len(local_names) > 10:
|
|
299
|
+
print(f" ... and {len(local_names) - 10} more")
|
|
300
|
+
|
|
301
|
+
def debug_up():
|
|
302
|
+
"""Move to caller frame (up the stack)."""
|
|
303
|
+
new_idx = state["current_idx"] - 1
|
|
304
|
+
if new_idx < 0:
|
|
305
|
+
print("Already at top of stack")
|
|
306
|
+
return
|
|
307
|
+
inject_frame(new_idx)
|
|
308
|
+
|
|
309
|
+
def debug_down():
|
|
310
|
+
"""Move to callee frame (down the stack, toward exception)."""
|
|
311
|
+
new_idx = state["current_idx"] + 1
|
|
312
|
+
if new_idx >= len(frames):
|
|
313
|
+
print("Already at bottom of stack (exception site)")
|
|
314
|
+
return
|
|
315
|
+
inject_frame(new_idx)
|
|
316
|
+
|
|
317
|
+
def debug_goto(idx: int):
|
|
318
|
+
"""Jump to specific frame."""
|
|
319
|
+
inject_frame(idx)
|
|
320
|
+
|
|
321
|
+
# Inject navigation functions
|
|
322
|
+
ipython.user_ns["_debug_frames"] = frames
|
|
323
|
+
ipython.user_ns["_debug_show"] = show_frames
|
|
324
|
+
ipython.user_ns["_debug_up"] = debug_up
|
|
325
|
+
ipython.user_ns["_debug_down"] = debug_down
|
|
326
|
+
ipython.user_ns["_debug_goto"] = debug_goto
|
|
327
|
+
|
|
328
|
+
# Inject default frame
|
|
329
|
+
logger.info("Injecting debug locals into notebook namespace...")
|
|
330
|
+
inject_frame(default_idx)
|
|
331
|
+
|
|
332
|
+
# Show quick help
|
|
333
|
+
print("\n💡 Debug Mode: Use _debug_show() to see all frames")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _handle_exception_debug(exc_info):
|
|
337
|
+
"""
|
|
338
|
+
Handle exception for debugging based on environment.
|
|
339
|
+
|
|
340
|
+
- Notebook: Inject locals with navigation
|
|
341
|
+
- Interactive shell: Inject locals (no navigation, use pdb if needed)
|
|
342
|
+
- Script: Drop into PDB post-mortem
|
|
343
|
+
"""
|
|
344
|
+
# Get configuration
|
|
345
|
+
strategy = os.environ.get("FLEXLOCK_DEBUG_STRATEGY", "auto").lower()
|
|
346
|
+
|
|
347
|
+
logger.debug(f"Debug strategy: {strategy}")
|
|
348
|
+
# Extract frames
|
|
349
|
+
frames = _extract_frames(exc_info)
|
|
350
|
+
logger.debug(f"Extracted {len(frames)} frames for debugging")
|
|
351
|
+
if not frames:
|
|
352
|
+
logger.warning("No frames found in traceback")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
# Select default frame
|
|
356
|
+
default_idx = _select_default_frame(frames)
|
|
357
|
+
logger.debug(f"Default debug frame index: {default_idx}")
|
|
358
|
+
|
|
359
|
+
# Determine behavior
|
|
360
|
+
in_notebook = _is_notebook()
|
|
361
|
+
in_shell = _is_interactive_shell()
|
|
362
|
+
logger.debug(
|
|
363
|
+
f"Detected environment - Notebook: {in_notebook}, Interactive Shell: {in_shell}"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if strategy == "pdb":
|
|
367
|
+
# Force PDB
|
|
368
|
+
import pdb
|
|
369
|
+
|
|
370
|
+
pdb.post_mortem(exc_info[2])
|
|
371
|
+
elif strategy == "inject":
|
|
372
|
+
# Force injection (even in script)
|
|
373
|
+
if in_notebook or in_shell:
|
|
374
|
+
_inject_notebook_debug(frames, default_idx)
|
|
375
|
+
else:
|
|
376
|
+
logger.warning("Cannot inject in non-interactive environment")
|
|
377
|
+
else: # 'auto'
|
|
378
|
+
if in_notebook:
|
|
379
|
+
# Notebook: Inject with navigation
|
|
380
|
+
logger.info("In notebook - injecting locals with navigation.")
|
|
381
|
+
_inject_notebook_debug(frames, default_idx)
|
|
382
|
+
elif in_shell:
|
|
383
|
+
# Interactive shell: Inject or PDB
|
|
384
|
+
logger.info(
|
|
385
|
+
"In interactive shell - injecting locals. Use pdb.post_mortem() for debugger."
|
|
386
|
+
)
|
|
387
|
+
_inject_notebook_debug(frames, default_idx)
|
|
388
|
+
else:
|
|
389
|
+
# Script: PDB
|
|
390
|
+
logger.info("Dropping into PDB post-mortem debugger...")
|
|
391
|
+
import pdb
|
|
392
|
+
|
|
393
|
+
pdb.post_mortem(exc_info[2])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def debug_on_fail(fn=None, **legacy_kwargs):
|
|
397
|
+
"""
|
|
398
|
+
A decorator that provides enhanced debugging on exception.
|
|
399
|
+
|
|
400
|
+
Features:
|
|
401
|
+
- Smart frame selection (prefers project code over libraries)
|
|
402
|
+
- Frame navigation in notebooks (_debug_up, _debug_down, _debug_goto)
|
|
403
|
+
- Handles C extensions gracefully
|
|
404
|
+
- PDB post-mortem for scripts
|
|
405
|
+
- Configurable via environment variables
|
|
406
|
+
|
|
407
|
+
Environment Variables:
|
|
408
|
+
FLEXLOCK_NODEBUG: Set to '1' or 'true' to disable this decorator
|
|
409
|
+
(turns it into a no-op).
|
|
410
|
+
FLEXLOCK_DEBUG_STRATEGY: 'auto' (default), 'pdb', or 'inject'.
|
|
411
|
+
|
|
412
|
+
Note: ``FLEXLOCK_DEBUG=1`` is consumed by ``flexlock-run`` and
|
|
413
|
+
``@flexcli`` to *opt into* wrapping the user function with this
|
|
414
|
+
decorator. Setting ``FLEXLOCK_DEBUG=0`` does not disable a manually
|
|
415
|
+
applied ``@debug_on_fail`` — use ``FLEXLOCK_NODEBUG=1`` for that.
|
|
416
|
+
|
|
417
|
+
Usage::
|
|
418
|
+
|
|
419
|
+
@debug_on_fail
|
|
420
|
+
def my_function():
|
|
421
|
+
...
|
|
422
|
+
|
|
423
|
+
In notebooks after exception, the following helpers are injected:
|
|
424
|
+
|
|
425
|
+
- ``_debug_show()``: Show all frames
|
|
426
|
+
- ``_debug_up()``: Move to caller
|
|
427
|
+
- ``_debug_down()``: Move toward exception
|
|
428
|
+
- ``_debug_goto(n)``: Jump to frame n
|
|
429
|
+
"""
|
|
430
|
+
if legacy_kwargs:
|
|
431
|
+
# `stack_depth=` was a real argument long ago; accept and ignore
|
|
432
|
+
# so older snippets in docs don't blow up.
|
|
433
|
+
logger.warning(
|
|
434
|
+
f"debug_on_fail: ignoring deprecated kwargs {sorted(legacy_kwargs)}."
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def decorator(fn):
|
|
438
|
+
# Check if debug is disabled
|
|
439
|
+
flexlock_nodebug = config.get_env_bool("FLEXLOCK_NODEBUG", False)
|
|
440
|
+
|
|
441
|
+
if flexlock_nodebug:
|
|
442
|
+
return fn
|
|
443
|
+
|
|
444
|
+
def _fn(*args, **kwargs):
|
|
445
|
+
try:
|
|
446
|
+
return fn(*args, **kwargs)
|
|
447
|
+
except Exception:
|
|
448
|
+
exc_info = sys.exc_info()
|
|
449
|
+
|
|
450
|
+
# Log exception
|
|
451
|
+
logger.error(
|
|
452
|
+
f"Exception in {fn.__name__}(): {exc_info[1]}",
|
|
453
|
+
exc_info=False, # Don't double-log traceback
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Handle debug
|
|
457
|
+
try:
|
|
458
|
+
_handle_exception_debug(exc_info)
|
|
459
|
+
except Exception as debug_err:
|
|
460
|
+
logger.error(f"Error in debug handler: {debug_err}")
|
|
461
|
+
finally:
|
|
462
|
+
# Clean up to avoid reference cycles
|
|
463
|
+
del exc_info
|
|
464
|
+
|
|
465
|
+
# Re-raise original exception
|
|
466
|
+
raise
|
|
467
|
+
|
|
468
|
+
return _fn
|
|
469
|
+
|
|
470
|
+
if fn is None:
|
|
471
|
+
# Called as @debug_on_fail(...): return a decorator.
|
|
472
|
+
return decorator
|
|
473
|
+
return decorator(fn)
|