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

Support async callbacks for popup #6390

Merged
merged 14 commits into from
Oct 18, 2024
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 @@ -210,7 +211,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 @@ -360,7 +361,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 @@ -395,7 +396,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 @@ -663,15 +664,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:
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -690,7 +693,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 @@ -747,6 +753,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 @@ -259,6 +259,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 @@ -407,9 +425,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
Loading