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/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)