diff --git a/src/app/advanced_popup.py b/src/app/advanced_popup.py new file mode 100644 index 00000000..84364fd3 --- /dev/null +++ b/src/app/advanced_popup.py @@ -0,0 +1,63 @@ +from PyQt6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QPushButton, QDialog, QTextEdit, QMessageBox +from PyQt6.QtGui import QFontMetricsF +import json +import re + +class AdvancedPopup(QDialog): + def __init__(self, rect=None, edit=False): + super().__init__() + + form_layout = QVBoxLayout() + state_label = QLabel('State:') + + if edit: + self.setWindowTitle('Edit State') + else: + self.setWindowTitle('Add State') + + self.state_input = QTextEdit() + if rect: + self.state_input.setText(rect.get_state_string()) + + font = self.state_input.font() + fontMetrics = QFontMetricsF(font) + spaceWidth = fontMetrics.horizontalAdvance(' ') + self.state_input.setTabStopDistance(spaceWidth * 4) + + self.save_button = QPushButton('Save') + self.save_button.clicked.connect(self.check_state) + + form_layout.addWidget(state_label) + form_layout.addWidget(self.state_input, 3) + form_layout.addWidget(self.save_button) + + self.setLayout(form_layout) + + def get_state(self): + state_text = self.state_input.toPlainText() + + try: + state_json = json.loads(state_text) + except json.decoder.JSONDecodeError: + state_json = {} + title = [key for key in state_json.keys()][0] + + state_dict = state_json[title] + state_dict['Title'] = title + return state_dict + + def check_state(self): + state = self.get_state() + state_type = state['Type'] + + def error_popup(text): + error_msg = QMessageBox() + error_msg.setIcon(QMessageBox.Icon.Critical) + error_msg.setWindowTitle('Error in State') + error_msg.setText(text) + error_msg.exec() + + if state_type not in ['Choice', 'MethodCall']: + error_popup('Invalid type') + elif state_type == 'Choice' and 'Choices' not in state.keys(): + error_popup('Choice type, but no Choices key') diff --git a/src/app/app.py b/src/app/app.py new file mode 100644 index 00000000..4b3e1e19 --- /dev/null +++ b/src/app/app.py @@ -0,0 +1,243 @@ +import sys +from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QHBoxLayout, QListWidget, QFrame, QDialog, QLabel, QToolBar, QMenu, QFileDialog +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction +from import_form import ImportForm +from meta_form import MetaForm +from workflow_diagram import WorkflowDiagram +from rect_connect import CustomItem +import json + + +with open('dependencies.json') as file: + data = json.load(file) + +form = {} + +class GUI(QMainWindow): + def __init__(self, setting): + """QMainWindow to contain MetaForm, ImportForm, and Workflow Diagram. + + Args: + setting: (optional) + """ + super().__init__() + self.initialize_ui(setting) + + def initialize_ui(self, setting): + self.setWindowTitle('ConStrain') + + self.meta_form = MetaForm() + self.import_form = ImportForm() + self.states_form = WorkflowDiagram(setting) + + self.column_list = QListWidget() + self.column_list.addItems(['Meta', 'Imports', 'State']) + self.column_list.currentItemChanged.connect(self.display_form) + + self.column_frame = QFrame() + self.column_frame.setFrameStyle(QFrame.Shape.NoFrame) + self.column_frame.setMaximumWidth(100) + column_layout = QVBoxLayout() + column_layout.addWidget(self.column_list) + self.column_frame.setLayout(column_layout) + + middle_layout = QHBoxLayout() + middle_layout.addWidget(self.column_frame) + middle_layout.addWidget(self.meta_form) + middle_layout.addWidget(self.import_form) + middle_layout.addWidget(self.states_form) + + self.validate_button = QPushButton('Validate') + self.validate_button.setFixedSize(100, 20) + self.validate_button.clicked.connect(self.validate_form) + + self.submit_button = QPushButton('Submit') + self.submit_button.setEnabled(False) + self.submit_button.setFixedSize(100, 20) + self.submit_button.clicked.connect(self.submit_form) + + buttons = QHBoxLayout() + buttons.addWidget(self.validate_button) + buttons.addWidget(self.submit_button) + buttons.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + main_layout = QVBoxLayout() + main_layout.addLayout(middle_layout) + main_layout.addLayout(buttons) + + central_widget = QWidget() + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + self.initialize_toolbar() + + def initialize_toolbar(self): + toolbar = QToolBar() + self.addToolBar(toolbar) + + file_menu = QMenu('File', self) + + import_action = QAction('Import', self) + import_action.triggered.connect(self.importFile) + file_menu.addAction(import_action) + + export_action = QAction('Export', self) + export_action.triggered.connect(self.exportFile) + file_menu.addAction(export_action) + + toolbar.addAction(file_menu.menuAction()) + + def exportFile(self): + + fp, _ = QFileDialog.getSaveFileName(self, 'Save JSON File', '', 'JSON Files (*.json);;All Files (*)') + + if fp: + try: + workflow = self.get_workflow() + with open(fp, 'w', encoding='utf-8') as f: + json.dump(self.create_json(workflow), f, indent=4) + except Exception: + print('error') + + def importFile(self): + file_dialog = QFileDialog() + file_dialog.setWindowTitle('Select a JSON File') + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + file_dialog.setNameFilter('JSON files (*.json)') + if file_dialog.exec() == QFileDialog.DialogCode.Accepted: + file_path = file_dialog.selectedFiles()[0] + with open(file_path, 'r') as f: + workflow = json.load(f) + if isinstance(workflow, dict): + self.meta_form.read_import(workflow.get('workflow_name'), workflow.get('meta')) + self.import_form.read_import(workflow.get('imports')) + self.states_form.read_import(workflow.get('states')) + self.get_workflow() + else: + print('error') + + + + + def display_form(self, current_item): + if current_item.text() == self.column_list.item(0).text(): + self.meta_form.show() + self.import_form.hide() + self.states_form.hide() + elif current_item.text() == self.column_list.item(1).text(): + self.meta_form.hide() + self.import_form.show() + self.states_form.hide() + else: + self.meta_form.hide() + self.import_form.hide() + self.states_form.show() + + def create_json(self, workflow): + data = {'workflow_name': self.meta_form.get_workflow_name(), 'meta': self.meta_form.get_meta(), + 'imports': self.import_form.get_imports(), 'states': {}} + for item in workflow: + copy_item = dict(item) + title = copy_item.pop('Title') + data['states'][title] = copy_item + json_data = data + return json_data + + def submit_form(self): + self.submit_button.setEnabled(False) + + def get_workflow(self): + items = [item for item in self.states_form.scene.items() if isinstance(item, CustomItem)] + roots = [] + for i in items: + parent = True + for j in items: + if i in j.children: + parent = False + break + if parent: + roots.append(i) + + visited = set() + paths = [] + + def dfs_helper(item, path): + path.append(item) + visited.add(item) + + if item not in items or not item.children: + item.setBrush('red') + item.state['End'] = 'True' + paths.append(path[:]) + else: + item.setBrush() + + for child in item.children: + if child not in visited: + dfs_helper(child, path) + + path.pop() + visited.remove(item) + + for root in roots: + root.state['Start'] = 'True' + self.states_form.view.arrange_tree(root, 0, 0, 150) + if root not in visited: + dfs_helper(root, []) + root.setBrush('green') + + workflow_path = [] + visited = set() + for path in paths: + for node in path: + if node not in visited: + workflow_path.append(node.state) + visited.add(node) + return workflow_path + + def validate_form(self, data): + workflow_path = self.get_workflow() + json_data = self.create_json(workflow_path) + valid = True + if valid: + self.submit_button.setEnabled(True) + +class UserSetting(QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle('ConStrain') + query = QLabel('Advanced or basic user settings?') + self.advanced_button = QPushButton('Advanced') + self.advanced_button.clicked.connect(self.showAdvanced) + self.basic_button = QPushButton('Basic') + self.basic_button.clicked.connect(self.showBasic) + + form_layout = QVBoxLayout() + button_layout = QHBoxLayout() + + button_layout.addWidget(self.advanced_button) + button_layout.addWidget(self.basic_button) + + form_layout.addWidget(query) + form_layout.addLayout(button_layout) + + self.setLayout(form_layout) + + def showBasic(self): + self.close() + self.gui = GUI('basic') + self.gui.show() + + def showAdvanced(self): + self.close() + self.gui = GUI('advanced') + self.gui.show() + + +app = QApplication(sys.argv) + +window = UserSetting() +window.show() + +sys.exit(app.exec()) \ No newline at end of file diff --git a/src/app/app.spec b/src/app/app.spec new file mode 100644 index 00000000..081e6497 --- /dev/null +++ b/src/app/app.spec @@ -0,0 +1,58 @@ +# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + datas=[ + ('advanced_popup.py', '.'), + ('import_form.py', '.'), + ('meta_form.py', '.'), + ('popup_window.py', '.'), + ('rect_connect.py', '.'), + ('workflow_diagram.py', '.'), + ('dependencies.json', '.') + ], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='app', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='app', +) diff --git a/src/app/data.json b/src/app/data.json new file mode 100644 index 00000000..a6c2c6da --- /dev/null +++ b/src/app/data.json @@ -0,0 +1,11 @@ +{ + "workflow_name": "", + "meta": { + "author": "", + "date": "01/01/2000", + "version": "", + "description": "" + }, + "imports": [], + "states": {} +} \ No newline at end of file diff --git a/src/app/data.txt b/src/app/data.txt new file mode 100644 index 00000000..96336097 --- /dev/null +++ b/src/app/data.txt @@ -0,0 +1 @@ +"{\n \"workflow_name\": \"\",\n \"meta\": {\n \"author\": \"\",\n \"date\": \"01/01/2000\",\n \"version\": \"\",\n \"description\": \"\"\n },\n \"imports\": [],\n \"states\": {\n \"Load data\": {\n \"Type\": \"MethodCall\",\n \"MethodCall\": \"DataProcessing\",\n \"Parameters\": {\n \"data_path\": \"./tests/api/data/data_eplus.csv\",\n \"data_source\": \"EnergyPlus\"\n },\n \"Payloads\": {\n \"data\": \"$.data\",\n \"data_processing_obj\": \"$\"\n },\n \"Next\": \"Slice data\",\n \"Start\": \"True\"\n }\n }\n}" \ No newline at end of file diff --git a/src/app/demo_workflow.json b/src/app/demo_workflow.json new file mode 100644 index 00000000..5ab53dc1 --- /dev/null +++ b/src/app/demo_workflow.json @@ -0,0 +1,184 @@ +{ + "workflow_name": "Demo workflow", + "meta": { + "author": "Xuechen (Jerry) Lei", + "date": "03/15/2023", + "version": "1.0", + "description": "Demo workflow to showcase core Workflow API functionalities" + }, + "imports": [ + "numpy as np", + "pandas as pd", + "datetime", + "glob" + ], + "states": { + "load data": { + "Type": "MethodCall", + "MethodCall": "DataProcessing", + "Parameters": { + "data_path": "./demo/api_demo/demo_dataset.csv", + "data_source": "EnergyPlus" + }, + "Payloads": { + "data_processing_obj": "$" + }, + "Start": "True", + "Next": "slice data to get the first two months" + }, + "slice data to get the first two months": { + "Type": "MethodCall", + "MethodCall": "Payloads['data_processing_obj'].slice", + "Parameters": { + "start_time": { + "Type": "Embedded MethodCall", + "MethodCall": "datetime.datetime", + "Parameters": [ + 2000, + 1, + 1, + 0 + ] + }, + "end_time": { + "Type": "Embedded MethodCall", + "MethodCall": "datetime.datetime", + "Parameters": [ + 2000, + 3, + 1, + 0 + ] + } + }, + "Payloads": { + "sliced_data": "$" + }, + "Next": "load original verification case" + }, + "load original verification case": { + "Type": "MethodCall", + "MethodCall": "VerificationCase", + "Parameters": { + "json_case_path": "./demo/api_demo/demo_verification_cases.json" + }, + "Payloads": { + "verification_case_obj": "$", + "original_case_keys": "$.case_suite.keys()" + }, + "Next": "check original case length" + }, + "check original case length": { + "Type": "Choice", + "Choices": [ + { + "Value": "len(Payloads['original_case_keys']) == 3", + "Equals": "True", + "Next": "validate cases" + } + ], + "Default": "Report Error in workflow" + }, + "validate cases": { + "Type": "Choice", + "Choices": [ + { + "Value": "Payloads['verification_case_obj'].validate()", + "Equals": "True", + "Next": "setup verification" + } + ], + "Default": "Report Error in workflow" + }, + "setup verification": { + "Type": "MethodCall", + "MethodCall": "Verification", + "Parameters": { + "verifications": "Payloads['verification_case_obj']" + }, + "Payloads": { + "verification_obj": "$" + }, + "Next": "configure verification runner" + }, + "configure verification runner": { + "Type": "MethodCall", + "MethodCall": "Payloads['verification_obj'].configure", + "Parameters": { + "output_path": "./demo/api_demo", + "lib_items_path": "./schema/library.json", + "plot_option": "+x None", + "fig_size": "+x (6, 5)", + "num_threads": 1, + "preprocessed_data": "Payloads['sliced_data']" + }, + "Payloads": {}, + "Next": "run verification" + }, + "run verification": { + "Type": "MethodCall", + "MethodCall": "Payloads['verification_obj'].run", + "Parameters": {}, + "Payloads": {"verification_return": "$"}, + "Next": "check results" + }, + "check results": { + "Type": "MethodCall", + "MethodCall": "glob.glob", + "Parameters": [ + "./demo/api_demo/*_md.json" + ], + "Payloads": { + "length_of_mdjson": "len($)" + }, + "Next": "check number of result files" + }, + "check number of result files": { + "Type": "Choice", + "Choices": [ + { + "Value": "Payloads['length_of_mdjson']", + "Equals": "3", + "Next": "reporting_object_instantiation" + } + ], + "Default": "Report Error in workflow" + }, + "reporting_object_instantiation": { + "Type": "MethodCall", + "MethodCall": "Reporting", + "Parameters": { + "verification_json": "./demo/api_demo/*_md.json", + "result_md_name": "report_summary.md", + "report_format": "markdown" + }, + "Payloads": { + "reporting_obj": "$" + }, + "Next": "report_cases" + }, + "report_cases": { + "Type": "MethodCall", + "MethodCall": "Payloads['reporting_obj'].report_multiple_cases", + "Parameters": {}, + "Payloads": {}, + "Next": "Success" + }, + "Success": { + "Type": "MethodCall", + "MethodCall": "print", + "Parameters": [ + "Congratulations! the demo workflow is executed with expected results and no error!" + ], + "End": "True" + }, + "Report Error in workflow": { + "Type": "MethodCall", + "MethodCall": "logging.error", + "Parameters": [ + "Something is wrong in the workflow execution" + ], + "End": "True" + } + } +} \ No newline at end of file diff --git a/src/app/dependencies.json b/src/app/dependencies.json new file mode 100644 index 00000000..57133556 --- /dev/null +++ b/src/app/dependencies.json @@ -0,0 +1,112 @@ +{ + "Verification Case": { + "Initialize": [ + {"label": "Cases", "type": "line_edit"}, + {"label": "JSON Case Path", "type": "line_edit"} + ], + "Check File": [ + {"label": "File Path Name", "type": "line_edit"}, + {"label": "File Path", "type": "line_edit"} + ], + "Check JSON Path Type": [ + {"label": "JSON Path", "type": "line_edit"} + ], + "Check Type": [ + {"label": "Var Name", "type": "line_edit"}, + {"label": "Var Value", "type": "line_edit"}, + {"label": "Var Type", "type": "line_edit"} + ] + }, + "Data Processing": { + "Initialize": [ + {"label": "Data Path", "type": "line_edit"}, + {"label": "Data Source", "type": "line_edit"}, + {"label": "Timestamp Column Name", "type": "line_edit"} + ], + "Add Parameter": [ + {"label": "Name", "type": "line_edit"}, + {"label": "Value", "type": "line_edit"}, + {"label": "In Place", "type": "combo_box"} + ], + "Apply Function": [ + {"label": "Variable Names", "type": "line_edit"}, + {"label": "New Variable Name", "type": "line_edit"}, + {"label": "Function to Apply", "type": "line_edit"}, + {"label": "In Place", "type": "combo_box"} + ], + "Check": [], + "Concatenate": [ + {"label": "Datasets", "type": "line_edit"}, + {"label": "Axis", "type": "line_edit"}, + {"label": "In Place", "type": "combo_box"} + ], + "Downsample": [ + {"label": "Frequency Type", "type": "line_edit"}, + {"label": "Number of Periods", "type": "line_edit"}, + {"label": "Sampling Function", "type": "line_edit"}, + {"label": "In Place", "type": "combo_box"} + ], + "Fill Missing Values": [ + {"label": "Method", "type": "line_edit"}, + {"label": "Variable Names", "type": "line_edit"}, + {"label": "In Place", "type": "combo_box"} + ], + "Plot": [ + {"label": "Variable Names", "type": "line_edit"}, + {"label": "Kind", "type": "line_edit"} + ], + "Slice": [ + {"label": "Start Time", "type": "line_edit"}, + {"label": "End Time", "type": "line_edit"}, + {"label": "In Place", "type": "combo_box"} + ], + "Summary": [] + }, + "Reporting": { + "Initialize": [ + {"label": "Verification JSON", "type": "line_edit"}, + {"label": "Result MD Name", "type": "line_edit"}, + {"label": "Report Format", "type": "line_edit"} + ], + "Report Multiple Cases": [ + {"label": "Item Names", "type": "line_edit"} + ] + }, + "Verification Library": { + "Initialize": [ + {"label": "Lib Path", "type": "line_edit"} + ], + "Get Applicable Library Items by Datapoints": [ + {"label": "Datapoints", "type": "line_edit"} + ], + "Get Library Item": [ + {"label": "Item Name", "type": "line_edit"} + ], + "Get Library Items": [ + {"label": "Items", "type": "line_edit"} + ], + "Get Required Datapoints by Library Items": [ + {"label": "Datapoints", "type": "line_edit"} + ], + "Validate Library": [ + {"label": "Items", "type": "line_edit"} + ] + }, + "Verification": { + "Initialize": [ + {"label": "Verifications", "type": "line_edit"} + ], + "Configure": [ + {"label": "Output Path", "type": "line_edit"}, + {"label": "Lib Items Path", "type": "line_edit"}, + {"label": "Plot Option", "type": "line_edit"}, + {"label": "Fig Size", "type": "line_edit"}, + {"label": "Num Threads", "type": "line_edit"}, + {"label": "Preprocessed Data", "type": "line_edit"} + ], + "Run": [], + "Run Single Verification": [ + {"label": "Case", "type": "line_edit"} + ] + } +} \ No newline at end of file diff --git a/src/app/import_form.py b/src/app/import_form.py new file mode 100644 index 00000000..7432c5eb --- /dev/null +++ b/src/app/import_form.py @@ -0,0 +1,47 @@ +from PyQt6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget, QPushButton, QHBoxLayout, QListWidget + +class ImportForm(QWidget): + def __init__(self): + super().__init__() + + import_label = QLabel("Imports:") + self.import_input = QLineEdit() + add_button = QPushButton('Add') + self.import_list = QListWidget() + + middle = QHBoxLayout() + middle.addWidget(self.import_input) + middle.addWidget(add_button) + + bottom = QVBoxLayout() + bottom.addWidget(self.import_list) + + add_button.clicked.connect(self.add_import) + self.imports = [] + + layout = QVBoxLayout() + layout.addWidget(import_label) + + layout.addLayout(middle) + layout.addLayout(bottom) + + self.setLayout(layout) + + + def add_import(self): + import_text = self.import_input.text() + if import_text: + self.imports.append(import_text) + self.import_list.addItem(import_text) + self.import_input.clear() + + def read_import(self, imports): + if isinstance(imports, list) and all(isinstance(item, str) for item in imports): + for i in imports: + self.imports.append(i) + self.import_list.addItem(i) + self.update() + + + def get_imports(self): + return self.imports \ No newline at end of file diff --git a/src/app/meta_form.py b/src/app/meta_form.py new file mode 100644 index 00000000..54a185f4 --- /dev/null +++ b/src/app/meta_form.py @@ -0,0 +1,89 @@ +from PyQt6.QtWidgets import QLabel, QLineEdit, QDateEdit, QTextEdit, QVBoxLayout, QWidget, QHBoxLayout +from PyQt6.QtCore import QDate + +class MetaForm(QWidget): + def __init__(self): + super().__init__() + + name_label = QLabel('Workflow Name:') + self.name_input = QLineEdit() + + author_label = QLabel('Author:') + self.author_input = QLineEdit() + + date_label = QLabel('Date:') + self.date_input = QDateEdit() + self.date_format = 'MM/dd/yyyy' + self.date_input.setDisplayFormat(self.date_format) + + version_label = QLabel("Version:") + self.version_input = QLineEdit() + + description_label = QLabel("Description:") + self.description_input = QTextEdit() + + layout = QVBoxLayout() + + top = QHBoxLayout() + top.addWidget(name_label) + top.addWidget(self.name_input) + top.addWidget(author_label) + top.addWidget(self.author_input) + top.addWidget(date_label) + top.addWidget(self.date_input) + top.addWidget(version_label) + top.addWidget(self.version_input) + + layout.addLayout(top) + layout.addWidget(description_label) + layout.addWidget(self.description_input) + + self.setLayout(layout) + + + def get_meta(self): + return {'author': self.author_input.text(), 'date': self.date_input.text(), + 'version': self.version_input.text(), 'description': self.description_input.toPlainText()} + + + def read_import(self, workflow_name=None, meta=None): + + def isStr(input): + return isinstance(input, str) + + if workflow_name: + if isStr(workflow_name): + self.name_input.setText(workflow_name) + else: + print('error') + + if isinstance(meta, dict): + if 'author' in meta.keys(): + author = meta['author'] + if isStr(author): + self.author_input.setText(author) + else: + print('invalid author') + if 'date' in meta.keys(): + date = meta['date'] + if isStr(date): + d = QDate.fromString(date, self.date_format) + self.date_input.setDate(d) + else: + print('invalid date') + if 'version' in meta.keys(): + version = meta['version'] + if isStr(version): + self.version_input.setText(version) + else: + print('invalid version') + if 'description' in meta.keys(): + description = meta['description'] + if isStr(description): + self.description_input.setText(description) + else: + print('invalid description') + self.update() + + def get_workflow_name(self): + return self.name_input.text() diff --git a/src/app/popup_window.py b/src/app/popup_window.py new file mode 100644 index 00000000..491d65d0 --- /dev/null +++ b/src/app/popup_window.py @@ -0,0 +1,593 @@ +from PyQt6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QPushButton, QHBoxLayout, QComboBox, QListWidget, QDialog, QGroupBox, QDialogButtonBox, QMenu, QMessageBox +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction +import json +import re + +with open('dependencies.json') as file: + schema = json.load(file) + +class PopupWindow(QDialog): + def __init__(self, schema, payloads={}, edit=False, rect=None): + + super().__init__() + self.schema = schema + self.edit = edit + self.rect_item = rect + self.current_payloads = None + self.current_params = None + self.current_choices = None + self.error = False + # self.payload_to_payload_type = {} + + self.type_combo_box = QComboBox() + self.object_type_combo_box = QComboBox() + self.method_combo_box = QComboBox() + self.form_layout = QVBoxLayout() + + self.buttons = QHBoxLayout() + self.save_button = QPushButton('Save') + self.save_button.setFixedSize(100, 20) + self.save_button.clicked.connect(self.save) + + self.cancel_button = QPushButton('Cancel') + self.cancel_button.setFixedSize(100, 20) + self.cancel_button.clicked.connect(self.close) + + self.payload_button = QPushButton('Edit') + self.payload_button.setFixedSize(100, 20) + self.payload_button.clicked.connect(self.payload_form) + + self.payload_list_widget = QListWidget() + self.parameter_list_widget = QListWidget() + + self.buttons.addWidget(self.save_button) + self.buttons.addWidget(self.cancel_button) + + self.payloads = payloads + self.form_data = {} + + self.initialize_ui() + + + def initialize_ui(self): + + if self.edit: + self.setWindowTitle('Edit State') + else: + self.setWindowTitle('Add State') + + layout = QVBoxLayout() + self.setLayout(layout) + + self.type_combo_box.addItems(['', 'MethodCall', 'Choice']) + + object_types = list(self.schema.keys()) + object_types.insert(0, '') + object_types.append('Custom') + + self.object_type_combo_box.addItems(object_types) + self.method_combo_box.addItems(self.schema[object_types[1]]) + + layout.addWidget(self.type_combo_box) + layout.addWidget(self.object_type_combo_box) + layout.addWidget(self.method_combo_box) + + self.object_type_combo_box.hide() + self.method_combo_box.hide() + + layout.addLayout(self.form_layout) + layout.addLayout(self.buttons) + + self.type_combo_box.currentIndexChanged.connect(self.on_type_selected) + self.object_type_combo_box.currentIndexChanged.connect(self.on_state_selected) + self.method_combo_box.currentIndexChanged.connect(lambda: self.update_form(False)) + + def load_ui(self): + print('yo') + + def on_type_selected(self): + type = self.type_combo_box.currentText() + self.clear_form() + if type == 'MethodCall': + self.object_type_combo_box.show() + self.method_combo_box.hide() + elif type == 'Choice': + self.choice_form() + self.object_type_combo_box.hide() + self.method_combo_box.hide() + else: + self.object_type_combo_box.hide() + self.method_combo_box.hide() + + def choice_form(self): + def make_gb(title, type): + gb = QGroupBox() + gb.setTitle(title) + layout = QVBoxLayout() + layout.addWidget(type) + gb.setLayout(layout) + self.form_layout.addWidget(gb) + return gb + + make_gb("Name of State", QLineEdit()) + + layout = QVBoxLayout() + choice_widget = QGroupBox() + choice_widget.setTitle('Choices') + edit_button = QPushButton('Edit') + edit_button.setFixedSize(100, 20) + edit_button.clicked.connect(self.choice_popup) + layout.addWidget(edit_button) + + self.choice_list_widget = QListWidget() + layout.addWidget(self.choice_list_widget) + + choice_widget.setLayout(layout) + self.form_layout.addWidget(choice_widget) + + make_gb('Default', QLineEdit()) + + def choice_popup(self): + choice_popup = ChoicesPopup(self.payloads) + if choice_popup.exec() == QDialog.DialogCode.Accepted: + self.current_choices = choice_popup.get_input() + self.update_list(self.choice_list_widget) + + def on_state_selected(self): + object_type = self.object_type_combo_box.currentText() + self.method_combo_box.clear() + + if object_type == 'Custom': + self.update_form(custom=True) + else: + methods = self.schema[object_type].keys() + self.method_combo_box.addItems(methods) + self.method_combo_box.show() + + def update_form(self, custom=False): + if not custom: + object_type = self.object_type_combo_box.currentText() + method = self.method_combo_box.currentText() + try: + fields = self.schema[object_type][method] + except KeyError: + object_type = "Verification Case" + method = "Initialize" + fields = self.schema[object_type][method] + + self.clear_form() + self.current_payloads = {} + self.current_params = [] + + def make_gb(title, type): + gb = QGroupBox() + gb.setTitle(title) + layout = QVBoxLayout() + layout.addWidget(type) + gb.setLayout(layout) + self.form_layout.addWidget(gb) + return gb + + self.payload_combo_box = QComboBox() + if self.payloads: + payloads_formatted = [f'{item}' for item in self.payloads] + payloads_formatted.insert(0, '') + self.payload_combo_box.addItems(payloads_formatted) + + make_gb("Name of State", QLineEdit()) + + if custom or method != 'Initialize': + make_gb("Object:", self.payload_combo_box) + + if custom: + layout = QVBoxLayout() + make_gb('MethodCall', QLineEdit()) + parameter_widget = QGroupBox() + parameter_widget.setTitle('Parameters') + parameter_button = QPushButton('Edit') + parameter_button.setFixedSize(100, 20) + parameter_button.clicked.connect(self.parameter_form) + layout.addWidget(parameter_button) + + self.parameter_list_widget = QListWidget() + layout.addWidget(self.parameter_list_widget) + + parameter_widget.setLayout(layout) + self.form_layout.addWidget(parameter_widget) + else: + for field in fields: + if field['type'] == 'line_edit': + make_gb(field['label'], QLineEdit()) + elif field['type'] == 'combo_box': + combo_box = QComboBox() + combo_box.addItems(['True', 'False']) + make_gb(field['label'], combo_box) + + layout = QVBoxLayout() + payload_widget = QGroupBox() + payload_widget.setTitle('Payloads') + layout.addWidget(self.payload_button) + + self.payload_list_widget = QListWidget() + layout.addWidget(self.payload_list_widget) + + payload_widget.setLayout(layout) + self.form_layout.addWidget(payload_widget) + + make_gb('Next', QLineEdit()) + + def payload_form(self): + payload_popup = ListPopup(self.current_payloads) + if payload_popup.exec() == QDialog.DialogCode.Accepted: + self.current_payloads = payload_popup.get_input() + self.update_list(self.payload_list_widget) + + def parameter_form(self): + popup = ListPopup(self.current_params, payload=False) + if popup.exec() == QDialog.DialogCode.Accepted: + self.params = popup.get_input() + self.current_params = popup.get_input() + self.update_list(self.parameter_list_widget) + + def update_list(self, list_widget): + list_widget.clear() + + if list_widget == self.parameter_list_widget: + to_add = self.current_params + for input in to_add.keys(): + list_widget.addItem(input) + elif list_widget == self.payload_list_widget: + to_add = self.current_payloads + for input in to_add.keys(): + list_widget.addItem(f'{input}: {to_add[input]}') + else: + to_add = self.current_choices + for input in to_add: + list_widget.addItem(f'{input[0]}({input[1]}) equals {input[2]}: {input[3]}') + + + def clear_form(self): + while self.form_layout.count() > 0: + item = self.form_layout.takeAt(0) + widget = item.widget() + widget.deleteLater() + + def save(self): + self.form_data = {} + + type = self.type_combo_box.currentText() + object_type = self.object_type_combo_box.currentText() + method = self.method_combo_box.currentText() + capitalized_keys = ['Type', 'MethodCall', 'Parameters', 'Payloads', 'Next', 'Choices', 'Default', 'Title'] + + self.form_data['Type'] = type + if type == 'MethodCall': + if method == 'Initialize': + self.form_data['MethodCall'] = object_type + else: + method = (method.lower()).replace(' ', '_') + object = self.payload_combo_box.currentText() + self.form_data['MethodCall'] = f'Payloads[\'{object}\'].{method}()' + + self.error = False + + self.form_data['Parameters'] = [] + + for i in range(self.form_layout.count()): + item = self.form_layout.itemAt(i).widget() + parameter = item.title() + + if item.findChild(QLineEdit): + text = item.findChild(QLineEdit).text() + elif item.findChild(QComboBox): + text = item.findChild(QComboBox).currentText() + + if not text: + message = f'No input for {parameter}' + self.send_error(message) + self.error = True + break + + if parameter == 'Name of State': + parameter = 'Title' + + if parameter in capitalized_keys: + self.form_data[parameter] = text + elif not self.current_params: + parameter = (parameter.lower()).replace(' ', '_') + self.form_data['Parameters'].append({parameter: text}) + + if not self.error: + self.close() + + def send_error(self, text): + error_msg = QMessageBox() + error_msg.setIcon(QMessageBox.Icon.Critical) + error_msg.setWindowTitle('Error in State') + error_msg.setText(text) + error_msg.exec() + + def get_state(self): + if self.current_payloads is not None and len(self.current_payloads) > 0: + print(self.current_payloads) + self.form_data['Payloads'] = self.current_payloads + + if self.current_params is not None and len(self.current_params) > 0: + print(self.current_params) + self.form_data['Parameters'] = self.current_params + + if self.current_choices is not None and len(self.current_choices) > 0: + self.form_data['Choices'] = [] + for c in self.current_choices: + if len(c) == 4: + method = (c[1].lower()).replace(' ', '_') + '()' + value = f'Payloads[\'{c[0]}\'].{method}' + self.form_data['Choices'].append({'Value': value, 'Equals': c[2], 'Next': c[3]}) + elif len(c) == 3: + self.form_data['Choices'].append({'Value': c[0], 'Equals': c[1], 'Next': c[2]}) + + return self.form_data + + +class ListPopup(QDialog): + def __init__(self, input=None, payload=True): + super().__init__() + + self.payload = payload + + self.initialize_ui() + + if payload: + self.current_input = input if input else {} + else: + self.current_input = input if input else [] + + self.populate_input_list() + + def initialize_ui(self): + + layout = QVBoxLayout() + + name_label = QLabel("Name:") + self.name_line_edit = QLineEdit() + + if self.payload: + self.setWindowTitle('Payloads') + label = QLabel('Payload:') + + # Don't add name field if popup is not for payloads + layout.addWidget(name_label) + layout.addWidget(self.name_line_edit) + else: + self.setWindowTitle('Parameters') + label = QLabel('Parameter:') + + self.line_edit = QLineEdit() + + add_button = QPushButton("Add") + add_button.clicked.connect(self.add_input) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + self.input_list = QListWidget() + self.input_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.input_list.customContextMenuRequested.connect(self.show_context_menu) + + layout.addWidget(label) + layout.addWidget(self.line_edit) + layout.addWidget(self.input_list) + layout.addWidget(add_button) + layout.addWidget(buttons) + + self.setLayout(layout) + + + def populate_input_list(self): + self.input_list.clear() + + if self.payload: + for input in self.current_input.keys(): + self.input_list.addItem(f'{input}: {self.current_input[input]}') + else: + for input in self.current_input: + self.input_list.addItem(input) + + def add_input(self): + title = self.name_line_edit.text() + input = self.line_edit.text() + + if self.payload: + self.current_input[title] = input + else: + self.current_input.append(input) + + self.populate_input_list() + self.name_line_edit.clear() + self.line_edit.clear() + + + def show_context_menu(self, position): + item = self.input_list.itemAt(position) + if item is None: + return + + menu = QMenu(self) + delete_action = QAction("Delete", self) + + delete_action.triggered.connect(lambda: self.delete_input(item)) + + menu.addAction(delete_action) + + menu.exec(self.input_list.mapToGlobal(position)) + + def delete_input(self, item): + if ':' in item.text(): + self.current_input.pop(item.text().split(': ')[0]) + else: + self.current_input.remove(item.text()) + + self.populate_input_list() + self.input_list.takeItem(self.input_list.row(item)) + + def get_input(self): + return self.current_input + + +class ChoicesPopup(QDialog): + def __init__(self, payloads=[], choices=[]): + super().__init__() + + self.payloads = payloads + self.current_input = choices + self.input_dict = {} + + self.initialize_ui() + + self.populate_input_list() + + def initialize_ui(self): + self.setWindowTitle('Choices') + + self.layout = QVBoxLayout() + + object_type_label = QLabel('Object Type') + self.object_type_combo_box = QComboBox() + object_types = list(schema.keys()) + object_types.insert(0, '') + object_types.append('Custom') + self.object_type_combo_box.addItems(object_types) + + self.method_label = QLabel('Method') + self.method_input = QLineEdit() + self.method_label.hide() + self.method_input.hide() + + self.method_combo_box = QComboBox() + self.method_combo_box.addItems(schema[object_types[1]]) + self.method_combo_box.hide() + + self.object_type_combo_box.currentIndexChanged.connect(self.on_state_selected) + + self.payload_combo_box = QComboBox() + payloads_formatted = [''] + if self.payloads: + payloads_formatted += [f'{item}' for item in self.payloads] + self.payload_combo_box.addItems(payloads_formatted) + + object_label = QLabel('Object') + self.object_input = self.payload_combo_box + + equals_label = QLabel('Equals') + self.equals_input = QComboBox() + self.equals_input.addItems(['True', 'False']) + + next_label = QLabel('Next') + self.next_input = QLineEdit() + + add_button = QPushButton("Add") + add_button.clicked.connect(self.add_input) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + self.input_list = QListWidget() + self.input_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.input_list.customContextMenuRequested.connect(self.show_context_menu) + + self.layout.addWidget(object_type_label) + self.layout.addWidget(self.object_type_combo_box) + self.layout.addWidget(self.method_label) + self.layout.addWidget(self.method_input) + self.layout.addWidget(self.method_combo_box) + self.layout.addWidget(object_label) + self.layout.addWidget(self.object_input) + self.layout.addWidget(equals_label) + self.layout.addWidget(self.equals_input) + self.layout.addWidget(next_label) + self.layout.addWidget(self.next_input) + self.layout.addWidget(add_button) + self.layout.addWidget(self.input_list) + self.layout.addWidget(buttons) + + self.setLayout(self.layout) + + def on_state_selected(self): + object_type = self.object_type_combo_box.currentText() + self.method_combo_box.clear() + + if object_type == 'Custom': + self.method_combo_box.hide() + self.method_label.show() + self.method_input.show() + + else: + self.method_input.hide() + self.method_label.show() + methods = schema[object_type].keys() + self.method_combo_box.addItems(methods) + self.method_combo_box.show() + + def clear_form(self): + while self.layout.count() > 0: + item = self.layout.takeAt(0) + widget = item.widget() + widget.deleteLater() + + def populate_input_list(self): + self.input_list.clear() + + if self.current_input: + if len(self.current_input) == 3: + for method, equals, next in self.current_input: + new_input = f'{method} equals {equals}: {next}' + else: + for object, method, equals, next in self.current_input: + new_input = f'{method}({object}) equals {equals}: {next}' + self.input_dict[new_input] = self.current_input[0] + self.input_list.addItem(new_input) + + def add_input(self): + object = self.object_input.currentText() + if object != 'Custom': + method = self.method_combo_box.currentText() + else: + method = self.method_input.text() + equals = self.equals_input.currentText() + next = self.next_input.text() + + if method and equals and next: + if object: + self.current_input.append((object, method, equals, next)) + else: + self.current_input.append((method, equals, next)) + self.populate_input_list() + self.equals_input.clear() + self.next_input.clear() + + def show_context_menu(self, position): + item = self.input_list.itemAt(position) + if item is None: + return + + menu = QMenu(self) + delete_action = QAction("Delete", self) + + delete_action.triggered.connect(lambda: self.delete_input(item)) + + menu.addAction(delete_action) + + menu.exec(self.input_list.mapToGlobal(position)) + + def delete_input(self, item): + self.current_input.remove(self.input_dict[item.text()]) + self.input_dict.pop(item.text()) + self.populate_input_list() + self.input_list.takeItem(self.input_list.row(item)) + + def get_input(self): + print(self.current_input) + return self.current_input diff --git a/src/app/rect_connect.py b/src/app/rect_connect.py new file mode 100644 index 00000000..55fc1c03 --- /dev/null +++ b/src/app/rect_connect.py @@ -0,0 +1,433 @@ +from PyQt6 import QtCore, QtGui, QtWidgets +import json +import math +import re + +class Path(QtWidgets.QGraphicsPathItem): + def __init__(self, start, p2, end=None): + super(Path, self).__init__() + + self.start = start + self.end = end + + self._arrow_height = 5 + self._arrow_width = 4 + + self._path = QtGui.QPainterPath() + self._path.moveTo(start.scenePos()) + self._path.lineTo(p2) + + self.setPath(self._path) + + def controlPoints(self): + return self.start, self.end + + def setP2(self, p2): + self._path.lineTo(p2) + self.setPath(self._path) + + def setStart(self, start): + self._start = start + self.updatePath() + + def setEnd(self, end): + self.end = end + self.updatePath(end) + + def updatePath(self, source): + if source == self.start: + self._path = QtGui.QPainterPath(source.scenePos()) + self._path.lineTo(self.end.scenePos()) + else: + self._path = QtGui.QPainterPath(self.start.scenePos()) + self._path.lineTo(source.scenePos()) + + self.setPath(self._path) + + def arrowCalc(self, start_point=None, end_point=None): # calculates the point where the arrow should be drawn + + try: + startPoint, endPoint = start_point, end_point + + if start_point is None: + startPoint = self.start + + if endPoint is None: + endPoint = self.end + + dx, dy = startPoint.x() - endPoint.x(), startPoint.y() - endPoint.y() + + leng = math.sqrt(dx ** 2 + dy ** 2) + normX, normY = dx / leng, dy / leng # normalize + + # perpendicular vector + perpX = -normY + perpY = normX + + leftX = endPoint.x() + self._arrow_height * normX + self._arrow_width * perpX + leftY = endPoint.y() + self._arrow_height * normY + self._arrow_width * perpY + + rightX = endPoint.x() + self._arrow_height * normX - self._arrow_width * perpX + rightY = endPoint.y() + self._arrow_height * normY - self._arrow_width * perpY + + point2 = QtCore.QPointF(leftX, leftY) + point3 = QtCore.QPointF(rightX, rightY) + + return QtGui.QPolygonF([point2, endPoint, point3]) + + except (ZeroDivisionError, Exception): + return None + + def directPath(self): + path = QtGui.QPainterPath(self.start.scenePos()) + path.lineTo(self.end.scenePos()) + return path + + def paint(self, painter: QtGui.QPainter, option, widget=None) -> None: + + painter.pen().setWidth(2) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + + if self.end: + path = self.directPath() + triangle_source = self.arrowCalc(path.pointAtPercent(0.1), self.end.scenePos()) + else: + path = self._path + triangle_source = None + painter.drawPath(path) + self.setPath(path) + + if triangle_source is not None: + painter.drawPolyline(triangle_source) + + def shape(self): + shape = super().shape() + shape.addRect(shape.boundingRect().adjusted(-5, -5, 5, 5)) + return shape + + def contextMenuEvent(self, event): + menu = QtWidgets.QMenu() + delete_action = menu.addAction('Delete') + action = menu.exec(event.screenPos()) + + if action == delete_action: + self.start.removeLine(self) + self.end.removeLine(self) + + +class ControlPoint(QtWidgets.QGraphicsEllipseItem): + def __init__(self, parent): + super().__init__(-5, -5, 10, 10, parent) + self.parent = parent + self.paths = [] + + self.setAcceptHoverEvents(True) + self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsScenePositionChanges) + + self.setOpacity(0.3) + self.clicked = False + + def addLine(self, pathItem): + viable = self.newLineErrorCheck(pathItem) + if viable: + self.paths.append(pathItem) + return True + return False + + def newLineErrorCheck(self, pathItem): + for existing in self.paths: + if existing.controlPoints() == pathItem.controlPoints(): + return False + + def send_error(text): + error_msg = QtWidgets.QMessageBox() + error_msg.setIcon(QtWidgets.QMessageBox.Icon.Critical) + error_msg.setWindowTitle('Error in Path') + error_msg.setText(text) + error_msg.exec() + + if pathItem.start == self: + rect_children_amt = len(self.parent.children) + if rect_children_amt >= 1: + if self.parent.state['Type'] != 'Choice': + error_msg = 'This type cannot connect to more than 1 state' + send_error(error_msg) + return False + elif self.parent.state['Type'] == 'Choice': + choices_amt = len(self.parent.state['Choices']) + if 'Default' in self.parent.state.keys(): + choices_amt += 1 + + if rect_children_amt >= choices_amt: + error_msg = f'This type cannot connect to more than {choices_amt} states' + send_error(error_msg) + return False + self.parent.children.append(pathItem.end.parent) + return True + + def removeLine(self, pathItem): + for existing in self.paths: + if existing.controlPoints() == pathItem.controlPoints(): + self.scene().removeItem(existing) + self.paths.remove(existing) + if pathItem.start == self: + self.parent.children.remove(pathItem.end.parent) + return True + return False + + def itemChange(self, change, value): + for path in self.paths: + path.updatePath(self) + return super().itemChange(change, value) + + def hoverEnterEvent(self, event): + self.setOpacity(1.0) + + def hoverLeaveEvent(self, event): + self.setOpacity(0.3) + + + +class CustomItem(QtWidgets.QGraphicsItem): + pen = QtGui.QPen(QtGui.QColor(98,99,102,255)) + controlBrush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) + + def __init__(self, state, left=False, right=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.brush = QtGui.QBrush(QtGui.QColor(214, 127, 46)) + self.state = state + self.setFlag(self.GraphicsItemFlag.ItemIsMovable) + self.setFlag(self.GraphicsItemFlag.ItemIsSelectable) + self.rect = QtCore.QRectF(0, 0, 100, 30) + self.titleItem = QtWidgets.QGraphicsTextItem(parent=self) + + self.children = [] + self.controls = [] + self.initialize_ui() + + + def initialize_ui(self): + if 'Title' not in self.state.keys(): + self.state['Title'] = '' + title = self.state['Title'] + + self.titleItem.setHtml(f'
{title}
') + + max_width = 100 + self.titleItem.setTextWidth(max_width) + + self.titleRect = self.titleItem.boundingRect() + text_width = self.titleRect.width() + text_height = self.titleRect.height() + + diamond_width = text_width * 1.2 + diamond_height = text_height * 1.2 + + self.rect.setRect(0, 0, diamond_width, diamond_height) + + self.titleRect.moveCenter(self.rect.center()) + self.titleItem.setPos(self.titleRect.topLeft()) + + control_placements = [(self.rect.width() / 2, self.rect.height()), (self.rect.width(), self.rect.height() / 2), (self.rect.width() / 2, 0), (0, self.rect.height() / 2)] + + if not self.controls: + self.controls = [ControlPoint(self) for i in range(len(control_placements))] + + for i, control in enumerate(self.controls): + control.setPen(self.pen) + control.setBrush(self.controlBrush) + control.setX(control_placements[i][0]) + control.setY(control_placements[i][1]) + + + def boundingRect(self): + adjust = self.pen.width() / 2 + return self.rect.adjusted(-adjust, -adjust, adjust, adjust) + + def paint(self, painter, option, widget=None): + painter.save() + painter.setPen(self.pen) + painter.setBrush(self.brush) + + if self.state['Type'] == 'Choice': + diamond_points = [ + QtCore.QPointF(self.rect.center().x(), self.rect.top()), + QtCore.QPointF(self.rect.right(), self.rect.center().y()), + QtCore.QPointF(self.rect.center().x(), self.rect.bottom()), + QtCore.QPointF(self.rect.left(), self.rect.center().y()) + ] + painter.drawPolygon(QtGui.QPolygonF(diamond_points)) + else: + painter.drawRoundedRect(self.rect, 4, 4) + painter.restore() + + def setBrush(self, color='orange'): + if color == 'red': + color = QtGui.QColor(214, 54, 64) + elif color == 'green': + color = QtGui.QColor(10, 168, 89) + else: + color = QtGui.QColor(214, 127, 46) + self.brush = QtGui.QBrush(color) + self.update() + + def contextMenuEvent(self, event): + menu = QtWidgets.QMenu() + delete_action = menu.addAction('Delete') + + action = menu.exec(event.screenPos()) + + if action == delete_action: + objects_created = self.get_objects_created() + all_objects_in_use = self.scene().getObjectsinUse() + + for created_object in objects_created: + if created_object in all_objects_in_use: + self.sendError('Object created in use') + return + + for c in self.controls: + for p in c.paths: + p1 = p.start + p2 = p.end + if p1 in self.controls: + p2.removeLine(p) + else: + p1.removeLine(p) + self.scene().removeItem(self) + + def sendError(self, text): + error_msg = QtWidgets.QMessageBox() + error_msg.setIcon(QtWidgets.QMessageBox.Icon.Critical) + error_msg.setWindowTitle('Error in State') + error_msg.setText(text) + error_msg.exec() + + def get_objects_created(self): + if self.state['Type'] == 'MethodCall': + payloads = self.state['Payloads'] + return [object_name for object_name in payloads] + return [] + + # get objects that are used in a state + def get_objects_used(self): + objects = [] + pattern = r"Payloads\['(.*?)'\]" + if self.state['Type'] == 'MethodCall': + methodcall = self.state['MethodCall'] + match = re.search(pattern, methodcall) + + if match: + object = match.group(1) + objects.append(object) + elif self.state['Type'] == 'Choice': + choices = self.state['Choices'] + for choice in choices: + match = re.search(pattern, choice['Value']) + + if match: + object = match.group(1) + objects.append(object) + return objects + + def get_state_string(self): + print(self.state) + copy_of_state = dict(self.state) + title = copy_of_state.pop('Title') + state_string = json.dumps({title: copy_of_state}, indent=4) + return state_string + + def set_state(self, new_state): + self.state = new_state + self.initialize_ui() + + def get_nexts(self): + next = [] + if self.state['Type'] == 'MethodCall': + if 'Next' in self.state.keys(): + next.append(self.state['Next']) + else: + if 'Choices' in self.state.keys(): + choices = self.state['Choices'] + if isinstance(choices, list): + next = [choices[i]['Next'] for i in range(len(choices)) if 'Next' in choices[i]] + elif isinstance(choices, dict): + if 'Next' in choices.keys(): + next = [choices['Next']] + if 'Default' in self.state.keys(): + next.append(self.state['Default']) + return next + + +class Scene(QtWidgets.QGraphicsScene): + startItem = newConnection = None + def controlPointAt(self, pos): + mask = QtGui.QPainterPath() + mask.setFillRule(QtCore.Qt.FillRule.WindingFill) + for item in self.items(pos): + if mask.contains(pos): + # ignore objects hidden by others + return + if isinstance(item, ControlPoint): + return item + if not isinstance(item, Path): + mask.addPath(item.shape().translated(item.scenePos())) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + item = self.controlPointAt(event.scenePos()) + if item: + self.startItem = item + self.newConnection = Path(item, event.scenePos()) + self.addItem(self.newConnection) + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.newConnection: + item = self.controlPointAt(event.scenePos()) + if (item and item != self.startItem): + p2 = item.scenePos() + else: + p2 = event.scenePos() + self.newConnection.setP2(p2) + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self.newConnection: + item = self.controlPointAt(event.scenePos()) + if item and item != self.startItem: + self.newConnection.setEnd(item) + if self.startItem.addLine(self.newConnection): + item.addLine(self.newConnection) + else: + # delete the connection if it exists; remove the following + # line if this feature is not required + self.startItem.removeLine(self.newConnection) + self.removeItem(self.newConnection) + else: + self.removeItem(self.newConnection) + self.startItem = self.newConnection = None + super().mouseReleaseEvent(event) + + def getObjectsinUse(self): + rect_items = [item for item in self.items() if isinstance(item, CustomItem)] + objects_in_use = [] + for rect_item in rect_items: + rect_item_objects = rect_item.get_objects_used() + for rect_item_object in rect_item_objects: + objects_in_use.append(rect_item_object) + return objects_in_use + + def getObjectsCreated(self): + rect_items = [item for item in self.items() if isinstance(item, CustomItem)] + objects_in_use = [] + for rect_item in rect_items: + rect_item_objects = rect_item.get_objects_created() + for rect_item_object in rect_item_objects: + objects_in_use.append(rect_item_object) + return objects_in_use + + + \ No newline at end of file diff --git a/src/app/self.json b/src/app/self.json new file mode 100644 index 00000000..d139fac8 --- /dev/null +++ b/src/app/self.json @@ -0,0 +1,186 @@ +{ + "workflow_name": "Demo workflow", + "meta": { + "author": "Xuechen (Jerry) Lei", + "date": "03/15/2023", + "version": "1.0", + "description": "Demo workflow to showcase core Workflow API functionalities" + }, + "imports": [ + "numpy as np", + "pandas as pd", + "datetime", + "glob" + ], + "states": { + "load data": { + "Type": "MethodCall", + "MethodCall": "DataProcessing", + "Parameters": { + "data_path": "./demo/api_demo/demo_dataset.csv", + "data_source": "EnergyPlus" + }, + "Payloads": { + "data_processing_obj": "$" + }, + "Start": "True", + "Next": "slice data to get the first two months" + }, + "slice data to get the first two months": { + "Type": "MethodCall", + "MethodCall": "Payloads['data_processing_obj'].slice", + "Parameters": { + "start_time": { + "Type": "Embedded MethodCall", + "MethodCall": "datetime.datetime", + "Parameters": [ + 2000, + 1, + 1, + 0 + ] + }, + "end_time": { + "Type": "Embedded MethodCall", + "MethodCall": "datetime.datetime", + "Parameters": [ + 2000, + 3, + 1, + 0 + ] + } + }, + "Payloads": { + "sliced_data": "$" + }, + "Next": "load original verification case" + }, + "load original verification case": { + "Type": "MethodCall", + "MethodCall": "VerificationCase", + "Parameters": { + "json_case_path": "./demo/api_demo/demo_verification_cases.json" + }, + "Payloads": { + "verification_case_obj": "$", + "original_case_keys": "$.case_suite.keys()" + }, + "Next": "check original case length" + }, + "check original case length": { + "Type": "Choice", + "Choices": [ + { + "Value": "len(Payloads['original_case_keys']) == 3", + "Equals": "True", + "Next": "validate cases" + } + ], + "Default": "Report Error in workflow" + }, + "validate cases": { + "Type": "Choice", + "Choices": [ + { + "Value": "Payloads['verification_case_obj'].validate()", + "Equals": "True", + "Next": "setup verification" + } + ], + "Default": "Report Error in workflow" + }, + "setup verification": { + "Type": "MethodCall", + "MethodCall": "Verification", + "Parameters": { + "verifications": "Payloads['verification_case_obj']" + }, + "Payloads": { + "verification_obj": "$" + }, + "Next": "configure verification runner" + }, + "configure verification runner": { + "Type": "MethodCall", + "MethodCall": "Payloads['verification_obj'].configure", + "Parameters": { + "output_path": "./demo/api_demo", + "lib_items_path": "./schema/library.json", + "plot_option": "+x None", + "fig_size": "+x (6, 5)", + "num_threads": 1, + "preprocessed_data": "Payloads['sliced_data']" + }, + "Payloads": {}, + "Next": "run verification" + }, + "run verification": { + "Type": "MethodCall", + "MethodCall": "Payloads['verification_obj'].run", + "Parameters": {}, + "Payloads": { + "verification_return": "$" + }, + "Next": "check results" + }, + "check results": { + "Type": "MethodCall", + "MethodCall": "glob.glob", + "Parameters": [ + "./demo/api_demo/*_md.json" + ], + "Payloads": { + "length_of_mdjson": "len($)" + }, + "Next": "check number of result files" + }, + "check number of result files": { + "Type": "Choice", + "Choices": [ + { + "Value": "Payloads['length_of_mdjson']", + "Equals": "3", + "Next": "reporting_object_instantiation" + } + ], + "Default": "Report Error in workflow" + }, + "reporting_object_instantiation": { + "Type": "MethodCall", + "MethodCall": "Reporting", + "Parameters": { + "verification_json": "./demo/api_demo/*_md.json", + "result_md_name": "report_summary.md", + "report_format": "markdown" + }, + "Payloads": { + "reporting_obj": "$" + }, + "Next": "report_cases" + }, + "report_cases": { + "Type": "MethodCall", + "MethodCall": "Payloads['reporting_obj'].report_multiple_cases", + "Parameters": {}, + "Payloads": {}, + "Next": "Success" + }, + "Success": { + "Type": "MethodCall", + "MethodCall": "print", + "Parameters": [ + "Congratulations! the demo workflow is executed with expected results and no error!" + ], + "End": "True" + }, + "Report Error in workflow": { + "Type": "MethodCall", + "MethodCall": "logging.error", + "Parameters": [ + "Something is wrong in the workflow execution" + ], + "End": "True" + } + } +} \ No newline at end of file diff --git a/src/app/test.py b/src/app/test.py new file mode 100644 index 00000000..f5e8a187 --- /dev/null +++ b/src/app/test.py @@ -0,0 +1,124 @@ + +{ + "Load data": { + "Type": "MethodCall", + "MethodCall": "DataProcessing", + "Parameters": { + "data_path": "./tests/api/data/data_eplus.csv", + "data_source": "EnergyPlus" + }, + "Payloads": { + "data": "$.data", + "data_processing_obj": "$" + }, + "Next": "Slice data", + "Start": "True" + } +} + +{ + "Slice data": { + "Type": "MethodCall", + "MethodCall": "Payloads['data_processing_obj'].slice", + "Parameters": { + "start_time": { + "Type": "Embedded MethodCall", + "MethodCall": "datetime.datetime", + "Parameters": [ + 2000, + 1, + 1, + 12 + ] + }, + "end_time": { + "Type": "Embedded MethodCall", + "MethodCall": "datetime.datetime", + "Parameters": [ + 2000, + 1, + 1, + 13 + ] + } + }, + "Payloads": { + "sliced_data": "$" + }, + "Next": "Data Length Check" + } +} + +{ + "Data Length Check": { + "Type": "Choice", + "Choices": [ + { + "Value": "len(Payloads['sliced_data']) > 1", + "Equals": "True", + "Next": "Initialize verification object" + }, + { + "ANY": [ + { + "Value": 1, + "Equals": "True" + }, + { + "Value": "'data' in Payloads", + "Equals": "True" + }, + { + "Value": "'fake_data' in Payloads", + "Equals": "False" + } + ], + "Next": "Run verification" + } + ], + "Default": "Reporting" + } +} + +{ + "Initialize verification object": { + "Type": "MethodCall", + "MethodCall": "Verification", + "Parameters": { + "verification_cases_path": "x/yy.json" + }, + "Payloads": { + "verification": "$" + }, + "Next": "Run verification" + } +} + +{ + "Run verification": { + "Type": "MethodCall", + "MethodCall": "Payloads['verification'].run", + "Parameters": {}, + "Payloads": { + "verification_md": "$.md", + "verification_flag": "$.check_bool" + }, + "Next": "Reporting" + } +} + +{ + "Reporting": { + "Type": "MethodCall", + "MethodCall": "Reporting", + "Parameters": { + "verification_md": "Payloads['verification_md']", + "report_path": "x/yy.md", + "report_format": "markdown" + }, + "Payloads": { + "verification": "$" + }, + "End": "True" + } +} \ No newline at end of file diff --git a/src/app/workflow.py b/src/app/workflow.py new file mode 100644 index 00000000..66a65a04 --- /dev/null +++ b/src/app/workflow.py @@ -0,0 +1,60 @@ +import json +from collections import defaultdict + +class Workflow(): + def __init__(self, workflow): + self.workflow = workflow + +class State(): + def __init__(self, state): + self.state = defaultdict() + + if isinstance(state, str): + try: + unformmated_state = json.loads(state) + except json.decoder.JSONDecodeError: + raise ValueError('Invalid JSON') + + state_title = next(iter(unformmated_state)) + self.state.update(unformmated_state[state_title]) + self.state['Title'] = state_title + elif isinstance(state, dict): + self.state.update(state) + + + def set_state(self, state): + self.state = state + + def check_state(self, state): + viable = True + + def error(): + raise ValueError('Invalid JSON') + + if isinstance(state, str): + try: + uf_state = json.loads(state) + if uf_state.keys() > 1: + error() + except json.decoder.JSONDecodeError: + error() + + state_title = next(iter(uf_state)) + self.state.update(uf_state[state_title]) + self.state['Title'] = state_title + + state_type = self.state['Type'] + if state_type is None: + error() + elif state_type == 'MethodCall': + if not state['MethodCall']: + error() + elif state_type == 'Choice': + if not state['Choices']: + error() + else: + if not isinstance(self.state['Choices'], list): + error() + + + diff --git a/src/app/workflow_diagram.py b/src/app/workflow_diagram.py new file mode 100644 index 00000000..1d53386f --- /dev/null +++ b/src/app/workflow_diagram.py @@ -0,0 +1,182 @@ +from PyQt6.QtWidgets import QVBoxLayout, QWidget, QPushButton, QGraphicsView, QGraphicsTextItem +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QPainter, QWheelEvent, QKeyEvent, QKeySequence +from popup_window import PopupWindow +from advanced_popup import AdvancedPopup +from rect_connect import Scene, CustomItem, ControlPoint, Path +import json + +with open('dependencies.json') as file: + data = json.load(file) + +class Zoom(QGraphicsView): + clicked = pyqtSignal() + + def __init__(self, scene): + super().__init__(scene) + self.scene = scene + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing) + + self.zoom = 1.1 + self.zoom_in = QKeySequence.StandardKey.ZoomIn + self.zoom_out = QKeySequence.StandardKey.ZoomOut + self.itemClicked = None + self.dragStartPosition = None + + def wheelEvent(self, event: QWheelEvent): + factor = self.zoom ** (event.angleDelta().y() / 240.0) + self.scale(factor, factor) + + def keyPressEvent(self, event: QKeyEvent): + if event.matches(self.zoom_in): + self.scale(self.zoom, self.zoom) + event.accept() + elif event.matches(self.zoom_out): + self.scale(1 / self.zoom, 1 / self.zoom) + event.accept() + else: + super().keyPressEvent(event) + + def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.button() == Qt.MouseButton.LeftButton: + self.dragStartPosition = event.pos() + item = self.itemAt(event.pos()) + if item: + if isinstance(item, QGraphicsTextItem): + item = item.parentItem() + elif isinstance(item, ControlPoint): + return + if isinstance(item, CustomItem): + self.itemClicked = item + + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + if event.button() == Qt.MouseButton.LeftButton and event.pos() == self.dragStartPosition: + item = self.itemAt(event.pos()) + if item: + if isinstance(item, QGraphicsTextItem): + item = item.parentItem() + elif isinstance(item, ControlPoint): + return + if isinstance(item, CustomItem): + self.clicked.emit() + self.itemClicked = item + + def arrange_tree(self, parent_item, x, y, step): + if not parent_item: + return + + parent_item.setPos(x, y) + parent_item_h = parent_item.boundingRect().height() + num_children = len(parent_item.children) + if num_children: + total_width = (num_children - 1) * step + start_x = x - total_width / 2 + + for child in parent_item.children: + self.arrange_tree(child, start_x, y + parent_item_h + 20, step) + start_x += step + + + +class WorkflowDiagram(QWidget): + def __init__(self, setting): + super().__init__() + + self.setting = setting + layout = QVBoxLayout(self) + self.scene = Scene() + self.view = Zoom(self.scene) + self.view.clicked.connect(self.item_clicked) + + self.add_button = QPushButton('Add') + self.add_button.setFixedSize(100, 20) + self.add_button.clicked.connect(self.call_popup) + + layout.addWidget(self.view) + layout.addWidget(self.add_button) + layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.setLayout(layout) + + def add_state(self): + if self.popup.error: + return + + state = self.popup.get_state() + print(state) + if 'Type' in state.keys() and state['Type'] not in ['Choice', 'MethodCall']: + return + + self.create_item(state) + + def create_item(self, state): + rect_item = CustomItem(state, left=True) + + def connect_rects(parent, child): + child_control = child.controls[2] + parent_control = parent.controls[0] + path = Path(parent_control, child_control.scenePos(), child_control) + if parent_control.addLine(path) and child_control.addLine(path): + self.scene.addItem(path) + + rects = [item for item in self.scene.items() if isinstance(item, CustomItem)] + if 'Next' in state.keys(): + matching_rects = [rect for rect in rects if rect.state['Title'] == state['Next']] + if len(matching_rects) == 1: + child_rect = matching_rects[0] + connect_rects(rect_item, child_rect) + for rect in rects: + nexts = rect.get_nexts() + for next in nexts: + if next == state['Title']: + connect_rects(rect, rect_item) + + self.scene.addItem(rect_item) + self.update() + + def edit_state(self, rect): + current_state = self.popup.get_state() + if current_state['Type'] not in ['Choice', 'MethodCall']: + return + + old_state = rect.get_state_string() + old_state = json.loads(old_state) + title = next(iter(old_state.keys())) + old_state = old_state[title] + old_state['Title'] = title + + if old_state != current_state: + rect.set_state(current_state) + + + def call_popup(self, rect=None, edit=False): + if self.setting == 'basic': + payloads = self.scene.getObjectsCreated() + self.popup = PopupWindow(data, payloads, rect, edit) + else: + self.popup = AdvancedPopup(rect, edit) + + + if edit and rect: + self.popup.save_button.clicked.connect(lambda: self.edit_state(rect)) + else: + self.popup.save_button.clicked.connect(self.add_state) + self.popup.exec() + + def item_clicked(self): + if self.view.itemClicked: + rect = self.view.itemClicked + self.call_popup(rect, True) + + def read_import(self, states): + if isinstance(states, dict): + for state_name in states.keys(): + # turn state into a dictionary containing state_name key + new_state = states[state_name] + new_state['Title'] = state_name + self.create_item(new_state) diff --git a/src/gui b/src/gui deleted file mode 160000 index cb47d9d1..00000000 --- a/src/gui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cb47d9d1b9efe2b2ae54bfdac347501cbf85e8cc