From fd1327384ae55028e9f18be0c51fb39df5372479 Mon Sep 17 00:00:00 2001 From: YooSunyoung Date: Thu, 8 Aug 2024 15:31:15 +0200 Subject: [PATCH 1/4] Switchable widget. --- src/ess/reduce/ui.py | 8 +++- src/ess/reduce/widgets/__init__.py | 22 ++++++++++ src/ess/reduce/widgets/_switchable_widget.py | 42 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/ess/reduce/widgets/_switchable_widget.py diff --git a/src/ess/reduce/ui.py b/src/ess/reduce/ui.py index 03706c95..6e4a696b 100644 --- a/src/ess/reduce/ui.py +++ b/src/ess/reduce/ui.py @@ -8,7 +8,7 @@ from IPython import display from ipywidgets import Layout, TwoByTwoLayout -from .widgets import create_parameter_widget +from .widgets import SwitchWidget, create_parameter_widget from .workflow import ( assign_parameter_values, get_parameters, @@ -120,6 +120,12 @@ def run_workflow(_: widgets.Button) -> None: values = { node: parameter_box.children[i].children[0].value for i, node in enumerate(registry.keys()) + if ( + not isinstance( + widget := parameter_box.children[i].children[0], SwitchWidget + ) + ) + or widget.enabled } workflow = assign_parameter_values(selected_workflow, values) diff --git a/src/ess/reduce/widgets/__init__.py b/src/ess/reduce/widgets/__init__.py index 224d45a4..8627c87a 100644 --- a/src/ess/reduce/widgets/__init__.py +++ b/src/ess/reduce/widgets/__init__.py @@ -22,6 +22,7 @@ from ._linspace_widget import LinspaceWidget from ._vector_widget import VectorWidget from ._bounds_widget import BoundsWidget +from ._switchable_widget import SwitchWidget class EssWidget(Protocol): @@ -36,6 +37,26 @@ class EssWidget(Protocol): def value(self) -> Any: ... +from collections.abc import Callable +from functools import wraps + + +def switchable_widget( + func: Callable[[Parameter], widgets.Widget], +) -> Callable[[Parameter], widgets.Widget]: + """Wrap a widget in a switchable widget.""" + + @wraps(func) + def wrapper(param: Parameter) -> widgets.Widget: + widget = func(param) + if param.switchable: + return SwitchWidget(widget, name=param.name) + return widget + + return wrapper + + +@switchable_widget @singledispatch def create_parameter_widget(param: Parameter) -> widgets.Widget: """Create a widget for a parameter depending on the ``param`` type. @@ -133,5 +154,6 @@ def vector_parameter_widget(param: VectorParameter): 'LinspaceWidget', 'EssWidget', 'VectorWidget', + 'SwitchWidget', 'create_parameter_widget', ] diff --git a/src/ess/reduce/widgets/_switchable_widget.py b/src/ess/reduce/widgets/_switchable_widget.py new file mode 100644 index 00000000..03665fcb --- /dev/null +++ b/src/ess/reduce/widgets/_switchable_widget.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) +from typing import Any + +from ipywidgets import Checkbox, HBox, Label, Stack, Widget + +from ._config import default_style + + +class SwitchWidget(HBox): + """Wrapper widget to handle switchable widgets. + + When you retrieve the value of this widget, + it will return the value of the wrapped widget. + It is expected not to be set in the workflow if ``enabled`` is False. + """ + + def __init__(self, wrapped: Widget, name: str = '') -> None: + super().__init__() + self.enable_box = Checkbox(description='Use Parameter', style=default_style) + self.wrapped = wrapped + wrapped_stack = Stack([Label(name, style=default_style), self.wrapped]) + wrapped_stack.selected_index = 0 + + def handle_checkbox(change) -> None: + wrapped_stack.selected_index = 1 if change.new else 0 + + self.enable_box.observe(handle_checkbox, names='value') + + self.children = [self.enable_box, wrapped_stack] + + @property + def enabled(self) -> bool: + return self.enable_box.value + + @enabled.setter + def enabled(self, value: bool) -> None: + self.enable_box.value = value + + @property + def value(self) -> Any: + return self.wrapped.value From 849054580b8b4ba90d553b9ec8dd02721ef11bc7 Mon Sep 17 00:00:00 2001 From: YooSunyoung Date: Thu, 8 Aug 2024 16:33:16 +0200 Subject: [PATCH 2/4] Extract switchable widget handling and add test. --- requirements/basetest.in | 1 + requirements/basetest.txt | 52 +++++++++++++++++++++++++++++++++++++-- src/ess/reduce/ui.py | 27 ++++++++++++-------- tests/widget_test.py | 39 +++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 tests/widget_test.py diff --git a/requirements/basetest.in b/requirements/basetest.in index dd8cd9a6..63ad8f91 100644 --- a/requirements/basetest.in +++ b/requirements/basetest.in @@ -3,3 +3,4 @@ pytest numpy +ipywidgets diff --git a/requirements/basetest.txt b/requirements/basetest.txt index ea4c2047..8c77f184 100644 --- a/requirements/basetest.txt +++ b/requirements/basetest.txt @@ -1,21 +1,69 @@ -# SHA1:43a90d54540ba9fdbb2c21a785a9d5d7483af53b +# SHA1:8c92c02ac11274f9d1f3b5b2fa648275b8e32140 # # This file is autogenerated by pip-compile-multi # To update, run: # # pip-compile-multi # +asttokens==2.4.1 + # via stack-data +comm==0.2.2 + # via ipywidgets +decorator==5.1.1 + # via ipython exceptiongroup==1.2.2 - # via pytest + # via + # ipython + # pytest +executing==2.0.1 + # via stack-data iniconfig==2.0.0 # via pytest +ipython==8.26.0 + # via ipywidgets +ipywidgets==8.1.3 + # via -r basetest.in +jedi==0.19.1 + # via ipython +jupyterlab-widgets==3.0.11 + # via ipywidgets +matplotlib-inline==0.1.7 + # via ipython numpy==2.0.1 # via -r basetest.in packaging==24.1 # via pytest +parso==0.8.4 + # via jedi +pexpect==4.9.0 + # via ipython pluggy==1.5.0 # via pytest +prompt-toolkit==3.0.47 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 + # via stack-data +pygments==2.18.0 + # via ipython pytest==8.3.2 # via -r basetest.in +six==1.16.0 + # via asttokens +stack-data==0.6.3 + # via ipython tomli==2.0.1 # via pytest +traitlets==5.14.3 + # via + # comm + # ipython + # ipywidgets + # matplotlib-inline +typing-extensions==4.12.2 + # via ipython +wcwidth==0.2.13 + # via prompt-toolkit +widgetsnbextension==4.0.11 + # via ipywidgets diff --git a/src/ess/reduce/ui.py b/src/ess/reduce/ui.py index 6e4a696b..befd93f4 100644 --- a/src/ess/reduce/ui.py +++ b/src/ess/reduce/ui.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -from collections.abc import Callable -from typing import cast +from collections.abc import Callable, Iterable +from typing import Any, cast import ipywidgets as widgets import sciline @@ -10,6 +10,7 @@ from .widgets import SwitchWidget, create_parameter_widget from .workflow import ( + Key, assign_parameter_values, get_parameters, get_possible_outputs, @@ -111,15 +112,12 @@ def reset_button_clicked(b): output = widgets.Output() -def run_workflow(_: widgets.Button) -> None: - workflow_constructor = cast(Callable[[], sciline.Pipeline], workflow_select.value) - selected_workflow = workflow_constructor() - outputs = possible_outputs_widget.value + typical_outputs_widget.value - registry = get_parameters(selected_workflow, outputs) - - values = { +def collect_values( + parameter_box: widgets.VBox, param_keys: Iterable[Key] +) -> dict[Key, Any]: + return { node: parameter_box.children[i].children[0].value - for i, node in enumerate(registry.keys()) + for i, node in enumerate(param_keys) if ( not isinstance( widget := parameter_box.children[i].children[0], SwitchWidget @@ -128,6 +126,15 @@ def run_workflow(_: widgets.Button) -> None: or widget.enabled } + +def run_workflow(_: widgets.Button) -> None: + workflow_constructor = cast(Callable[[], sciline.Pipeline], workflow_select.value) + selected_workflow = workflow_constructor() + outputs = possible_outputs_widget.value + typical_outputs_widget.value + registry = get_parameters(selected_workflow, outputs) + + values = collect_values(parameter_box, registry.keys()) + workflow = assign_parameter_values(selected_workflow, values) with output: diff --git a/tests/widget_test.py b/tests/widget_test.py new file mode 100644 index 00000000..e6ac07a0 --- /dev/null +++ b/tests/widget_test.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) +import pytest +from ess.reduce.parameter import Parameter +from ess.reduce.widgets import SwitchWidget, create_parameter_widget + + +@pytest.mark.filterwarnings( + 'ignore::DeprecationWarning' +) # Ignore deprecation warning from widget library +def test_switchable_widget_dispatch() -> None: + switchable_param = Parameter('a', 'a', 1, switchable=True) + assert isinstance(create_parameter_widget(switchable_param), SwitchWidget) + non_switchable_param = Parameter('b', 'b', 2, switchable=False) + assert not isinstance(create_parameter_widget(non_switchable_param), SwitchWidget) + + +@pytest.mark.filterwarnings( + 'ignore::DeprecationWarning' +) # Ignore deprecation warning from widget library +def test_collect_values_from_disabled_switchable_widget() -> None: + from ess.reduce.ui import collect_values + from ipywidgets import Box, Text, VBox + + enabled_switch_widget = SwitchWidget(Text('int'), name='int') + enabled_switch_widget.enabled = True + disabled_switch_widget = SwitchWidget(Text('float'), name='float') + disabled_switch_widget.enabled = False + non_switch_widget = Text('str') + test_box = VBox( + [ + Box([enabled_switch_widget]), + Box([disabled_switch_widget]), + Box([non_switch_widget]), + ] + ) + + values = collect_values(test_box, (int, float, str)) + assert values == {int: 'int', str: 'str'} From 3d21cd0d4c6250a1cd55090b0513f30c92ed7d92 Mon Sep 17 00:00:00 2001 From: YooSunyoung Date: Thu, 8 Aug 2024 21:43:41 +0200 Subject: [PATCH 3/4] Remove switchable widget checkbox description. --- src/ess/reduce/widgets/_switchable_widget.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ess/reduce/widgets/_switchable_widget.py b/src/ess/reduce/widgets/_switchable_widget.py index 03665fcb..10e37543 100644 --- a/src/ess/reduce/widgets/_switchable_widget.py +++ b/src/ess/reduce/widgets/_switchable_widget.py @@ -17,25 +17,30 @@ class SwitchWidget(HBox): def __init__(self, wrapped: Widget, name: str = '') -> None: super().__init__() - self.enable_box = Checkbox(description='Use Parameter', style=default_style) + self._enable_box = Checkbox(description='', style=default_style) + # The layout is not applied if they are set in the constructor + self._enable_box.layout.description_width = '0px' + self._enable_box.layout.width = 'auto' + self.wrapped = wrapped wrapped_stack = Stack([Label(name, style=default_style), self.wrapped]) + # We wanted to implement this by greying out the widget when disabled + # but ``disabled`` is not a common property of all widgets wrapped_stack.selected_index = 0 def handle_checkbox(change) -> None: wrapped_stack.selected_index = 1 if change.new else 0 - self.enable_box.observe(handle_checkbox, names='value') - - self.children = [self.enable_box, wrapped_stack] + self._enable_box.observe(handle_checkbox, names='value') + self.children = [self._enable_box, wrapped_stack] @property def enabled(self) -> bool: - return self.enable_box.value + return self._enable_box.value @enabled.setter def enabled(self, value: bool) -> None: - self.enable_box.value = value + self._enable_box.value = value @property def value(self) -> Any: From 67295da4751db98ec89ea7acc2a0a332a0739500 Mon Sep 17 00:00:00 2001 From: YooSunyoung Date: Fri, 9 Aug 2024 10:52:03 +0200 Subject: [PATCH 4/4] Update depcreated way of setting style to widget. --- src/ess/reduce/ui.py | 9 ++------- src/ess/reduce/widgets/_config.py | 6 +----- tests/widget_test.py | 7 ------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/ess/reduce/ui.py b/src/ess/reduce/ui.py index befd93f4..67c62318 100644 --- a/src/ess/reduce/ui.py +++ b/src/ess/reduce/ui.py @@ -8,7 +8,7 @@ from IPython import display from ipywidgets import Layout, TwoByTwoLayout -from .widgets import SwitchWidget, create_parameter_widget +from .widgets import SwitchWidget, create_parameter_widget, default_style from .workflow import ( Key, assign_parameter_values, @@ -18,11 +18,6 @@ workflow_registry, ) -_style = { - 'description_width': 'auto', - 'value_width': 'auto', - 'button_width': 'auto', -} workflow_select = widgets.Dropdown( options=[(workflow.__name__, workflow) for workflow in workflow_registry], description='Workflow:', @@ -35,7 +30,7 @@ possible_outputs_widget = widgets.SelectMultiple( description='Extended Outputs:', - style=_style, + style=default_style, layout=Layout(width='80%', height='150px'), ) diff --git a/src/ess/reduce/widgets/_config.py b/src/ess/reduce/widgets/_config.py index 4393c68b..e70e2d18 100644 --- a/src/ess/reduce/widgets/_config.py +++ b/src/ess/reduce/widgets/_config.py @@ -3,8 +3,4 @@ from ipywidgets import Layout default_layout = Layout(width='80%') -default_style = { - 'description_width': 'auto', - 'value_width': 'auto', - 'button_width': 'auto', -} +default_style = {'description_width': 'auto'} diff --git a/tests/widget_test.py b/tests/widget_test.py index e6ac07a0..b0e58b91 100644 --- a/tests/widget_test.py +++ b/tests/widget_test.py @@ -1,13 +1,9 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -import pytest from ess.reduce.parameter import Parameter from ess.reduce.widgets import SwitchWidget, create_parameter_widget -@pytest.mark.filterwarnings( - 'ignore::DeprecationWarning' -) # Ignore deprecation warning from widget library def test_switchable_widget_dispatch() -> None: switchable_param = Parameter('a', 'a', 1, switchable=True) assert isinstance(create_parameter_widget(switchable_param), SwitchWidget) @@ -15,9 +11,6 @@ def test_switchable_widget_dispatch() -> None: assert not isinstance(create_parameter_widget(non_switchable_param), SwitchWidget) -@pytest.mark.filterwarnings( - 'ignore::DeprecationWarning' -) # Ignore deprecation warning from widget library def test_collect_values_from_disabled_switchable_widget() -> None: from ess.reduce.ui import collect_values from ipywidgets import Box, Text, VBox