Source code for cdl.plugins

# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
DataLab plugin system
---------------------

DataLab plugin system provides a way to extend the application with new
functionalities.

Plugins are Python modules that relies on two classes:

    - :class:`PluginInfo`, which stores information about the plugin
    - :class:`PluginBase`, which is the base class for all plugins

Plugins may also extends DataLab I/O features by providing new image or
signal formats. To do so, they must provide a subclass of :class:`ImageFormatBase`
or :class:`SignalFormatBase`, in which format infos are defined using the
:class:`FormatInfo` class.
"""

from __future__ import annotations

import abc
import dataclasses
import importlib
import os
import os.path as osp
import pkgutil
import sys
from typing import TYPE_CHECKING

from qtpy import QtWidgets as QW

from cdl.config import MOD_NAME, OTHER_PLUGINS_PATHLIST, Conf, _

# pylint: disable=unused-import
from cdl.core.io.base import FormatInfo  # noqa: F401
from cdl.core.io.image.base import ImageFormatBase  # noqa: F401
from cdl.core.io.image.formats import ClassicsImageFormat  # noqa: F401
from cdl.core.io.signal.base import SignalFormatBase  # noqa: F401
from cdl.env import execenv
from cdl.proxy import LocalProxy

if TYPE_CHECKING:
    from cdl.core.gui import main
    from cdl.core.gui.panel.image import ImagePanel
    from cdl.core.gui.panel.signal import SignalPanel
    from cdl.core.model.image import NewImageParam
    from cdl.core.model.signal import NewSignalParam


PLUGINS_DEFAULT_PATH = Conf.get_path("plugins")

if not osp.isdir(PLUGINS_DEFAULT_PATH):
    os.makedirs(PLUGINS_DEFAULT_PATH)


#  pylint: disable=bad-mcs-classmethod-argument
[docs] class PluginRegistry(type): """Metaclass for registering plugins""" _plugin_classes: list[type[PluginBase]] = [] _plugin_instances: list[PluginBase] = [] def __init__(cls, name, bases, attrs): super().__init__(name, bases, attrs) if name != "PluginBase": cls._plugin_classes.append(cls)
[docs] @classmethod def get_plugin_classes(cls) -> list[type[PluginBase]]: """Return plugin classes""" return cls._plugin_classes
[docs] @classmethod def get_plugins(cls) -> list[PluginBase]: """Return plugin instances""" return cls._plugin_instances
[docs] @classmethod def get_plugin(cls, name_or_class: str | type[PluginBase]) -> PluginBase | None: """Return plugin instance""" for plugin in cls._plugin_instances: if name_or_class in (plugin.info.name, plugin.__class__): return plugin return None
[docs] @classmethod def register_plugin(cls, plugin: PluginBase): """Register plugin""" if plugin.info.name in [plug.info.name for plug in cls._plugin_instances]: raise ValueError(f"Plugin {plugin.info.name} already registered") cls._plugin_instances.append(plugin) execenv.log(cls, f"Plugin {plugin.info.name} registered")
[docs] @classmethod def unregister_plugin(cls, plugin: PluginBase): """Unregister plugin""" cls._plugin_instances.remove(plugin) execenv.log(cls, f"Plugin {plugin.info.name} unregistered") execenv.log(cls, f"{len(cls._plugin_instances)} plugins left")
[docs] @classmethod def get_plugin_infos(cls, html: bool = True) -> str: """Return plugin infos (names, versions, descriptions) in html format Args: html: return html formatted text (default: True) """ linesep = "<br>" if html else os.linesep bullet = "• " if html else " " * 4 def italic(text: str) -> str: """Return italic text""" return f"<i>{text}</i>" if html else text if Conf.main.plugins_enabled.get(): plugins = cls.get_plugins() if plugins: text = italic(_("Registered plugins:")) text += linesep for plugin in plugins: text += f"{bullet}{plugin.info.name} ({plugin.info.version})" if plugin.info.description: text += f": {plugin.info.description}" text += linesep else: text = italic(_("No plugins available")) else: text = italic(_("Plugins are disabled (see DataLab settings)")) return text
[docs] @dataclasses.dataclass class PluginInfo: """Plugin info""" name: str = None version: str = "0.0.0" description: str = "" icon: str = None
[docs] class PluginBaseMeta(PluginRegistry, abc.ABCMeta): """Mixed metaclass to avoid conflicts"""
[docs] class PluginBase(abc.ABC, metaclass=PluginBaseMeta): """Plugin base class""" PLUGIN_INFO: PluginInfo = None def __init__(self): self.main: main.CDLMainWindow = None self.proxy: LocalProxy = None self._is_registered = False self.info = self.PLUGIN_INFO if self.info is None: raise ValueError(f"Plugin info not set for {self.__class__.__name__}") @property def signalpanel(self) -> SignalPanel: """Return signal panel""" return self.main.signalpanel @property def imagepanel(self) -> ImagePanel: """Return image panel""" return self.main.imagepanel
[docs] def show_warning(self, message: str): """Show warning message""" QW.QMessageBox.warning(self.main, _("Warning"), message)
[docs] def show_error(self, message: str): """Show error message""" QW.QMessageBox.critical(self.main, _("Error"), message)
[docs] def show_info(self, message: str): """Show info message""" QW.QMessageBox.information(self.main, _("Information"), message)
[docs] def ask_yesno( self, message: str, title: str | None = None, cancelable: bool = False ) -> bool: """Ask yes/no question""" if title is None: title = _("Question") buttons = QW.QMessageBox.Yes | QW.QMessageBox.No if cancelable: buttons |= QW.QMessageBox.Cancel answer = QW.QMessageBox.question(self.main, title, message, buttons) if answer == QW.QMessageBox.Yes: return True if answer == QW.QMessageBox.No: return False return None
[docs] def edit_new_signal_parameters( self, title: str | None = None, size: int | None = None, hide_signal_type: bool = True, ) -> NewSignalParam: """Create and edit new signal parameter dataset Args: title: title of the new signal size: size of the new signal (default: None, get from current signal) hide_signal_type: hide signal type parameter (default: True) Returns: New signal parameter dataset (or None if canceled) """ newparam = self.signalpanel.get_newparam_from_current(title=title) if size is not None: newparam.size = size newparam.hide_signal_type = hide_signal_type if newparam.edit(self.main): return newparam return None
[docs] def edit_new_image_parameters( self, title: str | None = None, shape: tuple[int, int] | None = None, hide_image_type: bool = True, hide_image_dtype: bool = False, ) -> NewImageParam | None: """Create and edit new image parameter dataset Args: title: title of the new image shape: shape of the new image (default: None, get from current image) hide_image_type: hide image type parameter (default: True) hide_image_dtype: hide image data type parameter (default: False) Returns: New image parameter dataset (or None if canceled) """ newparam = self.imagepanel.get_newparam_from_current(title=title) if shape is not None: newparam.width, newparam.height = shape newparam.hide_image_type = hide_image_type newparam.hide_image_dtype = hide_image_dtype if newparam.edit(self.main): return newparam return None
[docs] def is_registered(self): """Return True if plugin is registered""" return self._is_registered
[docs] def register(self, main: main.CDLMainWindow) -> None: """Register plugin""" if self._is_registered: return PluginRegistry.register_plugin(self) self._is_registered = True self.main = main self.proxy = LocalProxy(main) self.register_hooks()
[docs] def unregister(self): """Unregister plugin""" if not self._is_registered: return PluginRegistry.unregister_plugin(self) self._is_registered = False self.unregister_hooks() self.main = None self.proxy = None
[docs] def register_hooks(self): """Register plugin hooks"""
[docs] def unregister_hooks(self): """Unregister plugin hooks"""
[docs] @abc.abstractmethod def create_actions(self): """Create actions"""
[docs] def discover_plugins() -> list[type[PluginBase]]: """Discover plugins using naming convention Returns: List of discovered plugins (as classes) """ if Conf.main.plugins_enabled.get(): for path in [ Conf.main.plugins_path.get(), PLUGINS_DEFAULT_PATH, ] + OTHER_PLUGINS_PATHLIST: rpath = osp.realpath(path) if rpath not in sys.path: sys.path.append(rpath) return [ importlib.import_module(name) for _finder, name, _ispkg in pkgutil.iter_modules() if name.startswith(f"{MOD_NAME}_") ] return []
[docs] def get_available_plugins() -> list[PluginBase]: """Instantiate and get available plugins Returns: List of available plugins (as instances) """ # Note: this function is not used by DataLab itself, but it is used by the # test suite to get a list of available plugins discover_plugins() return [plugin_class() for plugin_class in PluginRegistry.get_plugin_classes()]