Skip to content

Commit 69e6577

Browse files
committed
implemented debug() editor function which lets you navigate in the stack frame
run_editor_on_exception now uses debug so that we can navigate the traceback
1 parent 9d4f835 commit 69e6577

File tree

5 files changed

+293
-35
lines changed

5 files changed

+293
-35
lines changed

doc/source/changes/version_0_32.rst.inc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
.. py:currentmodule:: larray_editor
22

3-
.. _misc_editor:
3+
New features
4+
^^^^^^^^^^^^
5+
6+
* added :py:obj:`debug()` function which opens an editor window with an extra widget to navigate back in the call
7+
stack (the chain of functions called to reach the current line of code).
8+
49

510
Miscellaneous improvements
611
^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -11,6 +16,9 @@ Miscellaneous improvements
1116
* Sizes of the main window and the resizable components are saved when closing the viewer and restored
1217
when the viewer is reopened (closes :editor_issue:`165`).
1318

19+
* :py:obj:`run_editor_on_exception()` now uses :py:obj:`debug()` so that one can inspect what the state was in all
20+
functions traversed to reach the code which triggered the exception.
21+
1422

1523
Fixes
1624
^^^^^

larray_editor/api.py

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
import larray as la
1212

1313
from larray_editor.editor import REOPEN_LAST_FILE, MappingEditor, ArrayEditor
14+
from larray_editor.traceback_tools import extract_stack, extract_tb, StackSummary
1415

15-
__all__ = ['view', 'edit', 'compare', 'REOPEN_LAST_FILE', 'run_editor_on_exception']
16+
__all__ = ['view', 'edit', 'debug', 'compare', 'REOPEN_LAST_FILE', 'run_editor_on_exception']
1617

1718

1819
def qapplication():
@@ -104,7 +105,10 @@ def edit(obj=None, title='', minvalue=None, maxvalue=None, readonly=False, depth
104105
>>> # will open an editor for a1 only
105106
>>> edit(a1) # doctest: +SKIP
106107
"""
107-
install_except_hook()
108+
# we don't use install_except_hook/restore_except_hook so that we can restore the hook actually used when
109+
# this function is called instead of the one which was used when the module was loaded.
110+
orig_except_hook = sys.excepthook
111+
sys.excepthook = _qt_except_hook
108112

109113
_app = QApplication.instance()
110114
if _app is None:
@@ -147,7 +151,7 @@ def edit(obj=None, title='', minvalue=None, maxvalue=None, readonly=False, depth
147151
dlg.show()
148152
_app.exec_()
149153

150-
restore_except_hook()
154+
sys.excepthook = orig_except_hook
151155

152156

153157
def view(obj=None, title='', depth=0, display_caller_info=True):
@@ -182,6 +186,45 @@ def view(obj=None, title='', depth=0, display_caller_info=True):
182186
edit(obj, title=title, readonly=True, depth=depth + 1, display_caller_info=display_caller_info)
183187

184188

189+
def _debug(stack_summary, stack_pos=None):
190+
# we don't use install_except_hook/restore_except_hook so that we can restore the hook actually used when
191+
# this function is called instead of the one which was used when the module was loaded.
192+
orig_except_hook = sys.excepthook
193+
sys.excepthook = _qt_except_hook
194+
195+
_app = QApplication.instance()
196+
if _app is None:
197+
_app = qapplication()
198+
_app.setOrganizationName("LArray")
199+
_app.setApplicationName("Debugger")
200+
parent = None
201+
else:
202+
parent = _app.activeWindow()
203+
204+
assert isinstance(stack_summary, StackSummary)
205+
dlg = MappingEditor(parent)
206+
setup_ok = dlg.setup_and_check(stack_summary, stack_pos=stack_pos)
207+
if setup_ok:
208+
dlg.show()
209+
_app.exec_()
210+
211+
sys.excepthook = orig_except_hook
212+
213+
214+
def debug(depth=0):
215+
r"""
216+
Opens a new debug window.
217+
218+
Parameters
219+
----------
220+
depth : int, optional
221+
Stack depth where to look for variables. Defaults to 0 (where this function was called).
222+
"""
223+
caller_frame = sys._getframe(depth + 1)
224+
stack_summary = extract_stack(caller_frame)
225+
_debug(stack_summary)
226+
227+
185228
def compare(*args, **kwargs):
186229
"""
187230
Opens a new comparator window, comparing arrays or sessions.
@@ -223,7 +266,10 @@ def compare(*args, **kwargs):
223266
>>> compare(a1, a2, title='first comparison') # doctest: +SKIP
224267
>>> compare(a1 + 1, a2, title='second comparison', names=['a1+1', 'a2']) # doctest: +SKIP
225268
"""
226-
install_except_hook()
269+
# we don't use install_except_hook/restore_except_hook so that we can restore the hook actually used when
270+
# this function is called instead of the one which was used when the module was loaded.
271+
orig_except_hook = sys.excepthook
272+
sys.excepthook = _qt_except_hook
227273

228274
title = kwargs.pop('title', '')
229275
names = kwargs.pop('names', None)
@@ -268,7 +314,8 @@ def get_name(i, obj, depth=0):
268314
dlg.show()
269315
_app.exec_()
270316

271-
restore_except_hook()
317+
sys.excepthook = orig_except_hook
318+
272319

273320
_orig_except_hook = sys.excepthook
274321

@@ -308,15 +355,7 @@ def _trace_code_file(tb):
308355
return os.path.normpath(tb.tb_frame.f_code.co_filename)
309356

310357

311-
def _get_vars_from_frame(frame):
312-
frame_globals, frame_locals = frame.f_globals, frame.f_locals
313-
d = collections.OrderedDict()
314-
d.update([(k, frame_globals[k]) for k in sorted(frame_globals.keys())])
315-
d.update([(k, frame_locals[k]) for k in sorted(frame_locals.keys())])
316-
return d
317-
318-
319-
def _get_debug_except_hook(root_path=None, usercode_traceback=True):
358+
def _get_debug_except_hook(root_path=None, usercode_traceback=True, usercode_frame=True):
320359
try:
321360
main_file = os.path.abspath(sys.modules['__main__'].__file__)
322361
except AttributeError:
@@ -334,29 +373,31 @@ def excepthook(type, value, tback):
334373

335374
main_tb = current_tb if _trace_code_file(current_tb) == main_file else tback
336375

337-
if usercode_traceback:
376+
user_tb_length = None
377+
if usercode_traceback or usercode_frame:
338378
if main_tb != current_tb:
339379
print("Warning: couldn't find frame corresponding to user code, showing the full traceback "
340380
"and inspect last frame instead (which might be in library code)",
341381
file=sys.stderr)
342-
limit = None
343382
else:
344383
user_tb_length = 1
345384
# continue as long as the next tb is still in the current project
346385
while current_tb.tb_next and _trace_code_file(current_tb.tb_next).startswith(root_path):
347386
current_tb = current_tb.tb_next
348387
user_tb_length += 1
349-
limit = user_tb_length
350-
else:
351-
limit = None
352-
traceback.print_exception(type, value, main_tb, limit=limit)
388+
389+
tb_limit = user_tb_length if usercode_traceback else None
390+
traceback.print_exception(type, value, main_tb, limit=tb_limit)
391+
392+
stack = extract_tb(main_tb, limit=tb_limit)
393+
stack_pos = user_tb_length - 1 if user_tb_length is not None and usercode_frame else None
353394
print("\nlaunching larray editor to debug...", file=sys.stderr)
354-
edit(_get_vars_from_frame(current_tb.tb_frame))
395+
_debug(stack, stack_pos=stack_pos)
355396

356397
return excepthook
357398

358399

359-
def run_editor_on_exception(root_path=None, usercode_traceback=True):
400+
def run_editor_on_exception(root_path=None, usercode_traceback=True, usercode_frame=True):
360401
"""
361402
Run the editor when an unhandled exception (a fatal error) happens.
362403
@@ -367,9 +408,13 @@ def run_editor_on_exception(root_path=None, usercode_traceback=True):
367408
usercode_traceback : bool, optional
368409
Whether or not to show only the part of the traceback (error log) which corresponds to the user code.
369410
Otherwise, it will show the complete traceback, including code inside libraries. Defaults to True.
411+
usercode_frame : bool, optional
412+
Whether or not to start the debug window in the frame corresponding to the user code.
413+
This argument is ignored (it is always True) if usercode_traceback is True. Defaults to True.
370414
371415
Notes
372416
-----
373417
sets sys.excepthook
374418
"""
375-
sys.excepthook = _get_debug_except_hook(root_path=root_path, usercode_traceback=usercode_traceback)
419+
sys.excepthook = _get_debug_except_hook(root_path=root_path, usercode_traceback=usercode_traceback,
420+
usercode_frame=usercode_frame)

larray_editor/editor.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
22
import re
3+
34
import matplotlib
45
import numpy as np
56
import collections
67

78
import larray as la
89

10+
from larray_editor.traceback_tools import StackSummary
911
from larray_editor.utils import (PY2, PYQT5, _, create_action, show_figure, ima, commonpath, dependencies,
1012
get_versions, get_documentation_url, urls, RecentlyUsedList)
1113
from larray_editor.arraywidget import ArrayEditorWidget
@@ -14,7 +16,7 @@
1416
from qtpy.QtCore import Qt, QUrl, QSettings
1517
from qtpy.QtGui import QDesktopServices, QKeySequence
1618
from qtpy.QtWidgets import (QMainWindow, QWidget, QListWidget, QListWidgetItem, QSplitter, QFileDialog, QPushButton,
17-
QDialogButtonBox, QShortcut, QHBoxLayout, QVBoxLayout, QGridLayout, QLineEdit, QUndoStack,
19+
QDialogButtonBox, QShortcut, QVBoxLayout, QGridLayout, QLineEdit, QUndoStack,
1820
QCheckBox, QComboBox, QMessageBox, QDialog, QInputDialog, QLabel, QGroupBox, QRadioButton)
1921

2022
try:
@@ -338,7 +340,7 @@ def __init__(self, parent=None):
338340

339341
self.setup_menu_bar()
340342

341-
def _setup_and_check(self, widget, data, title, readonly, **kwargs):
343+
def _setup_and_check(self, widget, data, title, readonly, stack_pos=None):
342344
"""Setup MappingEditor"""
343345
layout = QVBoxLayout()
344346
widget.setLayout(layout)
@@ -363,6 +365,7 @@ def _setup_and_check(self, widget, data, title, readonly, **kwargs):
363365
kernel = kernel_manager.kernel
364366

365367
# TODO: use self._reset() instead
368+
# FIXME: when using the editor as a debugger this is annoying
366369
kernel.shell.run_cell('from larray import *')
367370
text_formatter = kernel.shell.display_formatter.formatters['text/plain']
368371

@@ -405,9 +408,34 @@ def void_formatter(array, *args, **kwargs):
405408
right_panel_widget.setLayout(right_panel_layout)
406409

407410
main_splitter = QSplitter(Qt.Horizontal)
408-
main_splitter.addWidget(self._listwidget)
411+
debug = isinstance(data, StackSummary)
412+
if debug:
413+
self._stack_frame_widget = QListWidget(self)
414+
stack_frame_widget = self._stack_frame_widget
415+
stack_frame_widget.itemSelectionChanged.connect(self.on_stack_frame_changed)
416+
stack_frame_widget.setMinimumWidth(60)
417+
418+
for frame_summary in data:
419+
funcname = frame_summary.name
420+
filename = os.path.basename(frame_summary.filename)
421+
listitem = QListWidgetItem(stack_frame_widget)
422+
listitem.setText("{}, {}:{}".format(funcname, filename, frame_summary.lineno))
423+
# we store the frame summary object in the user data of the list
424+
listitem.setData(Qt.UserRole, frame_summary)
425+
listitem.setToolTip(frame_summary.line)
426+
row = stack_pos if stack_pos is not None else len(data) - 1
427+
stack_frame_widget.setCurrentRow(row)
428+
429+
left_panel_widget = QSplitter(Qt.Vertical)
430+
left_panel_widget.addWidget(self._listwidget)
431+
left_panel_widget.addWidget(stack_frame_widget)
432+
left_panel_widget.setSizes([500, 200])
433+
data = self.data
434+
else:
435+
left_panel_widget = self._listwidget
436+
main_splitter.addWidget(left_panel_widget)
409437
main_splitter.addWidget(right_panel_widget)
410-
main_splitter.setSizes([10, 90])
438+
main_splitter.setSizes([180, 620])
411439
main_splitter.setCollapsible(1, False)
412440
self.widget_state_settings['main_splitter'] = main_splitter
413441

@@ -427,8 +455,7 @@ def void_formatter(array, *args, **kwargs):
427455
else:
428456
QMessageBox.critical(self, "Error", "File {} could not be found".format(data))
429457
self.new()
430-
# convert input data to Session if not
431-
else:
458+
elif not debug:
432459
self._push_data(data)
433460

434461
def _push_data(self, data):
@@ -439,6 +466,28 @@ def _push_data(self, data):
439466
self.add_list_items(arrays)
440467
self._listwidget.setCurrentRow(0)
441468

469+
def on_stack_frame_changed(self):
470+
selected = self._stack_frame_widget.selectedItems()
471+
if selected:
472+
assert len(selected) == 1
473+
selected_item = selected[0]
474+
assert isinstance(selected_item, QListWidgetItem)
475+
476+
frame_summary = selected_item.data(Qt.UserRole)
477+
frame_globals, frame_locals = frame_summary.globals, frame_summary.locals
478+
data = collections.OrderedDict()
479+
data.update([(k, frame_globals[k]) for k in sorted(frame_globals.keys())])
480+
data.update([(k, frame_locals[k]) for k in sorted(frame_locals.keys())])
481+
482+
# CHECK:
483+
# * This clears the undo/redo stack, which is safer but is not ideal.
484+
# When inspecting, for all frames except the last one the editor should be readonly (we should allow
485+
# creating new temporary variables but not change existing ones).
486+
# * Does changing the last frame values has any effect after quitting the editor?
487+
# It would be nice if we could do that (possibly with a warning when quitting the debug window)
488+
self._reset()
489+
self._push_data(data)
490+
442491
def _reset(self):
443492
self.data = la.Session()
444493
self._listwidget.clear()
@@ -521,9 +570,9 @@ def add_list_items(self, names):
521570

522571
def delete_list_item(self, to_delete):
523572
deleted_items = self._listwidget.findItems(to_delete, Qt.MatchExactly)
524-
assert len(deleted_items) == 1
525-
deleted_item_idx = self._listwidget.row(deleted_items[0])
526-
self._listwidget.takeItem(deleted_item_idx)
573+
if len(deleted_items) == 1:
574+
deleted_item_idx = self._listwidget.row(deleted_items[0])
575+
self._listwidget.takeItem(deleted_item_idx)
527576

528577
def select_list_item(self, to_display):
529578
changed_items = self._listwidget.findItems(to_display, Qt.MatchExactly)
@@ -644,7 +693,7 @@ def ipython_cell_executed(self):
644693
# last command. Which means that if the last command did not produce any output, _ is not modified.
645694
cur_output = user_ns['_oh'].get(cur_input_num)
646695
if cur_output is not None:
647-
if self._display_in_grid('_', cur_output):
696+
if self._display_in_grid('<expr>', cur_output):
648697
self.view_expr(cur_output)
649698

650699
if isinstance(cur_output, collections.Iterable):

larray_editor/tests/test_api_larray.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from larray_editor.api import *
1111
from larray_editor.utils import logger
1212

13-
1413
logger.setLevel(logging.DEBUG)
1514

1615
lipro = la.Axis(['P%02d' % i for i in range(1, 16)], 'lipro')
@@ -126,6 +125,7 @@ def make_demo(width=20, ball_radius=5, path_radius=5, steps=30):
126125
# import cProfile as profile
127126
# profile.runctx('edit(Session(arr2=arr2))', vars(), {},
128127
# 'c:\\tmp\\edit.profile')
128+
debug()
129129
edit()
130130
# edit(ses)
131131
# edit(file)
@@ -170,3 +170,13 @@ def make_demo(width=20, ball_radius=5, path_radius=5, steps=30):
170170
arr2 = 2 * arr1
171171
arr3 = la.where(arr1 % 2 == 0, arr1, -arr1)
172172
compare(arr1, arr2, arr3, bg_gradient='blue-red')
173+
174+
175+
def test_run_editor_on_exception(local_arr1):
176+
return arr2['my_invalid_key']
177+
178+
run_editor_on_exception()
179+
# run_editor_on_exception(usercode_traceback=False)
180+
# run_editor_on_exception(usercode_traceback=False, usercode_frame=False)
181+
182+
test_run_editor_on_exception(arr1)

0 commit comments

Comments
 (0)