Skip to content

Commit

Permalink
Add interactive DataFrame pane (#560)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr committed Oct 15, 2019
1 parent 72d9bb1 commit 45177aa
Show file tree
Hide file tree
Showing 8 changed files with 430 additions and 4 deletions.
125 changes: 125 additions & 0 deletions examples/reference/widgets/DataFrame.ipynb
@@ -0,0 +1,125 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"import panel as pn\n",
"\n",
"pn.extension()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``DataFrame`` widget allows displaying and editing a pandas DataFrame.\n",
"\n",
"For more information about listening to widget events and laying out widgets refer to the [widgets user guide](../../user_guide/Widgets.ipynb). Alternatively you can learn how to build GUIs by declaring parameters independently of any specific widgets in the [param user guide](../../user_guide/Param.ipynb). To express interactivity entirely using Javascript without the need for a Python server take a look at the [links user guide](../../user_guide/Param.ipynb).\n",
"\n",
"#### Parameters:\n",
"\n",
"For layout and styling related parameters see the [customization user guide](../../user_guide/Customization.ipynb).\n",
"\n",
"##### Core\n",
"\n",
"* **``editors``** (``dict``): A dictionary mapping from column name to a bokeh CellEditor instance, which overrides the default.\n",
"* **``fit_column``** (``boolean``): Whether columns should be fit to the available width. \n",
"* **``formatters``** (``dict``): A dictionary mapping from column name to a bokeh CellFormatter instance, which overrides the default.\n",
"* **``row_height``** (``int``): The height of each table row.\n",
"* **``selection``** (``list``) The currently selected rows \n",
"* **``value``** (``pd.DataFrame``): The pandas DataFrame to display and edit\n",
"* **``widths``** (``dict``): A dictionary mapping from column name to column width in the rendered table.\n",
"\n",
"##### Display\n",
"\n",
"* **``disabled``** (boolean): Whether the widget is editable\n",
"* **``name``** (str): The title of the widget\n",
"\n",
"___"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``DataFrame`` widget renders an table which allows directly editing the contents of the dataframe with any changes being synced with Python. Note that it modifies the ``pd.DataFrame`` in place."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df = pd.DataFrame({'int': [1, 2, 3], 'float': [3.14, 6.28, 9.42], 'str': ['A', 'B', 'C']}, index=[1, 2, 3])\n",
"\n",
"pn.widgets.DataFrame(df)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By default the widget will pick bokeh ``CellFormatter`` and ``CellEditor`` types appropriate to the dtype of the column. These may be overriden by explicit dictionaries mapping from the column name to the editor or formatter instance. For example below we create a ``SelectEditor`` instance to pick from four options in the ``str`` column and a ``NumberFormatter`` to customize the formatting of the float values:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from bokeh.models.widgets.tables import SelectEditor, NumberFormatter\n",
"\n",
"editor = SelectEditor(options=['A', 'B', 'C', 'D'])\n",
"formatter = NumberFormatter(format='0.00000') \n",
"\n",
"table = pn.widgets.DataFrame(df, editors={'str': editor}, formatters={'float': formatter})\n",
"table"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Once initialized the ``selection`` property will return the integer indexes of the selected rows and the ``selected_dataframe`` property will return a new DataFrame containing just the selected rows:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"table.selection = [0, 2]\n",
"\n",
"table.selected_dataframe"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.8"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
10 changes: 10 additions & 0 deletions panel/tests/conftest.py
Expand Up @@ -25,6 +25,16 @@ def comm():
return Comm()


@pytest.fixture
def dataframe():
import pandas as pd
return pd.DataFrame({
'int': [1, 2, 3],
'float': [3.14, 6.28, 9.42],
'str': ['A', 'B', 'C']
}, index=[1, 2, 3], columns=['int', 'float', 'str'])


@pytest.yield_fixture
def hv_bokeh():
import holoviews as hv
Expand Down
5 changes: 3 additions & 2 deletions panel/tests/widgets/test_base.py
Expand Up @@ -4,11 +4,12 @@
import pytest

from panel.io import block_comm
from panel.widgets import CompositeWidget, TextInput, Widget
from panel.widgets import CompositeWidget, DataFrame, TextInput, Widget
from panel.tests.util import check_layoutable_properties

all_widgets = [w for w in param.concrete_descendents(Widget).values()
if not w.__name__.startswith('_') and not issubclass(w, CompositeWidget)]
if not w.__name__.startswith('_') and
not issubclass(w, (CompositeWidget, DataFrame))]


@pytest.mark.parametrize('widget', all_widgets)
Expand Down
82 changes: 82 additions & 0 deletions panel/tests/widgets/test_tables.py
@@ -0,0 +1,82 @@
from __future__ import absolute_import, division, unicode_literals

try:
import pandas as pd
except ImportError:
pass

from bokeh.models.widgets.tables import (
NumberFormatter, IntEditor, NumberEditor, StringFormatter,
SelectEditor
)

from panel.widgets import DataFrame


def test_dataframe_widget(dataframe, document, comm):

table = DataFrame(dataframe)

model = table.get_root(document, comm)

index_col, int_col, float_col, str_col = model.columns

assert index_col.title == 'index'
assert isinstance(index_col.formatter, NumberFormatter)
assert isinstance(index_col.editor, IntEditor)

assert int_col.title == 'int'
assert isinstance(int_col.formatter, NumberFormatter)
assert isinstance(int_col.editor, IntEditor)

assert float_col.title == 'float'
assert isinstance(float_col.formatter, NumberFormatter)
assert isinstance(float_col.editor, NumberEditor)

assert str_col.title == 'str'
assert isinstance(float_col.formatter, StringFormatter)
assert isinstance(float_col.editor, NumberEditor)


def test_dataframe_editors(dataframe, document, comm):
editor = SelectEditor(options=['A', 'B', 'C'])
table = DataFrame(dataframe, editors={'str': editor})
model = table.get_root(document, comm)

assert model.columns[-1].editor is editor


def test_dataframe_formatter(dataframe, document, comm):
formatter = NumberFormatter(format='0.0000')
table = DataFrame(dataframe, formatters={'float': formatter})
model = table.get_root(document, comm)
assert model.columns[2].formatter is formatter


def test_dataframe_triggers(dataframe):
events = []

def increment(event, events=events):
events.append(event)

table = DataFrame(dataframe)
table.param.watch(increment, 'value')
table._process_events({'data': {'str': ['C', 'B', 'A']}})
assert len(events) == 1


def test_dataframe_does_not_trigger(dataframe):
events = []

def increment(event, events=events):
events.append(event)

table = DataFrame(dataframe)
table.param.watch(increment, 'value')
table._process_events({'data': {'str': ['A', 'B', 'C']}})
assert len(events) == 0


def test_dataframe_selected_dataframe(dataframe):
table = DataFrame(dataframe, selection=[0, 2])
pd.testing.assert_frame_equal(dataframe.iloc[[0, 2]], table.selected_dataframe)
13 changes: 11 additions & 2 deletions panel/viewable.py
Expand Up @@ -645,13 +645,19 @@ def param_change(*events):
def _link_props(self, model, properties, doc, root, comm=None):
if comm is None:
for p in properties:
if isinstance(p, tuple):
p, _ = p
model.on_change(p, partial(self._server_change, doc))
elif config.embed:
pass
else:
client_comm = state._comm_manager.get_client_comm(on_msg=self._comm_change)
for p in properties:
customjs = self._get_customjs(p, client_comm, root.ref['id'])
if isinstance(p, tuple):
p, attr = p
else:
p, attr = p, p
customjs = self._get_customjs(attr, client_comm, root.ref['id'])
model.js_on_change(p, customjs)

def _comm_change(self, msg):
Expand All @@ -671,6 +677,9 @@ def _server_change(self, doc, attr, old, new):
self._processing = True
doc.add_timeout_callback(partial(self._change_event, doc), self._debounce)

def _process_events(self, events):
self.set_param(**self._process_property_change(events))

def _change_event(self, doc=None):
try:
state.curdoc = doc
Expand All @@ -679,7 +688,7 @@ def _change_event(self, doc=None):
state._thread_id = thread_id
events = self._events
self._events = {}
self.set_param(**self._process_property_change(events))
self._process_events(events)
finally:
self._processing = False
state.curdoc = None
Expand Down
1 change: 1 addition & 0 deletions panel/widgets/__init__.py
Expand Up @@ -17,4 +17,5 @@
from .select import (# noqa
AutocompleteInput, CheckBoxGroup, CheckButtonGroup, CrossSelector,
MultiSelect, RadioButtonGroup, RadioBoxGroup, Select, ToggleGroup)
from .tables import DataFrame # noqa

30 changes: 30 additions & 0 deletions panel/widgets/base.py
Expand Up @@ -5,11 +5,15 @@
"""
from __future__ import absolute_import, division, unicode_literals

from functools import partial

import param

from ..io import push, state
from ..viewable import Reactive, Layoutable



class Widget(Reactive):
"""
Widgets allow syncing changes in bokeh widget models with the
Expand All @@ -34,8 +38,13 @@ class Widget(Reactive):

_widget_type = None

# Whether the widget supports embedding
_supports_embed = False

# Any parameters that require manual updates handling for the models
# e.g. parameters which affect some sub-model
_manual_params = []

_rename = {'name': 'title'}

def __init__(self, **params):
Expand All @@ -44,6 +53,24 @@ def __init__(self, **params):
if '_supports_embed' in params:
self._supports_embed = params.pop('_supports_embed')
super(Widget, self).__init__(**params)
self.param.watch(self._update_widget, self._manual_params)

def _manual_update(self, event, model, doc, root, parent, comm):
"""
Method for handling any manual update events, i.e. events triggered
by changes in the manual params.
"""

def _update_widget(self, event):
for ref, (model, parent) in self._models.items():
viewable, root, doc, comm = state._views[ref]
if comm or state._unblocked(doc):
self._manual_update(event, model, doc, root, parent, comm)
if comm and 'embedded' not in root.tags:
push(doc, comm)
else:
cb = partial(self._manual_update, event, model, doc, root, parent, comm)
doc.add_next_tick_callback(cb)

def _get_model(self, doc, root=None, parent=None, comm=None):
model = self._widget_type(**self._process_param_change(self._init_properties()))
Expand All @@ -56,6 +83,9 @@ def _get_model(self, doc, root=None, parent=None, comm=None):
self._link_props(model, properties, doc, root, comm)
return model

def _synced_params(self):
return [p for p in self.param if p not in self._manual_params]

def _filter_properties(self, properties):
return [p for p in properties if p not in Layoutable.param]

Expand Down

0 comments on commit 45177aa

Please sign in to comment.