From 1b4a52c740a8faa7973c169af4af6654f1e09959 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:29:18 +0200 Subject: [PATCH 1/3] fix(plugins): register NumPy functions wrapped with Wrap1to1Func (#415) Since plugins register their processing with `register_1_to_1`, a NumPy function wrapped with Sigima's `Wrap1to1Func` was refused as "not a valid computation function", so the documented example `plugins/examples/datalab_custom_func.py` no longer loaded. Such a wrapper is a valid feature function: it is picklable, named after the wrapped function and returns the expected object, as Sigima documents it. 1-to-1 features now accept the `Wrap1to1Func` instances of `sigima.proc.signal` and `sigima.proc.image`, besides decorated computation functions. Other callables are still refused at registration, with a message naming the accepted forms, and so is a wrapper registered for another pattern. Assisted-by: Claude Opus 5.5 --- datalab/gui/processor/base.py | 51 ++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) 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. From 5cf064e5985338d33bf42e7fc2691fa97c83fdf5 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:29:35 +0200 Subject: [PATCH 2/3] test(plugins): load every shipped example plugin in DataLab (#415) Examples were only imported, so registering their processing could fail unnoticed. A new application test loads every plugin of `plugins/examples` in DataLab from a plugin directory, checks their stable IDs and the menu of the custom function example, and runs its processing on an image, also after reloading the plugins. It fails without the fix. Unit tests cover wrapped NumPy functions registered as signal and image 1-to-1 features, and their refusal for other patterns, as well as the refusal of plain functions. Assisted-by: Claude Opus 5.5 --- .../backbone/processor_registry_unit_test.py | 45 +++++++++++ .../features/plugins/plugins_app_test.py | 74 +++++++++++++++++++ 2 files changed, 119 insertions(+) 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( From 3d795e36b191bb6f71200d080dd8a3f4925f1382 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:29:37 +0200 Subject: [PATCH 3/3] docs(plugins): state which functions processing plugins may register (#415) A Sigima computation function, or, for a 1-to-1 feature without parameters, a NumPy function wrapped with `Wrap1to1Func`, as in the processing plugin example. With its French translation. Assisted-by: Claude Opus 5.5 --- doc/features/advanced/plugins.rst | 4 ++++ doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po | 3 +++ 2 files changed, 7 insertions(+) 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"