diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index e87b34955..6d11e06fb 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -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 @@ -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 @@ -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: @@ -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. diff --git a/datalab/tests/backbone/processor_registry_unit_test.py b/datalab/tests/backbone/processor_registry_unit_test.py index 97639c133..6efa72fd3 100644 --- a/datalab/tests/backbone/processor_registry_unit_test.py +++ b/datalab/tests/backbone/processor_registry_unit_test.py @@ -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 @@ -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) diff --git a/datalab/tests/features/plugins/plugins_app_test.py b/datalab/tests/features/plugins/plugins_app_test.py index 8d901ba23..37562483b 100644 --- a/datalab/tests/features/plugins/plugins_app_test.py +++ b/datalab/tests/features/plugins/plugins_app_test.py @@ -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 @@ -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( diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index 89b6eb4df..cd084d189 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -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 ---------------- @@ -721,6 +723,8 @@ To develop in your usual Python environment (e.g., with an IDE like `Spyder :`` 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 ``:`` 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"