# 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 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()]