Source code for sas.qtgui.Utilities.TabbedModelEditor

# global
import sys
import os
import re
import ast
import datetime
import logging
import traceback

from PyQt5 import QtWidgets, QtCore, QtGui

from sas.sascalc.fit import models

import sas.qtgui.Utilities.GuiUtils as GuiUtils
from sas.qtgui.Utilities.UI.TabbedModelEditor import Ui_TabbedModelEditor
from sas.qtgui.Utilities.PluginDefinition import PluginDefinition
from sas.qtgui.Utilities.ModelEditor import ModelEditor

[docs]class TabbedModelEditor(QtWidgets.QDialog, Ui_TabbedModelEditor): """ Model editor "container" class describing interaction between plugin definition widget and model editor widget. Once the model is defined, it can be saved as a plugin. """ # Signals for intertab communication plugin -> editor
[docs] def __init__(self, parent=None, edit_only=False): super(TabbedModelEditor, self).__init__(parent._parent) self.parent = parent self.setupUi(self) # disable the context help icon self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) # globals self.filename = "" self.window_title = self.windowTitle() self.edit_only = edit_only self.is_modified = False self.label = None self.addWidgets() self.addSignals()
[docs] def addWidgets(self): """ Populate tabs with widgets """ # Set up widget enablement/visibility self.cmdLoad.setVisible(self.edit_only) # Add tabs # Plugin definition widget self.plugin_widget = PluginDefinition(self) self.tabWidget.addTab(self.plugin_widget, "Plugin Definition") self.setPluginActive(True) self.editor_widget = ModelEditor(self) # Initially, nothing in the editor self.editor_widget.setEnabled(False) self.tabWidget.addTab(self.editor_widget, "Model editor") self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) if self.edit_only: self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setText("Save") # Hide signals from the plugin widget self.plugin_widget.blockSignals(True) # and hide the tab/widget itself self.tabWidget.removeTab(0)
[docs] def addSignals(self): """ Define slots for common widget signals """ # buttons self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).clicked.connect(self.onApply) self.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel).clicked.connect(self.onCancel) self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp) self.cmdLoad.clicked.connect(self.onLoad) # signals from tabs self.plugin_widget.modelModified.connect(self.editorModelModified) self.editor_widget.modelModified.connect(self.editorModelModified) self.plugin_widget.txtName.editingFinished.connect(self.pluginTitleSet)
[docs] def setPluginActive(self, is_active=True): """ Enablement control for all the controls on the simple plugin editor """ self.plugin_widget.setEnabled(is_active)
[docs] def saveClose(self): """ Check if file needs saving before closing or model reloading """ saveCancelled = False ret = self.onModifiedExit() if ret == QtWidgets.QMessageBox.Cancel: saveCancelled = True elif ret == QtWidgets.QMessageBox.Save: self.updateFromEditor() return saveCancelled
[docs] def closeEvent(self, event): """ Overwrite the close even to assure intent """ if self.is_modified: saveCancelled = self.saveClose() if saveCancelled: return event.accept()
[docs] def onLoad(self): """ Loads a model plugin file """ if self.is_modified: saveCancelled = self.saveClose() if saveCancelled: return self.is_modified = False plugin_location = models.find_plugins_dir() filename = QtWidgets.QFileDialog.getOpenFileName( self, 'Open Plugin', plugin_location, 'SasView Plugin Model (*.py)', None, QtWidgets.QFileDialog.DontUseNativeDialog)[0] # Load the file if not filename: logging.info("No data file chosen.") return # remove c-plugin tab, if present. if self.tabWidget.count()>1: self.tabWidget.removeTab(1) self.loadFile(filename)
[docs] 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: 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 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 # 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 # add a tab with the same highlighting display_name = os.path.basename(c_path) self.c_editor_widget = ModelEditor(self, is_python=False) self.tabWidget.addTab(self.c_editor_widget, display_name) # Read in the file and set in on the widget with open(c_path, 'r', encoding="utf-8") as plugin: self.c_editor_widget.txtEditor.setPlainText(plugin.read()) self.c_editor_widget.modelModified.connect(self.editorModelModified)
[docs] def onModifiedExit(self): msg_box = QtWidgets.QMessageBox(self) msg_box.setWindowTitle("SasView Model Editor") msg_box.setText("The document has been modified.") msg_box.setInformativeText("Do you want to save your changes?") msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel) msg_box.setDefaultButton(QtWidgets.QMessageBox.Save) return msg_box.exec()
[docs] def onCancel(self): """ Accept if document not modified, confirm intent otherwise. """ if self.is_modified: saveCancelled = self.saveClose() if saveCancelled: return self.reject()
[docs] def onApply(self): """ Write the plugin and update the model editor if plugin editor open Write/overwrite the plugin if model editor open """ if isinstance(self.tabWidget.currentWidget(), PluginDefinition): self.updateFromPlugin() else: self.updateFromEditor() self.is_modified = False
[docs] def editorModelModified(self): """ User modified the model in the Model Editor. Disable the plugin editor and show that the model is changed. """ self.setTabEdited(True) self.plugin_widget.txtFunction.setStyleSheet("") self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True) self.is_modified = True
[docs] def pluginTitleSet(self): """ User modified the model name. Display the model name in the window title and allow for model save. """ # Ensure plugin name is non-empty model = self.getModel() if 'filename' in model and model['filename']: self.setWindowTitle(self.window_title + " - " + model['filename']) self.setTabEdited(True) self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True) self.is_modified = True else: # the model name is empty - disable Apply and clear the editor self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) self.editor_widget.blockSignals(True) self.editor_widget.txtEditor.setPlainText('') self.editor_widget.blockSignals(False) self.editor_widget.setEnabled(False)
[docs] def setTabEdited(self, is_edited): """ Change the widget name to indicate unsaved state Unsaved state: add "*" to filename display saved state: remove "*" from filename display """ current_text = self.windowTitle() if is_edited: if current_text[-1] != "*": current_text += "*" else: if current_text[-1] == "*": current_text = current_text[:-1] self.setWindowTitle(current_text)
[docs] def updateFromPlugin(self): """ Write the plugin and update the model editor """ # get current model model = self.getModel() if 'filename' not in model: return # get required filename filename = model['filename'] # check if file exists plugin_location = models.find_plugins_dir() full_path = os.path.join(plugin_location, filename) if os.path.splitext(full_path)[1] != ".py": full_path += ".py" # Update the global path definition self.filename = full_path if not self.canWriteModel(model, full_path): return # generate the model representation as string model_str = self.generateModel(model, full_path) self.writeFile(full_path, model_str) # disable "Apply" self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) # Run the model test in sasmodels if not self.isModelCorrect(full_path): return self.editor_widget.setEnabled(True) # Update the editor here. # Simple string forced into control. self.editor_widget.blockSignals(True) self.editor_widget.txtEditor.setPlainText(model_str) self.editor_widget.blockSignals(False) # Set the widget title self.setTabEdited(False) # Notify listeners self.parent.communicate.customModelDirectoryChanged.emit() # Notify the user msg = "Custom model "+filename + " successfully created." self.parent.communicate.statusBarUpdateSignal.emit(msg) logging.info(msg)
[docs] def checkModel(self, model_str): """ Run the ast check and return True if the model is good. False otherwise. """ # successfulCheck = True error_line = 0 try: ast.parse(model_str) except SyntaxError as ex: msg = "Error building model: " + str(ex) logging.error(msg) # print four last lines of the stack trace # this will point out the exact line failing all_lines = traceback.format_exc().split('\n') last_lines = all_lines[-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) # 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
[docs] def isModelCorrect(self, full_path): """ Run the sasmodels method for model check and return True if the model is good. False otherwise. """ successfulCheck = True try: model_results = GuiUtils.checkModel(full_path) logging.info(model_results) # We can't guarantee the type of the exception coming from # Sasmodels, so need the overreaching general Exception except Exception 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") # Remove the file so it is not being loaded on refresh os.remove(full_path) # Put a thick, red border around the mini-editor self.plugin_widget.txtFunction.setStyleSheet("border: 5px solid red") # Use the last line of the traceback for the tooltip last_lines = traceback.format_exc().split('\n')[-2:] traceback_to_show = '\n'.join(last_lines) self.plugin_widget.txtFunction.setToolTip(traceback_to_show) successfulCheck = False return successfulCheck
[docs] def updateFromEditor(self): """ Save the current state of the Model Editor """ filename = self.filename w = self.tabWidget.currentWidget() if not w.is_python: base, _ = os.path.splitext(filename) filename = base + '.c' # make sure we have the file handle ready assert(filename != "") # Retrieve model string model_str = self.getModel()['text'] 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 w.txtEditor.setStyleSheet("") w.txtEditor.setToolTip("") # Save the file self.writeFile(filename, model_str) # Update the tab title self.setTabEdited(False) # Notify listeners, since the plugin name might have changed self.parent.communicate.customModelDirectoryChanged.emit() # notify the user msg = filename + " successfully saved." self.parent.communicate.statusBarUpdateSignal.emit(msg) logging.info(msg)
[docs] def canWriteModel(self, model=None, full_path=""): """ Determine if the current plugin can be written to file """ assert(isinstance(model, dict)) assert(full_path!="") # Make sure we can overwrite the file if it exists if os.path.isfile(full_path): # can we overwrite it? if not model['overwrite']: # notify the viewer msg = "Plugin with specified name already exists.\n" msg += "Please specify different filename or allow file overwrite." QtWidgets.QMessageBox.critical(self, "Plugin Error", msg) # Don't accept but return return False # Update model editor if plugin definition changed func_str = model['text'] msg = None if func_str: if 'return' not in func_str: msg = "Error: The func(x) must 'return' a value at least.\n" msg += "For example: \n\nreturn 2*x" else: msg = 'Error: Function is not defined.' if msg is not None: QtWidgets.QMessageBox.critical(self, "Plugin Error", msg) return False return True
[docs] def onHelp(self): """ Bring up the Model Editor Documentation whenever the HELP button is clicked. Calls Documentation Window with the path of the location within the documentation tree (after /doc/ ....". """ location = "/user/qtgui/Perspectives/Fitting/plugin.html" self.parent.showHelp(location)
[docs] def getModel(self): """ Retrieves plugin model from the currently open tab """ return self.tabWidget.currentWidget().getModel()
[docs] @classmethod def writeFile(cls, fname, model_str=""): """ Write model content to file "fname" """ with open(fname, 'w', encoding="utf-8") as out_f: out_f.write(model_str)
[docs] def generateModel(self, model, fname): """ generate model from the current plugin state """ name = model['filename'] if not name: model['filename'] = fname name = fname desc_str = model['description'] param_str = self.strFromParamDict(model['parameters']) pd_param_str = self.strFromParamDict(model['pd_parameters']) func_str = model['text'] model_text = CUSTOM_TEMPLATE % { 'name': name, 'title': 'User model for ' + name, 'description': desc_str, 'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'), } # Write out parameters param_names = [] # to store parameter names pd_params = [] model_text += 'parameters = [ \n' model_text += '# ["name", "units", default, [lower, upper], "type", "description"],\n' if param_str: for pname, pvalue, desc in self.getParamHelper(param_str): param_names.append(pname) model_text += " ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc) if pd_param_str: for pname, pvalue, desc in self.getParamHelper(pd_param_str): param_names.append(pname) pd_params.append(pname) model_text += " ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc) model_text += ' ]\n' # Write out function definition model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names) model_text += ' """Absolute scattering"""\n' if "scipy." in func_str: model_text +=" import scipy\n" if "numpy." in func_str: model_text +=" import numpy\n" if "np." in func_str: model_text +=" import numpy as np\n" for func_line in func_str.split('\n'): model_text +='%s%s\n' % (" ", func_line) model_text +='## uncomment the following if Iq works for vector x\n' model_text +='#Iq.vectorized = True\n' # If polydisperse, create place holders for form_volume, ER and VR if pd_params: model_text +="\n" model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)} # Create place holder for Iqxy model_text +="\n" model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names) model_text +='# """Absolute scattering of oriented particles."""\n' model_text +='# ...\n' model_text +='# return oriented_form(x, y, args)\n' model_text +='## uncomment the following if Iqxy works for vector x, y\n' model_text +='#Iqxy.vectorized = True\n' return model_text
[docs] @classmethod def getParamHelper(cls, param_str): """ yield a sequence of name, value pairs for the parameters in param_str Parameters can be defined by one per line by name=value, or multiple on the same line by separating the pairs by semicolon or comma. The value is optional and defaults to "1.0". """ for line in param_str.replace(';', ',').split('\n'): for item in line.split(','): defn, desc = item.split('#', 1) if '#' in item else (item, '') name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0') if name: yield [v.strip() for v in (name, value, desc)]
[docs] @classmethod def strFromParamDict(cls, param_dict): """ Creates string from parameter dictionary Example:: { 0: ('variable','value'), 1: ('variable','value'), ... } """ param_str = "" for _, params in param_dict.items(): if not params[0]: continue value = 1 if params[1]: try: value = float(params[1]) except ValueError: # convert to default value = 1 param_str += params[0] + " = " + str(value) + "\n" return param_str
CUSTOM_TEMPLATE = '''\ r""" Definition ---------- Calculates %(name)s. %(description)s References ---------- Authorship and Verification --------------------------- * **Author:** --- **Date:** %(date)s * **Last Modified by:** --- **Date:** %(date)s * **Last Reviewed by:** --- **Date:** %(date)s """ from sasmodels.special import * from numpy import inf name = "%(name)s" title = "%(title)s" description = """%(description)s""" ''' CUSTOM_TEMPLATE_PD = '''\ def form_volume(%(args)s): """ Volume of the particles used to compute absolute scattering intensity and to weight polydisperse parameter contributions. """ return 0.0 def ER(%(args)s): """ Effective radius of particles to be used when computing structure factors. Input parameters are vectors ranging over the mesh of polydispersity values. """ return 0.0 def VR(%(args)s): """ Volume ratio of particles to be used when computing structure factors. Input parameters are vectors ranging over the mesh of polydispersity values. """ return 1.0 ''' SUM_TEMPLATE = """ from sasmodels.core import load_model_info from sasmodels.sasview_model import make_model_from_info model_info = load_model_info('{model1}{operator}{model2}') model_info.name = '{name}'{desc_line} Model = make_model_from_info(model_info) """ if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) sheet = TabbedModelEditor() sheet.show() app.exec_()