Skip to content

Commit

Permalink
Support async callbacks for popup (#6390)
Browse files Browse the repository at this point in the history
Co-authored-by: Philipp Rudiger <[email protected]>
  • Loading branch information
ahuang11 and philippjfr authored Oct 18, 2024
1 parent 4061614 commit 1eede32
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 47 deletions.
30 changes: 20 additions & 10 deletions holoviews/plotting/bokeh/callbacks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import base64
import inspect
import time
from collections import defaultdict
from functools import partial
Expand Down Expand Up @@ -211,7 +212,7 @@ def _filter_msg(self, msg, ids):
filtered_msg[k] = v
return filtered_msg

def on_msg(self, msg):
async def on_msg(self, msg):
streams = []
for stream in self.streams:
handle_ids = self.handle_ids[stream]
Expand Down Expand Up @@ -361,7 +362,7 @@ async def process_on_event(self, timeout=None):
for attr, path in self.attributes.items():
model_obj = self.plot_handles.get(self.models[0])
msg[attr] = self.resolve_attr_spec(path, event, model_obj)
self.on_msg(msg)
await self.on_msg(msg)
await self.process_on_event()

async def process_on_change(self):
Expand Down Expand Up @@ -396,7 +397,7 @@ async def process_on_change(self):
equal = isequal(msg, self._prev_msg)

if not equal or any(s.transient for s in self.streams):
self.on_msg(msg)
await self.on_msg(msg)
self._prev_msg = msg
await self.process_on_change()

Expand Down Expand Up @@ -664,15 +665,17 @@ def _update_selection_event(self, event):
self._selection_event = event
self._processed_event = not event.final
if event.final and self._skipped_partial_event:
self._process_selection_event()
self._skipped_partial_event = False
if self.plot.document.session_context and self.plot.document.session_context.server_context:
self.plot.document.add_next_tick_callback(self._process_selection_partial_event)
else:
state.execute(self._process_selection_partial_event)

def on_msg(self, msg):
super().on_msg(msg)
async def on_msg(self, msg):
await super().on_msg(msg)
if hasattr(self, '_panel'):
self._process_selection_event()
await self._process_selection_event()

def _process_selection_event(self):
async def _process_selection_event(self):
event = self._selection_event
if event is not None:
if self.geom_type not in (event.geometry["type"], "any"):
Expand All @@ -691,7 +694,10 @@ def _process_selection_event(self):
popup_is_callable = callable(popup)
if popup_is_callable:
with set_curdoc(self.plot.document):
popup = popup(**stream.contents)
if inspect.iscoroutinefunction(popup):
popup = await popup(**stream.contents)
else:
popup = popup(**stream.contents)

# If no popup is defined, hide the bokeh panel wrapper
if popup is None:
Expand Down Expand Up @@ -748,6 +754,10 @@ def _process_selection_event(self):
push_on_root(self.plot.root.ref['id'])
self._existing_popup = popup_pane

async def _process_selection_partial_event(self):
await self._process_selection_event()
self._skipped_partial_event = False


class TapCallback(PopupMixin, PointerXYCallback):
"""
Expand Down
66 changes: 33 additions & 33 deletions holoviews/tests/plotting/bokeh/test_callbacks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime as dt
from collections import deque, namedtuple
from unittest import SkipTest
from unittest import IsolatedAsyncioTestCase, SkipTest

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -44,7 +44,7 @@
bokeh_renderer = BokehRenderer.instance()


class CallbackTestCase(ComparisonTestCase):
class CallbackTestCase(IsolatedAsyncioTestCase, ComparisonTestCase):

def setUp(self):
self.previous_backend = Store.current_backend
Expand All @@ -62,33 +62,33 @@ def tearDown(self):

class TestCallbacks(CallbackTestCase):

def test_stream_callback(self):
async def test_stream_callback(self):
dmap = DynamicMap(lambda x, y: Points([(x, y)]), kdims=[], streams=[PointerXY()])
plot = bokeh_server_renderer.get_plot(dmap)
bokeh_server_renderer(plot)
set_curdoc(plot.document)
plot.callbacks[0].on_msg({"x": 0.3, "y": 0.2})
await plot.callbacks[0].on_msg({"x": 0.3, "y": 0.2})
data = plot.handles['source'].data
self.assertEqual(data['x'], np.array([0.3]))
self.assertEqual(data['y'], np.array([0.2]))

def test_point_stream_callback_clip(self):
async def test_point_stream_callback_clip(self):
dmap = DynamicMap(lambda x, y: Points([(x, y)]), kdims=[], streams=[PointerXY()])
plot = bokeh_server_renderer.get_plot(dmap)
bokeh_server_renderer(plot)
set_curdoc(plot.document)
plot.callbacks[0].on_msg({"x": -0.3, "y": 1.2})
await plot.callbacks[0].on_msg({"x": -0.3, "y": 1.2})
data = plot.handles['source'].data
self.assertEqual(data['x'], np.array([0]))
self.assertEqual(data['y'], np.array([1]))

def test_stream_callback_on_clone(self):
async def test_stream_callback_on_clone(self):
points = Points([])
stream = PointerXY(source=points)
plot = bokeh_server_renderer.get_plot(points.clone())
bokeh_server_renderer(plot)
set_curdoc(plot.document)
plot.callbacks[0].on_msg({"x": 0.8, "y": 0.3})
await plot.callbacks[0].on_msg({"x": 0.8, "y": 0.3})
self.assertEqual(stream.x, 0.8)
self.assertEqual(stream.y, 0.3)

Expand All @@ -99,13 +99,13 @@ def test_stream_callback_on_unlinked_clone(self):
bokeh_server_renderer(plot)
self.assertTrue(len(plot.callbacks) == 0)

def test_stream_callback_with_ids(self):
async def test_stream_callback_with_ids(self):
dmap = DynamicMap(lambda x, y: Points([(x, y)]), kdims=[], streams=[PointerXY()])
plot = bokeh_server_renderer.get_plot(dmap)
bokeh_server_renderer(plot)
set_curdoc(plot.document)
model = plot.state
plot.callbacks[0].on_msg({"x": {'id': model.ref['id'], 'value': 0.5},
await plot.callbacks[0].on_msg({"x": {'id': model.ref['id'], 'value': 0.5},
"y": {'id': model.ref['id'], 'value': 0.4}})
data = plot.handles['source'].data
self.assertEqual(data['x'], np.array([0.5]))
Expand Down Expand Up @@ -147,15 +147,15 @@ def test_selection1d_syncs_to_selected(self):

class TestResetCallback(CallbackTestCase):

def test_reset_callback(self):
async def test_reset_callback(self):
resets = []
def record(resetting):
resets.append(resetting)
curve = Curve([])
stream = PlotReset(source=curve)
stream.add_subscriber(record)
plot = bokeh_server_renderer.get_plot(curve)
plot.callbacks[0].on_msg({'reset': True})
await plot.callbacks[0].on_msg({'reset': True})
self.assertEqual(resets, [True])
self.assertIs(stream.source, curve)

Expand Down Expand Up @@ -191,14 +191,14 @@ def test_tap_datetime_out_of_bounds(self):

class TestEditToolCallbacks(CallbackTestCase):

def test_point_draw_callback(self):
async def test_point_draw_callback(self):
points = Points([(0, 1)])
point_draw = PointDraw(source=points)
plot = bokeh_server_renderer.get_plot(points)
self.assertIsInstance(plot.callbacks[0], PointDrawCallback)
callback = plot.callbacks[0]
data = {'x': [1, 2, 3], 'y': [1, 2, 3]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
self.assertEqual(point_draw.element, Points(data))

def test_point_draw_callback_initialized_server(self):
Expand All @@ -213,25 +213,25 @@ def test_point_draw_callback_with_vdims_initialization(self):
bokeh_server_renderer.get_plot(points)
self.assertEqual(stream.element.dimension_values('A'), np.array(['A']))

def test_point_draw_callback_with_vdims(self):
async def test_point_draw_callback_with_vdims(self):
points = Points([(0, 1, 'A')], vdims=['A'])
point_draw = PointDraw(source=points)
plot = bokeh_server_renderer.get_plot(points)
self.assertIsInstance(plot.callbacks[0], PointDrawCallback)
callback = plot.callbacks[0]
data = {'x': [1, 2, 3], 'y': [1, 2, 3], 'A': [None, None, 1]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
processed = dict(data, A=[np.nan, np.nan, 1])
self.assertEqual(point_draw.element, Points(processed, vdims=['A']))

def test_poly_draw_callback(self):
async def test_poly_draw_callback(self):
polys = Polygons([[(0, 0), (2, 2), (4, 0)]])
poly_draw = PolyDraw(source=polys)
plot = bokeh_server_renderer.get_plot(polys)
self.assertIsInstance(plot.callbacks[0], PolyDrawCallback)
callback = plot.callbacks[0]
data = {'x': [[1, 2, 3], [3, 4, 5]], 'y': [[1, 2, 3], [3, 4, 5]]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Polygons([[(1, 1), (2, 2), (3, 3)], [(3, 3), (4, 4), (5, 5)]])
self.assertEqual(poly_draw.element, element)

Expand All @@ -241,31 +241,31 @@ def test_poly_draw_callback_initialized_server(self):
plot = bokeh_server_renderer.get_plot(polys)
assert 'data' in plot.handles['source']._callbacks

def test_poly_draw_callback_with_vdims(self):
async def test_poly_draw_callback_with_vdims(self):
polys = Polygons([{'x': [0, 2, 4], 'y': [0, 2, 0], 'A': 1}], vdims=['A'])
poly_draw = PolyDraw(source=polys)
plot = bokeh_server_renderer.get_plot(polys)
self.assertIsInstance(plot.callbacks[0], PolyDrawCallback)
callback = plot.callbacks[0]
data = {'x': [[1, 2, 3], [3, 4, 5]], 'y': [[1, 2, 3], [3, 4, 5]], 'A': [1, 2]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Polygons([{'x': [1, 2, 3], 'y': [1, 2, 3], 'A': 1},
{'x': [3, 4, 5], 'y': [3, 4, 5], 'A': 2}], vdims=['A'])
self.assertEqual(poly_draw.element, element)

def test_poly_draw_callback_with_vdims_no_color_index(self):
async def test_poly_draw_callback_with_vdims_no_color_index(self):
polys = Polygons([{'x': [0, 2, 4], 'y': [0, 2, 0], 'A': 1}], vdims=['A']).options(color_index=None)
poly_draw = PolyDraw(source=polys)
plot = bokeh_server_renderer.get_plot(polys)
self.assertIsInstance(plot.callbacks[0], PolyDrawCallback)
callback = plot.callbacks[0]
data = {'x': [[1, 2, 3], [3, 4, 5]], 'y': [[1, 2, 3], [3, 4, 5]], 'A': [1, 2]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Polygons([{'x': [1, 2, 3], 'y': [1, 2, 3], 'A': 1},
{'x': [3, 4, 5], 'y': [3, 4, 5], 'A': 2}], vdims=['A'])
self.assertEqual(poly_draw.element, element)

def test_box_edit_callback(self):
async def test_box_edit_callback(self):
boxes = Rectangles([(-0.5, -0.5, 0.5, 0.5)])
box_edit = BoxEdit(source=boxes)
plot = bokeh_server_renderer.get_plot(boxes)
Expand All @@ -277,11 +277,11 @@ def test_box_edit_callback(self):
self.assertEqual(source.data['right'], [0.5])
self.assertEqual(source.data['top'], [0.5])
data = {'left': [-0.25, 0], 'bottom': [-1, 0.75], 'right': [0.25, 2], 'top': [1, 1.25]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Rectangles([(-0.25, -1, 0.25, 1), (0, 0.75, 2, 1.25)])
self.assertEqual(box_edit.element, element)

def test_box_edit_callback_legacy(self):
async def test_box_edit_callback_legacy(self):
boxes = Polygons([Box(0, 0, 1)])
box_edit = BoxEdit(source=boxes)
plot = bokeh_server_renderer.get_plot(boxes)
Expand All @@ -293,7 +293,7 @@ def test_box_edit_callback_legacy(self):
self.assertEqual(source.data['right'], [0.5])
self.assertEqual(source.data['top'], [0.5])
data = {'left': [-0.25, 0], 'bottom': [-1, 0.75], 'right': [0.25, 2], 'top': [1, 1.25]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Polygons([Box(0, 0, (0.5, 2)), Box(1, 1, (2, 0.5))])
self.assertEqual(box_edit.element, element)

Expand All @@ -304,14 +304,14 @@ def test_box_edit_callback_initialized_server(self):
assert 'data' in plot.handles['cds']._callbacks

@pytest.mark.flaky(reruns=3)
def test_poly_edit_callback(self):
async def test_poly_edit_callback(self):
polys = Polygons([[(0, 0), (2, 2), (4, 0)]])
poly_edit = PolyEdit(source=polys)
plot = bokeh_server_renderer.get_plot(polys)
self.assertIsInstance(plot.callbacks[0], PolyEditCallback)
callback = plot.callbacks[0]
data = {'x': [[1, 2, 3], [3, 4, 5]], 'y': [[1, 2, 3], [3, 4, 5]]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Polygons([[(1, 1), (2, 2), (3, 3)], [(3, 3), (4, 4), (5, 5)]])
self.assertEqual(poly_edit.element, element)

Expand All @@ -321,7 +321,7 @@ def test_poly_edit_callback_initialized_server(self):
plot = bokeh_server_renderer.get_plot(polys)
assert 'data' in plot.handles['source']._callbacks

def test_poly_edit_shared_callback(self):
async def test_poly_edit_shared_callback(self):
polys = Polygons([[(0, 0), (2, 2), (4, 0)]])
polys2 = Polygons([[(0, 0), (2, 2), (4, 0)]])
poly_edit = PolyEdit(source=polys, shared=True)
Expand All @@ -333,11 +333,11 @@ def test_poly_edit_shared_callback(self):
self.assertIsInstance(plot1.callbacks[0], PolyEditCallback)
callback = plot1.callbacks[0]
data = {'x': [[1, 2, 3], [3, 4, 5]], 'y': [[1, 2, 3], [3, 4, 5]]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
self.assertIsInstance(plot2.callbacks[0], PolyEditCallback)
callback = plot2.callbacks[0]
data = {'x': [[1, 2, 3], [3, 4, 5]], 'y': [[1, 2, 3], [3, 4, 5]]}
callback.on_msg({'data': data})
await callback.on_msg({'data': data})
element = Polygons([[(1, 1), (2, 2), (3, 3)], [(3, 3), (4, 4), (5, 5)]])
self.assertEqual(poly_edit.element, element)
self.assertEqual(poly_edit2.element, element)
Expand Down Expand Up @@ -422,7 +422,7 @@ def test_cds_resolves(self):
self.assertEqual(resolved, {'id': cds.ref['id'],
'value': points.columns()})

def test_rangexy_datetime(self):
async def test_rangexy_datetime(self):
df = pd.DataFrame(
data = np.random.default_rng(2).standard_normal((30, 4)),
columns=list('ABCD'),
Expand All @@ -432,7 +432,7 @@ def test_rangexy_datetime(self):
stream = RangeXY(source=curve)
plot = bokeh_server_renderer.get_plot(curve)
callback = plot.callbacks[0]
callback.on_msg({"x0": curve.iloc[0, 0], 'x1': curve.iloc[3, 0],
await callback.on_msg({"x0": curve.iloc[0, 0], 'x1': curve.iloc[3, 0],
"y0": 0.2, 'y1': 0.8})
self.assertEqual(stream.x_range[0], curve.iloc[0, 0])
self.assertEqual(stream.x_range[1], curve.iloc[3, 0])
Expand Down
24 changes: 21 additions & 3 deletions holoviews/tests/ui/bokeh/test_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,24 @@ def popup_form(x, y):
expect(locator).to_have_count(2)


@skip_popup
@pytest.mark.usefixtures("bokeh_backend")
def test_stream_popup_async_callbacks(serve_hv):
async def popup_form(x, y):
return pn.widgets.Button(name=f"{x},{y}")

points = hv.Points(np.random.randn(10, 2)).opts(tools=["tap"])
hv.streams.Tap(source=points, popup=popup_form)

page = serve_hv(points)
hv_plot = page.locator('.bk-events')
hv_plot.click()
expect(hv_plot).to_have_count(1)

locator = page.locator(".bk-btn")
expect(locator).to_have_count(2)


@skip_popup
@pytest.mark.usefixtures("bokeh_backend")
def test_stream_popup_visible(serve_hv, points):
Expand Down Expand Up @@ -492,9 +510,9 @@ def popup_form(index):
expect(hv_plot).to_have_count(1)

box = hv_plot.bounding_box()
start_x, start_y = box['x'] + 10, box['y'] + box['height'] - 10
mid_x, mid_y = box['x'] + 10, box['y'] + 10
end_x, end_y = box['x'] + box['width'] - 10, box['y'] + 10
start_x, start_y = box['x'] + 1, box['y'] + box['height'] - 1
mid_x, mid_y = box['x'] + 1, box['y'] + 1
end_x, end_y = box['x'] + box['width'] - 1, box['y'] + 1

page.mouse.move(start_x, start_y)
hv_plot.click()
Expand Down
1 change: 1 addition & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ xarray = ">=0.10.4"
[feature.test-core.dependencies]
psutil = "*"
pytest = "*"
pytest-asyncio = "*"
pytest-cov = "*"
pytest-github-actions-annotate-failures = "*"
pytest-rerunfailures = "*"
Expand Down
Loading

0 comments on commit 1eede32

Please sign in to comment.