From c1dd0ee1906dba1f2e2ae9ce40a84d55c26a1cce Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 26 Jun 2024 22:44:14 +0200 Subject: [PATCH] feat(designer): added support for creating designer plugins automatically --- bec_widgets/cli/generate_cli.py | 21 +++ bec_widgets/utils/generate_designer_plugin.py | 138 ++++++++++++++++++ .../utils/plugin_templates/plugin.template | 54 +++++++ .../utils/plugin_templates/register.template | 15 ++ 4 files changed, 228 insertions(+) create mode 100644 bec_widgets/utils/generate_designer_plugin.py create mode 100644 bec_widgets/utils/plugin_templates/plugin.template create mode 100644 bec_widgets/utils/plugin_templates/register.template diff --git a/bec_widgets/cli/generate_cli.py b/bec_widgets/cli/generate_cli.py index e88756f8..25ef0e70 100644 --- a/bec_widgets/cli/generate_cli.py +++ b/bec_widgets/cli/generate_cli.py @@ -10,6 +10,7 @@ from typing import Literal import black import isort +from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator from bec_widgets.utils.plugin_utils import get_rpc_classes if sys.version_info >= (3, 11): @@ -161,6 +162,26 @@ def main(): generator.generate_client(rpc_classes) generator.write(client_path) + for cls in rpc_classes["top_level_classes"]: + plugin = DesignerPluginGenerator(cls) + if not hasattr(plugin, "info"): + continue + + # if the class directory already has a register, plugin and pyproject file, skip + if os.path.exists( + os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py") + ): + continue + if os.path.exists( + os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py") + ): + continue + if os.path.exists( + os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject") + ): + continue + plugin.run() + if __name__ == "__main__": # pragma: no cover sys.argv = ["generate_cli.py", "--core"] diff --git a/bec_widgets/utils/generate_designer_plugin.py b/bec_widgets/utils/generate_designer_plugin.py new file mode 100644 index 00000000..31a46f8f --- /dev/null +++ b/bec_widgets/utils/generate_designer_plugin.py @@ -0,0 +1,138 @@ +import inspect +import os +import re +from unittest import mock + +from qtpy.QtCore import QObject +from qtpy.QtWidgets import QWidget + + +class DesignerPluginInfo: + def __init__(self, plugin_class): + self.plugin_class = plugin_class + self.plugin_name_pascal = plugin_class.__name__ + self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal) + self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}" + plugin_module = ( + ".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin" + ) + self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin" + + # first sentence / line of the docstring is used as tooltip + self.plugin_tooltip = ( + plugin_class.__doc__.split("\n")[0].strip().replace('"', "'") + if plugin_class.__doc__ + else self.plugin_name_pascal + ) + + self.base_path = os.path.dirname(inspect.getfile(plugin_class)) + + @staticmethod + def pascal_to_snake(name: str) -> str: + """ + Convert PascalCase to snake_case. + + Args: + name (str): The name to be converted. + + Returns: + str: The converted name. + """ + s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1) + return s2.lower() + + +class DesignerPluginGenerator: + def __init__(self, widget: type): + self.widget = widget + self.info = DesignerPluginInfo(widget) + + self.templates = {} + self.template_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "plugin_templates" + ) + + def run(self): + self._check_class_validity() + self._load_templates() + self._write_templates() + + def _check_class_validity(self): + + # Check if the widget is a QWidget subclass + if not issubclass(self.widget, QObject): + return + + # Check if the widget class has parent as the first argument. This is a strict requirement of Qt! + signature = list(inspect.signature(self.widget.__init__).parameters.values()) + if signature[1].name != "parent": + raise ValueError( + f"Widget class {self.widget.__name__} must have parent as the first argument." + ) + + base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)] + if not base_cls: + raise ValueError( + f"Widget class {self.widget.__name__} must inherit from a QObject subclass." + ) + + # Check if the widget class calls the super constructor with parent argument + init_source = inspect.getsource(self.widget.__init__) + cls_init_found = ( + bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent")) + or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)")) + or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,")) + ) + super_init_found = ( + bool(init_source.find(f"super({self.widget.__name__}, self).__init__(parent=parent")) + or bool(init_source.find(f"super({self.widget.__name__}, self).__init__(parent,")) + or bool(init_source.find(f"super({self.widget.__name__}, self).__init__(parent)")) + ) + if issubclass(self.widget.__bases__[0], QObject) and super_init_found == -1: + super_init_found = ( + bool(init_source.find("super().__init__(parent=parent")) + or bool(init_source.find("super().__init__(parent,")) + or bool(init_source.find("super().__init__(parent)")) + ) + + if not cls_init_found and not super_init_found: + raise ValueError( + f"Widget class {self.widget.__name__} must call the super constructor with parent." + ) + + def _write_templates(self): + self._write_register() + self._write_plugin() + self._write_pyproject() + + def _write_register(self): + file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py") + with open(file_path, "w", encoding="utf-8") as f: + f.write(self.templates["register"].format(**self.info.__dict__)) + + def _write_plugin(self): + file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py") + with open(file_path, "w", encoding="utf-8") as f: + f.write(self.templates["plugin"].format(**self.info.__dict__)) + + def _write_pyproject(self): + file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject") + out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]} + with open(file_path, "w", encoding="utf-8") as f: + f.write(str(out)) + + def _load_templates(self): + for file in os.listdir(self.template_path): + if not file.endswith(".template"): + continue + with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f: + self.templates[file.split(".")[0]] = f.read() + + +if __name__ == "__main__": + # from bec_widgets.widgets.bec_queue.bec_queue import BECQueue + from bec_widgets.widgets.ring_progress_bar.ring_progress_bar import RingProgressBar + + generator = DesignerPluginGenerator(RingProgressBar) + generator.run() diff --git a/bec_widgets/utils/plugin_templates/plugin.template b/bec_widgets/utils/plugin_templates/plugin.template new file mode 100644 index 00000000..bfb4e123 --- /dev/null +++ b/bec_widgets/utils/plugin_templates/plugin.template @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtGui import QIcon + +{widget_import} + +DOM_XML = """ + + + + +""" + + +class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = {plugin_name_pascal}(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return QIcon() + + def includeFile(self): + return "{plugin_name_snake}" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "{plugin_name_pascal}" + + def toolTip(self): + return "{plugin_tooltip}" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/utils/plugin_templates/register.template b/bec_widgets/utils/plugin_templates/register.template new file mode 100644 index 00000000..1c5cdc01 --- /dev/null +++ b/bec_widgets/utils/plugin_templates/register.template @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + {plugin_import} + + QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin()) + + +if __name__ == "__main__": # pragma: no cover + main()