From 6374517dd48573f32ed8b51a3dab0ccb734cbc2f Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Mon, 24 Oct 2022 13:48:57 +0100 Subject: [PATCH] Template debugger: Add breakpoints for python templates. On a breakpoint open a dialog displaying variables in the current frame. Also indicate when the debugger is waiting for the 'Go' button. This fixes problems when people don't notice they have checked the Enable breakpoints box. --- src/calibre/gui2/dialogs/template_dialog.py | 187 +++++++++++++++----- src/calibre/utils/formatter.py | 8 +- 2 files changed, 145 insertions(+), 50 deletions(-) diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py index b3167cc7c9..45784fc833 100644 --- a/src/calibre/gui2/dialogs/template_dialog.py +++ b/src/calibre/gui2/dialogs/template_dialog.py @@ -7,6 +7,7 @@ __license__ = 'GPL v3' import json, os, traceback, re from functools import partial +import sys from qt.core import (Qt, QDialog, QDialogButtonBox, QSyntaxHighlighter, QFont, QApplication, QTextCharFormat, QColor, QCursor, @@ -389,6 +390,18 @@ class TemplateDialog(QDialog, Ui_TemplateDialog): self.builtins = (builtin_functions if builtin_functions else formatter_functions().get_builtins_and_aliases()) + # Set up the breakpoint bar + s = gprefs.get('template_editor_break_on_print', False) + self.go_button.setEnabled(s) + self.remove_all_button.setEnabled(s) + self.set_all_button.setEnabled(s) + self.toggle_button.setEnabled(s) + self.breakpoint_line_box.setEnabled(s) + self.breakpoint_line_box_label.setEnabled(s) + self.break_box.setChecked(s) + self.break_box.stateChanged.connect(self.break_box_changed) + self.go_button.clicked.connect(self.go_button_pressed) + # Set up the display table self.table_column_widths = None try: @@ -451,16 +464,6 @@ class TemplateDialog(QDialog, Ui_TemplateDialog): '{}'.format( localize_user_manual_link('https://manual.calibre-ebook.com/generated/en/template_ref.html'), tt)) - s = gprefs.get('template_editor_break_on_print', False) - self.go_button.setEnabled(s) - self.remove_all_button.setEnabled(s) - self.set_all_button.setEnabled(s) - self.toggle_button.setEnabled(s) - self.breakpoint_line_box.setEnabled(s) - self.breakpoint_line_box_label.setEnabled(s) - self.break_box.setChecked(s) - self.break_box.stateChanged.connect(self.break_box_changed) - self.go_button.clicked.connect(self.go_button_pressed) self.textbox.setFocus() self.set_up_font_boxes() self.toggle_button.clicked.connect(self.toggle_button_pressed) @@ -546,11 +549,19 @@ class TemplateDialog(QDialog, Ui_TemplateDialog): for r in range(0, len(mi)): w = QLineEdit(tv) w.setReadOnly(True) + w.setText(mi[r].title) tv.setCellWidget(r, 0, w) w = QLineEdit(tv) w.setReadOnly(True) tv.setCellWidget(r, 1, w) - self.display_values('') + self.set_waiting_message() + + def set_waiting_message(self): + if self.break_box.isChecked(): + for i in range(len(self.mi)): + self.template_value.cellWidget(i, 1).setText('') + self.template_value.cellWidget(0, 1).setText( + _("*** Breakpoints are enabled. Waiting for the 'Go' button to be pressed")) def show_context_menu(self, point): m = self.textbox.createStandardContextMenu() @@ -678,6 +689,8 @@ def evaluate(book, context): self.breakpoint_line_box_label.setEnabled(new_state != 0) if new_state == 0: self.display_values(str(self.textbox.toPlainText())) + else: + self.set_waiting_message() def go_button_pressed(self): self.display_values(str(self.textbox.toPlainText())) @@ -760,14 +773,17 @@ def evaluate(book, context): c = app.clipboard() c.setText(str(self.icon_files.currentText())) + @property + def is_python(self): + return self.textbox.toPlainText().startswith('python:') + def textbox_changed(self): cur_text = str(self.textbox.toPlainText()) - if cur_text.startswith('python:'): + if self.is_python: if self.highlighting_gpm is True: self.highlighter.initialize_rules(self.builtins, True) self.highlighting_gpm = False - self.break_box.setChecked(False) - self.break_box.setEnabled(False) + self.break_box.setEnabled(True) elif not self.highlighting_gpm: self.highlighter.initialize_rules(self.builtins, False) self.highlighting_gpm = True @@ -776,8 +792,33 @@ def evaluate(book, context): self.last_text = cur_text self.highlighter.regenerate_paren_positions() self.text_cursor_changed() - if self.break_box.checkState() == Qt.CheckState.Unchecked: + if not self.break_box.isChecked(): self.display_values(cur_text) + else: + self.set_waiting_message() + + def trace_lines(self, frame, event, arg): + if event != 'line': + return + # Only respond to events in the "string" which is the template + if frame.f_code.co_filename != '': + return + # Check that there is a breakpoint at the line + if frame.f_lineno not in self.textbox.clicked_line_numbers: + return + l = self.template_value.selectionModel().selectedRows() + mi_to_use = self.mi[0 if len(l) == 0 else l[0].row()] + self.break_reporter_dialog = PythonBreakReporter(self, mi_to_use, frame) + if not self.break_reporter_dialog.exec(): + raise StopException() + + def trace_calls(self, frame, event, arg): + if event != 'call': + return + # If this is the "string" file (the template), return the trace_lines function + if frame.f_code.co_filename == '': + return self.trace_lines + return None def display_values(self, txt): tv = self.template_value @@ -787,14 +828,21 @@ def evaluate(book, context): w = tv.cellWidget(r, 0) w.setText(mi.title) w.setCursorPosition(0) - v = SafeFormat().safe_format(txt, mi, _('EXCEPTION:'), - mi, global_vars=self.global_vars, - template_functions=self.all_functions, - break_reporter=self.break_reporter if r == break_on_mi else None, - python_context_object=self.python_context_object) - w = tv.cellWidget(r, 1) - w.setText(v.translate(translate_table)) - w.setCursorPosition(0) + if self.break_box.isChecked() and r == break_on_mi and self.is_python: + sys.settrace(self.trace_calls) + else: + sys.settrace(None) + try: + v = SafeFormat().safe_format(txt, mi, _('EXCEPTION:'), + mi, global_vars=self.global_vars, + template_functions=self.all_functions, + break_reporter=self.break_reporter if r == break_on_mi else None, + python_context_object=self.python_context_object) + w = tv.cellWidget(r, 1) + w.setText(v.translate(translate_table)) + w.setCursorPosition(0) + finally: + sys.settrace(None) def text_cursor_changed(self): cursor = self.textbox.textCursor() @@ -896,20 +944,21 @@ class BreakReporterItem(QTableWidgetItem): def __init__(self, txt): super().__init__(txt.translate(translate_table) if txt else txt) - self.setFlags(self.flags() & ~(Qt.ItemFlag.ItemIsEditable|Qt.ItemFlag.ItemIsSelectable)) + self.setFlags(self.flags() & ~(Qt.ItemFlag.ItemIsEditable)) -class BreakReporter(QDialog): +class BreakReporterBase(QDialog): - def __init__(self, parent, mi, op_label, op_value, locals_, line_number): - super().__init__(parent) + def setup_ui(self, mi, line_number, locals_, leading_rows): self.mi = mi + self.leading_rows = leading_rows self.setModal(True) l = QVBoxLayout(self) t = self.table = QTableWidget(self) + t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) t.setColumnCount(2) t.setHorizontalHeaderLabels((_('Name'), _('Value'))) - t.setRowCount(2) + t.setRowCount(leading_rows) l.addWidget(t) self.table_column_widths = None @@ -935,35 +984,34 @@ class BreakReporter(QDialog): bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) self.setLayout(l) - self.setWindowTitle(_('Break: line {0}, book {1}').format(line_number, self.mi.title)) - local_names = sorted(locals_.keys()) - rows = len(local_names) - self.table.setRowCount(rows+2) - self.table.setItem(0, 0, BreakReporterItem(op_label)) - self.table.item(0,0).setToolTip(_('The name of the template language operation')) - self.table.setItem(0, 1, BreakReporterItem(op_value)) - self.mi_combo = QComboBox() - t.setCellWidget(1, 0, self.mi_combo) + t.setCellWidget(leading_rows-1, 0, self.mi_combo) self.mi_combo.addItems(self.get_field_keys()) self.mi_combo.setToolTip('Choose a book metadata field to display') - self.mi_combo.setCurrentIndex(-1) self.mi_combo.currentTextChanged.connect(self.get_field_value) - for i,k in enumerate(local_names): - itm = BreakReporterItem(k) - itm.setToolTip(_('A variable in the template')) - self.table.setItem(i+2, 0, itm) - itm = BreakReporterItem(locals_[k]) - itm.setToolTip(_('The value of the variable')) - self.table.setItem(i+2, 1, itm) - + self.mi_combo.setCurrentIndex(self.mi_combo.findText('title')) self.restore_geometry(gprefs, 'template_editor_break_geometry') + self.setup_locals(locals_) + + def setup_locals(self, locals_): + raise NotImplementedError + + def add_local_line(self, locals, row, key): + itm = BreakReporterItem(key) + itm.setToolTip(_('A variable in the template')) + self.table.setItem(row, 0, itm) + itm = BreakReporterItem(repr(locals[key])) + itm.setToolTip(_('The value of the variable')) + self.table.setItem(row, 1, itm) def get_field_value(self, field): - val = self.mi.format_field('timestamp' if field == 'date' else field)[1] - self.table.setItem(1, 1, BreakReporterItem(val)) + val = self.displayable_field_value(self.mi, field) + self.table.setItem(self.leading_rows-1, 1, BreakReporterItem(val)) + + def displayable_field_value(self, mi, field): + raise NotImplementedError def table_column_resized(self, col, old, new): self.table_column_widths = [] @@ -992,6 +1040,49 @@ class BreakReporter(QDialog): QDialog.accept(self) +class BreakReporter(BreakReporterBase): + + def __init__(self, parent, mi, op_label, op_value, locals_, line_number): + super().__init__(parent) + self.setup_ui(mi, line_number, locals_, leading_rows=2) + self.table.setItem(0, 0, BreakReporterItem(op_label)) + self.table.item(0,0).setToolTip(_('The name of the template language operation')) + self.table.setItem(0, 1, BreakReporterItem(op_value)) + + def setup_locals(self, locals): + local_names = sorted(locals.keys()) + rows = len(local_names) + self.table.setRowCount(rows+2) + for i,k in enumerate(local_names, start=2): + self.add_local_line(locals, i, k) + + def displayable_field_value(self, mi, field): + return self.mi.format_field('timestamp' if field == 'date' else field)[1] + + +class PythonBreakReporter(BreakReporterBase): + + def __init__(self, parent, mi, frame): + super().__init__(parent) + self.frame = frame + line_number = frame.f_lineno + locals = frame.f_locals + self.setup_ui(mi, line_number, locals, leading_rows=1) + + def setup_locals(self, locals): + locals = self.frame.f_locals + local_names = sorted(k for k in locals.keys() if k not in ('book', 'context')) + rows = len(local_names) + self.table.setRowCount(rows+1) + + for i,k in enumerate(local_names, start=1): + if k in ('book', 'context'): continue + self.add_local_line(locals, i, k) + + def displayable_field_value(self, mi, field): + return repr(self.mi.get('timestamp' if field == 'date' else field)) + + class EmbeddedTemplateDialog(TemplateDialog): def __init__(self, parent): diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 9c43746e3f..473e4624d4 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -1231,10 +1231,12 @@ class _Interpreter: if (self.break_reporter): self.break_reporter(prog.node_name, res, prog.line_number) return res + except StopException: + raise except: self.error(_("Unknown field '{0}'").format(name), prog.line_number) - except (StopException, ValueError) as e: - raise e + except (StopException, ValueError): + raise except: self.error(_("Unknown field '{0}'").format('internal parse error'), prog.line_number) @@ -1690,6 +1692,8 @@ class TemplateFormatter(string.Formatter): formatter=self, funcs=self._caller) rslt = compiled_template(self.book, self.python_context_object) + except StopException: + raise except Exception as e: stack = traceback.extract_tb(exc_info()[2]) ss = stack[-1]