diff --git a/examples/user_guide/Deploy_and_Export.ipynb b/examples/user_guide/Deploy_and_Export.ipynb index 5cb40e52ca..eb4afe0d4b 100644 --- a/examples/user_guide/Deploy_and_Export.ipynb +++ b/examples/user_guide/Deploy_and_Export.ipynb @@ -6,7 +6,9 @@ "metadata": {}, "outputs": [], "source": [ + "import numpy as np\n", "import panel as pn\n", + "\n", "pn.extension()" ] }, @@ -496,7 +498,98 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Accessing the Bokeh model\n", + "## Scheduling callbacks\n", + "\n", + "When you build an app you frequently want to schedule a callback to be run periodically to refresh the data and update visual components. Additionally if you want to update Bokeh components directly you may need to schedule a callback to get around Bokeh's document lock to avoid errors like this:\n", + "\n", + "> RuntimeError: _pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes\n", + "\n", + "In this section we will discover how we can leverage Bokeh's Document and `pn.state.add_periodic_callback` to set this up.\n", + "\n", + "### Server callbacks\n", + "\n", + "The Bokeh server that Panel builds on is designed to be thread safe which requires a set of locks to avoid multiple threads modifying the Bokeh models simultaneously. Therefore if we want to work with Bokeh models directly we should ensure that any changes to a Bokeh model are executed on the correct thread by adding a callback, which the event loop will then execute safely.\n", + "\n", + "In the example below we will launch an application on a thread using `pn.serve` and make the Bokeh plot (in practice you may provide handles to this object on a class). Finally we will wait 1 second until the server is launched and schedule a callback which updates the `y_range` by accessing the `Document` and calling `add_next_tick_callback` on it. This pattern will ensure that the update to the Bokeh model is executed on the correct thread:\n", + "\n", + "```python\n", + "import time\n", + "import panel as pn\n", + "\n", + "from bokeh.plotting import figure\n", + "\n", + "global p\n", + "p = None\n", + "\n", + "def app():\n", + " global p\n", + " doc = pn.state.curdoc\n", + " p = figure()\n", + " p.line([1, 2, 3], [1, 2, 3])\n", + " return p\n", + "\n", + "pn.serve(app, threaded=True)\n", + "\n", + "time.sleep(1)\n", + "\n", + "p.document.add_next_tick_callback(lambda: p.y_range.update(start=0, end=4))\n", + "```\n", + "\n", + "### Periodic callbacks\n", + "\n", + "As we discussed above periodic callbacks allow periodically updating your application with new data. Below we will create a simple Bokeh plot and display it with Panel:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from bokeh.models import ColumnDataSource\n", + "from bokeh.plotting import figure\n", + "\n", + "source = ColumnDataSource({\"x\": range(10), \"y\": range(10)})\n", + "p = figure()\n", + "p.line(x=\"x\", y=\"y\", source=source)\n", + "\n", + "bokeh_pane = pn.pane.Bokeh(p)\n", + "bokeh_pane.servable()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we will define a callback that updates the data on the `ColumnDataSource` and use the `pn.state.add_periodic_callback` method to schedule updates every 200 ms. We will also set a timeout of 5 seconds after which the callback will automatically stop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def update():\n", + " data = np.random.randint(0, 2 ** 31, 10)\n", + " source.data.update({\"y\": data})\n", + " bokeh_pane.param.trigger('object') # Only needed in notebook\n", + "\n", + "cb = pn.state.add_periodic_callback(update, 200, timeout=5000)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In a notebook or bokeh server context we should now see the plot update periodically. The other nice thing about this is that `pn.state.add_periodic_callback` returns `PeriodicCallback` we can call `.stop()` and `.start()` on if we want to stop or pause the periodic execution. Additionally we can also dynamically adjust the period to speed up or slow down the callback." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Accessing the Bokeh model\n", "\n", "Since Panel is built on top of Bokeh, all Panel objects can easily be converted to a Bokeh model. The ``get_root`` method returns a model representing the contents of a Panel:" ] diff --git a/examples/user_guide/Overview.ipynb b/examples/user_guide/Overview.ipynb index 648808b385..1d6d5dd0bb 100644 --- a/examples/user_guide/Overview.ipynb +++ b/examples/user_guide/Overview.ipynb @@ -201,8 +201,10 @@ "\n", "> #### Methods\n", "\n", + "> - `add_periodic_callback`: Schedules a periodic callback to be run at an interval set by the period\n", "> - `kill_all_servers`: Stops all running server sessions.\n", - "> - `onload`: Allows defining a callback which is run when a server is fully loaded" + "> - `onload`: Allows defining a callback which is run when a server is fully loaded\n", + "> - `sync_busy`: Sync an indicator with a boolean value parameter to the busy property on state" ] } ], diff --git a/panel/callbacks.py b/panel/callbacks.py index c35be59b67..2ef929da35 100644 --- a/panel/callbacks.py +++ b/panel/callbacks.py @@ -2,81 +2,12 @@ Defines callbacks to be executed on a thread or by scheduling it on a running bokeh server. """ -from __future__ import absolute_import, division, unicode_literals - - -import time import param -from .io.state import state - - -class PeriodicCallback(param.Parameterized): - """ - Periodic encapsulates a periodic callback which will run both - in tornado based notebook environments and on bokeh server. By - default the callback will run until the stop method is called, - but count and timeout values can be set to limit the number of - executions or the maximum length of time for which the callback - will run. - """ - - callback = param.Callable(doc=""" - The callback to execute periodically.""") - - count = param.Integer(default=None, doc=""" - Number of times the callback will be executed, by default - this is unlimited.""") - - period = param.Integer(default=500, doc=""" - Period in milliseconds at which the callback is executed.""") - - timeout = param.Integer(default=None, doc=""" - Timeout in seconds from the start time at which the callback - expires""") - - def __init__(self, **params): - super(PeriodicCallback, self).__init__(**params) - self._counter = 0 - self._start_time = None - self._timeout = None - self._cb = None - self._doc = None - - def start(self): - if self._cb is not None: - raise RuntimeError('Periodic callback has already started.') - self._start_time = time.time() - if state.curdoc and state.curdoc.session_context: - self._doc = state.curdoc - self._cb = self._doc.add_periodic_callback(self._periodic_callback, self.period) - else: - from tornado.ioloop import PeriodicCallback - self._cb = PeriodicCallback(self._periodic_callback, self.period) - self._cb.start() - - @param.depends('period', watch=True) - def _update_period(self): - if self._cb: - self.stop() - self.start() - - def _periodic_callback(self): - self.callback() - self._counter += 1 - if self._timeout is not None: - dt = (time.time() - self._start_time) - if dt > self._timeout: - self.stop() - if self._counter == self.count: - self.stop() - - def stop(self): - self._counter = 0 - self._timeout = None - if self._doc: - self._doc.remove_periodic_callback(self._cb) - else: - self._cb.stop() - self._cb = None +from .io.callbacks import PeriodicCallback # noqa +param.main.param.warning( + "panel.callbacks module is deprecated and has been moved to " + "panel.io.callbacks. Update your import as it will be removed " + "in the next minor release." +) diff --git a/panel/io/__init__.py b/panel/io/__init__.py index 5bed9065f0..740a8915bb 100644 --- a/panel/io/__init__.py +++ b/panel/io/__init__.py @@ -7,6 +7,7 @@ from ..config import config +from .callbacks import PeriodicCallback # noqa from .embed import embed_state # noqa from .state import state # noqa from .model import add_to_doc, remove_root, diff # noqa diff --git a/panel/io/callbacks.py b/panel/io/callbacks.py new file mode 100644 index 0000000000..47133efd30 --- /dev/null +++ b/panel/io/callbacks.py @@ -0,0 +1,79 @@ +""" +Defines callbacks to be executed on a thread or by scheduling it +on a running bokeh server. +""" +from __future__ import absolute_import, division, unicode_literals + +import time +import param + +from .state import state + + +class PeriodicCallback(param.Parameterized): + """ + Periodic encapsulates a periodic callback which will run both + in tornado based notebook environments and on bokeh server. By + default the callback will run until the stop method is called, + but count and timeout values can be set to limit the number of + executions or the maximum length of time for which the callback + will run. + """ + + callback = param.Callable(doc=""" + The callback to execute periodically.""") + + count = param.Integer(default=None, doc=""" + Number of times the callback will be executed, by default + this is unlimited.""") + + period = param.Integer(default=500, doc=""" + Period in milliseconds at which the callback is executed.""") + + timeout = param.Integer(default=None, doc=""" + Timeout in milliseconds from the start time at which the callback + expires.""") + + def __init__(self, **params): + super(PeriodicCallback, self).__init__(**params) + self._counter = 0 + self._start_time = None + self._cb = None + self._doc = None + + def start(self): + if self._cb is not None: + raise RuntimeError('Periodic callback has already started.') + self._start_time = time.time() + if state.curdoc and state.curdoc.session_context: + self._doc = state.curdoc + self._cb = self._doc.add_periodic_callback(self._periodic_callback, self.period) + else: + from tornado.ioloop import PeriodicCallback + self._cb = PeriodicCallback(self._periodic_callback, self.period) + self._cb.start() + + @param.depends('period', watch=True) + def _update_period(self): + if self._cb: + self.stop() + self.start() + + def _periodic_callback(self): + self.callback() + self._counter += 1 + if self.timeout is not None: + dt = (time.time() - self._start_time) * 1000 + if dt > self.timeout: + self.stop() + if self._counter == self.count: + self.stop() + + def stop(self): + self._counter = 0 + self._timeout = None + if self._doc: + self._doc.remove_periodic_callback(self._cb) + else: + self._cb.stop() + self._cb = None diff --git a/panel/io/server.py b/panel/io/server.py index 1ed06fc984..d566600629 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -20,6 +20,7 @@ from bokeh.server.views.static_handler import StaticHandler from bokeh.server.urls import per_app_patterns from bokeh.settings import settings +from tornado.ioloop import IOLoop from tornado.websocket import WebSocketHandler from tornado.web import RequestHandler, StaticFileHandler, authenticated from tornado.wsgi import WSGIContainer @@ -152,7 +153,7 @@ def unlocked(): def serve(panels, port=0, address=None, websocket_origin=None, loop=None, show=True, start=True, title=None, verbose=True, location=True, - **kwargs): + threaded=False, **kwargs): """ Allows serving one or more panel objects on a single server. The panels argument should be either a Panel object or a function @@ -191,11 +192,26 @@ def serve(panels, port=0, address=None, websocket_origin=None, loop=None, location : boolean or panel.io.location.Location Whether to create a Location component to observe and set the URL location. + threaded: boolean (default=False) + Whether to start the server on a new Thread kwargs: dict Additional keyword arguments to pass to Server instance """ - return get_server(panels, port, address, websocket_origin, loop, - show, start, title, verbose, location, **kwargs) + kwargs = dict(kwargs, **dict( + port=port, address=address, websocket_origin=websocket_origin, + loop=loop, show=show, start=start, title=title, verbose=verbose, + location=location + )) + if threaded: + from tornado.ioloop import IOLoop + kwargs['loop'] = loop = IOLoop() if loop is None else loop + server = StoppableThread( + target=get_server, io_loop=loop, args=(panels,), kwargs=kwargs + ) + server.start() + else: + server = get_server(panels, **kwargs) + return server class ProxyFallbackHandler(RequestHandler): @@ -215,7 +231,6 @@ def prepare(self): self.on_finish() - def get_static_routes(static_dirs): """ Returns a list of tornado routes of StaticFileHandlers given a @@ -301,8 +316,6 @@ def get_server(panel, port=0, address=None, websocket_origin=None, server : bokeh.server.server.Server Bokeh Server instance running this panel """ - from tornado.ioloop import IOLoop - server_id = kwargs.pop('server_id', uuid.uuid4().hex) kwargs['extra_patterns'] = extra_patterns = kwargs.get('extra_patterns', []) if isinstance(panel, dict): diff --git a/panel/io/state.py b/panel/io/state.py index cdcd454989..e80c0ec8a4 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -85,6 +85,20 @@ def __repr__(self): return "state(servers=[])" return "state(servers=[\n {}\n])".format(",\n ".join(server_info)) + def _unblocked(self, doc): + thread = threading.current_thread() + thread_id = thread.ident if thread else None + return doc is self.curdoc and self._thread_id == thread_id + + @param.depends('busy', watch=True) + def _update_busy(self): + for indicator in self._indicators: + indicator.value = self.busy + + #---------------------------------------------------------------- + # Public Methods + #---------------------------------------------------------------- + def kill_all_servers(self): """Stop all servers and clear them from the current state.""" for server_id in self._servers: @@ -94,11 +108,6 @@ def kill_all_servers(self): pass self._servers = {} - def _unblocked(self, doc): - thread = threading.current_thread() - thread_id = thread.ident if thread else None - return doc is self.curdoc and self._thread_id == thread_id - def onload(self, callback): """ Callback that is triggered when a session has been served. @@ -110,18 +119,67 @@ def onload(self, callback): self._onload[self.curdoc] = [] self._onload[self.curdoc].append(callback) + def add_periodic_callback(self, callback, period=500, count=None, + timeout=None, start=True): + """ + Schedules a periodic callback to be run at an interval set by + the period. Returns a PeriodicCallback object with the option + to stop and start the callback. + + Arguments + --------- + callback: callable + Callable function to be executed at periodic interval. + period: int + Interval in milliseconds at which callback will be executed. + count: int + Maximum number of times callback will be invoked. + timeout: int + Timeout in seconds when the callback should be stopped. + start: boolean (default=True) + Whether to start callback immediately. + + Returns + ------- + Return a PeriodicCallback object with start and stop methods. + """ + from .callbacks import PeriodicCallback + + cb = PeriodicCallback(callback=callback, period=period, + count=count, timeout=timeout) + if start: + cb.start() + return cb + def sync_busy(self, indicator): """ Syncs the busy state with an indicator with a boolean value parameter. + + Arguments + --------- + indicator: An BooleanIndicator to sync with the busy property """ + if not isinstance(indicator.param.value, param.Boolean): + raise ValueError("Busy indicator must have a value parameter" + "of Boolean type.") self._indicators.append(indicator) - @param.depends('busy', watch=True) - def _update_busy(self): - for indicator in self._indicators: - indicator.value = self.busy + #---------------------------------------------------------------- + # Public Properties + #---------------------------------------------------------------- + @property + def access_token(self): + from ..config import config + access_token = self.cookies.get('access_token') + if access_token is None: + return None + access_token = decode_signed_value(config.cookie_secret, 'access_token', access_token) + if self.encryption is None: + return access_token.decode('utf-8') + return self.encryption.decrypt(access_token).decode('utf-8') + @property def curdoc(self): if self._curdoc: @@ -142,19 +200,19 @@ def headers(self): return self.curdoc.session_context.request.headers if self.curdoc else {} @property - def session_args(self): - return self.curdoc.session_context.request.arguments if self.curdoc else {} + def location(self): + if self.curdoc and self.curdoc not in self._locations: + from .location import Location + self._locations[self.curdoc] = loc = Location() + return loc + elif self.curdoc is None: + return self._location + else: + return self._locations.get(self.curdoc) if self.curdoc else None @property - def access_token(self): - from ..config import config - access_token = self.cookies.get('access_token') - if access_token is None: - return None - access_token = decode_signed_value(config.cookie_secret, 'access_token', access_token) - if self.encryption is None: - return access_token.decode('utf-8') - return self.encryption.decrypt(access_token).decode('utf-8') + def session_args(self): + return self.curdoc.session_context.request.arguments if self.curdoc else {} @property def user(self): @@ -182,16 +240,5 @@ def user_info(self): payload_segment = id_token return json.loads(base64url_decode(payload_segment).decode('utf-8')) - @property - def location(self): - if self.curdoc and self.curdoc not in self._locations: - from .location import Location - self._locations[self.curdoc] = loc = Location() - return loc - elif self.curdoc is None: - return self._location - else: - return self._locations.get(self.curdoc) if self.curdoc else None - state = _state() diff --git a/panel/reactive.py b/panel/reactive.py index 5c06adb33a..295eec2a81 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -13,8 +13,8 @@ from bokeh.models import LayoutDOM from tornado import gen -from .callbacks import PeriodicCallback from .config import config +from .io.callbacks import PeriodicCallback from .io.model import hold from .io.notebook import push from .io.server import unlocked @@ -266,6 +266,11 @@ def add_periodic_callback(self, callback, period=500, count=None, ------- Return a PeriodicCallback object with start and stop methods. """ + self.param.warning( + "Calling add_periodic_callback on a Panel component is " + "deprecated and will be removed in the next minor release. " + "Use the pn.state.add_periodic_callback API instead." + ) cb = PeriodicCallback(callback=callback, period=period, count=count, timeout=timeout) if start: diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index b875ab17ed..15b372333a 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -89,7 +89,7 @@ def tmpdir(request, tmpdir_factory): @pytest.fixture() def html_server_session(): html = HTML('

Title

') - server = html._get_server(port=5006) + server = serve(html, port=5006, show=False, start=False) session = pull_session( session_id='Test', url="http://localhost:{:d}/".format(server.port), @@ -105,7 +105,7 @@ def html_server_session(): @pytest.fixture() def markdown_server_session(): html = Markdown('#Title') - server = html._get_server(port=5007) + server = serve(html, port=5007, show=False, start=False) session = pull_session( session_id='Test', url="http://localhost:{:d}/".format(server.port), diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 0ab93e71cc..35b94733be 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -6,12 +6,10 @@ import pytest import requests -from tornado.ioloop import IOLoop - from panel.io import state from panel.models import HTML as BkHTML from panel.pane import Markdown -from panel.io.server import StoppableThread +from panel.io.server import serve from panel.template import Template @@ -47,12 +45,8 @@ def handle_event(event): def test_server_static_dirs(): html = Markdown('# Title') - loop = IOLoop() - server = StoppableThread( - target=html._get_server, io_loop=loop, - args=(5008, None, None, loop, False, True, None, False, None), - kwargs=dict(static_dirs={'tests': os.path.dirname(__file__)})) - server.start() + static = {'tests': os.path.dirname(__file__)} + server = serve(html, port=5008, threaded=True, static_dirs=static, show=False) # Wait for server to start time.sleep(1) @@ -99,12 +93,7 @@ def test_template_css(): f.write(css) t.add_variable('template_css_files', [ntf.name]) - loop = IOLoop() - server = StoppableThread( - target=t._get_server, io_loop=loop, - args=(5009, None, None, loop, False, True, None, False, None) - ) - server.start() + server = serve(t, port=5009, threaded=True, show=False) # Wait for server to start time.sleep(1) diff --git a/panel/viewable.py b/panel/viewable.py index eec1e94920..babdae9b64 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -29,7 +29,7 @@ ) from .io.save import save from .io.state import state -from .io.server import StoppableThread, get_server +from .io.server import serve from .util import escape, param_reprs @@ -228,12 +228,6 @@ def _modify_doc(self, server_id, title, doc, location): state._servers[server_id][2].append(doc) return self.server_doc(doc, title, location) - def _get_server(self, port=0, address=None, websocket_origin=None, loop=None, - show=False, start=False, title=None, verbose=False, - location=True, **kwargs): - return get_server(self, port, address, websocket_origin, loop, - show, start, title, verbose, **kwargs) - def _add_location(self, doc, location, root=None): from .io.location import Location if isinstance(location, Location): @@ -353,21 +347,11 @@ def show(self, title=None, port=0, address=None, websocket_origin=None, Returns the Bokeh server instance or the thread the server was launched on (if threaded=True) """ - if threaded: - from tornado.ioloop import IOLoop - loop = IOLoop() - server = StoppableThread( - target=self._get_server, io_loop=loop, - args=(port, address, websocket_origin, loop, open, - True, title, verbose, location), - kwargs=kwargs) - server.start() - else: - server = self._get_server( - port, address, websocket_origin, show=open, start=True, - title=title, verbose=verbose, location=location, **kwargs - ) - return server + return serve( + self, port=port, address=address, websocket_origin=websocket_origin, + show=open, start=True, title=title, verbose=verbose, + location=location, threaded=threaded, **kwargs + ) class Renderable(param.Parameterized): diff --git a/setup.py b/setup.py index d08008220d..bb520adc08 100644 --- a/setup.py +++ b/setup.py @@ -108,13 +108,14 @@ def run(self): _tests = [ 'flake8', 'parameterized', - 'pytest', + 'pytest<6.0', # temporary fix for nbval incompatibility 'scipy', 'nbsmoke >=0.2.0', 'pytest-cov', 'codecov', 'folium', - 'ipympl' + 'ipympl', + 'pandas<1.1' # temporary fix for streamz incompatibility ] extras_require = {