Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch/Optional widget fields setter/getter #148

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 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
106 changes: 85 additions & 21 deletions src/ess/reduce/widgets/_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
import warnings
from collections.abc import Iterable
from typing import Any, Protocol, runtime_checkable

from ipywidgets import Widget
Expand All @@ -13,18 +14,22 @@ def set_fields(self, new_values: dict[str, Any]) -> None: ...
def get_fields(self) -> dict[str, Any]: ...


def _warn_invalid_field(invalid_fields: Iterable[str]) -> None:
for field_name in invalid_fields:
warning_msg = f"Cannot set field '{field_name}'."
" The field does not exist in the widget."
"The field value will be ignored."
warnings.warn(warning_msg, UserWarning, stacklevel=2)


class WidgetWithFieldsMixin:
def set_fields(self, new_values: dict[str, Any]) -> None:
# Extract valid fields
new_field_names = set(new_values.keys())
valid_field_names = new_field_names & set(self.fields.keys())
# Warn for invalid fields
invalid_field_names = new_field_names - valid_field_names
for field_name in invalid_field_names:
warning_msg = f"Cannot set field '{field_name}'."
" The field does not exist in the widget."
"The field value will be ignored."
warnings.warn(warning_msg, UserWarning, stacklevel=1)
_warn_invalid_field(invalid_field_names)
# Set valid fields
for field_name in valid_field_names:
self.fields[field_name].value = new_values[field_name]
Expand All @@ -36,28 +41,87 @@ def get_fields(self) -> dict[str, Any]:
}


def _has_widget_value_setter(widget: Widget) -> bool:
widget_type = type(widget)
return (
widget_property := getattr(widget_type, 'value', None)
) is not None and getattr(widget_property, 'fset', None) is not None
def set_fields(widget: Widget, new_values: dict[str, Any]) -> None:
"""Set the fields of a widget with the given values.

Parameters
----------
widget:
The widget to set the fields. It should either be an instance of
``WidgetWithFieldsProtocol`` or have a value property setter.
new_values:
The new values to set for the fields.
i.e. ``{'field_name': field_value}``
If the widget does not have a ``set_fields/get_fields`` method,
(e.g. it is not an instance of ``WidgetWithFieldsProtocol``),
it will try to set the value of the widget directly.
The value of the widget should be available in the ``new_values`` dictionary
with the key 'value'.
i.e. ``{'value': widget_value}``

Raises
------
TypeError:
If ``new_values`` is not a dictionary.

"""
if not isinstance(new_values, dict):
raise TypeError(f"new_values must be a dictionary, got {type(new_values)}")

def set_fields(widget: Widget, new_values: Any) -> None:
if isinstance(widget, WidgetWithFieldsProtocol) and isinstance(new_values, dict):
widget.set_fields(new_values)
elif _has_widget_value_setter(widget):
widget.value = new_values
else:
warnings.warn(
f"Cannot set value or fields for widget of type {type(widget)}."
" The new_value(s) will be ignored.",
UserWarning,
stacklevel=1,
)
try:
# Use value property setter if ``new_values`` contains 'value'
if 'value' in new_values:
widget.value = new_values['value']
# Warn if there is any other fields in new_values
_warn_invalid_field(set(new_values.keys()) - {'value'})
except AttributeError as error:
# Checking if the widget value property has a setter in advance, i.e.
# ```python
# (widget_property := getattr(type(widget), 'value', None)) is not None
# and getattr(widget_property, 'fset', None) is not None
# ```
# does not work with a class that inherits Traitlets class.
# In those classes, even if a property has a setter,
# it may not have `fset` attribute.
# It is not really feasible to check all possible cases of value setters.
# Instead, we try setting the value and catch the AttributeError.
# to determine if the widget has a value setter.
warnings.warn(
f"Cannot set value for widget of type {type(widget)}."
" The new_value(s) will be ignored."
f" Setting value caused the following error: {error}",
UserWarning,
stacklevel=1,
)


def get_fields(widget: Widget) -> Any:
def get_fields(widget: Widget) -> dict[str, Any] | None:
"""Get the fields of a widget.

If the widget is an instance of ``WidgetWithFieldsProtocol``,
it will return the fields of the widget.
i.e. ``{'field_name': field_value}``
Otherwise, it will try to get the value of the widget and return a dictionary
with the key 'value' and the value of the widget.
i.e. ``{'value': widget_value}``

Parameters
----------
widget:
The widget to get the fields. It should either be an instance of
``WidgetWithFieldsProtocol`` or have a value property.

"""
if isinstance(widget, WidgetWithFieldsProtocol):
return widget.get_fields()
return widget.value
try:
return {'value': widget.value}
except AttributeError:
warnings.warn(
f"Cannot get value or fields for widget of type {type(widget)}.",
UserWarning,
stacklevel=1,
)
25 changes: 25 additions & 0 deletions src/ess/reduce/widgets/_optional_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ipywidgets import HTML, HBox, Layout, RadioButtons, Widget

from ._base import get_fields, set_fields
from ._config import default_style


Expand Down Expand Up @@ -64,3 +65,27 @@ def value(self, value: Any) -> None:
else:
self._option_box.value = self.name
self.wrapped.value = value

def set_fields(self, new_values: dict[str, Any]) -> None:
new_values = dict(new_values)
# Set the value of the option box
opted_out_flag = (
new_values.pop( # We assume ``opted-out`` is not used in any wrapped widget
'opted-out',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is opted-out a safe enough name? I seems rather safe, but should we make it even less likely to clash? e.g. essreduce-opted-out or something like that? (in the past with plopp we often added the name of the library in such attributes).

I'm guessing the opted-out is never seen by users, so the cleanliness of the name doesn't really matter?

self._option_box.value is None,
)
)
if not isinstance(opted_out_flag, bool):
raise ValueError(
f"Invalid value for 'opted-out' field: {opted_out_flag}."
" The value should be a boolean."
)
self._option_box.value = None if opted_out_flag else self.name
# Set the value of the wrapped widget
set_fields(self.wrapped, new_values)

def get_fields(self) -> dict[str, Any] | None:
return {
**(get_fields(self.wrapped) or {}),
'opted-out': self._option_box.value is None,
}
15 changes: 15 additions & 0 deletions src/ess/reduce/widgets/_switchable_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ipywidgets import Checkbox, HBox, Label, Stack, Widget

from ._base import get_fields, set_fields
from ._config import default_style


Expand Down Expand Up @@ -49,3 +50,17 @@ def value(self) -> Any:
@value.setter
def value(self, value: Any) -> None:
self.wrapped.value = value

def set_fields(self, new_values: dict[str, Any]) -> None:
# Retrieve and set the enabled flag first
new_values = dict(new_values)
enabled_flag = new_values.pop('enabled', self.enabled)
if not isinstance(enabled_flag, bool):
raise ValueError(f"`enabled` must be a boolean, got {enabled_flag}")
self.enabled = enabled_flag
# Set the rest of the fields
set_fields(self.wrapped, new_values)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we only fill the rest of the fields if the widget is enabled, or do we fill in anyway?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should restore the state even if they are disabled. So that user can easily switch it on and see the restored status.


def get_fields(self) -> dict[str, Any]:
wrapped_fields = get_fields(self.wrapped)
return {'enabled': self.enabled, **(wrapped_fields or {})}
116 changes: 115 additions & 1 deletion tests/widget_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@
import scipp as sc
from ipywidgets import FloatText, IntText

from ess.reduce.parameter import BinEdgesParameter, Parameter, parameter_registry
from ess.reduce.parameter import (
BinEdgesParameter,
Parameter,
Vector3dParameter,
parameter_registry,
)
from ess.reduce.ui import ResultBox, WorkflowWidget, workflow_widget
from ess.reduce.widgets import OptionalWidget, SwitchWidget, create_parameter_widget
from ess.reduce.widgets._base import WidgetWithFieldsProtocol, get_fields, set_fields
from ess.reduce.workflow import register_workflow, workflow_registry

SwitchableInt = NewType('SwitchableInt', int)
Expand Down Expand Up @@ -167,6 +173,44 @@ def test_switchable_optional_parameter_switchable_first() -> None:
assert isinstance(dummy_widget.wrapped, OptionalWidget)


def test_optional_widget_set_value_get_fields() -> None:
optional_param = IntParam('a', 'a', 1, optional=True)
optional_widget = create_parameter_widget(optional_param)
assert isinstance(optional_widget, WidgetWithFieldsProtocol)
assert isinstance(optional_widget, OptionalWidget)
# Check initial state
assert optional_widget._option_box.value is None
assert get_fields(optional_widget) == {'opted-out': True, 'value': 1}
# Update the value of the wrapped widget and check the fields
set_fields(optional_widget, {'value': 2})
assert optional_widget.value is None # Opted-out is not changed
assert get_fields(optional_widget) == {'opted-out': True, 'value': 2}
optional_widget.value = 3
assert get_fields(optional_widget) == {'opted-out': False, 'value': 3}


def test_optional_widget_set_fields_get_fields() -> None:
optional_param = Vector3dParameter(
'a', 'a', sc.vector([1, 2, 3], unit='m'), optional=True
)
optional_widget = create_parameter_widget(optional_param)
assert isinstance(optional_widget, WidgetWithFieldsProtocol)
assert isinstance(optional_widget, OptionalWidget)
# Check initial state
assert optional_widget._option_box.value is None
expected = {'opted-out': True, 'x': 1, 'y': 2, 'z': 3, 'unit': 'm'}
assert optional_widget.get_fields() == expected
# Update the value of the wrapped widget
optional_widget.set_fields({'opted-out': True, 'x': 4, 'y': 5, 'z': 6, 'unit': 'm'})
assert optional_widget.value is None # Opted-out is not changed
optional_widget.set_fields({'opted-out': False})
assert optional_widget.value == sc.vector([4, 5, 6], unit='m')
# Check the fields and the option box value
expected = {'opted-out': False, 'x': 4, 'y': 5, 'z': 6, 'unit': 'm'}
assert optional_widget.get_fields() == expected
assert optional_widget._option_box.value == optional_param.name


def test_optional_widget_dispatch() -> None:
optional_param = Parameter('a', 'a', 1, optional=True)
assert isinstance(create_parameter_widget(optional_param), OptionalWidget)
Expand Down Expand Up @@ -423,3 +467,73 @@ def run_workflow():

ResultBox(run_workflow).run_button.click()
assert was_called


def test_switchable_widget_set_values() -> None:
param = IntParam('a', 'a', 1, switchable=True)
widget = create_parameter_widget(param)
assert isinstance(widget, SwitchWidget)
assert not widget.enabled
widget.set_fields({'enabled': True})
assert widget.enabled
widget.set_fields({'enabled': False})
assert not widget.enabled
widget.set_fields({'enabled': True, 'value': 2})
assert widget.enabled
assert widget.value == 2
widget.set_fields({'enabled': False, 'value': 3})
assert not widget.enabled
assert widget.value == 3
widget.set_fields({'value': 4})
assert not widget.enabled
assert widget.value == 4


def test_switchable_widget_get_fields_only_value() -> None:
param = IntParam('a', 'a', 1, switchable=True)
widget = create_parameter_widget(param)
assert isinstance(widget, SwitchWidget)
assert widget.get_fields() == {'enabled': False, 'value': 1}
widget.enabled = True
assert widget.get_fields() == {'enabled': True, 'value': 1}
widget.value = 2
assert widget.get_fields() == {'enabled': True, 'value': 2}
widget.enabled = False
assert widget.get_fields() == {'enabled': False, 'value': 2}


def test_switchable_widget_set_fields() -> None:
param = Vector3dParameter('a', 'a', sc.vector([1, 2, 3], unit='m'), switchable=True)
widget = create_parameter_widget(param)
assert isinstance(widget, SwitchWidget)
assert not widget.enabled
assert widget.value == sc.vector([1, 2, 3], unit='m')
widget.set_fields({'enabled': True, 'x': 4, 'y': 5, 'z': 6, 'unit': 'm'})
assert widget.enabled
assert widget.value == sc.vector([4, 5, 6], unit='m')
widget.set_fields({'x': 7, 'y': 8})
assert widget.enabled
assert widget.value == sc.vector([7, 8, 6], unit='m')


def test_switchable_widget_get_fields_sub_fields() -> None:
param = Vector3dParameter('a', 'a', sc.vector([1, 2, 3], unit='m'), switchable=True)
widget = create_parameter_widget(param)
assert isinstance(widget, SwitchWidget)
assert widget.get_fields() == {
'enabled': False,
'x': 1,
'y': 2,
'z': 3,
'unit': 'm',
}
widget.enabled = True
assert widget.get_fields() == {'enabled': True, 'x': 1, 'y': 2, 'z': 3, 'unit': 'm'}
widget.set_fields({'enabled': False, 'x': 4, 'y': 5, 'unit': 'mm'})
assert widget.get_fields() == {
'enabled': False,
'x': 4,
'y': 5,
'z': 3,
'unit': 'mm',
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably a stupid question but do we really need the switchable and optional widgets?
I found their usability confusing, and their behaviour sometimes inpredictable (e.g. in the Loki workflow, when toggling a switchable widget on (direct beam filename), sometimes it is then set to a default value, and sometimes it has no value set)

Can we do without? It would simplify the code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the optional widget is for setting None
and switchable is for setting it as a parameter, which effectively prune a graph from the workflow.

The usability indeed needs improvement, so we have a issue about that #74

But I don't think we can't simply remove them to support those use cases I mentioned at the beginning.

If you want to remove them from the specific workflow, you can just set optional or switchable from the parameter.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I still don't really understand the difference between optional and switchable. They seem very similar: if enabled, use the value?
Can't we manage with only one of the two?
(sorry for being slow here)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nvaytet No worries, we should have had a better documentation from the first place...
I'll first write down some description here and update the documentation.

Switchable widget

is about setting values as parameters of the workflow (sciline.Pipeline object )

  • if enabled, it set the value as the parameter before it computes things
    it could mean it set an intermediate result and prune the graph.
  • if disabled it is ignored and not seen by the workflow object.

Optional widget

is about setting None or a meaningful value.
Some of providers are setting a local variable if an argument is None for example... https://github.com/scipp/esssans/blob/05b6521213150ff89caa75dac00406436607df02/src/ess/sans/normalization.py#L268-L271

So in any cases Optional widget value will be set as a parameter when compute is requested.
We needed a radio button or sth similar to it because the actual meaningful value and the None can't be represented by a same widget.