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
98 changes: 71 additions & 27 deletions datalab/gui/processor/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,15 @@ def name(self) -> str:
)
return self.function.__name__

@property
def feature_id(self) -> str:
"""Return the identifier persisted in processing metadata and History.

Built-in feature identifiers are computation function names, which must be
unique within a processor.
"""
return self.name

@property
def action_title(self) -> str:
"""Return the action title of the computing feature."""
Expand Down Expand Up @@ -1769,7 +1778,7 @@ def _compute_1_to_1_subroutine(

# Store processing metadata for interactive re-processing
pp = ProcessingParameters(
func_name=name,
func_name=self.get_feature_id(func),
pattern="1-to-1",
param=param,
source_uuid=get_uuid(obj),
Expand Down Expand Up @@ -1915,7 +1924,7 @@ def compute_1_to_1(

draft = copy.deepcopy(param)
preview_results = []
feature = self.computing_registry.get(func.__name__)
feature = self.computing_registry.get(func)
allowed = preview_enabled and (feature is None or feature.preview_enabled)
if not edit_processing_parameters(
draft,
Expand Down Expand Up @@ -1948,7 +1957,10 @@ def compute_1_to_1(
self.PARAM_DEFAULTS[type(param).__name__] = copy.deepcopy(param)
plugin_origin = self._get_plugin_origin_for(func)
pp = build_processing_parameters(
func.__name__, "1-to-1", param=param, plugin_origin=plugin_origin
self.get_feature_id(func),
"1-to-1",
param=param,
plugin_origin=plugin_origin,
)
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title or func.__name__,
Expand Down Expand Up @@ -2004,14 +2016,15 @@ def compute_multiple_1_to_1(
return
if len(funcs) != len(params):
raise ValueError("Number of functions must match number of parameters")
func_names = [self.get_feature_id(func) for func in funcs]
pp = build_processing_parameters(
funcs[0].__name__ if funcs else "", "multiple-1-to-1"
func_names[0] if func_names else "", "multiple-1-to-1"
)
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title or "compute_multiple_1_to_1",
pp,
panel_str=self.panel.PANEL_STR_ID,
func_names=[f.__name__ for f in funcs],
func_names=func_names,
params=params if any(p is not None for p in params) else None,
plugin_origin=(self._get_plugin_origin_for(funcs[0]) if funcs else None),
)
Expand Down Expand Up @@ -2052,7 +2065,7 @@ def compute_1_to_n(
group = gds.DataSetGroup(params, title=_("Parameters"))
if not group.edit(parent=self.mainwindow):
return
pp = build_processing_parameters(func.__name__, "1-to-n")
pp = build_processing_parameters(self.get_feature_id(func), "1-to-n")
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title or func.__name__,
pp,
Expand Down Expand Up @@ -2119,7 +2132,8 @@ def compute_1_to_0(
return None
current_obj = self.panel.objview.get_current_object()
title = func.__name__ if title is None else title
pp_history = build_processing_parameters(func.__name__, "1-to-0", param=param)
feature_id = self.get_feature_id(func)
pp_history = build_processing_parameters(feature_id, "1-to-0", param=param)
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title,
pp_history,
Expand Down Expand Up @@ -2160,7 +2174,7 @@ def persist_result(result=result, obj=obj) -> bool:
adapter = create_adapter(result)
adapter.add_to(obj, param)
pp = ProcessingParameters(
func_name=func.__name__,
func_name=feature_id,
pattern="1-to-0",
param=param,
source_uuid=get_uuid(obj),
Expand Down Expand Up @@ -2251,8 +2265,9 @@ def compute_n_to_1(
objmodel = self.panel.objmodel
pairwise = is_pairwise_mode() if pairwise is None else pairwise
name = func.__name__
feature_id = self.get_feature_id(func)

pp_history = build_processing_parameters(name, "n-to-1", param=param)
pp_history = build_processing_parameters(feature_id, "n-to-1", param=param)
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title or name,
pp_history,
Expand Down Expand Up @@ -2342,7 +2357,7 @@ def compute_n_to_1(

# Store lightweight processing metadata (non-interactive)
proc_params = ProcessingParameters(
func_name=name,
func_name=feature_id,
pattern="n-to-1",
param=param,
source_uuids=[get_uuid(obj) for obj in src_objs_pair],
Expand Down Expand Up @@ -2434,7 +2449,7 @@ def compute_n_to_1(

# Store lightweight processing metadata (non-interactive)
proc_params = ProcessingParameters(
func_name=name,
func_name=feature_id,
pattern="n-to-1",
param=param,
source_uuids=[get_uuid(obj) for obj in src_obj_list],
Expand Down Expand Up @@ -2521,6 +2536,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
objmodel = self.panel.objmodel
pairwise = is_pairwise_mode() if pairwise is None else pairwise
name = func.__name__
feature_id = self.get_feature_id(func)

if obj2 is None:
objs2 = []
Expand Down Expand Up @@ -2556,9 +2572,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
if objs2 is None:
return

pp_history = build_processing_parameters(
func.__name__, "2-to-1", param=param
)
pp_history = build_processing_parameters(feature_id, "2-to-1", param=param)
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title or func.__name__,
pp_history,
Expand Down Expand Up @@ -2637,7 +2651,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements

# Store lightweight processing metadata (non-interactive)
proc_params = ProcessingParameters(
func_name=name,
func_name=feature_id,
pattern="2-to-1",
param=param,
source_uuids=[
Expand Down Expand Up @@ -2673,9 +2687,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
return
obj2 = objs2[0]

pp_history = build_processing_parameters(
func.__name__, "2-to-1", param=param
)
pp_history = build_processing_parameters(feature_id, "2-to-1", param=param)
action = self.mainwindow.historypanel.add_compute_entry_from_pp(
title or func.__name__,
pp_history,
Expand Down Expand Up @@ -2768,7 +2780,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements

# Store lightweight processing metadata (non-interactive)
proc_params = ProcessingParameters(
func_name=name,
func_name=feature_id,
pattern="2-to-1",
param=param,
source_uuids=[
Expand Down Expand Up @@ -3009,6 +3021,19 @@ def _get_plugin_origin_for(self, func: Callable) -> dict[str, Any] | None:
return feature.plugin_origin
return _detect_plugin_origin(func)

def get_feature_id(self, func: Callable) -> str:
"""Return the identifier persisted for ``func``.

Args:
func: Computation function.

Returns:
Identifier of the registered feature, or the function name if ``func``
is not registered.
"""
feature = self.computing_registry.get(func)
return func.__name__ if feature is None else feature.feature_id

def get_feature(
self,
function_or_name: Callable | str,
Expand All @@ -3017,10 +3042,15 @@ def get_feature(
) -> ComputingFeature:
"""Get a computing feature by name or function.

When several features share the same identifier, built-in features are
preferred unless ``plugin_origin`` is given, in which case features from
the same plugin module are preferred.

Args:
function_or_name: Name of the feature or the function itself.
plugin_origin: Optional plugin origin descriptor used to enrich the
:class:`FeatureNotFoundError` raised when the feature is unknown.
function_or_name: Identifier of the feature or the function itself.
plugin_origin: Optional plugin origin descriptor used to select the
plugin feature and to enrich the :class:`FeatureNotFoundError` raised
when the feature is unknown.
paramclass_name: Optional name of the required parameter class, also
used to enrich the error message.

Expand All @@ -3032,12 +3062,26 @@ def get_feature(
exception subclasses :class:`ValueError` to preserve backward
compatibility with existing callers.
"""
try:
if isinstance(function_or_name, str):
candidates = [
feature
for feature in self.computing_registry.values()
if feature.feature_id == function_or_name
]
if plugin_origin is None:
preferred = [f for f in candidates if f.plugin_origin is None]
else:
module = plugin_origin.get("module")
preferred = [
f
for f in candidates
if f.plugin_origin is not None
and f.plugin_origin.get("module") == module
]
if preferred or candidates:
return (preferred or candidates)[0]
elif function_or_name in self.computing_registry:
return self.computing_registry[function_or_name]
except KeyError:
for _func, feature in self.computing_registry.items():
if feature.name == function_or_name:
return feature
raise FeatureNotFoundError(
str(function_or_name),
plugin_origin=plugin_origin,
Expand Down
5 changes: 0 additions & 5 deletions datalab/gui/processor/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,6 @@ def register_processing(self) -> None:
" • z' = a0 + a1*z + a2*z^2 + ..."
),
)
self.register_1_to_1(
sipi.transpose,
_("Swap X/Y axes"),
icon_name="swap_x_y.svg",
)
# Level adjustment
self.register_1_to_1(
sipi.normalize,
Expand Down
20 changes: 0 additions & 20 deletions datalab/gui/processor/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,26 +544,6 @@ def register_processing(self) -> None:
_("Hadamard variance"),
paramclass=sigima.params.AllanVarianceParam,
)
self.register_1_to_1(
sips.modified_allan_variance,
_("Modified Allan variance"),
paramclass=sigima.params.AllanVarianceParam,
)
self.register_1_to_1(
sips.hadamard_variance,
_("Hadamard variance"),
paramclass=sigima.params.AllanVarianceParam,
)
self.register_1_to_1(
sips.modified_allan_variance,
_("Modified Allan variance"),
paramclass=sigima.params.AllanVarianceParam,
)
self.register_1_to_1(
sips.hadamard_variance,
_("Hadamard variance"),
paramclass=sigima.params.AllanVarianceParam,
)
self.register_1_to_1(
sips.total_variance,
_("Total variance"),
Expand Down
97 changes: 97 additions & 0 deletions datalab/tests/features/common/feature_identity_unit_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""Computing feature identity unit tests."""

from __future__ import annotations

import dataclasses
from collections import Counter

import numpy as np
import sigima.params
import sigima.proc.signal as sips
from sigima.objects import SignalObj, create_signal
from sigima.proc.decorator import computation_function

from datalab.gui.processor.base import BaseProcessor, ComputingFeature
from datalab.tests import datalab_test_app_context

PLUGIN_ORIGIN = {
"plugin_class": "IdentityTestPlugin",
"module": "datalab_identity_test_plugin",
"directory": None,
"version": None,
}


@computation_function()
def normalize(src: SignalObj) -> SignalObj:
"""Plugin computation sharing a built-in feature identifier."""
return src.copy()


def test_builtin_feature_ids_are_unique(monkeypatch) -> None:
"""Built-in registrations never reuse an identifier within a panel."""
registrations: list[tuple[str, str]] = []
add_feature = BaseProcessor.add_feature

def record_feature(self: BaseProcessor, feature: ComputingFeature) -> None:
add_feature(self, feature)
if feature.plugin_origin is None:
registrations.append((self.panel.PANEL_STR_ID, feature.feature_id))

monkeypatch.setattr(BaseProcessor, "add_feature", record_feature)
with datalab_test_app_context(console=False):
pass
assert registrations
counts = Counter(registrations)
assert [key for key, count in counts.items() if count > 1] == []


def test_get_feature_prefers_builtin_or_matching_plugin() -> None:
"""Identifier lookup does not depend on registration order."""
with datalab_test_app_context(console=False) as win:
processor = win.signalpanel.processor
plugin_feature = ComputingFeature(
"1_to_1",
function=normalize,
title="Plugin normalization",
plugin_origin=dict(PLUGIN_ORIGIN),
)
processor.computing_registry = {
normalize: plugin_feature,
**processor.computing_registry,
}

assert processor.get_feature("normalize").function is sips.normalize
feature = processor.get_feature("normalize", plugin_origin=PLUGIN_ORIGIN)
assert feature is plugin_feature
assert processor.get_feature(normalize) is plugin_feature
assert processor.get_feature_id(normalize) == "normalize"


def test_compute_1_to_1_honours_registered_preview_flag(monkeypatch) -> None:
"""Direct 1-to-1 calls respect the registered preview capability."""
allowed_values: list[bool] = []

def cancel_edit(draft, function, sources, parent, allowed, results, **kwargs):
allowed_values.append(allowed)
return False

monkeypatch.setattr(
"datalab.widgets.processingpreview.edit_processing_parameters", cancel_edit
)
with datalab_test_app_context(console=False) as win:
panel = win.signalpanel
processor = panel.processor
feature = processor.get_feature(sips.moving_average)
processor.add_feature(dataclasses.replace(feature, preview_enabled=False))
source = create_signal("Source", np.arange(10.0), np.arange(10.0))
panel.add_object(source)
panel.objview.select_objects([source])
processor.compute_1_to_1(
sips.moving_average,
paramclass=sigima.params.MovingAverageParam,
edit=True,
)
assert allowed_values == [False]
1 change: 1 addition & 0 deletions datalab/tests/features/common/history_model_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def test_compute_n_to_1_uses_provided_history_title() -> None:
),
mainwindow=SimpleNamespace(historypanel=history_panel),
_get_plugin_origin_for=Mock(return_value=None),
get_feature_id=lambda func: func.__name__,
)

def average(_objects: list[object]) -> None:
Expand Down
6 changes: 6 additions & 0 deletions doc/release_notes/release_1.04.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@
* Existing PlotPy annotations remain readable and are converted only after an annotation edit is accepted; simply opening a workspace or cancelling the editor leaves its data unchanged
* Annotation identifiers, lock state, custom metadata and extension data are preserved across edits, while unknown third-party payloads are retained without modification
* DataLab now requires Sigima ≥ 1.3.0 and SigimaX ≥ 1.1.0

### 🛠️ Bug Fixes ###

**Plugins and History:**

* Replaying a History action or re-processing a result created by a plugin feature that shares its name with a built-in feature now runs the plugin feature instead of the built-in one
Loading