diff --git a/doc/how_to/components/index.md b/doc/how_to/components/index.md index fee7f1452e..ec8723c3bb 100644 --- a/doc/how_to/components/index.md +++ b/doc/how_to/components/index.md @@ -26,6 +26,13 @@ How to access Pane Type. How to access and set widget values. ::: +:::{grid-item-card} {octicon}`telescope;2.5em;sd-mr-1 sd-animate-grow50` Construct Widget from Data +:link: widget_from_values +:link-type: doc + +How to automatically infer widget parameters from data values. +::: + :::{grid-item-card} {octicon}`file-diff;2.5em;sd-mr-1 sd-animate-grow50` Add or Remove Components :link: add_remove :link-type: doc @@ -42,5 +49,6 @@ How to add or remove components from a panel. construct_panes pane_type widget_values +widget_from_values add_remove ``` diff --git a/doc/how_to/components/widget_from_values.md b/doc/how_to/components/widget_from_values.md new file mode 100644 index 0000000000..8d55d73d65 --- /dev/null +++ b/doc/how_to/components/widget_from_values.md @@ -0,0 +1,35 @@ +# Construct Widgets from Data + +This guide discusses how to automatically generate widget from data. + +--- + +When working with data, be it in the form of lists, arrays or DataFrames it is common to want to filter that data. Manually computing the `start` and `end` values of a slider or the unique values of a dropdown can be an annoyance so widgets have a `classmethod` called `from_values` to help with this. + +```{pyodide} +import pandas as pd +import panel as pn +pn.extension() # for notebook + +df = pd.read_csv("https://datasets.holoviz.org/penguins/v1/penguins.csv") + +species = pn.widgets.MultiSelect.from_values(df.species) + +species +``` + +As we can see the special constructor automatically inferred both the `option` and the `name` for the widget. + +Similarly we can also use this to infer the values of a numeric column: + +```{pyodide} +body_mass = pn.widgets.RangeSlider.from_values(df.body_mass_g) + +body_mass +``` + +--- + +## Related Resources + +- Learn about building interactive data pipelines [How-To > Interactivity -> ](../how_to/interactivity/hvplot_interactive.md). diff --git a/doc/how_to/interactivity/index.md b/doc/how_to/interactivity/index.md index 1f77ef4c36..4bbccda510 100644 --- a/doc/how_to/interactivity/index.md +++ b/doc/how_to/interactivity/index.md @@ -32,7 +32,7 @@ Discover how to bind parameters, widgets, and bound functions to components. :link: hvplot_interactive :link-type: doc -How to use `hvplot.interactive` with widgets to make your data workflows interactive +How to use `param.rx` with widgets to make your data workflows interactive ::: :::: diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index e04cdee9d6..126c34051b 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -277,6 +277,31 @@ def dataframe(): }, index=[1, 2, 3], columns=['int', 'float', 'str']) +@pytest.fixture +def df_mixed(): + df = pd.DataFrame({ + 'int': [1, 2, 3, 4], + 'float': [3.14, 6.28, 9.42, -2.45], + 'str': ['A', 'B', 'C', 'D'], + 'bool': [True, True, True, False], + 'date': [dt.date(2019, 1, 1), dt.date(2020, 1, 1), dt.date(2020, 1, 10), dt.date(2019, 1, 10)], + 'datetime': [dt.datetime(2019, 1, 1, 10), dt.datetime(2020, 1, 1, 12), dt.datetime(2020, 1, 10, 13), dt.datetime(2020, 1, 15, 13)] + }, index=['idx0', 'idx1', 'idx2', 'idx3']) + return df + + +@pytest.fixture +def df_multiindex(df_mixed): + df_mi = df_mixed.copy() + df_mi.index = pd.MultiIndex.from_tuples([ + ('group0', 'subgroup0'), + ('group0', 'subgroup1'), + ('group1', 'subgroup0'), + ('group1', 'subgroup1'), + ], names=['groups', 'subgroups']) + return df_mi + + @pytest.fixture def hv_bokeh(): import holoviews as hv @@ -544,18 +569,6 @@ def eh(exception): config.exception_handler = old_eh -@pytest.fixture -def df_mixed(): - df = pd.DataFrame({ - 'int': [1, 2, 3, 4], - 'float': [3.14, 6.28, 9.42, -2.45], - 'str': ['A', 'B', 'C', 'D'], - 'bool': [True, True, True, False], - 'date': [dt.date(2019, 1, 1), dt.date(2020, 1, 1), dt.date(2020, 1, 10), dt.date(2019, 1, 10)], - 'datetime': [dt.datetime(2019, 1, 1, 10), dt.datetime(2020, 1, 1, 12), dt.datetime(2020, 1, 10, 13), dt.datetime(2020, 1, 15, 13)] - }, index=['idx0', 'idx1', 'idx2', 'idx3']) - return df - @pytest.fixture def df_strings(): descr = [ diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 484f8433d7..869aaeac1a 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -71,18 +71,6 @@ def df_mixed_as_string(): """ -@pytest.fixture -def df_multiindex(df_mixed): - df_mi = df_mixed.copy() - df_mi.index = pd.MultiIndex.from_tuples([ - ('group0', 'subgroup0'), - ('group0', 'subgroup1'), - ('group1', 'subgroup0'), - ('group1', 'subgroup1'), - ], names=['groups', 'subgroups']) - return df_mi - - def count_per_page(count: int, page_size: int): """ >>> count_per_page(12, 7) diff --git a/panel/tests/widgets/test_select.py b/panel/tests/widgets/test_select.py index 51e78cb5e2..db19c2730f 100644 --- a/panel/tests/widgets/test_select.py +++ b/panel/tests/widgets/test_select.py @@ -1,4 +1,5 @@ import numpy as np +import pandas as pd import pytest from panel.layout import GridBox, Row @@ -39,6 +40,32 @@ def test_select_text_option_with_equality(widget): select.value = 'DEF' assert select.value == 'DEF' +def test_select_from_list(document, comm): + select = Select.from_values(['A', 'B', 'A', 'B', 'C']) + + assert select.options == ['A', 'B', 'C'] + assert select.value == 'A' + +def test_select_from_array(document, comm): + select = Select.from_values(np.array(['A', 'B', 'A', 'B', 'C'])) + + assert select.options == ['A', 'B', 'C'] + assert select.value == 'A' + +def test_select_from_index(document, comm): + select = Select.from_values(pd.Index(['A', 'B', 'A', 'B', 'C'], name='index')) + + assert select.options == ['A', 'B', 'C'] + assert select.value == 'A' + assert select.name == 'index' + +def test_select_from_series(document, comm): + select = Select.from_values(pd.Series(['A', 'B', 'A', 'B', 'C'], name='Series')) + + assert select.options == ['A', 'B', 'C'] + assert select.value == 'A' + assert select.name == 'Series' + def test_select(document, comm): opts = {'A': 'a', '1': 1} select = Select(options=opts, value=opts['1'], name='Select') @@ -275,6 +302,31 @@ def test_nested_select_defaults(document, comm): assert select._max_depth == 3 +def test_nested_select_from_multi_index(df_multiindex): + select = NestedSelect.from_values(df_multiindex.index) + + assert select.options == { + 'group0': ['subgroup0', 'subgroup1'], + 'group1': ['subgroup0', 'subgroup1'], + } + assert select.value == {'groups': 'group0', 'subgroups': 'subgroup0'} + assert select._max_depth == 2 + assert select.levels == ['groups', 'subgroups'] + +def test_nested_select_from_index(): + select = NestedSelect.from_values(pd.Index(['A', 'B', 'A', 'B', 'C'], name='index')) + + assert select.options == ['A', 'B', 'C'] + assert select.value == {'index': 'A'} + assert select._max_depth == 1 + +def test_nested_select_from_series(): + select = NestedSelect.from_values(pd.Series(['A', 'B', 'A', 'B', 'C'], name='Series')) + + assert select.options == ['A', 'B', 'C'] + assert select.value == {'Series': 'A'} + assert select._max_depth == 1 + def test_nested_select_init_value(document, comm): options = { "Andrew": { @@ -856,6 +908,32 @@ def test_select_disabled_options_set_value_and_disabled_options(options, size, d assert widget.disabled_options == [10] +def test_multi_select_from_list(): + select = MultiSelect.from_values(['A', 'B', 'A', 'B', 'C']) + + assert select.options == ['A', 'B', 'C'] + assert select.value == [] + +def test_multi_select_from_array(): + select = MultiSelect.from_values(np.array(['A', 'B', 'A', 'B', 'C'])) + + assert select.options == ['A', 'B', 'C'] + assert select.value == [] + +def test_multi_select_from_index(): + select = MultiSelect.from_values(pd.Index(['A', 'B', 'A', 'B', 'C'], name='index')) + + assert select.options == ['A', 'B', 'C'] + assert select.value == [] + assert select.name == 'index' + +def test_multi_select_from_series(document, comm): + select = MultiSelect.from_values(pd.Series(['A', 'B', 'A', 'B', 'C'], name='Series')) + + assert select.options == ['A', 'B', 'C'] + assert select.value == [] + assert select.name == 'Series' + def test_multi_select(document, comm): select = MultiSelect(options={'A': 'A', '1': 1, 'C': object}, value=[object, 1], name='Select') diff --git a/panel/tests/widgets/test_slider.py b/panel/tests/widgets/test_slider.py index 08c58222cc..be3f78a29a 100644 --- a/panel/tests/widgets/test_slider.py +++ b/panel/tests/widgets/test_slider.py @@ -1,6 +1,7 @@ from datetime import date, datetime import numpy as np +import pandas as pd import pytest from bokeh.models import ( @@ -15,6 +16,28 @@ ) +def test_float_slider_from_list(): + slider = FloatSlider.from_values([1.1, 2.2]) + + assert slider.start == 1.1 + assert slider.end == 2.2 + assert slider.value == 1.1 + +def test_float_slider_from_array(): + slider = FloatSlider.from_values(np.array([1.1, 2.2])) + + assert slider.start == 1.1 + assert slider.end == 2.2 + assert slider.value == 1.1 + +def test_float_slider_from_series(): + slider = FloatSlider.from_values(pd.Series([1.1, 2.2], name='Series')) + + assert slider.start == 1.1 + assert slider.end == 2.2 + assert slider.value == 1.1 + assert slider.name == 'Series' + def test_float_slider(document, comm): slider = FloatSlider(start=0.1, end=0.5, value=0.4, name='Slider') @@ -84,6 +107,29 @@ def test_int_slider(document, comm): assert widget.value == 2 + +def test_range_slider_from_list(): + slider = RangeSlider.from_values([1.1, 2.2]) + + assert slider.start == 1.1 + assert slider.end == 2.2 + assert slider.value == (1.1, 2.2) + +def test_range_slider_from_array(): + slider = RangeSlider.from_values(np.array([1.1, 2.2])) + + assert slider.start == 1.1 + assert slider.end == 2.2 + assert slider.value == (1.1, 2.2) + +def test_range_slider_from_series(): + slider = RangeSlider.from_values(pd.Series([1.1, 2.2], name='Series')) + + assert slider.start == 1.1 + assert slider.end == 2.2 + assert slider.value == (1.1, 2.2) + assert slider.name == 'Series' + def test_range_slider(document, comm): slider = RangeSlider(start=0., end=3, value=(0, 3), name='Slider') diff --git a/panel/util/__init__.py b/panel/util/__init__.py index bcb728b6a0..326df8b074 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -508,6 +508,16 @@ def safe_next(): break yield value +def unique_iterator(seq): + """ + Returns an iterator containing all non-duplicate elements + in the input sequence. + """ + seen = set() + for item in seq: + if item not in seen: + seen.add(item) + yield item def prefix_length(a: str, b: str) -> int: """ diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 1a6867f61c..53beeabc54 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -12,6 +12,7 @@ TYPE_CHECKING, Any, ClassVar, TypeVar, ) +import numpy as np import param # type: ignore from bokeh.models import ImportedStyleSheet, Tooltip @@ -21,6 +22,7 @@ from .._param import Margin from ..layout.base import Row from ..reactive import Reactive +from ..util import unique_iterator from ..viewable import Layoutable, Viewable if TYPE_CHECKING: @@ -69,6 +71,45 @@ def from_param(cls: type[T], parameter: param.Parameter, **params) -> T: ) return layout[0] + @classmethod + def _infer_params(cls, values, **params): + if 'name' not in params and getattr(values, 'name', None): + params['name'] = values.name + if 'start' in cls.param and 'start' not in params: + params['start'] = np.nanmin(values) + if 'end' in cls.param and 'end' not in params: + params['end'] = np.nanmax(values) + if 'options' in cls.param and 'options' not in params: + if isinstance(values, dict): + params['options'] = values + else: + params['options'] = list(unique_iterator(values)) + if 'value' not in params: + p = cls.param['value'] + if isinstance(p, param.Tuple): + params['value'] = (params['start'], params['end']) + elif 'start' in params: + params['value'] = params['start'] + elif ('options' in params and not isinstance(p, (param.List, param.ListSelector)) + and not getattr(cls, '_allows_none', False)): + params['value'] = params['options'][0] + return params + + @classmethod + def from_values(cls, values, **params): + """ + Creates an instance of this Widget where the parameters are + inferred from the data. + + Arguments + --------- + values: Iterable + The values to infer the parameters from. + params: dict + Additional parameters to pass to the widget. + """ + return cls(**cls._infer_params(values, **params)) + @property def rx(self): return self.param.value.rx diff --git a/panel/widgets/select.py b/panel/widgets/select.py index eaae7dc9dd..ad99e6cbc5 100644 --- a/panel/widgets/select.py +++ b/panel/widgets/select.py @@ -6,6 +6,7 @@ import itertools import re +import sys from collections.abc import Awaitable, Callable, Mapping from functools import partial @@ -30,7 +31,9 @@ CustomMultiSelect as _BkMultiSelect, CustomSelect, RadioButtonGroup as _BkRadioButtonGroup, SingleSelect as _BkSingleSelect, ) -from ..util import PARAM_NAME_PATTERN, indexOf, isIn +from ..util import ( + PARAM_NAME_PATTERN, indexOf, isIn, unique_iterator, +) from ._mixin import TooltipMixin from .base import CompositeWidget, Widget from .button import Button, _ButtonBase @@ -351,16 +354,8 @@ class NestedSelect(CompositeWidget): ... ) """ - value = param.Dict(doc=""" - The value from all the Select widgets; the keys are the levels names. - If no levels names are specified, the keys are the levels indices.""") - - options = param.ClassSelector(class_=(dict, FunctionType), doc=""" - The options to select from. The options may be nested dictionaries, lists, - or callables that return those types. If callables are used, the callables - must accept `level` and `value` keyword arguments, where `level` is the - level that updated and `value` is a dictionary of the current values, containing keys - up to the level that was updated.""") + disabled = param.Boolean(default=False, doc=""" + Whether the widget is disabled.""") layout = param.Parameter(default=Column, doc=""" The layout type of the widgets. If a dictionary, a "type" key can be provided, @@ -374,8 +369,16 @@ class NestedSelect(CompositeWidget): is used as the type of widget, and any corresponding widget keyword arguments. Must be specified if options is callable.""") - disabled = param.Boolean(default=False, doc=""" - Whether the widget is disabled.""") + options = param.ClassSelector(class_=(list, dict, FunctionType), doc=""" + The options to select from. The options may be nested dictionaries, lists, + or callables that return those types. If callables are used, the callables + must accept `level` and `value` keyword arguments, where `level` is the + level that updated and `value` is a dictionary of the current values, containing keys + up to the level that was updated.""") + + value = param.Dict(doc=""" + The value from all the Select widgets; the keys are the levels names. + If no levels names are specified, the keys are the levels indices.""") _widgets = param.List(doc="The nested select widgets.") @@ -384,6 +387,38 @@ class NestedSelect(CompositeWidget): _levels = param.List(doc=""" The internal rep of levels to prevent overwriting user provided levels.""") + @classmethod + def _infer_params(cls, values, **params): + if 'pandas' in sys.modules and isinstance(values, sys.modules['pandas'].MultiIndex): + params['options'] = options = {} + params['levels'] = levels = list(values.names) + depth = len(values.names) + value = {} + for vals in values.to_list(): + current = options + for i, (l, v) in enumerate(zip(levels, vals)): + if 'value' not in params: + value[l] = v + if i == (depth-1): + if v not in current: + current.append(v) + continue + elif v not in current: + container = [] if i == (depth-2) else {} + current[v] = container + current = current[v] + if 'value' not in params: + params['value'] = value + else: + params['options'] = options = list(unique_iterator(values)) + if hasattr(values, 'name'): + params['levels'] = [values.name] + params['value'] = {values.name: options[0]} + else: + params['levels'] = [] + params['value'] = {0: options[0]} + return super()._infer_params(values, **params) + def __init__(self, **params): super().__init__(**params) self._update_widgets() @@ -443,6 +478,8 @@ def _update_widgets(self): if not self.levels: raise ValueError("levels must be specified if options is callable") self._max_depth = len(self.levels) + elif isinstance(self.options, list): + self._max_depth = 1 else: self._max_depth = self._find_max_depth(self.options) + 1 diff --git a/panel/widgets/slider.py b/panel/widgets/slider.py index 36ea343122..6ba12b44e0 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -563,7 +563,6 @@ def values(self): return list(self.options.values()) if isinstance(self.options, dict) else self.options - class _RangeSliderBase(_SliderBase): value = param.Tuple(default=(None, None), length=2, allow_None=False, nested_refs=True, doc="""