Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions datalab/gui/processor/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
prepare_execution,
)
from sigima.proc.decorator import is_computation_function
from sigima.proc.image import Wrap1to1Func as ImageWrap1to1Func
from sigima.proc.signal import Wrap1to1Func as SignalWrap1to1Func
from sigima.tools.signal.interpolation import interpolate
from sigimax.adapters_plotpy import coordutils
from sigimax.widgets.warningerror import show_warning_error
Expand Down Expand Up @@ -902,13 +904,51 @@ class SourcePreparationTransaction(Generic[TypeObj]):
]


#: Sigima wrappers turning a NumPy function into a 1-to-1 computation function
_WRAPPED_1_TO_1_TYPES = (SignalWrap1to1Func, ImageWrap1to1Func)


def _check_feature_function(function: Callable, pattern: str) -> None:
"""Check that a function may be registered as a computing feature.

A feature runs either a Sigima computation function, decorated with
:func:`sigima.proc.decorator.computation_function`, or, for a 1-to-1 feature,
a NumPy function wrapped with ``Wrap1to1Func`` of :mod:`sigima.proc.signal`
or :mod:`sigima.proc.image`. Other callables are refused when they are
registered, rather than when processing objects in a separate process.

Args:
function: Function of the feature
pattern: Pattern of the feature

Raises:
ValueError: The function cannot run this feature
"""
if is_computation_function(function):
return
name = getattr(function, "__name__", repr(function))
if isinstance(function, _WRAPPED_1_TO_1_TYPES):
if pattern == "1_to_1":
return
raise ValueError(
f"'{name}' is wrapped with Wrap1to1Func, which only applies to "
f"1-to-1 features, not to {pattern} features."
)
raise ValueError(
f"'{name}' is not a valid computation function: decorate it with "
"sigima.proc.decorator.computation_function(), or, for a 1-to-1 feature, "
"wrap a NumPy function with Wrap1to1Func."
)


@dataclass
class ComputingFeature(Generic[TypeObj]):
"""Computing feature dataclass.

Args:
pattern: pattern
function: function
function: Sigima computation function or, for a 1-to-1 feature, NumPy
function wrapped with ``Wrap1to1Func`` (see :func:`_check_feature_function`)
paramclass: parameter class
title: title
icon_name: icon name
Expand Down Expand Up @@ -947,10 +987,8 @@ class ComputingFeature(Generic[TypeObj]):

def __post_init__(self):
"""Validate the function and resolve the stable feature identifier."""
if self.function is not None and not is_computation_function(self.function):
raise ValueError(
f"'{self.function.__name__}' is not a valid computation function."
)
if self.function is not None:
_check_feature_function(self.function, self.pattern)
if self.owner_plugin_id is not None and not self.owner_plugin_id.strip():
raise ValueError("ComputingFeature owner_plugin_id must not be empty.")
if self.feature_id is None:
Expand Down Expand Up @@ -3322,7 +3360,8 @@ def register_1_to_1(
function is returned.

Args:
function: function to register
function: Sigima computation function, or NumPy function wrapped with
``Wrap1to1Func`` (:mod:`sigima.proc.signal` or :mod:`sigima.proc.image`)
title: title of the function
paramclass: parameter class. Defaults to None.
icon_name: icon name. Defaults to None.
Expand Down
45 changes: 45 additions & 0 deletions datalab/tests/backbone/processor_registry_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from __future__ import annotations

import numpy as np
import pytest
import sigima.proc.image as sipi
import sigima.proc.signal as sips

from datalab.gui.processor.base import BaseProcessor, ComputingFeature
Expand Down Expand Up @@ -146,3 +148,46 @@ def test_implicit_plugin_feature_id_is_namespaced() -> None:

assert feature.feature_id == "org.example.plugin:derivative"
assert processor.get_feature("derivative") is feature


def positive_part(values: np.ndarray) -> np.ndarray:
"""NumPy function of a plugin, wrapped to process signals or images."""
return np.maximum(values, 0.0)


@pytest.mark.parametrize("module", [sips, sipi], ids=["signal", "image"])
def test_wrapped_numpy_function_is_a_1_to_1_feature(module) -> None:
"""Plugins may register a NumPy function wrapped with ``Wrap1to1Func``."""
processor = ProcessorRegistryHarness()
function = module.Wrap1to1Func(positive_part)
feature = ComputingFeature(
pattern="1_to_1",
function=function,
title="Positive part",
)

processor.add_feature(feature, owner="org.example.plugin")

assert feature.name == "positive_part"
assert feature.feature_id == "org.example.plugin:positive_part"
assert processor.get_feature(function) is feature


@pytest.mark.parametrize("pattern", ["1_to_0", "1_to_n", "n_to_1", "2_to_1"])
def test_wrapped_numpy_function_is_only_a_1_to_1_feature(pattern: str) -> None:
"""``Wrap1to1Func`` produces one object from one object only."""
with pytest.raises(ValueError, match="only applies to 1-to-1 features"):
ComputingFeature(
pattern=pattern,
function=sips.Wrap1to1Func(positive_part),
title="Positive part",
)


def test_plain_function_is_refused_with_accepted_forms() -> None:
"""Other callables are refused when registered, naming the accepted forms."""
with pytest.raises(ValueError, match="'positive_part' is not a valid") as error:
ComputingFeature(pattern="1_to_1", function=positive_part, title="Positive")

assert "computation_function()" in str(error.value)
assert "Wrap1to1Func" in str(error.value)
74 changes: 74 additions & 0 deletions datalab/tests/features/plugins/plugins_app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
import importlib.util
import os
import os.path as osp
import shutil
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch

import numpy as np
import pytest
from qtpy import QtWidgets as QW
from sigima.objects import create_image
from sigima.tests.data import create_paracetamol_signal

from datalab.config import Conf
Expand Down Expand Up @@ -814,6 +817,77 @@ def test_plugin_examples_import() -> None:
spec.loader.exec_module(module)


#: Shipped example plugins, by module, with the stable ID of their plugin class
#: (the image format example only declares a file format)
SHIPPED_EXAMPLES = {
"datalab_custom_func": "org.datalab.examples.custom-filters",
"datalab_example_dialogs": "org.datalab.examples.dialog-methods",
"datalab_example_empty": "org.datalab.examples.empty",
"datalab_example_imageformat": None,
"datalab_example_imageproc": "org.datalab.examples.extract-blobs",
"datalab_example_nested_menus": "org.datalab.examples.nested-menus",
"datalab_exemple_contour_plot": "org.datalab.examples.contour-plot",
}


def _run_custom_filter(win: DLMainWindow) -> np.ndarray:
"""Run the processing of the custom function example on a new image.

Returns:
Data of the processed image
"""
menus = [
item
for item in win.imagepanel.get_category_actions(ActionCategory.PLUGINS)
if isinstance(item, QW.QMenu) and item.title() == "My custom filters"
]
assert len(menus) == 1, "'My custom filters' menu not found"
menus[0].aboutToShow.emit()
assert [action.text() for action in menus[0].actions()] == [
"Weighted average denoise"
]
data = np.arange(64, dtype=float).reshape(8, 8)
win.imagepanel.add_object(create_image("Ramp", data))
count = len(win.imagepanel)
win.imagepanel.processor.run_feature(
"org.datalab.examples.custom-filters:weighted-average-denoise"
)
assert len(win.imagepanel) == count + 1
return win.imagepanel[len(win.imagepanel)].data


@enabled_plugins_context()
def test_shipped_plugin_examples_load_in_datalab() -> None:
"""All shipped examples load in DataLab, and their processing runs."""
examples_dir = Path(__file__).parents[4] / "plugins" / "examples"
assert {path.stem for path in examples_dir.glob("*.py")} == set(SHIPPED_EXAMPLES)
with temporary_plugin_dir(module_prefixes=tuple(SHIPPED_EXAMPLES)) as plugin_dir:
for module_name in SHIPPED_EXAMPLES:
shutil.copy(examples_dir / f"{module_name}.py", plugin_dir)
spec = importlib.util.spec_from_file_location(
"custom_func_reference", examples_dir / "datalab_custom_func.py"
)
reference = importlib.util.module_from_spec(spec)
spec.loader.exec_module(reference)
expected = reference.weighted_average_denoise(
np.arange(64, dtype=float).reshape(8, 8)
)

with datalab_test_app_context(console=False) as win:
for _reload in range(2):
failed = [
failed.name
for failed in PluginRegistry.get_failed_plugins()
if failed.name.split(".")[0] in SHIPPED_EXAMPLES
]
assert not failed, f"Example plugins failed to load: {failed}"
for plugin_id in filter(None, SHIPPED_EXAMPLES.values()):
assert PluginRegistry.get_plugin(plugin_id) is not None, plugin_id
assert np.allclose(_run_custom_filter(win), expected)
win.reload_plugins()
QW.QApplication.processEvents()


if __name__ == "__main__":
_launch_path = osp.join(osp.dirname(__file__), "launch_with_test_plugins.py")
_spec = importlib.util.spec_from_file_location(
Expand Down
4 changes: 4 additions & 0 deletions doc/features/advanced/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ image panels exist. Owned features are removed automatically when the plugin is
disabled, reloaded, or uninstalled. ``create_actions()`` may then reference the
registered feature by its stable ID.

A registered feature runs a Sigima computation function, decorated with :func:`sigima.proc.decorator.computation_function`. For a 1-to-1 feature without parameters, the simplest is to wrap a function processing NumPy arrays with :class:`sigima.proc.signal.Wrap1to1Func` (signal Y values) or :class:`sigima.proc.image.Wrap1to1Func` (image data), as in the processing plugin example (see :ref:`plugin_example_processing`). DataLab refuses other functions when they are registered.

Headless recipes
----------------

Expand Down Expand Up @@ -721,6 +723,8 @@ To develop in your usual Python environment (e.g., with an IDE like `Spyder <htt

Even if you’ve installed `datalab` in your environment, you cannot run the full DataLab application directly from an IDE. You must launch DataLab via the command line or using the installer-created shortcut to properly test your plugin.

.. _plugin_example_processing:

Example: processing plugin
--------------------------

Expand Down
3 changes: 3 additions & 0 deletions doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ msgstr "Ces aides simplifient le code des plugins et le maintiennent cohérent a
msgid "Processing plugins should override ``register_computations()`` and register each feature with a stable ``feature_id`` of the form ``<plugin_id>:<local_id>`` and ``owner_plugin_id=self.plugin_id``. DataLab calls this hook after the signal and image panels exist. Owned features are removed automatically when the plugin is disabled, reloaded, or uninstalled. ``create_actions()`` may then reference the registered feature by its stable ID."
msgstr "Les plugins de traitement doivent surcharger ``register_computations()`` et enregistrer chaque fonctionnalité avec un ``feature_id`` stable de la forme ``<plugin_id>:<local_id>`` et ``owner_plugin_id=self.plugin_id``. DataLab appelle cette méthode une fois les panneaux Signal et Image créés. Les fonctionnalités détenues par le plugin sont supprimées automatiquement lorsque celui-ci est désactivé, rechargé ou désinstallé. ``create_actions()`` peut ensuite faire référence à la fonctionnalité enregistrée par son identifiant stable."

msgid "A registered feature runs a Sigima computation function, decorated with :func:`sigima.proc.decorator.computation_function`. For a 1-to-1 feature without parameters, the simplest is to wrap a function processing NumPy arrays with :class:`sigima.proc.signal.Wrap1to1Func` (signal Y values) or :class:`sigima.proc.image.Wrap1to1Func` (image data), as in the processing plugin example (see :ref:`plugin_example_processing`). DataLab refuses other functions when they are registered."
msgstr "Une fonctionnalité enregistrée exécute une fonction de calcul Sigima, décorée par :func:`sigima.proc.decorator.computation_function`. Pour une fonctionnalité 1 vers 1 sans paramètre, le plus simple est d'envelopper une fonction traitant des tableaux NumPy avec :class:`sigima.proc.signal.Wrap1to1Func` (valeurs Y d'un signal) ou :class:`sigima.proc.image.Wrap1to1Func` (données d'une image), comme dans l'exemple de plugin de traitement (voir :ref:`plugin_example_processing`). DataLab refuse les autres fonctions dès leur enregistrement."

msgid "Headless recipes"
msgstr "Recettes sans interface graphique"

Expand Down
Loading