diff --git a/components/plugin_components.py b/components/plugin_components.py index 8b4784a..a15650d 100644 --- a/components/plugin_components.py +++ b/components/plugin_components.py @@ -36,7 +36,7 @@ import traceback from datetime import datetime from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, Literal, overload +from typing import Any, Optional, Literal, overload from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtGui import QColor as Qcolor from PyQt6.QtWidgets import QWidget @@ -154,7 +154,7 @@ def public(func): return func -def get_public_methods(obj) -> Dict: +def get_public_methods(obj) -> dict: """ Get a dict of public methods in an object instance that are marked with the @public decorator. @@ -167,7 +167,7 @@ def get_public_methods(obj) -> Dict: return {name: getattr(obj, name) for name in dir(obj) if callable(getattr(obj, name, None)) and getattr(getattr(obj, name, None), "_is_public", False)} -def filter_to_valid_methods(function_dict: Dict[str, Any], required_functions: Dict[str, list]) -> Tuple[bool, List[str]]: +def filter_to_valid_methods(function_dict: dict[str, Any], required_functions: dict[str, list]) -> tuple[bool, list[str]]: """ Filter a function dictionary to only include valid methods based on required functions. UPDATING IS DONE IN PLACE FOR FUNCTION_DICT ARGUMENT. @@ -181,7 +181,7 @@ def filter_to_valid_methods(function_dict: Dict[str, Any], required_functions: D unresolved methods per dependency type. A method is unresolved when no single plugin can satisfy the full required method set for that dependency type. """ - missing_functions: List[str] = [] + missing_functions: list[str] = [] is_valid = True for dependency_type, required_funcs in required_functions.items(): @@ -233,11 +233,11 @@ class FileManager: """Component that builds standard file headers for plugins. Provides headers for standard IV and standard spectro.""" @staticmethod - def create_file_header(settings: Dict[str, Any], smu_settings: Dict[str, Any]) -> str: + def create_file_header(settings: dict[str, Any], smu_settings: dict[str, Any]) -> str: """Creates a legacy compatible SMU file header. The args accept standard SWEEP settings dict and SMU settings dict. Args: - settings (Dict[str, Any]): Dictionary with keys: + settings (dict[str, Any]): Dictionary with keys: - samplename: str, name of the sample - channel: str, source channel used - inject: str, injection mode for source (voltage or current) @@ -359,7 +359,7 @@ def create_file_header(settings: Dict[str, Any], smu_settings: Dict[str, Any]) - return comment @staticmethod - def create_spectrometer_header(varDict: Optional[Dict[str, Any]] = None, separator: str = ";") -> str: + def create_spectrometer_header(varDict: Optional[dict[str, Any]] = None, separator: str = ";") -> str: """Build a standard spectrometer header from a dictionary Args: @@ -459,14 +459,14 @@ class DependencyManager: - Access available function_dict through the property. This includes all plugins that satisfy the required method sets. - "parse_dependencies" is used to parse the settings widgets for plugins and add their settings to the settings dict. - Idea to clarify this: - 1. This should only do update on a hook (initGUI?). I say this because that time is the only time when the list of method actually can change. + Idea to clarify this: + 1. This should only do update on a hook (initGUI?). I say this because that time is the only time when the list of method actually can change. 2. This should not deal with the GUI, i think. It should only take in the info dicts and return function dicts. - 3. It should be possible to provide the current settings dict to this, and this would parse the selected plugins. + 3. It should be possible to provide the current settings dict to this, and this would parse the selected plugins. """ - def __init__(self, plugin_name: str, dependencies: Dict[str, list]): + def __init__(self, plugin_name: str, dependencies: dict[str, list]): """ Initialize dependency manager. @@ -482,27 +482,27 @@ def __init__(self, plugin_name: str, dependencies: Dict[str, list]): self.missing_functions = [] self.dependency_settings = {} self.last_selected = {} - self.selected_dependencies: Dict[str, str] = {} + self.selected_dependencies: dict[str, str] = {} @property - def function_dict(self) -> Dict[str, Any]: + def function_dict(self) -> dict[str, Any]: """Get the current function dictionary.""" return self._function_dict @function_dict.setter - def function_dict(self, value: Dict[str, Any]) -> None: + def function_dict(self, value: dict[str, Any]) -> None: """Set available dependency functions, pruning invalid providers first.""" pruned_function_dict, is_valid, missing_functions = self._prune_dependency_function_dict(value) self._function_dict = pruned_function_dict self.missing_functions = missing_functions - def _prune_dependency_function_dict(self, function_dict: Dict[str, Any]) -> Tuple[Dict[str, Any], bool, List[str]]: + def _prune_dependency_function_dict(self, function_dict: dict[str, Any]) -> tuple[dict[str, Any], bool, list[str]]: """Keep only declared dependency types and plugins that satisfy required methods.""" dependency_function_dict = {dependency_type: function_dict.get(dependency_type, {}) for dependency_type in self.dependencies.keys()} is_valid, missing_functions = filter_to_valid_methods(dependency_function_dict, self.dependencies) return dependency_function_dict, is_valid, missing_functions - def set_available_dependency_functions(self, function_dict: Dict[str, Any]) -> Tuple[bool, List[str]]: + def set_available_dependency_functions(self, function_dict: dict[str, Any]) -> tuple[bool, list[str]]: """Set and validate dependency functions from the plugin system. Returns: @@ -511,12 +511,12 @@ def set_available_dependency_functions(self, function_dict: Dict[str, Any]) -> T self.function_dict = function_dict return len(self.missing_functions) == 0, self.missing_functions - def initialize_dependency_selection(self, settings: Dict[str, Any]): + def initialize_dependency_selection(self, settings: dict[str, Any]): """Initialize remembered dependency selections from settings.""" self.last_selected = {dependency_type: settings.get(dependency_type, "") for dependency_type in self.dependencies.keys() if settings.get(dependency_type, "")} return (0, {}) - def set_selected_dependency_plugins(self, selected: Dict[str, str]) -> None: + def set_selected_dependency_plugins(self, selected: dict[str, str]) -> None: """Set selected dependency plugins from caller-managed UI state.""" for dependency_type in self.dependencies.keys(): selected_plugin = selected.get(dependency_type, "") @@ -524,7 +524,7 @@ def set_selected_dependency_plugins(self, selected: Dict[str, str]) -> None: self.selected_dependencies[dependency_type] = selected_plugin self.last_selected[dependency_type] = selected_plugin - def get_selected_dependency_plugins(self) -> Dict[str, str]: + def get_selected_dependency_plugins(self) -> dict[str, str]: """ Get currently selected dependencies. @@ -533,12 +533,12 @@ def get_selected_dependency_plugins(self) -> Dict[str, str]: """ return self.selected_dependencies.copy() - def get_available_dependency_plugins(self) -> Dict[str, List[str]]: + def get_available_dependency_plugins(self) -> dict[str, list[str]]: """Get valid plugin names for each dependency type after filtering.""" return {dependency_type: list(self._function_dict.get(dependency_type, {}).keys()) for dependency_type in self.dependencies.keys()} - def _resolve_selected_dependencies(self, target_settings_dict: Dict[str, Any]) -> Tuple[int, Dict[str, str] | Dict[str, Any]]: - selected_deps: Dict[str, str] = {} + def _resolve_selected_dependencies(self, target_settings_dict: dict[str, Any]) -> tuple[int, dict[str, str] | dict[str, Any]]: + selected_deps: dict[str, str] = {} for dependency_type in self.dependencies.keys(): selected_plugin = target_settings_dict.get(dependency_type, "") if not selected_plugin: @@ -561,7 +561,7 @@ def _resolve_selected_dependencies(self, target_settings_dict: Dict[str, Any]) - self.last_selected.update(selected_deps) return (0, selected_deps) - def parse_dependencies(self, target_settings_dict: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]: + def parse_dependencies(self, target_settings_dict: dict[str, Any]) -> tuple[int, dict[str, Any]]: """ Validates all dependency selections and extracts their settings. @@ -621,11 +621,11 @@ def parse_dependencies(self, target_settings_dict: Dict[str, Any]) -> Tuple[int, target_settings_dict.update(dependency_settings) return (0, target_settings_dict) - def set_dependency_settings(self, settings: Dict[str, Any]) -> None: + def set_dependency_settings(self, settings: dict[str, Any]) -> None: """Call setSettings for selected deps to update their internal state Args: - settings (Dict[str, Any]): _description_ + settings (dict [str, Any]): _description_ """ status, selected_or_error = self._resolve_selected_dependencies(settings) if status != 0: @@ -643,7 +643,7 @@ def set_dependency_settings(self, settings: Dict[str, Any]) -> None: continue plugin_functions["setSettings"](settings[settings_key]) - def update_dep_guis(self, selected_dependencies: Optional[Dict[str, str]] = None) -> None: + def update_dep_guis(self, selected_dependencies: Optional[dict[str, str]] = None) -> None: """Call any GUI update functions for the selected dependencies to reflect any changes in their settings.""" selected_deps = selected_dependencies or self.selected_dependencies or self.last_selected @@ -718,7 +718,7 @@ def info_popup(self, message: str) -> None: self.info_popup_signal.emit(message) -def handle_ret(pyIVLS_return: Tuple[int, Dict[str, Any]]) -> Any: +def handle_ret(pyIVLS_return: tuple[int, dict[str, Any]]) -> Any: """Return the dict on success, otherwise raise an exception. dont integrate this, this is just a tester""" ret_code, ret_dict = pyIVLS_return print("Warn: unstable handle_ret used in 'production' code.") @@ -736,4 +736,3 @@ def handle_ret(pyIVLS_return: Tuple[int, Dict[str, Any]]) -> Any: raise RuntimeError(f"Plugin returned THREAD_STOPPED: {ret_dict.get('Error message', 'No error message provided')}") else: raise RuntimeError(f"Plugin returned unknown error code {ret_code}: {ret_dict.get('Error message', 'No error message provided')}") - diff --git a/plugins/pydantic_poc/pluginTemplate.py b/plugins/pydantic_poc/pluginTemplate.py new file mode 100644 index 0000000..3bcd001 --- /dev/null +++ b/plugins/pydantic_poc/pluginTemplate.py @@ -0,0 +1,35 @@ +""" +This is a template for a plugin core implementation in pyIVLS +This file should be independent on GUI, i.e. it should be made in a way that allows to reuse it in other scripts +""" + + +class pluginTemplate: + def __init__(self): + self.int_var = True + + def core_functionality(self, arg1: float, arg2: float) -> bool: + """This is an example of a core functionality function. It should be implemented in a way that it can be used outside of GUI. + Args: + arg1 (float): + arg2 (float): + Returns: + bool: Success or failure of the function + """ + if arg1 > arg2: + return True + else: + return False + + def get_internal_state(self) -> dict: + """This is an example of a function that returns the internal state of the plugin. + dict: A dictionary containing the internal state of the plugin + """ + return {"int_var": self.int_var} + + def will_fail(self) -> bool: + """This will fail + Returns: + bool: This function always returns False to indicate failure + """ + return False diff --git a/plugins/pydantic_poc/pluginTemplateGUI.py b/plugins/pydantic_poc/pluginTemplateGUI.py new file mode 100644 index 0000000..1cb2870 --- /dev/null +++ b/plugins/pydantic_poc/pluginTemplateGUI.py @@ -0,0 +1,202 @@ +""" +This is a template for a plugin GUI implementation in pyIVLS + +This file should provide +- functions for interaction with other plugins (those that will be exported on get_functions hook call, these should not start with "_") +- functions that will implement functionality of the hooks (see pyIVLS_pluginTemplate) +- GUI functionality - code that interracts with Qt GUI elements from widgets + +The standard implementation may (but not must) include +- GUI a Qt widget implementation +- GUI functionality (e.g. pluginTemplateGUI.py) - code that interracts with Qt GUI elements from widgets +- plugin core implementation - a set of functions that may be used outside of GUI +""" + +import os +from PyQt6 import QtWidgets +from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtWidgets import QWidget +from pluginTemplate import pluginTemplate +from plugin_components import ( + CloseLockSignalProvider, + public, + get_public_methods, + load_widget, + LoggingHelper, + DependencyManager, +) +from MplCanvas import MplCanvas +from schema import SchemaPOCSettings +from widget_builder import pyIVLS_settings_widget +# this is loade from components directory that contains shared classes + + +class pluginTemplateGUI(QObject): + @property + def MDIWidget(self) -> QWidget: + if not hasattr(self, "_mdiWidget"): + raise NotImplementedError("MDI widget not implemented, remove this function if MDI widget is not needed.") + if self._mdiWidget is None: + raise RuntimeError("MDI widget not initialized.") + return self._mdiWidget + + def notify_user(self, message: str): + """Utility to create popup and corresponding log entry for events that should be clearly visible""" + self.logger.log_info(message) + self.logger.info_popup(message) + + update_gui_signal = pyqtSignal() + + ########Functions + def __init__(self): + super(pluginTemplateGUI, self).__init__() ### this is needed if the class is a child of QObject + + self.path = os.path.dirname(__file__) + os.path.sep + # remove load_widget if no widgets are needed + self._mdiWidget = load_widget(settings=False, mdi=True, path=self.path) + self.abs = pyIVLS_settings_widget(SchemaPOCSettings) + self.settingsWidget = self.abs + print(f"Settings widget built from SchemaPOCSettings: {self.settingsWidget}") + # Initialize the functionality core that should be independent on GUI + self.templateFunctionality = pluginTemplate() + + # init settings + self.settings = {} + + # print components of the GUI for debugging + print(f"Components of the GUI for {self.__class__.__name__}:") + for child in self.settingsWidget.findChildren(QtWidgets.QWidget): + print(f"Child widget: {child.objectName()} of type: {type(child)}") + + # create plot + self._create_plt() + + # initialize logger + self.logger = LoggingHelper(self) + + # initialize dependency manager + self.dm = DependencyManager( + "pluginTemplate", + {"camera": []}, + ) + # initialize closelock if needed + self.cl = CloseLockSignalProvider() + + # prepare GUI + + # HOX: things such as adding items for comboboxes MUST be done here to prevent duplicate entries + # when initGUI is repeatedly called from pyIVLS_container when plugin list is updated. + + def _create_plt(self): + self.sc = MplCanvas(self, width=5, height=4, dpi=100) + self.axes = self.sc.fig.add_subplot(111) + self.axes.set_xlabel("time (HH:MM)") + self.axes.set_ylabel(r"Temperature ($^\circ$C)") + + # self.MDIWidget.previewForm.addWidget(self.sc._create_toolbar(self.MDIWidget)) + # self.MDIWidget.previewForm.addWidget(self.sc) + + ########Functions + ########GUI Slots + # This section should contain functions that should react to GUI events. + + ########Functions + ################################### internal + def _validate_settings(self, settings: dict) -> tuple[int, str]: + """Validate settings dict and convert values to correct dtype + + Args: + settings (dict): settings dict with correct data types, so not the initial .ini dict + + Returns: + tuple[int, str]: status code, error message (empty if no error) + """ + try: + settings["float"] = float(settings["float"]) + settings["int"] = int(settings["int"]) + if not (0 <= settings["float"] <= 1): + return (1, "Float value should be between 0 and 1.") + if settings["category"] not in ["option1", "option2", "option3"]: + return (1, "Category should be one of the following: option1, option2, option3.") + if settings["int"] < 0: + return (1, "Integer value should be non-negative.") + if len(settings["str"]) == 0: + return (1, "String value should not be empty.") + return (0, "") + # catch conversion errors if values read from gui + except ValueError as e: + return (1, f"Invalid data type in settings: {e}") + + ########Functions + ###############GUI setting up + def _initGUI( + self, + plugin_info: dict, + ): + """Initialize the GUI components with the provided plugin information. + This should not set the internal state of the plugin, but only set the GUI elements. + It should be possible to call this function multiple times without + side effects. This will in fact be called multiple times, since pyIVLS_container calls get_setup_interface + every time the pluginlist is updated. + + Args: + plugin_info (dict): dictionary with settings obtained from plugin_data in pyIVLS_ + """ + # initialize dependency manager with the provided settings to initialize combobox + self.dm.initialize_dependency_selection(plugin_info) + + # These are unguarded, meaning that a user messing around in the .ini file + # can cause an unhandled exception here. + # IMO this is the best way to handle wrong settings on startup, since the crash happens early and before data loss and + # the error is fairly descriptive. Besides, handling these errors would involve + # guessing what the value should actually be which could just lead to more pain down the line. + + # set actual values to GUI from plugin info + + ########Functions + ###############GUI react to change + + ########Functions + ########plugins interraction + def _get_public_methods(self): + return get_public_methods(self) + + @public + def setSettings(self, settings: dict): + """Set the settings for the templatePlugin. + + Args: + settings (dict): dictionary with settings for the templatePlugin. + """ + status, error_message = self._validate_settings(settings) + if status != 0: + return (1, {"Error message": error_message}) + self.settings = settings + + # update settings for deps: + self.dm.set_dependency_settings(settings) + + return (0, {"Error message": "ok"}) + + @public + def set_gui_from_settings(self): + """Set the GUI elements from the internal settings. This can be used after settings have been updated from an external plugin to update the GUI accordingly.""" + # Here we can assume that self.settings contains the values in correct datatype since they are checked before writing to settings. + self.update_gui_signal.emit() + self.dm.update_dep_guis() + + ########Functions to be used externally + ########Public API + @public + def parse_settings_widget(self) -> tuple[int, dict]: + """Parses the settings widget for the templatePlugin. Extracts current values. + Checks if values are allowed. Provides settings of template plugin to an external plugin + + Returns (status, settings_dict): + status: 0 - no error, ~0 - error (add error code later on if needed) + self.settings + """ + m = self.abs.to_model() + ts = m.model_dump() + + return (0, ts) diff --git a/plugins/pydantic_poc/pluginTemplate_MDIWidget.ui b/plugins/pydantic_poc/pluginTemplate_MDIWidget.ui new file mode 100644 index 0000000..38e2bef --- /dev/null +++ b/plugins/pydantic_poc/pluginTemplate_MDIWidget.ui @@ -0,0 +1,40 @@ + + + previewForm + + + + 0 + 0 + 594 + 474 + + + + + 200 + 200 + + + + Template MDI + + + + + + Template MDI :) + + + Qt::TextFormat::MarkdownText + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + diff --git a/plugins/pydantic_poc/pyIVLS_schema.py b/plugins/pydantic_poc/pyIVLS_schema.py new file mode 100644 index 0000000..9b83c80 --- /dev/null +++ b/plugins/pydantic_poc/pyIVLS_schema.py @@ -0,0 +1,131 @@ +#!/usr/bin/python3.8 + +""" +This is a template for a plugin in pyIVLS + +This file only implements the hooks for pyIVLS. +The proper implementation should be placed in a directory with the same name (for this template it is "pluginTemplate") next to this file. +The main reason to put implementation in a different calss is to allow to reuse it in other applications. + +The standard implementation may (but not must) include +- GUI a Qt widget implementation +- GUI functionality (e.g. pluginTemplateGUI.py) - code that interracts with Qt GUI elements from widgets +- plugin core implementation - a set of functions that may be used outside of GUI +""" + +import pluggy +from pluginTemplateGUI import pluginTemplateGUI +import os +import configparser + + +class pyIVLS_schema_plugin: + """Hooks for pluginTemplate plugin + Not all hooks must be implemented + If hook is not needed it should be deleted + """ + + hookimpl = pluggy.HookimplMarker("pyIVLS") + + def __init__(self): + # iterate current directory to find the .ini file + path = os.path.dirname(__file__) + for file in os.listdir(path): + if file.endswith(".ini"): + path = os.path.join(path, file) + break + config = configparser.ConfigParser() + config.read(path) + + self.name = config.get("plugin", "name") + self.type = config.get("plugin", "type") + self.function = config.get("plugin", "function") + self._class = config.get("plugin", "class") + self.dependencies = config.get("plugin", "dependencies", fallback="").split(",") + self.pluginClass = pluginTemplateGUI() + + @hookimpl + def get_setup_interface(self, plugin_data) -> dict: + """Returns GUI plugin for the docking area (settings/buttons). This function is called from pyIVLS_container + Args: + plugin_data (dict): plugin dict from pyIVLS_container. Used to get the initial settings. + Returns: + dict: name, widget + """ + ##IRtodo#### add check if (error) show message and return error + self.pluginClass._initGUI(plugin_data[self.name]["settings"]) + return {self.name: self.pluginClass.settingsWidget} + + @hookimpl + def get_MDI_interface(self, args=None) -> dict: + """Returns MDI window (visualisation). This function is called from pyIVLS_container + + Returns: + dict: name, widget + """ + return {self.name: self.pluginClass.MDIWidget} + + @hookimpl + def get_functions(self, args=None): + """Returns a dictionary of publicly accessible functions. This function is called from pyIVLS_container + + Args: + args (dict): function + + Returns: + dict: functions + """ + if args is None or args.get("function") == self.function: + return {self.name: self.pluginClass._get_public_methods()} + + @hookimpl + def set_function(self, function_dict): + """provides a list of publicly available functions to the plugin as a nested dict + {'function1' : {'def1': object, 'def2':object}, + 'function2' : {'def1': object, 'def2':object},} + + :return: list containing missed plugins or functions in form of [plg1, plg2:func3] + """ + # set functions to DependencyManager + is_valid, missing = self.pluginClass.dm.set_available_dependency_functions(function_dict) + + return {self.name: missing} + + @hookimpl + def get_log(self, args=None): + """provides the signal for logging to main app + + :return: dict that includes the log signal + """ + + if args is None or args.get("function") == self.function: + return {self.name: self.pluginClass.logger.logger_signal} + + @hookimpl + def get_info(self, args=None): + """provides the signal for logging to main app + + :return: dict that includes the log signal + """ + + if args is None or args.get("function") == self.function: + return {self.name: self.pluginClass.logger.info_popup_signal} + + @hookimpl + def get_closeLock(self, args=None): + """provides the signal for logging to main app + + :return: dict that includes the log signal + """ + + if args is None or args.get("function") == self.function: + return {self.name: self.pluginClass.cl.closeLock} + + @hookimpl + def get_plugin_settings(self, args=None): + """Reads the current settings from the settingswidget, returns a dict. Returns (name, status, settings_dict) + Called from pyIVLS_container when saving settings + """ + if args is None or args.get("function") == self.function: + status, settings = self.pluginClass.parse_settings_widget() + return (self.name, status, settings) diff --git a/plugins/pydantic_poc/schema.py b/plugins/pydantic_poc/schema.py new file mode 100644 index 0000000..105caf3 --- /dev/null +++ b/plugins/pydantic_poc/schema.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, DirectoryPath, Field, field_validator +from typing import Literal, Annotated +from annotated_types import Gt, Lt, Le, Ge + + +class SchemaPOCSettings(BaseModel): + test_directory: DirectoryPath = Field( + title="Test Directory", + description="Directory containing the measurement files.", + ) + + test_integer: Annotated[int, Gt(0), Lt(100)] = Field( + default=10, + title="Iterations", + description="Number of optimization iterations.", + examples=[25], + ) + + test_float: Annotated[float, Ge(0.0), Le(1.0)] = Field( + default=2, + title="Threshold", + description="Threshold used during optimization.", + ) + + test_string: str = Field( + default="plhl", + title="Sample Name", + description="Name used for the generated output.", + ) + + test_enum: Literal["option1", "option2", "option3"] = Field( + default="option2", + title="Algorithm", + description="Optimization algorithm to use.", + ) diff --git a/plugins/pydantic_poc/template.ini b/plugins/pydantic_poc/template.ini new file mode 100644 index 0000000..86828fc --- /dev/null +++ b/plugins/pydantic_poc/template.ini @@ -0,0 +1,18 @@ +[plugin] +# this name should match pyIVLS_{name}.py and the plugin object in that file (pyIVLS_{name}_plugin) +name = schema +type = script +function = template +class = step +load = False +dependencies = +version = 0.0.0 + +[settings] +# These are the default settings for the plugin. +test_directory = C:\Users\otsoh\Documents\repolaiset\pyIVLS\plugins\specTimeIV-0.0.0 +test_integer = 1 +test_float = 0.1 +test_string = placeholder +test_enum = option1 + diff --git a/plugins/pydantic_poc/widget_builder.py b/plugins/pydantic_poc/widget_builder.py new file mode 100644 index 0000000..8e7f29e --- /dev/null +++ b/plugins/pydantic_poc/widget_builder.py @@ -0,0 +1,199 @@ +from typing import Literal, get_args, get_origin + +from annotated_types import Ge, Gt, Le, Lt +from pydantic import BaseModel, DirectoryPath +from PyQt6 import QtWidgets +from PyQt6.QtWidgets import QWidget + + +class WidgetAdapter: + @staticmethod + def get(widget: QtWidgets.QWidget): + if isinstance(widget, QtWidgets.QLineEdit): + return widget.text() + elif isinstance(widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)): + return widget.value() + elif isinstance(widget, QtWidgets.QCheckBox): + return widget.isChecked() + elif isinstance(widget, QtWidgets.QComboBox): + return widget.currentText() + else: + raise TypeError(f"Unsupported widget type: {type(widget)}") + + @staticmethod + def set(widget: QtWidgets.QWidget, value): + if isinstance(widget, QtWidgets.QLineEdit): + widget.setText(str(value)) + elif isinstance(widget, QtWidgets.QSpinBox): + widget.setValue(int(value)) + elif isinstance(widget, QtWidgets.QDoubleSpinBox): + widget.setValue(float(value)) + elif isinstance(widget, QtWidgets.QCheckBox): + widget.setChecked(bool(value)) + elif isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(str(value)) + if index != -1: + widget.setCurrentIndex(index) + else: + raise ValueError(f"Value '{value}' not found in QComboBox for field: {value}") + else: + raise TypeError(f"Unsupported widget type: {type(widget)}") + + +class pyIVLS_settings_widget(QWidget): + def __init__(self, settings_model: type[BaseModel], parent: QtWidgets.QWidget | None = None, name="Settings"): + super().__init__(parent) + self.settings_model = settings_model + self.setwid = self + self._build_form() + self.setObjectName(name) + self.setWindowTitle(name) + test_enum = self.widgets.get("test_enum") + if isinstance(test_enum, QtWidgets.QComboBox): + test_enum.activated.connect(self.test_slot) + + def test_slot(self): + print("kutittaa") + + def _get_widget_value(self, field_name: str): + widget = self.findChild(QtWidgets.QWidget, field_name) + if widget is None: + raise ValueError(f"No widget found for field: {field_name}") + return WidgetAdapter.get(widget) + + def to_model(self) -> BaseModel: + """Get an instance of the pydantic base model""" + return self.settings_model(**{name: self._get_widget_value(name) for name in self.settings_model.model_fields}) + + def _set_widget_value(self, field_name: str, value): + widget = self.findChild(QtWidgets.QWidget, field_name) + if widget is None: + raise ValueError(f"No widget found for field: {field_name}") + WidgetAdapter.set(widget, value) + + def from_model(self, model_instance: BaseModel): + """Set the values of the widgets based on an instance of the pydantic base model""" + dict = model_instance.model_dump() + for name, value in dict.items(): + self._set_widget_value(name, value) + + def _build_form(self): + self.widgets = {} + + layout = QtWidgets.QFormLayout(self) + + for name, field in self.settings_model.model_fields.items(): + label = FormHelpers.create_label(name, field) + widget = FormHelpers.create_widget(field) + + FormHelpers.apply_constraints(widget, field) + FormHelpers.apply_default(widget, field) + FormHelpers.apply_tooltips(label, widget, field) + + widget.setObjectName(name) + self.widgets[name] = widget + + layout.addRow(label, widget) + + self.setLayout(layout) + return self + + +class FormHelpers: + @staticmethod + def create_label(name, field): + text = field.title or name.replace("_", " ").title() + return QtWidgets.QLabel(text) + + @staticmethod + def create_widget(field): + + annotation = field.annotation + origin = get_origin(annotation) + + if origin is Literal: + combo = QtWidgets.QComboBox() + + for value in get_args(annotation): + combo.addItem(str(value)) + + return combo + + if annotation is str: + return QtWidgets.QLineEdit() + + if annotation is int: + return QtWidgets.QSpinBox() + + if annotation is float: + return QtWidgets.QDoubleSpinBox() + + if annotation is bool: + return QtWidgets.QCheckBox() + + if annotation is DirectoryPath: + return QtWidgets.QLineEdit() + + return QtWidgets.QLineEdit() + + @staticmethod + def apply_constraints(widget, field): + + minimum = None + maximum = None + + for meta in field.metadata: + if isinstance(meta, Ge): + minimum = meta.ge + + elif isinstance(meta, Gt): + if isinstance(widget, QtWidgets.QSpinBox): + minimum = meta.gt + 1 + else: + minimum = meta.gt + + elif isinstance(meta, Le): + maximum = meta.le + + elif isinstance(meta, Lt): + if isinstance(widget, QtWidgets.QSpinBox): + maximum = meta.lt - 1 + else: + maximum = meta.lt + + if minimum is not None and isinstance(widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)): + widget.setMinimum(minimum) + + if maximum is not None and isinstance(widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)): + widget.setMaximum(maximum) + + @staticmethod + def apply_default(widget, field): + + if field.default is None: + return + + value = field.default + + if isinstance(widget, QtWidgets.QLineEdit): + widget.setText(str(value)) + + elif isinstance(widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)): + widget.setValue(value) + + elif isinstance(widget, QtWidgets.QCheckBox): + widget.setChecked(value) + + elif isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(str(value)) + if index >= 0: + widget.setCurrentIndex(index) + + @staticmethod + def apply_tooltips(label, widget, field): + + if field.description is None: + return + + label.setToolTip(field.description) + widget.setToolTip(field.description) diff --git a/pyproject.toml b/pyproject.toml index cd62db9..b828c91 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,6 @@ dependencies = [ "setuptools>=78.1.1", "six==1.17.0", "typing-extensions==4.12.2", - "uv>=0.11.6", "zeroconf==0.137.2", "zope-interface==7.2", ] diff --git a/uv.lock b/uv.lock index 84e9455..9276b86 100755 --- a/uv.lock +++ b/uv.lock @@ -894,7 +894,6 @@ dependencies = [ { name = "setuptools" }, { name = "six" }, { name = "typing-extensions" }, - { name = "uv" }, { name = "zeroconf" }, { name = "zope-interface" }, ] @@ -937,7 +936,6 @@ requires-dist = [ { name = "setuptools", specifier = ">=78.1.1" }, { name = "six", specifier = "==1.17.0" }, { name = "typing-extensions", specifier = "==4.12.2" }, - { name = "uv", specifier = ">=0.11.6" }, { name = "zeroconf", specifier = "==0.137.2" }, { name = "zope-interface", specifier = "==7.2" }, ] @@ -1432,32 +1430,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] -[[package]] -name = "uv" -version = "0.11.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/7d/17750123a8c8e324627534fe1ae2e7a46689db8492f1a834ab4fd229a7d8/uv-0.11.7.tar.gz", hash = "sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405", size = 4083385, upload-time = "2026-04-15T21:42:55.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/5b/2bb2ab6fe6c78c2be10852482ef0cae5f3171460a6e5e24c32c9a0843163/uv-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c", size = 23757265, upload-time = "2026-04-15T21:43:14.494Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f5/36ff27b01e60a88712628c8a5a6003b8e418883c24e084e506095844a797/uv-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a", size = 23184529, upload-time = "2026-04-15T21:42:30.225Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/f379be661316698f877e78f4c51e5044be0b6f390803387237ad92c4057f/uv-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9", size = 21780167, upload-time = "2026-04-15T21:42:44.942Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/fbed29775b0612f4f5679d3226268f1a347161abc1727b4080fb41d9f46f/uv-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71", size = 23609640, upload-time = "2026-04-15T21:42:22.57Z" }, - { url = "https://files.pythonhosted.org/packages/ad/de/989a69634a869a22322770120557c2d8cbba5b77ec7cfad326b4ec0f0547/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b", size = 23322484, upload-time = "2026-04-15T21:43:26.52Z" }, - { url = "https://files.pythonhosted.org/packages/24/08/c1af05ea602eb4eb75d86badb6b0594cc104c3ca83ccf06d9ed4dd2186ad/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924", size = 23326385, upload-time = "2026-04-15T21:42:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/68/99/e246962da06383e992ecab55000c62a50fb36efef855ea7264fad4816bf4/uv-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0", size = 24985751, upload-time = "2026-04-15T21:42:37.777Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/b0b68083859579ce811996c1480765ec6a2442b44c451eaef53e6218fbae/uv-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d", size = 25724160, upload-time = "2026-04-15T21:43:18.762Z" }, - { url = "https://files.pythonhosted.org/packages/4e/19/5970e89d9e458fd3c4966bbc586a685a1c0ab0a8bf334503f63fa20b925b/uv-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c", size = 25028512, upload-time = "2026-04-15T21:43:02.721Z" }, - { url = "https://files.pythonhosted.org/packages/83/eb/4e1557daf6693cb446ed28185664ad6682fd98c6dbac9e433cbc35df450a/uv-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d", size = 24933975, upload-time = "2026-04-15T21:42:18.828Z" }, - { url = "https://files.pythonhosted.org/packages/68/55/3b517ec8297f110d6981f525cccf26f86e30883fbb9c282769cffbcdcfca/uv-0.11.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605", size = 23706403, upload-time = "2026-04-15T21:43:10.664Z" }, - { url = "https://files.pythonhosted.org/packages/dc/30/7d93a0312d60e147722967036dc8ea37baab4802784bddc22464cb707deb/uv-0.11.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9", size = 24495797, upload-time = "2026-04-15T21:42:26.538Z" }, - { url = "https://files.pythonhosted.org/packages/8c/89/d49480bdab7725d36982793857e461d471bde8e1b7f438ffccee677a7bf8/uv-0.11.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9", size = 24580471, upload-time = "2026-04-15T21:42:52.871Z" }, - { url = "https://files.pythonhosted.org/packages/b6/9f/c57dc03b48be17b564e304eb9ff982890c12dfb888b1ce370788733329ab/uv-0.11.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f", size = 24113637, upload-time = "2026-04-15T21:42:34.094Z" }, - { url = "https://files.pythonhosted.org/packages/13/ba/b87e358b629a68258527e3490e73b7b148770f4d2257842dea3b7981d4e8/uv-0.11.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce", size = 25119850, upload-time = "2026-04-15T21:43:22.529Z" }, - { url = "https://files.pythonhosted.org/packages/4b/74/16d229e1d8574bcbafa6dc643ac20b70c3e581f42ac31a6f4fd53035ffe3/uv-0.11.7-py3-none-win32.whl", hash = "sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4", size = 22918454, upload-time = "2026-04-15T21:42:58.702Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1d/b73e473da616ac758b8918fb218febcc46ddf64cba9e03894dfa226b28bd/uv-0.11.7-py3-none-win_amd64.whl", hash = "sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d", size = 25447744, upload-time = "2026-04-15T21:42:48.813Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/e6bfdea92ed270f3445a5a3c17599d041b3f2dbc5026c09e02830a03bbaf/uv-0.11.7-py3-none-win_arm64.whl", hash = "sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1", size = 23941816, upload-time = "2026-04-15T21:43:06.732Z" }, -] - [[package]] name = "zeroconf" version = "0.137.2"