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

Implement Widget.from_values #7033

Merged
merged 3 commits into from
Jan 21, 2025
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
8 changes: 8 additions & 0 deletions doc/how_to/components/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,5 +49,6 @@ How to add or remove components from a panel.
construct_panes
pane_type
widget_values
widget_from_values
add_remove
```
35 changes: 35 additions & 0 deletions doc/how_to/components/widget_from_values.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion doc/how_to/interactivity/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
:::

::::
Expand Down
37 changes: 25 additions & 12 deletions panel/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
12 changes: 0 additions & 12 deletions panel/tests/ui/widgets/test_tabulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions panel/tests/widgets/test_select.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import numpy as np
import pandas as pd
import pytest

from panel.layout import GridBox, Row
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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')
Expand Down
46 changes: 46 additions & 0 deletions panel/tests/widgets/test_slider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import date, datetime

import numpy as np
import pandas as pd
import pytest

from bokeh.models import (
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
10 changes: 10 additions & 0 deletions panel/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
41 changes: 41 additions & 0 deletions panel/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
TYPE_CHECKING, Any, ClassVar, TypeVar,
)

import numpy as np
import param # type: ignore

from bokeh.models import ImportedStyleSheet, Tooltip
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading