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.
This commit is contained in:
Charles Haley 2022-10-24 13:48:57 +01:00
parent 77dc74137f
commit 6374517dd4
2 changed files with 145 additions and 50 deletions

View File

@ -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):
'<a href="{}">{}</a>'.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 != '<string>':
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 == '<string>':
return self.trace_lines
return None
def display_values(self, txt):
tv = self.template_value
@ -787,6 +828,11 @@ def evaluate(book, context):
w = tv.cellWidget(r, 0)
w.setText(mi.title)
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,
@ -795,6 +841,8 @@ def evaluate(book, context):
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):

View File

@ -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]