From 476af649911e08a9f2cba3cace9e3a2ad822babb Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 21 Jun 2021 11:03:30 +0200 Subject: [PATCH 1/6] Added a syntax check based on AST for the model editor. Replaced QTextEdit with a nicer editor, with line number printout. --- src/sas/qtgui/Utilities/CodeEditor.py | 96 ++++++++++++++++++++ src/sas/qtgui/Utilities/TabbedModelEditor.py | 39 +++++++- src/sas/qtgui/Utilities/UI/ModelEditor.ui | 17 ++-- src/sas/sasview/__init__.py | 2 +- 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/sas/qtgui/Utilities/CodeEditor.py diff --git a/src/sas/qtgui/Utilities/CodeEditor.py b/src/sas/qtgui/Utilities/CodeEditor.py new file mode 100644 index 0000000000..2a8ee96c82 --- /dev/null +++ b/src/sas/qtgui/Utilities/CodeEditor.py @@ -0,0 +1,96 @@ + +from PyQt5.QtCore import Qt, QRect, QSize +from PyQt5.QtWidgets import QWidget, QPlainTextEdit, QTextEdit +from PyQt5.QtGui import QColor, QPainter, QTextFormat + + +class QLineNumberArea(QWidget): + def __init__(self, editor): + super().__init__(editor) + self.codeEditor = editor + + def sizeHint(self): + return QSize(self.editor.lineNumberAreaWidth(), 0) + + def paintEvent(self, event): + self.codeEditor.lineNumberAreaPaintEvent(event) + + +class QCodeEditor(QPlainTextEdit): + def __init__(self, parent=None): + super().__init__(parent) + self.lineNumberArea = QLineNumberArea(self) + self.blockCountChanged.connect(self.updateLineNumberAreaWidth) + self.updateRequest.connect(self.updateLineNumberArea) + self.cursorPositionChanged.connect(self.highlightCurrentLine) + self.updateLineNumberAreaWidth(0) + + def lineNumberAreaWidth(self): + digits = 1 + max_value = max(1, self.blockCount()) + while max_value >= 10: + max_value /= 10 + digits += 1 + space = 3 + self.fontMetrics().width('9') * digits + return space + + def updateLineNumberAreaWidth(self, _): + self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0) + + def updateLineNumberArea(self, rect, dy): + if dy: + self.lineNumberArea.scroll(0, dy) + else: + self.lineNumberArea.update(0, rect.y(), self.lineNumberArea.width(), rect.height()) + if rect.contains(self.viewport().rect()): + self.updateLineNumberAreaWidth(0) + + def resizeEvent(self, event): + super().resizeEvent(event) + cr = self.contentsRect() + self.lineNumberArea.setGeometry(QRect(cr.left(), cr.top(), self.lineNumberAreaWidth(), cr.height())) + + def highlightCurrentLine(self): + extraSelections = [] + if not self.isReadOnly(): + selection = QTextEdit.ExtraSelection() + lineColor = QColor(Qt.yellow).lighter(160) + selection.format.setBackground(lineColor) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + selection.cursor = self.textCursor() + selection.cursor.clearSelection() + extraSelections.append(selection) + self.setExtraSelections(extraSelections) + + def lineNumberAreaPaintEvent(self, event): + painter = QPainter(self.lineNumberArea) + + painter.fillRect(event.rect(), Qt.lightGray) + + block = self.firstVisibleBlock() + blockNumber = block.blockNumber() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + + # Just to make sure I use the right font + height = self.fontMetrics().height() + while block.isValid() and (top <= event.rect().bottom()): + if block.isVisible() and (bottom >= event.rect().top()): + number = str(blockNumber + 1) + painter.setPen(Qt.black) + painter.drawText(0, top, self.lineNumberArea.width(), height, Qt.AlignRight, number) + + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + blockNumber += 1 + + +if __name__ == '__main__': + import sys + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + codeEditor = QCodeEditor() + codeEditor.show() + sys.exit(app.exec_()) diff --git a/src/sas/qtgui/Utilities/TabbedModelEditor.py b/src/sas/qtgui/Utilities/TabbedModelEditor.py index 0a0d74f0b1..c48a353833 100644 --- a/src/sas/qtgui/Utilities/TabbedModelEditor.py +++ b/src/sas/qtgui/Utilities/TabbedModelEditor.py @@ -1,6 +1,7 @@ # global import sys import os +import ast import datetime import logging import traceback @@ -274,7 +275,6 @@ def updateFromPlugin(self): # disable "Apply" self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) - # test the model # Run the model test in sasmodels if not self.isModelCorrect(full_path): @@ -299,6 +299,36 @@ def updateFromPlugin(self): self.parent.communicate.statusBarUpdateSignal.emit(msg) logging.info(msg) + def checkModel(self, model_str): + """ + Run the ast check + and return True if the model is good. + False otherwise. + """ + successfulCheck = True + try: + ast.parse(model_str) + + except SyntaxError as ex: + msg = "Error building model: " + str(ex) + logging.error(msg) + # print three last lines of the stack trace + # this will point out the exact line failing + last_lines = traceback.format_exc().split('\n')[-4:] + traceback_to_show = '\n'.join(last_lines) + logging.error(traceback_to_show) + + # Set the status bar message + self.parent.communicate.statusBarUpdateSignal.emit("Model check failed") + + # Put a thick, red border around the mini-editor + self.tabWidget.currentWidget().txtEditor.setStyleSheet("border: 5px solid red") + # last_lines = traceback.format_exc().split('\n')[-4:] + traceback_to_show = '\n'.join(last_lines) + self.tabWidget.currentWidget().txtEditor.setToolTip(traceback_to_show) + successfulCheck = False + return successfulCheck + def isModelCorrect(self, full_path): """ Run the sasmodels method for model check @@ -347,6 +377,13 @@ def updateFromEditor(self): assert(filename != "") # Retrieve model string model_str = self.getModel()['text'] + if self.tabWidget.currentWidget().is_python: + if not self.checkModel(model_str): + return + + # change the frame colours back + self.tabWidget.currentWidget().txtEditor.setStyleSheet("") + self.tabWidget.currentWidget().txtEditor.setToolTip("") # Save the file self.writeFile(filename, model_str) # Update the tab title diff --git a/src/sas/qtgui/Utilities/UI/ModelEditor.ui b/src/sas/qtgui/Utilities/UI/ModelEditor.ui index 9dbcab10de..4570aa287c 100755 --- a/src/sas/qtgui/Utilities/UI/ModelEditor.ui +++ b/src/sas/qtgui/Utilities/UI/ModelEditor.ui @@ -21,21 +21,20 @@ - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:7.8pt; font-weight:400; font-style:normal;"> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> - - + + + + QCodeEditor + QWidget +
sas.qtgui.Utilities.CodeEditor.h
+
+
diff --git a/src/sas/sasview/__init__.py b/src/sas/sasview/__init__.py index f1919b5f61..c7a13c2e57 100644 --- a/src/sas/sasview/__init__.py +++ b/src/sas/sasview/__init__.py @@ -1,5 +1,5 @@ from distutils.version import StrictVersion -__version__ = "5.0.5-alpha.1" +__version__ = "5.0.5a1" StrictVersion(__version__) __DOI__ = "Zenodo, DOI:10.5281/zenodo.4467703" __release_date__ = "2021" From 62ae4d16ac7b62363bffda55bb6ef2eaa542fdcc Mon Sep 17 00:00:00 2001 From: Piotr R Date: Tue, 22 Jun 2021 14:42:57 +0200 Subject: [PATCH 2/6] Code review changes --- src/sas/qtgui/Utilities/CodeEditor.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sas/qtgui/Utilities/CodeEditor.py b/src/sas/qtgui/Utilities/CodeEditor.py index 2a8ee96c82..636c0d05a9 100644 --- a/src/sas/qtgui/Utilities/CodeEditor.py +++ b/src/sas/qtgui/Utilities/CodeEditor.py @@ -23,7 +23,7 @@ def __init__(self, parent=None): self.blockCountChanged.connect(self.updateLineNumberAreaWidth) self.updateRequest.connect(self.updateLineNumberArea) self.cursorPositionChanged.connect(self.highlightCurrentLine) - self.updateLineNumberAreaWidth(0) + self.updateLineNumberAreaWidth() def lineNumberAreaWidth(self): digits = 1 @@ -31,10 +31,12 @@ def lineNumberAreaWidth(self): while max_value >= 10: max_value /= 10 digits += 1 + # line number display width padded with extra pixels. + # Chosen to "look nice", hence magic numbers space = 3 + self.fontMetrics().width('9') * digits return space - def updateLineNumberAreaWidth(self, _): + def updateLineNumberAreaWidth(self): self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0) def updateLineNumberArea(self, rect, dy): @@ -43,7 +45,7 @@ def updateLineNumberArea(self, rect, dy): else: self.lineNumberArea.update(0, rect.y(), self.lineNumberArea.width(), rect.height()) if rect.contains(self.viewport().rect()): - self.updateLineNumberAreaWidth(0) + self.updateLineNumberAreaWidth() def resizeEvent(self, event): super().resizeEvent(event) From 9a1c34c7b3ff4de9dda7d8dbf6fb3234a40dc0b1 Mon Sep 17 00:00:00 2001 From: Piotr R Date: Tue, 22 Jun 2021 14:57:26 +0200 Subject: [PATCH 3/6] Revert the version string fix, so the proper fix can be merged without conflict --- src/sas/sasview/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sas/sasview/__init__.py b/src/sas/sasview/__init__.py index c7a13c2e57..f1919b5f61 100644 --- a/src/sas/sasview/__init__.py +++ b/src/sas/sasview/__init__.py @@ -1,5 +1,5 @@ from distutils.version import StrictVersion -__version__ = "5.0.5a1" +__version__ = "5.0.5-alpha.1" StrictVersion(__version__) __DOI__ = "Zenodo, DOI:10.5281/zenodo.4467703" __release_date__ = "2021" From 651d8cf98d8ddf0e9ebf3db4ffa71dc6a58094fc Mon Sep 17 00:00:00 2001 From: Piotr R Date: Tue, 22 Jun 2021 15:39:41 +0200 Subject: [PATCH 4/6] Add model check on plugin load and enable the Save button so re-checking can be immediately done. --- src/sas/qtgui/Utilities/TabbedModelEditor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/sas/qtgui/Utilities/TabbedModelEditor.py b/src/sas/qtgui/Utilities/TabbedModelEditor.py index c48a353833..dc9e3f2720 100644 --- a/src/sas/qtgui/Utilities/TabbedModelEditor.py +++ b/src/sas/qtgui/Utilities/TabbedModelEditor.py @@ -120,7 +120,6 @@ def onLoad(self): if saveCancelled: return self.is_modified = False - self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) plugin_location = models.find_plugins_dir() filename = QtWidgets.QFileDialog.getOpenFileName( @@ -146,16 +145,22 @@ def loadFile(self, filename): Performs the load operation and updates the view """ self.editor_widget.blockSignals(True) + plugin_text = "" with open(filename, 'r', encoding="utf-8") as plugin: - self.editor_widget.txtEditor.setPlainText(plugin.read()) + plugin_text = plugin.read() + self.editor_widget.txtEditor.setPlainText(plugin_text) self.editor_widget.setEnabled(True) self.editor_widget.blockSignals(False) + self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True) self.filename = filename display_name, _ = os.path.splitext(os.path.basename(filename)) self.setWindowTitle(self.window_title + " - " + display_name) # Name the tab with .py filename display_name = os.path.basename(filename) self.tabWidget.setTabText(0, display_name) + # Check the validity of loaded model + if not self.checkModel(plugin_text): + return # See if there is filename.c present c_path = self.filename.replace(".py", ".c") From 2ff640bfc5c2779f00fdcee4785f0a876859b138 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Thu, 24 Jun 2021 12:57:09 +0200 Subject: [PATCH 5/6] Move cursor/highlight row to where the 1st syntax error occurs --- src/sas/qtgui/Utilities/TabbedModelEditor.py | 43 ++++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/sas/qtgui/Utilities/TabbedModelEditor.py b/src/sas/qtgui/Utilities/TabbedModelEditor.py index dc9e3f2720..ae6f584418 100644 --- a/src/sas/qtgui/Utilities/TabbedModelEditor.py +++ b/src/sas/qtgui/Utilities/TabbedModelEditor.py @@ -1,12 +1,13 @@ # global import sys import os +import re import ast import datetime import logging import traceback -from PyQt5 import QtWidgets, QtCore +from PyQt5 import QtWidgets, QtCore, QtGui from sas.sascalc.fit import models @@ -159,7 +160,11 @@ def loadFile(self, filename): display_name = os.path.basename(filename) self.tabWidget.setTabText(0, display_name) # Check the validity of loaded model - if not self.checkModel(plugin_text): + error_line = self.checkModel(plugin_text) + if error_line > 0: + # select bad line + cursor = QtGui.QTextCursor(self.editor_widget.txtEditor.document().findBlockByLineNumber(error_line-1)) + self.editor_widget.txtEditor.setTextCursor(cursor) return # See if there is filename.c present @@ -310,16 +315,18 @@ def checkModel(self, model_str): and return True if the model is good. False otherwise. """ - successfulCheck = True + # successfulCheck = True + error_line = 0 try: ast.parse(model_str) except SyntaxError as ex: msg = "Error building model: " + str(ex) logging.error(msg) - # print three last lines of the stack trace + # print four last lines of the stack trace # this will point out the exact line failing - last_lines = traceback.format_exc().split('\n')[-4:] + all_lines = traceback.format_exc().split('\n') + last_lines = all_lines[-4:] traceback_to_show = '\n'.join(last_lines) logging.error(traceback_to_show) @@ -331,8 +338,15 @@ def checkModel(self, model_str): # last_lines = traceback.format_exc().split('\n')[-4:] traceback_to_show = '\n'.join(last_lines) self.tabWidget.currentWidget().txtEditor.setToolTip(traceback_to_show) - successfulCheck = False - return successfulCheck + # attempt to find the failing command line number + for line in all_lines: + if 'File' in line and 'line' in line: + error_line = re.split('line ', line)[1] + try: + error_line = int(error_line) + except ValueError: + error_line = 0 + return error_line def isModelCorrect(self, full_path): """ @@ -374,7 +388,8 @@ def updateFromEditor(self): Save the current state of the Model Editor """ filename = self.filename - if not self.tabWidget.currentWidget().is_python: + w = self.tabWidget.currentWidget() + if not w.is_python: base, _ = os.path.splitext(filename) filename = base + '.c' @@ -382,13 +397,17 @@ def updateFromEditor(self): assert(filename != "") # Retrieve model string model_str = self.getModel()['text'] - if self.tabWidget.currentWidget().is_python: - if not self.checkModel(model_str): + if w.is_python: + error_line = self.checkModel(model_str) + if error_line > 0: + # select bad line + cursor = QtGui.QTextCursor(w.txtEditor.document().findBlockByLineNumber(error_line-1)) + w.txtEditor.setTextCursor(cursor) return # change the frame colours back - self.tabWidget.currentWidget().txtEditor.setStyleSheet("") - self.tabWidget.currentWidget().txtEditor.setToolTip("") + w.txtEditor.setStyleSheet("") + w.txtEditor.setToolTip("") # Save the file self.writeFile(filename, model_str) # Update the tab title From 83fb2c2d10594c50dd840e8f73cef52ea8e32cf7 Mon Sep 17 00:00:00 2001 From: Piotr R Date: Fri, 25 Jun 2021 12:47:02 +0200 Subject: [PATCH 6/6] No wrapping in the editor window, otherwise setting the cursor position on error is impossible. Added border colour reset on loading a new model. --- src/sas/qtgui/Utilities/CodeEditor.py | 1 + src/sas/qtgui/Utilities/TabbedModelEditor.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/sas/qtgui/Utilities/CodeEditor.py b/src/sas/qtgui/Utilities/CodeEditor.py index 636c0d05a9..342c36b112 100644 --- a/src/sas/qtgui/Utilities/CodeEditor.py +++ b/src/sas/qtgui/Utilities/CodeEditor.py @@ -20,6 +20,7 @@ class QCodeEditor(QPlainTextEdit): def __init__(self, parent=None): super().__init__(parent) self.lineNumberArea = QLineNumberArea(self) + self.setLineWrapMode(QPlainTextEdit.NoWrap) self.blockCountChanged.connect(self.updateLineNumberAreaWidth) self.updateRequest.connect(self.updateLineNumberArea) self.cursorPositionChanged.connect(self.highlightCurrentLine) diff --git a/src/sas/qtgui/Utilities/TabbedModelEditor.py b/src/sas/qtgui/Utilities/TabbedModelEditor.py index ae6f584418..1b64aed811 100644 --- a/src/sas/qtgui/Utilities/TabbedModelEditor.py +++ b/src/sas/qtgui/Utilities/TabbedModelEditor.py @@ -167,6 +167,10 @@ def loadFile(self, filename): self.editor_widget.txtEditor.setTextCursor(cursor) return + # In case previous model was incorrect, change the frame colours back + self.editor_widget.txtEditor.setStyleSheet("") + self.editor_widget.txtEditor.setToolTip("") + # See if there is filename.c present c_path = self.filename.replace(".py", ".c") if not os.path.isfile(c_path): return @@ -338,12 +342,14 @@ def checkModel(self, model_str): # last_lines = traceback.format_exc().split('\n')[-4:] traceback_to_show = '\n'.join(last_lines) self.tabWidget.currentWidget().txtEditor.setToolTip(traceback_to_show) - # attempt to find the failing command line number - for line in all_lines: + # attempt to find the failing command line number, usually the last line with + # `File ... line` syntax + for line in reversed(all_lines): if 'File' in line and 'line' in line: error_line = re.split('line ', line)[1] try: error_line = int(error_line) + break except ValueError: error_line = 0 return error_line