diff --git a/doc/source/changes/version_0_32.rst.inc b/doc/source/changes/version_0_32.rst.inc index 67b94b2..0958522 100644 --- a/doc/source/changes/version_0_32.rst.inc +++ b/doc/source/changes/version_0_32.rst.inc @@ -1,6 +1,11 @@ .. py:currentmodule:: larray_editor -.. _misc_editor: +New features +^^^^^^^^^^^^ + +* added :py:obj:`debug()` function which opens an editor window with an extra widget to navigate back in the call + stack (the chain of functions called to reach the current line of code). + Miscellaneous improvements ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -8,6 +13,9 @@ Miscellaneous improvements * added keyword arguments ``rtol``, ``atol`` and ``nans_equal`` to the :py:obj:`compare()` function (closes :editor_issue:`172`). +* :py:obj:`run_editor_on_exception()` now uses :py:obj:`debug()` so that one can inspect what the state was in all + functions traversed to reach the code which triggered the exception. + Fixes ^^^^^ diff --git a/larray_editor/api.py b/larray_editor/api.py index 55b6d09..ea4c4be 100644 --- a/larray_editor/api.py +++ b/larray_editor/api.py @@ -11,8 +11,9 @@ import larray as la from larray_editor.editor import REOPEN_LAST_FILE, MappingEditor, ArrayEditor +from larray_editor.traceback_tools import extract_stack, extract_tb, StackSummary -__all__ = ['view', 'edit', 'compare', 'REOPEN_LAST_FILE', 'run_editor_on_exception'] +__all__ = ['view', 'edit', 'debug', 'compare', 'REOPEN_LAST_FILE', 'run_editor_on_exception'] def qapplication(): @@ -104,7 +105,10 @@ def edit(obj=None, title='', minvalue=None, maxvalue=None, readonly=False, depth >>> # will open an editor for a1 only >>> edit(a1) # doctest: +SKIP """ - install_except_hook() + # we don't use install_except_hook/restore_except_hook so that we can restore the hook actually used when + # this function is called instead of the one which was used when the module was loaded. + orig_except_hook = sys.excepthook + sys.excepthook = _qt_except_hook _app = QApplication.instance() if _app is None: @@ -147,7 +151,7 @@ def edit(obj=None, title='', minvalue=None, maxvalue=None, readonly=False, depth dlg.show() _app.exec_() - restore_except_hook() + sys.excepthook = orig_except_hook def view(obj=None, title='', depth=0, display_caller_info=True): @@ -182,6 +186,43 @@ def view(obj=None, title='', depth=0, display_caller_info=True): edit(obj, title=title, readonly=True, depth=depth + 1, display_caller_info=display_caller_info) +def _debug(stack_summary, stack_pos=None): + # we don't use install_except_hook/restore_except_hook so that we can restore the hook actually used when + # this function is called instead of the one which was used when the module was loaded. + orig_except_hook = sys.excepthook + sys.excepthook = _qt_except_hook + + _app = QApplication.instance() + if _app is None: + _app = qapplication() + _app.setOrganizationName("LArray") + _app.setApplicationName("Debugger") + parent = None + else: + parent = _app.activeWindow() + + assert isinstance(stack_summary, StackSummary) + dlg = MappingEditor(parent) + setup_ok = dlg.setup_and_check(stack_summary, stack_pos=stack_pos) + if setup_ok: + dlg.show() + _app.exec_() + + sys.excepthook = orig_except_hook + + +def debug(depth=0): + """ + Opens a new debug window. + + depth : int, optional + Stack depth where to look for variables. Defaults to 0 (where this function was called). + """ + caller_frame = sys._getframe(depth + 1) + stack_summary = extract_stack(caller_frame) + _debug(stack_summary) + + def compare(*args, **kwargs): """ Opens a new comparator window, comparing arrays or sessions. @@ -224,7 +265,10 @@ def compare(*args, **kwargs): >>> compare(a1, a2, title='first comparison') # doctest: +SKIP >>> compare(a1 + 1, a2, title='second comparison', names=['a1+1', 'a2']) # doctest: +SKIP """ - install_except_hook() + # we don't use install_except_hook/restore_except_hook so that we can restore the hook actually used when + # this function is called instead of the one which was used when the module was loaded. + orig_except_hook = sys.excepthook + sys.excepthook = _qt_except_hook title = kwargs.pop('title', '') names = kwargs.pop('names', None) @@ -267,7 +311,8 @@ def get_name(i, obj, depth=0): dlg.show() _app.exec_() - restore_except_hook() + sys.excepthook = orig_except_hook + _orig_except_hook = sys.excepthook @@ -307,15 +352,7 @@ def _trace_code_file(tb): return os.path.normpath(tb.tb_frame.f_code.co_filename) -def _get_vars_from_frame(frame): - frame_globals, frame_locals = frame.f_globals, frame.f_locals - d = collections.OrderedDict() - d.update([(k, frame_globals[k]) for k in sorted(frame_globals.keys())]) - d.update([(k, frame_locals[k]) for k in sorted(frame_locals.keys())]) - return d - - -def _get_debug_except_hook(root_path=None, usercode_traceback=True): +def _get_debug_except_hook(root_path=None, usercode_traceback=True, usercode_frame=True): try: main_file = os.path.abspath(sys.modules['__main__'].__file__) except AttributeError: @@ -333,29 +370,31 @@ def excepthook(type, value, tback): main_tb = current_tb if _trace_code_file(current_tb) == main_file else tback - if usercode_traceback: + user_tb_length = None + if usercode_traceback or usercode_frame: if main_tb != current_tb: print("Warning: couldn't find frame corresponding to user code, showing the full traceback " "and inspect last frame instead (which might be in library code)", file=sys.stderr) - limit = None else: user_tb_length = 1 # continue as long as the next tb is still in the current project while current_tb.tb_next and _trace_code_file(current_tb.tb_next).startswith(root_path): current_tb = current_tb.tb_next user_tb_length += 1 - limit = user_tb_length - else: - limit = None - traceback.print_exception(type, value, main_tb, limit=limit) + + tb_limit = user_tb_length if usercode_traceback else None + traceback.print_exception(type, value, main_tb, limit=tb_limit) + + stack = extract_tb(main_tb, limit=tb_limit) + stack_pos = user_tb_length - 1 if user_tb_length is not None and usercode_frame else None print("\nlaunching larray editor to debug...", file=sys.stderr) - edit(_get_vars_from_frame(current_tb.tb_frame)) + _debug(stack, stack_pos=stack_pos) return excepthook -def run_editor_on_exception(root_path=None, usercode_traceback=True): +def run_editor_on_exception(root_path=None, usercode_traceback=True, usercode_frame=True): """ Run the editor when an unhandled exception (a fatal error) happens. @@ -366,9 +405,13 @@ def run_editor_on_exception(root_path=None, usercode_traceback=True): usercode_traceback : bool, optional Whether or not to show only the part of the traceback (error log) which corresponds to the user code. Otherwise, it will show the complete traceback, including code inside libraries. Defaults to True. + usercode_frame : bool, optional + Whether or not to start the debug window in the frame corresponding to the user code. + This argument is ignored (it is always True) if usercode_traceback is True. Defaults to True. Notes ----- sets sys.excepthook """ - sys.excepthook = _get_debug_except_hook(root_path=root_path, usercode_traceback=usercode_traceback) + sys.excepthook = _get_debug_except_hook(root_path=root_path, usercode_traceback=usercode_traceback, + usercode_frame=usercode_frame) diff --git a/larray_editor/editor.py b/larray_editor/editor.py index 37d7d23..bb21931 100644 --- a/larray_editor/editor.py +++ b/larray_editor/editor.py @@ -1,10 +1,12 @@ import os import re + import matplotlib import numpy as np import collections from larray import LArray, Session, empty +from larray_editor.traceback_tools import StackSummary from larray_editor.utils import (PY2, PYQT5, _, create_action, show_figure, ima, commonpath, dependencies, get_versions, get_documentation_url, urls, RecentlyUsedList) from larray_editor.arraywidget import ArrayEditorWidget @@ -13,7 +15,7 @@ from qtpy.QtCore import Qt, QUrl from qtpy.QtGui import QDesktopServices, QKeySequence from qtpy.QtWidgets import (QMainWindow, QWidget, QListWidget, QListWidgetItem, QSplitter, QFileDialog, QPushButton, - QDialogButtonBox, QShortcut, QHBoxLayout, QVBoxLayout, QGridLayout, QLineEdit, QUndoStack, + QDialogButtonBox, QShortcut, QVBoxLayout, QGridLayout, QLineEdit, QUndoStack, QCheckBox, QComboBox, QMessageBox, QDialog, QInputDialog, QLabel, QGroupBox, QRadioButton) try: @@ -42,9 +44,9 @@ REOPEN_LAST_FILE = object() -assignment_pattern = re.compile('[^\[\]]+[^=]=[^=].+') -setitem_pattern = re.compile('(.+)\[.+\][^=]=[^=].+') -history_vars_pattern = re.compile('_i?\d+') +assignment_pattern = re.compile(r'[^\[\]]+[^=]=[^=].+') +setitem_pattern = re.compile(r'(.+)\[.+\][^=]=[^=].+') +history_vars_pattern = re.compile(r'_i?\d+') # XXX: add all scalars except strings (from numpy or plain Python)? # (long) strings are not handled correctly so should NOT be in this list # tuple, list @@ -307,7 +309,7 @@ def __init__(self, parent=None): self.setup_menu_bar() - def _setup_and_check(self, widget, data, title, readonly, **kwargs): + def _setup_and_check(self, widget, data, title, readonly, stack_pos=None): """Setup MappingEditor""" layout = QVBoxLayout() widget.setLayout(layout) @@ -332,6 +334,7 @@ def _setup_and_check(self, widget, data, title, readonly, **kwargs): kernel = kernel_manager.kernel # TODO: use self._reset() instead + # FIXME: when using the editor as a debugger this is annoying kernel.shell.run_cell('from larray import *') text_formatter = kernel.shell.display_formatter.formatters['text/plain'] @@ -373,9 +376,34 @@ def void_formatter(array, *args, **kwargs): right_panel_widget.setLayout(right_panel_layout) main_splitter = QSplitter(Qt.Horizontal) - main_splitter.addWidget(self._listwidget) + debug = isinstance(data, StackSummary) + if debug: + self._stack_frame_widget = QListWidget(self) + stack_frame_widget = self._stack_frame_widget + stack_frame_widget.itemSelectionChanged.connect(self.on_stack_frame_changed) + stack_frame_widget.setMinimumWidth(60) + + for frame_summary in data: + funcname = frame_summary.name + filename = os.path.basename(frame_summary.filename) + listitem = QListWidgetItem(stack_frame_widget) + listitem.setText("{}, {}:{}".format(funcname, filename, frame_summary.lineno)) + # we store the frame locals in the user data of the list + listitem.setData(Qt.UserRole, frame_summary.locals) + listitem.setToolTip(frame_summary.line) + row = stack_pos if stack_pos is not None else len(data) - 1 + stack_frame_widget.setCurrentRow(row) + + left_panel_widget = QSplitter(Qt.Vertical) + left_panel_widget.addWidget(self._listwidget) + left_panel_widget.addWidget(stack_frame_widget) + left_panel_widget.setSizes([500, 200]) + data = self.data + else: + left_panel_widget = self._listwidget + main_splitter.addWidget(left_panel_widget) main_splitter.addWidget(right_panel_widget) - main_splitter.setSizes([10, 90]) + main_splitter.setSizes([180, 620]) main_splitter.setCollapsible(1, False) layout.addWidget(main_splitter) @@ -394,8 +422,7 @@ def void_formatter(array, *args, **kwargs): else: QMessageBox.critical(self, "Error", "File {} could not be found".format(data)) self.new() - # convert input data to Session if not - else: + elif not debug: self._push_data(data) def _push_data(self, data): @@ -406,6 +433,16 @@ def _push_data(self, data): self.add_list_items(arrays) self._listwidget.setCurrentRow(0) + def on_stack_frame_changed(self): + selected = self._stack_frame_widget.selectedItems() + if selected: + assert len(selected) == 1 + selected_item = selected[0] + assert isinstance(selected_item, QListWidgetItem) + data = selected_item.data(Qt.UserRole) + self._reset() + self._push_data(data) + def _reset(self): self.data = Session() self._listwidget.clear() @@ -488,9 +525,9 @@ def add_list_items(self, names): def delete_list_item(self, to_delete): deleted_items = self._listwidget.findItems(to_delete, Qt.MatchExactly) - assert len(deleted_items) == 1 - deleted_item_idx = self._listwidget.row(deleted_items[0]) - self._listwidget.takeItem(deleted_item_idx) + if len(deleted_items) == 1: + deleted_item_idx = self._listwidget.row(deleted_items[0]) + self._listwidget.takeItem(deleted_item_idx) def select_list_item(self, to_display): changed_items = self._listwidget.findItems(to_display, Qt.MatchExactly) @@ -614,7 +651,7 @@ def ipython_cell_executed(self): # last command. Which means that if the last command did not produce any output, _ is not modified. cur_output = user_ns['_oh'].get(cur_input_num) if cur_output is not None: - if self._display_in_grid('_', cur_output): + if self._display_in_grid('', cur_output): self.view_expr(cur_output) if isinstance(cur_output, collections.Iterable): diff --git a/larray_editor/tests/test_api_larray.py b/larray_editor/tests/test_api_larray.py index 3ddec51..7a022bd 100644 --- a/larray_editor/tests/test_api_larray.py +++ b/larray_editor/tests/test_api_larray.py @@ -11,6 +11,7 @@ from larray_editor.api import * from larray_editor.utils import logger +run_editor_on_exception(usercode_traceback=False, usercode_frame=True) logger.setLevel(logging.DEBUG) @@ -127,6 +128,7 @@ def make_demo(width=20, ball_radius=5, path_radius=5, steps=30): # import cProfile as profile # profile.runctx('edit(Session(arr2=arr2))', vars(), {}, # 'c:\\tmp\\edit.profile') +debug() edit() # edit(ses) # edit(file) @@ -171,3 +173,10 @@ def make_demo(width=20, ball_radius=5, path_radius=5, steps=30): arr2 = 2 * arr1 arr3 = where(arr1 % 2 == 0, arr1, -arr1) compare(arr1, arr2, arr3, bg_gradient='blue-red') + + +def test_run_editor_on_exception(): + return arr2['my_invalid_key'] + + +test_run_editor_on_exception() diff --git a/larray_editor/traceback_tools.py b/larray_editor/traceback_tools.py new file mode 100644 index 0000000..0508c3f --- /dev/null +++ b/larray_editor/traceback_tools.py @@ -0,0 +1,140 @@ +import collections +import itertools +import linecache +import sys +import traceback + + +# the classes and functions in this module are almost equivalent to (most code is copied as-is from) the +# corresponding class/function from the traceback module in the stdlib. The only significant difference (except from +# simplification thanks to only supporting the options we need) is locals are stored as-is in the FrameSummary +# instead of as a dict of repr. +class FrameSummary(object): + """A single frame from a traceback. + + Attributes + ---------- + filename : str + The filename for the frame. + lineno : int + The line within filename for the frame that was active when the frame was captured. + name : str + The name of the function or method that was executing when the frame was captured. + line : str + The text from the linecache module for the code that was running when the frame was captured. + locals : dict + The frame locals, which are stored as-is. + + Notes + ----- + equivalent to traceback.FrameSummary except locals are stored as-is instead of their repr. + """ + + __slots__ = ('filename', 'lineno', 'name', 'locals', '_line') + + def __init__(self, filename, lineno, name, locals): + """Construct a FrameSummary. + + Parameters + ---------- + filename : str + The filename for the frame. + lineno : int + The line within filename for the frame that was active when the frame was captured. + name : str + The name of the function or method that was executing when the frame was captured. + locals : dict + The frame locals, which are stored as-is. + """ + self.filename = filename + self.lineno = lineno + self.name = name + self.locals = locals + self._line = None + + @property + def line(self): + if self._line is None: + self._line = linecache.getline(self.filename, self.lineno).strip() + return self._line + + +class StackSummary(list): + @classmethod + def extract(klass, frame_gen, limit=None): + """Create a StackSummary from an iterable of frames. + + Parameters + ---------- + frame_gen : generator + A generator that yields (frame, lineno) tuples to include in the stack summary. + limit : int, optional + Number of frames to include. Defaults to None (include all frames). + + Notes + ----- + This is almost equivalent to (the code is mostly copied from) + + traceback.StackSummary.extract(frame_gen, limit=limit, lookup_lines=False, capture_locals=True) + + but the extracted locals are the actual dict instead of a repr of it. + """ + if limit is None: + limit = getattr(sys, 'tracebacklimit', None) + if limit is not None and limit < 0: + limit = 0 + if limit is not None: + if limit >= 0: + frame_gen = itertools.islice(frame_gen, limit) + else: + frame_gen = collections.deque(frame_gen, maxlen=-limit) + + result = klass() + filenames = set() + for frame, lineno in frame_gen: + f_code = frame.f_code + filename = f_code.co_filename + filenames.add(filename) + + # actual line lookups will happen lazily. + # f_globals is necessary for atypical modules where source must be fetched via module.__loader__.get_source + linecache.lazycache(filename, frame.f_globals) + + summary = FrameSummary(filename=filename, lineno=lineno, name=f_code.co_name, + locals=frame.f_locals) + result.append(summary) + + # Discard cache entries that are out of date. + for filename in filenames: + linecache.checkcache(filename) + return result + + +def extract_stack(frame, limit=None): + """Extract the raw traceback from the current stack frame. + + The return value has the same format as for extract_tb(). The + optional 'f' and 'limit' arguments have the same meaning as for + print_stack(). Each item in the list is a quadruple (filename, + line number, function name, text), and the entries are in order + from oldest to newest stack frame. + """ + stack = StackSummary.extract(traceback.walk_stack(frame), limit=limit) + stack.reverse() + return stack + + +def extract_tb(tb, limit=None): + """ + Return a StackSummary object representing a list of + pre-processed entries from traceback. + + This is useful for alternate formatting of stack traces. If + 'limit' is omitted or None, all entries are extracted. A + pre-processed stack trace entry is a FrameSummary object + containing attributes filename, lineno, name, and line + representing the information that is usually printed for a stack + trace. The line is a string with leading and trailing + whitespace stripped; if the source is not available it is None. + """ + return StackSummary.extract(traceback.walk_tb(tb), limit=limit)