diff --git a/.gitignore b/.gitignore index 51593bebee..0a0d06f315 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,4 @@ builtdocs/ # vscode settings .vscode/ .vscode/panel.code-workspace -discover/ +discover/ \ No newline at end of file diff --git a/examples/user_guide/Deploy_and_Export.ipynb b/examples/user_guide/Deploy_and_Export.ipynb index fa3b99f33a..d5958d0986 100644 --- a/examples/user_guide/Deploy_and_Export.ipynb +++ b/examples/user_guide/Deploy_and_Export.ipynb @@ -229,13 +229,8 @@ "pn.serve({'markdown': '# This is a Panel app', 'json': pn.pane.JSON({'abc': 123})})\n", "```\n", "\n", - "The ``pn.serve`` function accepts the same arguments as the `show` method." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ + "The ``pn.serve`` function accepts the same arguments as the `show` method.\n", + "\n", "\n", "\n", "## Launching a server on the commandline\n", @@ -332,6 +327,42 @@ "\n", "This mechanism may be used to modify the behavior of an app dependending on parameters provided in the URL. \n", "\n", + "#### Cookies\n", + "\n", + "The `panel.state.cookies` will allow accessing the cookies stored in the browser and on the bokeh server.\n", + "\n", + "#### Headers\n", + "\n", + "The `panel.state.headers` will allow accessing the HTTP headers stored in the browser and on the bokeh server.\n", + "\n", + "#### Location\n", + "\n", + "When starting a server session Panel will attach a `Location` component which can be accessed using `pn.state.location`. The `Location` component servers a number of functions:\n", + "\n", + "- Navigation between pages via ``pathname``\n", + "- Sharing (parts of) the page state in the url as ``search`` parameters for bookmarking and sharing.\n", + "- Navigating to subsections of the page via the ``hash_`` parameter.\n", + "\n", + "##### Core\n", + "\n", + "* **``pathname``** (string): pathname part of the url, e.g. '/user_guide/Interact.html'.\n", + "* **``search``** (string): search part of the url e.g. '?color=blue'.\n", + "* **``hash_``** (string): hash part of the url e.g. '#interact'.\n", + "* **``reload``** (bool): Whether or not to reload the page when the url is updated.\n", + " - For independent apps this should be set to True. \n", + " - For integrated or single page apps this should be set to False.\n", + "\n", + "##### Readonly\n", + "\n", + "* **``href``** (string): The full url, e.g. 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'.\n", + "* **``protocol``** (string): protocol part of the url, e.g. 'http:' or 'https:'\n", + "* **``port``** (string): port number, e.g. '80'" + ] + }, + { + "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 dae01b4d7c..50873c35f9 100644 --- a/examples/user_guide/Overview.ipynb +++ b/examples/user_guide/Overview.ipynb @@ -184,7 +184,16 @@ "\n", "> - `cache`: A global cache which can be used to share data between different processes.\n", "> - `cookies`: HTTP request cookies for the current session.\n", - "> - `curdoc`: When running a server session this property holds the current bokeh Document. \n", + "> - `curdoc`: When running a server session this property holds the current bokeh Document.\n", + "> - `location`: In a server context this provides read and write access to the URL:\n", + " * `hash`: hash in window.location e.g. '#interact'\n", + " * `pathname`: pathname in window.location e.g. '/user_guide/Interact.html'\n", + " * `search`: search in window.location e.g. '?color=blue'\n", + " * `reload`: Reloads the page when the location is updated.\n", + " * `href` (readonly): The full url, e.g. 'https://localhost:80?color=blue#interact'\n", + " * `hostname` (readonly): hostname in window.location e.g. 'panel.holoviz.org'\n", + " * `protocol` (readonly): protocol in window.location e.g. 'http:' or 'https:'\n", + " * `port` (readonly): port in window.location e.g. '80'\n", "> - `headers`: HTTP request headers for the current session.\n", "> - `session_args`: When running a server session this return the request arguments.\n", "> - `webdriver`: Caches the current webdriver to speed up export of bokeh models to PNGs.\n", diff --git a/examples/user_guide/Param.ipynb b/examples/user_guide/Param.ipynb index 7806f8f805..7ad697bd99 100644 --- a/examples/user_guide/Param.ipynb +++ b/examples/user_guide/Param.ipynb @@ -6,7 +6,9 @@ "source": [ "Panel supports using parameters and dependencies between parameters as expressed by ``param`` in a simple way to encapsulate dashboards as declarative, self-contained classes.\n", "\n", - "Parameters are Python attributes extended using the [Param library](https://github.com/ioam/param) to support types, ranges, and documentation, which turns out to be just the information you need to automatically create widgets for each parameter. \n", + "Parameters are Python attributes extended using the [Param library](https://github.com/holoviz/param) to support types, ranges, and documentation, which turns out to be just the information you need to automatically create widgets for each parameter.\n", + "\n", + "Additionally Panel provides support for linking parameters to the URL query string to allow parameterizing an app very easily.\n", "\n", "# Parameters and widgets\n", "\n", @@ -466,6 +468,62 @@ " viewer.panel())" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Syncing query parameters\n", + "\n", + "By default the current [query parameters](https://en.wikipedia.org/wiki/Query_string) in the URL (specified as a URL suffix such as `?color=blue`) are made available on `pn.state.location.query_params`. To make working with query parameters straightforward the `Location` object also provides a `sync` method which allows syncing query parameters with the parameters on a `Parameterized` object.\n", + "\n", + "We will start by defining a `Parameterized` class:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class QueryExample(param.Parameterized):\n", + " \n", + " integer = param.Integer(default=None, bounds=(0, 10))\n", + " \n", + " string = param.String(default='A string')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we will use the `pn.state.location` object to sync it with the URL query string (note that in a notebook environment `pn.state.location` is not initialized until the first plot has been displayed). The sync method takes the Parameterized object or instance to sync with as the first argument and a list or dictionary of the parameters as the second argument. If a dictionary is provided it should map from the Parameterized object's parameters to the query parameter name in the URL:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.state.location.sync(QueryExample, {'integer': 'int', 'string': 'str'})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now the Parameterized object is bi-directionally linked to the URL query parameter, if we set a query parameter in Python it will update the URL bar and when we specify a URL with a query parameter that will be set on the Parameterized, e.g. let us set the 'integer' parameter and watch the URL in your browser update:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "QueryExample.integer = 5" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -475,11 +533,24 @@ } ], "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", - "pygments_lexer": "ipython3" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.5" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/panel/io/embed.py b/panel/io/embed.py index ee08812381..50d1326309 100644 --- a/panel/io/embed.py +++ b/panel/io/embed.py @@ -91,7 +91,7 @@ def param_to_jslink(model, widget): """ Converts Param pane widget links into JS links if possible. """ - from ..viewable import Reactive + from ..reactive import Reactive from ..widgets import Widget, LiteralInput param_pane = widget._param_pane @@ -188,7 +188,7 @@ def embed_state(panel, model, doc, max_states=1000, max_opts=3, Arguments --------- - panel: panel.viewable.Reactive + panel: panel.reactive.Reactive The Reactive component being exported model: bokeh.model.Model The bokeh model being exported diff --git a/panel/io/location.py b/panel/io/location.py new file mode 100644 index 0000000000..2161d9a0b7 --- /dev/null +++ b/panel/io/location.py @@ -0,0 +1,120 @@ +""" +Defines the Location widget which allows changing the href of the window. +""" + +import urllib.parse as urlparse + +import param + +from ..models.location import Location as _BkLocation +from ..reactive import Syncable +from ..util import parse_query + + +class Location(Syncable): + """ + The Location component can be made available in a server context + to provide read and write access to the URL components in the + browser. + """ + + href = param.String(readonly=True, doc=""" + The full url, e.g. 'https://localhost:80?color=blue#interact'""") + + hostname = param.String(readonly=True, doc=""" + hostname in window.location e.g. 'panel.holoviz.org'""") + + pathname = param.String(regex=r"^$|[\/].*$", doc=""" + pathname in window.location e.g. '/user_guide/Interact.html'""") + + protocol = param.String(readonly=True, doc=""" + protocol in window.location e.g. 'http:' or 'https:'""") + + port = param.String(readonly=True, doc=""" + port in window.location e.g. '80'""") + + search = param.String(regex=r"^$|\?", doc=""" + search in window.location e.g. '?color=blue'""") + + hash = param.String(regex=r"^$|#", doc=""" + hash in window.location e.g. '#interact'""") + + reload = param.Boolean(default=False, doc=""" + Reload the page when the location is updated. For multipage + apps this should be set to True, For single page apps this + should be set to False""") + + # Mapping from parameter name to bokeh model property name + _rename = {"name": None} + + def __init__(self, **params): + super(Location, self).__init__(**params) + self._synced = [] + self._syncing = False + self.param.watch(self._update_synced, ['search']) + + def _get_model(self, doc, root=None, parent=None, comm=None): + model = _BkLocation(**self._process_param_change(self._init_properties())) + root = root or model + values = dict(self.param.get_param_values()) + properties = list(self._process_param_change(values)) + self._models[root.ref['id']] = (model, parent) + self._link_props(model, properties, doc, root, comm) + return model + + def _update_synced(self, event=None): + if self._syncing: + return + query_params = self.query_params + for p, parameters in self._synced: + mapping = {v: k for k, v in parameters.items()} + p.param.set_param(**{mapping[k]: v for k, v in query_params.items() + if k in mapping}) + + def _update_query(self, *events, query=None): + if self._syncing: + return + query = query or {} + for e in events: + matches = [ps for o, ps in self._synced if o in (e.cls, e.obj)] + if not matches: + continue + query[matches[0][e.name]] = e.new + self._syncing = True + try: + self.update_query(**{k: v for k, v in query.items() if v is not None}) + finally: + self._syncing = False + + @property + def query_params(self): + return parse_query(self.search) + + def update_query(self, **kwargs): + query = self.query_params + query.update(kwargs) + self.search = '?' + urlparse.urlencode(query) + + def sync(self, parameterized, parameters=None): + """ + Syncs the parameters of a Parameterized object with the query + parameters in the URL. + + Arguments + --------- + parameterized (param.Parameterized): + The Paramterized object to sync query parameters with + parameters (list or dict): + A list or dictionary specifying parameters to sync. + If a dictionary is supplied it should define a mapping from + the Parameterized's parameteres to the names of the query + parameters. + """ + parameters = parameters or [p for p in parameterized.param if p != 'name'] + if not isinstance(parameters, dict): + parameters = dict(zip(parameters, parameters)) + self._synced.append((parameterized, parameters)) + parameterized.param.watch(self._update_query, list(parameters)) + self._update_synced() + self._update_query(query={v: getattr(parameterized, k) + for k, v in parameters.items()}) diff --git a/panel/io/notebook.py b/panel/io/notebook.py index e853807249..12a536f95c 100644 --- a/panel/io/notebook.py +++ b/panel/io/notebook.py @@ -147,7 +147,7 @@ def render_model(model, comm=None): {EXEC_MIME: {'id': target}}) -def render_mimebundle(model, doc, comm, manager=None): +def render_mimebundle(model, doc, comm, manager=None, location=None): """ Displays bokeh output inside a notebook using the PyViz display and comms machinery. @@ -157,6 +157,9 @@ def render_mimebundle(model, doc, comm, manager=None): add_to_doc(model, doc, True) if manager is not None: doc.add_root(manager) + if location is not None: + loc = location._get_model(doc, model, model, comm) + doc.add_root(loc) return render_model(model, comm) diff --git a/panel/io/server.py b/panel/io/server.py index 58b2006441..da579f8d82 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -40,15 +40,15 @@ def _server_url(url, port): else: return 'http://%s:%d%s' % (url.split(':')[0], port, "/") -def _eval_panel(panel, server_id, title, doc): +def _eval_panel(panel, server_id, title, location, doc): from ..template import Template from ..pane import panel as as_panel if isinstance(panel, FunctionType): panel = panel() if isinstance(panel, Template): - return panel._modify_doc(server_id, title, doc) - return as_panel(panel)._modify_doc(server_id, title, doc) + return panel._modify_doc(server_id, title, doc, location) + return as_panel(panel)._modify_doc(server_id, title, doc, location) #--------------------------------------------------------------------- # Public API @@ -101,7 +101,7 @@ def unlocked(): def serve(panels, port=0, websocket_origin=None, loop=None, show=True, - start=True, title=None, verbose=True, **kwargs): + start=True, title=None, verbose=True, location=True, **kwargs): """ Allows serving one or more panel objects on a single server. The panels argument should be either a Panel object or a function @@ -134,11 +134,14 @@ def serve(panels, port=0, websocket_origin=None, loop=None, show=True, An HTML title for the application verbose: boolean (optional, default=True) Whether to print the address and port + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. kwargs: dict Additional keyword arguments to pass to Server instance """ return get_server(panels, port, websocket_origin, loop, show, start, - title, verbose, **kwargs) + title, verbose, location, **kwargs) class ProxyFallbackHandler(RequestHandler): @@ -159,7 +162,8 @@ def prepare(self): def get_server(panel, port=0, websocket_origin=None, loop=None, - show=False, start=False, title=None, verbose=False, **kwargs): + show=False, start=False, title=None, verbose=False, + location=True, **kwargs): """ Returns a Server instance with this panel attached as the root app. @@ -188,6 +192,9 @@ def get_server(panel, port=0, websocket_origin=None, loop=None, An HTML title for the application verbose: boolean (optional, default=False) Whether to report the address and port + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. kwargs: dict Additional keyword arguments to pass to Server instance @@ -215,9 +222,9 @@ def get_server(panel, port=0, websocket_origin=None, loop=None, extra_patterns.append(('^'+slug+'.*', ProxyFallbackHandler, dict(fallback=wsgi, proxy=slug))) continue - apps[slug] = partial(_eval_panel, app, server_id, title) + apps[slug] = partial(_eval_panel, app, server_id, title, location) else: - apps = {'/': partial(_eval_panel, panel, server_id, title)} + apps = {'/': partial(_eval_panel, panel, server_id, title, location)} opts = dict(kwargs) if loop: diff --git a/panel/io/state.py b/panel/io/state.py index c65f02d312..311ae739b8 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -5,7 +5,7 @@ import threading -from weakref import WeakSet +from weakref import WeakKeyDictionary, WeakSet import param @@ -21,12 +21,11 @@ class _state(param.Parameterized): """ cache = param.Dict(default={}, doc=""" - Global location you can use to cache large datasets or - expensive computation results across multiple client sessions - for a given server.""") + Global location you can use to cache large datasets or expensive computation results + across multiple client sessions for a given server.""") webdriver = param.Parameter(default=None, doc=""" - Selenium webdriver used to export bokeh models to pngs.""") + Selenium webdriver used to export bokeh models to pngs.""") _curdoc = param.ClassSelector(class_=Document, doc=""" The bokeh Document for which a server event is currently being @@ -40,6 +39,10 @@ class _state(param.Parameterized): _comm_manager = _CommManager + # Locations + _location = None # Global location, e.g. for notebook context + _locations = WeakKeyDictionary() # Server locations indexed by document + # An index of all currently active views _views = {} @@ -58,8 +61,8 @@ class _state(param.Parameterized): def __repr__(self): server_info = [] for server, panel, docs in self._servers.values(): - server_info.append("{}:{:d} - {!r}".format( - server.address or "localhost", server.port, panel) + server_info.append( + "{}:{:d} - {!r}".format(server.address or "localhost", server.port, panel) ) if not server_info: return "state(servers=[])" @@ -77,7 +80,7 @@ def kill_all_servers(self): 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) + return doc is self.curdoc and self._thread_id == thread_id @property def curdoc(self): @@ -102,5 +105,16 @@ def headers(self): def session_args(self): return self.curdoc.session_context.request.arguments if self.curdoc else {} + @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/layout/base.py b/panel/layout/base.py index 57c6a128a7..94c63dfa8d 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -12,8 +12,8 @@ from ..io.model import hold from ..io.state import state +from ..reactive import Reactive from ..util import param_name, param_reprs -from ..viewable import Reactive _row = namedtuple("row", ["children"]) _col = namedtuple("col", ["children"]) diff --git a/panel/layout/spacer.py b/panel/layout/spacer.py index cf31ea0e85..dbd0274a0b 100644 --- a/panel/layout/spacer.py +++ b/panel/layout/spacer.py @@ -6,7 +6,7 @@ from bokeh.models import Div as BkDiv, Spacer as BkSpacer -from ..viewable import Reactive +from ..reactive import Reactive class Spacer(Reactive): diff --git a/panel/links.py b/panel/links.py index 3896ed90dc..a2bcafcba7 100644 --- a/panel/links.py +++ b/panel/links.py @@ -7,7 +7,8 @@ import weakref import sys -from .viewable import Viewable, Reactive +from .reactive import Reactive +from .viewable import Viewable from bokeh.models import (CustomJS, Model as BkModel) diff --git a/panel/models/__init__.py b/panel/models/__init__.py index 2afd1d5085..63496b828f 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -6,6 +6,7 @@ """ from .layout import Card # noqa +from .location import Location # noqa from .markup import JSON, HTML # noqa from .state import State # noqa from .widgets import Audio, FileDownload, Player, Progress, Video, VideoStream # noqa diff --git a/panel/models/index.ts b/panel/models/index.ts index 036bedf6b7..399be66f39 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -7,6 +7,7 @@ export {HTML} from "./html" export {JSON} from "./json" export {FileDownload} from "./file_download" export {KaTeX} from "./katex" +export {Location} from "./location" export {MathJax} from "./mathjax" export {Player} from "./player" export {PlotlyPlot} from "./plotly" diff --git a/panel/models/location.py b/panel/models/location.py new file mode 100644 index 0000000000..3717eaeb0e --- /dev/null +++ b/panel/models/location.py @@ -0,0 +1,41 @@ +"""This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" + +from bokeh.core.properties import Bool, String +from bokeh.models import Model + + +class Location(Model): + """ + A python wrapper around the JS `window.location` api. See + https://www.w3schools.com/js/js_window_location.asp and + https://www.w3.org/TR/html52/browsers.html#the-location-interface + + You can use this model to provide (parts of) the app state to the + user as a bookmarkable and shareable link. + """ + + href = String(default="", help=""" + The full url, e.g. 'https://localhost:80?color=blue#interact'""") + + hostname = String(default="", help=""" + hostname in window.location e.g. 'panel.holoviz.org'""") + + pathname = String(default="", help=""" + pathname in window.location e.g. '/user_guide/Interact.html'""") + + protocol = String(default="", help=""" + protocol in window.location e.g. 'https'""") + + port = String(default="", help=""" + port in window.location e.g. 80""") + + search = String(default="", help=""" + search in window.location e.g. '?color=blue'""") + + hash = String(default="", help=""" + hash in window.location e.g. '#interact'""") + + reload = Bool(default=True, help=""" + Reload the page when the location is updated. For multipage apps + this should be set to True, For single page apps this should be + set to False""") diff --git a/panel/models/location.ts b/panel/models/location.ts new file mode 100644 index 0000000000..d18445ea0d --- /dev/null +++ b/panel/models/location.ts @@ -0,0 +1,91 @@ +import * as p from "@bokehjs/core/properties" +import {View} from "@bokehjs/core/view" +import {Model} from "@bokehjs/model" + +export class LocationView extends View { + model: Location + + initialize(): void { + super.initialize(); + + this.model.pathname = window.location.pathname; + this.model.search = window.location.search; + this.model.hash = window.location.hash; + + // Readonly parameters on python side + this.model.href = window.location.href; + this.model.hostname = window.location.hostname; + this.model.protocol = window.location.protocol; + this.model.port = window.location.port; + } + + connect_signals(): void { + super.connect_signals(); + + this.connect(this.model.properties.pathname.change, () => this.update('pathname')); + this.connect(this.model.properties.search.change, () => this.update('search')); + this.connect(this.model.properties.hash.change, () => this.update('hash')); + this.connect(this.model.properties.reload.change, () => this.update('reload')); + } + + update(change: string): void { + if (!this.model.reload || (change === 'reload')) { + window.history.pushState( + {}, + '', + `${this.model.pathname}${this.model.search}${this.model.hash}` + ); + this.model.href = window.location.href; + if (change === 'reload') + window.location.reload() + } else { + if (change == 'pathname') + window.location.pathname = (this.model.pathname as string); + if (change == 'search') + window.location.search = (this.model.search as string); + if (change == 'hash') + window.location.hash = (this.model.hash as string); + } + } +} + +export namespace Location { + export type Attrs = p.AttrsOf + export type Props = Model.Props & { + href: p.Property + hostname: p.Property + pathname: p.Property + protocol: p.Property + port: p.Property + search: p.Property + hash: p.Property + reload: p.Property + } +} + +export interface Location extends Location.Attrs { } + +export class Location extends Model { + properties: Location.Props + + static __module__ = "panel.models.location" + + constructor(attrs?: Partial) { + super(attrs) + } + + static init_Location(): void { + this.prototype.default_view = LocationView; + + this.define({ + href: [p.String, ''], + hostname: [p.String, ''], + pathname: [p.String, ''], + protocol: [p.String, ''], + port: [p.String, ''], + search: [p.String, ''], + hash: [p.String, ''], + reload: [p.Boolean, false], + }) + } +} diff --git a/panel/models/state.py b/panel/models/state.py index 718da9acd8..1c173b2bc3 100644 --- a/panel/models/state.py +++ b/panel/models/state.py @@ -1,5 +1,5 @@ -from bokeh.models import Model from bokeh.core.properties import Bool, Dict, Any, List +from bokeh.models import Model class State(Model): diff --git a/panel/pane/base.py b/panel/pane/base.py index 39b6b8f306..1991622311 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -14,7 +14,8 @@ from ..io import push, state, unlocked from ..layout import Panel, Row from ..links import Link -from ..viewable import Viewable, Reactive, Layoutable +from ..reactive import Reactive +from ..viewable import Layoutable, Viewable from ..util import param_reprs diff --git a/panel/reactive.py b/panel/reactive.py new file mode 100644 index 0000000000..8f65fe45eb --- /dev/null +++ b/panel/reactive.py @@ -0,0 +1,478 @@ +""" +Declares Syncable and Reactive classes which provides baseclasses +for Panel components which sync their state with one or more bokeh +models rendered on the frontend. +""" + +import difflib +import threading + +from collections import namedtuple +from functools import partial + +from tornado import gen + +from .callbacks import PeriodicCallback +from .config import config +from .io.model import hold +from .io.notebook import push +from .io.server import unlocked +from .io.state import state +from .util import edit_readonly +from .viewable import Layoutable, Renderable, Viewable + +LinkWatcher = namedtuple("Watcher","inst cls fn mode onlychanged parameter_names what queued target links transformed") + + +class Syncable(Renderable): + """ + Syncable is an extension of the Renderable object which can not + only render to a bokeh model but also sync the parameters on the + object with the properties on the model. + + In order to bi-directionally link parameters with bokeh model + instances the _link_params and _link_props methods define + callbacks triggered when either the parameter or bokeh property + values change. Since there may not be a 1-to-1 mapping between + parameter and the model property the _process_property_change and + _process_param_change may be overridden to apply any necessary + transformations. + """ + + # Timeout if a notebook comm message is swallowed + _timeout = 20000 + + # Timeout before the first event is processed + _debounce = 50 + + # Mapping from parameter name to bokeh model property name + _rename = {} + + __abstract = True + + events = [] + + def __init__(self, **params): + super(Syncable, self).__init__(**params) + self._processing = False + self._events = {} + self._callbacks = [] + self._links = [] + self._link_params() + self._changing = {} + + # Allows defining a mapping from model property name to a JS code + # snippet that transforms the object before serialization + _js_transforms = {} + + # Transforms from input value to bokeh property value + _source_transforms = {} + _target_transforms = {} + + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- + + def _process_property_change(self, msg): + """ + Transform bokeh model property changes into parameter updates. + Should be overridden to provide appropriate mapping between + parameter value and bokeh model change. By default uses the + _rename class level attribute to map between parameter and + property names. + """ + inverted = {v: k for k, v in self._rename.items()} + return {inverted.get(k, k): v for k, v in msg.items()} + + def _process_param_change(self, msg): + """ + Transform parameter changes into bokeh model property updates. + Should be overridden to provide appropriate mapping between + parameter value and bokeh model change. By default uses the + _rename class level attribute to map between parameter and + property names. + """ + properties = {self._rename.get(k, k): v for k, v in msg.items() + if self._rename.get(k, False) is not None} + if 'width' in properties and self.sizing_mode is None: + properties['min_width'] = properties['width'] + if 'height' in properties and self.sizing_mode is None: + properties['min_height'] = properties['height'] + return properties + + def _link_params(self): + params = self._synced_params() + if params: + watcher = self.param.watch(self._param_change, params) + self._callbacks.append(watcher) + + def _link_props(self, model, properties, doc, root, comm=None): + ref = root.ref['id'] + if config.embed: + return + + for p in properties: + if isinstance(p, tuple): + _, p = p + if comm: + model.on_change(p, partial(self._comm_change, doc, ref)) + else: + model.on_change(p, partial(self._server_change, doc, ref)) + + @property + def _linkable_params(self): + return [p for p in self._synced_params() + if self._source_transforms.get(p, False) is not None] + + def _synced_params(self): + return list(self.param) + + def _update_model(self, events, msg, root, model, doc, comm): + self._changing[root.ref['id']] = [ + attr for attr, value in msg.items() + if not model.lookup(attr).property.matches(getattr(model, attr), value) + ] + try: + model.update(**msg) + finally: + del self._changing[root.ref['id']] + + def _cleanup(self, root): + super(Syncable, self)._cleanup(root) + ref = root.ref['id'] + self._models.pop(ref, None) + comm, client_comm = self._comms.pop(ref, (None, None)) + if comm: + try: + comm.close() + except Exception: + pass + if client_comm: + try: + client_comm.close() + except Exception: + pass + + def _param_change(self, *events): + msgs = [] + for event in events: + msg = self._process_param_change({event.name: event.new}) + if msg: + msgs.append(msg) + + events = {event.name: event for event in events} + msg = {k: v for msg in msgs for k, v in msg.items()} + if not msg: + return + + for ref, (model, parent) in self._models.items(): + if ref not in state._views or ref in state._fake_roots: + continue + viewable, root, doc, comm = state._views[ref] + if comm or not doc.session_context or state._unblocked(doc): + with unlocked(): + self._update_model(events, msg, root, model, doc, comm) + if comm and 'embedded' not in root.tags: + push(doc, comm) + else: + cb = partial(self._update_model, events, msg, root, model, doc, comm) + doc.add_next_tick_callback(cb) + + def _process_events(self, events): + with edit_readonly(self): + self.param.set_param(**self._process_property_change(events)) + + @gen.coroutine + def _change_coroutine(self, doc=None): + self._change_event(doc) + + def _change_event(self, doc=None): + try: + state.curdoc = doc + thread = threading.current_thread() + thread_id = thread.ident if thread else None + state._thread_id = thread_id + events = self._events + self._events = {} + self._process_events(events) + finally: + self._processing = False + state.curdoc = None + state._thread_id = None + + def _comm_change(self, doc, ref, attr, old, new): + if attr in self._changing.get(ref, []): + self._changing[ref].remove(attr) + return + + with hold(doc): + self._process_events({attr: new}) + + def _server_change(self, doc, ref, attr, old, new): + if attr in self._changing.get(ref, []): + self._changing[ref].remove(attr) + return + + state._locks.clear() + self._events.update({attr: new}) + if not self._processing: + self._processing = True + if doc.session_context: + doc.add_timeout_callback(partial(self._change_coroutine, doc), self._debounce) + else: + self._change_event(doc) + + +class Reactive(Syncable, Viewable): + """ + Reactive is a Viewable object that also supports syncing between + the objects parameters and the underlying bokeh model either via + the defined pyviz_comms.Comm type or using bokeh server. + + In addition it defines various methods which make it easy to link + the parameters to other objects. + """ + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + 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. + """ + cb = PeriodicCallback(callback=callback, period=period, + count=count, timeout=timeout) + if start: + cb.start() + return cb + + def link(self, target, callbacks=None, **links): + """ + Links the parameters on this object to attributes on another + object in Python. Supports two modes, either specify a mapping + between the source and target object parameters as keywords or + provide a dictionary of callbacks which maps from the source + parameter to a callback which is triggered when the parameter + changes. + + Arguments + --------- + target: object + The target object of the link. + callbacks: dict + Maps from a parameter in the source object to a callback. + **links: dict + Maps between parameters on this object to the parameters + on the supplied object. + """ + if links and callbacks: + raise ValueError('Either supply a set of parameters to ' + 'link as keywords or a set of callbacks, ' + 'not both.') + elif not links and not callbacks: + raise ValueError('Declare parameters to link or a set of ' + 'callbacks, neither was defined.') + + _updating = [] + def link(*events): + for event in events: + if event.name in _updating: continue + _updating.append(event.name) + try: + if callbacks: + callbacks[event.name](target, event) + else: + setattr(target, links[event.name], event.new) + except Exception: + raise + finally: + _updating.pop(_updating.index(event.name)) + params = list(callbacks) if callbacks else list(links) + cb = self.param.watch(link, params) + link = LinkWatcher(*tuple(cb)+(target, links, callbacks is not None)) + self._links.append(link) + return cb + + def controls(self, parameters=[], jslink=True): + """ + Creates a set of widgets which allow manipulating the parameters + on this instance. By default all parameters which support + linking are exposed, but an explicit list of parameters can + be provided. + + Arguments + --------- + parameters: list(str) + An explicit list of parameters to return controls for. + jslink: bool + Whether to use jslinks instead of Python based links. + This does not allow using all types of parameters. + + Returns + ------- + A layout of the controls + """ + from .param import Param + from .layout import Tabs, WidgetBox + from .widgets import LiteralInput + + if parameters: + linkable = parameters + elif jslink: + linkable = self._linkable_params + else: + linkable = list(self.param) + + params = [p for p in linkable if p not in Layoutable.param] + controls = Param(self.param, parameters=params, default_layout=WidgetBox, + name='Controls') + layout_params = [p for p in linkable if p in Layoutable.param] + if 'name' not in layout_params and self._rename.get('name', False) is not None and not parameters: + layout_params.insert(0, 'name') + style = Param(self.param, parameters=layout_params, default_layout=WidgetBox, + name='Layout') + if jslink: + for p in params: + widget = controls._widgets[p] + widget.jslink(self, value=p, bidirectional=True) + if isinstance(widget, LiteralInput): + widget.serializer = 'json' + for p in layout_params: + widget = style._widgets[p] + widget.jslink(self, value=p, bidirectional=True) + if isinstance(widget, LiteralInput): + widget.serializer = 'json' + + if params and layout_params: + return Tabs(controls.layout[0], style.layout[0]) + elif params: + return controls.layout[0] + return style.layout[0] + + def jscallback(self, args={}, **callbacks): + """ + Allows defining a JS callback to be triggered when a property + changes on the source object. The keyword arguments define the + properties that trigger a callback and the JS code that gets + executed. + + Arguments + ---------- + args: dict + A mapping of objects to make available to the JS callback + **callbacks: dict + A mapping between properties on the source model and the code + to execute when that property changes + + Returns + ------- + callback: Callback + The Callback which can be used to disable the callback. + """ + + from .links import Callback + for k, v in list(callbacks.items()): + callbacks[k] = self._rename.get(v, v) + return Callback(self, code=callbacks, args=args) + + def jslink(self, target, code=None, args=None, bidirectional=False, **links): + """ + Links properties on the source object to those on the target + object in JS code. Supports two modes, either specify a + mapping between the source and target model properties as + keywords or provide a dictionary of JS code snippets which + maps from the source parameter to a JS code snippet which is + executed when the property changes. + + Arguments + ---------- + target: HoloViews object or bokeh Model or panel Viewable + The target to link the value to. + code: dict + Custom code which will be executed when the widget value + changes. + bidirectional: boolean + Whether to link source and target bi-directionally + **links: dict + A mapping between properties on the source model and the + target model property to link it to. + + Returns + ------- + link: GenericLink + The GenericLink which can be used unlink the widget and + the target model. + """ + if links and code: + raise ValueError('Either supply a set of properties to ' + 'link as keywords or a set of JS code ' + 'callbacks, not both.') + elif not links and not code: + raise ValueError('Declare parameters to link or a set of ' + 'callbacks, neither was defined.') + if args is None: + args = {} + + mapping = code or links + for k in mapping: + if k.startswith('event:'): + continue + elif k not in self.param and k not in list(self._rename.values()): + matches = difflib.get_close_matches(k, list(self.param)) + if matches: + matches = ' Similar parameters include: %r' % matches + else: + matches = '' + raise ValueError("Could not jslink %r parameter (or property) " + "on %s object because it was not found.%s" + % (k, type(self).__name__, matches)) + elif (self._source_transforms.get(k, False) is None or + self._rename.get(k, False) is None): + raise ValueError("Cannot jslink %r parameter on %s object, " + "the parameter requires a live Python kernel " + "to have an effect." % (k, type(self).__name__)) + + if isinstance(target, Syncable) and code is None: + for k, p in mapping.items(): + if k.startswith('event:'): + continue + elif p not in target.param and p not in list(target._rename.values()): + matches = difflib.get_close_matches(p, list(target.param)) + if matches: + matches = ' Similar parameters include: %r' % matches + else: + matches = '' + raise ValueError("Could not jslink %r parameter (or property) " + "on %s object because it was not found.%s" + % (p, type(self).__name__, matches)) + elif (target._source_transforms.get(p, False) is None or + target._rename.get(p, False) is None): + raise ValueError("Cannot jslink %r parameter on %s object " + "to %r parameter on %s object. It requires " + "a live Python kernel to have an effect." + % (k, type(self).__name__, p, type(target).__name__)) + + from .links import Link + return Link(self, target, properties=links, code=code, args=args, + bidirectional=bidirectional) diff --git a/panel/tests/io/__init__.py b/panel/tests/io/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/panel/tests/test_io.py b/panel/tests/io/test_embed.py similarity index 86% rename from panel/tests/test_io.py rename to panel/tests/io/test_embed.py index e30ae4c30b..0231632967 100644 --- a/panel/tests/test_io.py +++ b/panel/tests/io/test_embed.py @@ -6,19 +6,15 @@ from io import StringIO -from bokeh.models import ColumnDataSource, CustomJS +from bokeh.models import CustomJS from panel import Row -from panel.io.notebook import ipywidget from panel.config import config from panel.io.embed import embed_state -from panel.io.model import patch_cds_msg from panel.pane import Str from panel.param import Param from panel.widgets import Select, FloatSlider, Checkbox -from .util import jb_available - def test_embed_param_jslink(document, comm): select = Select(options=['A', 'B', 'C']) @@ -409,78 +405,3 @@ def link(target, event): assert event['kind'] == 'ModelChanged' assert event['attr'] == 'text' assert event['new'] == '<pre>%s</pre>' % v - - -@jb_available -def test_ipywidget(): - pane = Str('A') - widget = ipywidget(pane) - - assert widget._view_count == 0 - assert len(pane._models) == 1 - - init_id = list(pane._models)[0] - - widget._view_count = 1 - - assert widget._view_count == 1 - assert init_id in pane._models - - widget._view_count = 0 - - assert len(pane._models) == 0 - - widget._view_count = 1 - - assert len(pane._models) == 1 - prev_id = list(pane._models)[0] - - widget.notify_change({'new': 1, 'old': 1, 'name': '_view_count', - 'type': 'change', 'model': widget}) - assert prev_id in pane._models - assert len(pane._models) == 1 - - widget._view_count = 2 - - assert prev_id in pane._models - assert len(pane._models) == 1 - - -def test_patch_cds_typed_array(): - cds = ColumnDataSource() - msg = { - 'header': {'msgid': 'TEST', 'msgtype': 'PATCH-DOC'}, - 'metadata': {}, - 'content': { - 'events': [{ - 'kind': 'ModelChanged', - 'model': {'id': cds.ref['id']}, - 'attr': 'data', - 'new': { - 'a': {'2': 2, '0': 0, '1': 1}, - 'b': {'0': 'a', '2': 'c', '1': 'b'} - } - }], - 'references': [] - }, - 'buffers': [] - } - expected = { - 'header': {'msgid': 'TEST', 'msgtype': 'PATCH-DOC'}, - 'metadata': {}, - 'content': { - 'events': [{ - 'kind': 'ModelChanged', - 'model': {'id': cds.ref['id']}, - 'attr': 'data', - 'new': { - 'a': [0, 1, 2], - 'b': ['a', 'b', 'c'] - } - }], - 'references': [] - }, - 'buffers': [] - } - patch_cds_msg(cds, msg) - assert msg == expected diff --git a/panel/tests/io/test_location.py b/panel/tests/io/test_location.py new file mode 100644 index 0000000000..0ec35e415a --- /dev/null +++ b/panel/tests/io/test_location.py @@ -0,0 +1,81 @@ +import param +import pytest + +from panel.io.location import Location +from panel.util import edit_readonly + + +@pytest.fixture +def location(): + loc = Location() + with edit_readonly(loc): + loc.href = "http://localhost:5006" + loc.hostname = "localhost" + loc.pathname = "" + loc.protocol = 'http' + loc.search = "" + loc.hash = "" + return loc + + +class SyncParameterized(param.Parameterized): + + integer = param.Integer(default=None) + + string = param.String(default=None) + + +def test_location_update_query(location): + location.update_query(a=1) + assert location.search == "?a=1" + location.update_query(b='c') + assert location.search == "?a=1&b=c" + +def test_location_sync_query_init(location): + p = SyncParameterized(integer=1, string='abc') + location.sync(p) + assert location.search == "?integer=1&string=abc" + +def test_location_sync_query_init_partial(location): + p = SyncParameterized(integer=1, string='abc') + location.sync(p, ['integer']) + assert location.search == "?integer=1" + +def test_location_sync_query_init_rename(location): + p = SyncParameterized(integer=1, string='abc') + location.sync(p, {'integer': 'int', 'string': 'str'}) + assert location.search == "?int=1&str=abc" + +def test_location_sync_query(location): + p = SyncParameterized() + location.sync(p) + p.integer = 2 + assert location.search == "?integer=2" + +def test_location_sync_param_init(location): + p = SyncParameterized() + location.search = "?integer=1&string=abc" + location.sync(p) + assert p.integer == 1 + assert p.string == "abc" + +def test_location_sync_param_init_partial(location): + p = SyncParameterized() + location.search = "?integer=1&string=abc" + location.sync(p, ['integer']) + assert p.integer == 1 + assert p.string is None + +def test_location_sync_param_init_rename(location): + p = SyncParameterized() + location.search = "?int=1&str=abc" + location.sync(p, {'integer': 'int', 'string': 'str'}) + assert p.integer == 1 + assert p.string == 'abc' + +def test_location_sync_param_update(location): + p = SyncParameterized() + location.sync(p) + location.search = "?integer=1&string=abc" + assert p.integer == 1 + assert p.string == "abc" diff --git a/panel/tests/io/test_model.py b/panel/tests/io/test_model.py new file mode 100644 index 0000000000..49661fc544 --- /dev/null +++ b/panel/tests/io/test_model.py @@ -0,0 +1,42 @@ +from bokeh.models import ColumnDataSource + +from panel.io.model import patch_cds_msg + +def test_patch_cds_typed_array(): + cds = ColumnDataSource() + msg = { + 'header': {'msgid': 'TEST', 'msgtype': 'PATCH-DOC'}, + 'metadata': {}, + 'content': { + 'events': [{ + 'kind': 'ModelChanged', + 'model': {'id': cds.ref['id']}, + 'attr': 'data', + 'new': { + 'a': {'2': 2, '0': 0, '1': 1}, + 'b': {'0': 'a', '2': 'c', '1': 'b'} + } + }], + 'references': [] + }, + 'buffers': [] + } + expected = { + 'header': {'msgid': 'TEST', 'msgtype': 'PATCH-DOC'}, + 'metadata': {}, + 'content': { + 'events': [{ + 'kind': 'ModelChanged', + 'model': {'id': cds.ref['id']}, + 'attr': 'data', + 'new': { + 'a': [0, 1, 2], + 'b': ['a', 'b', 'c'] + } + }], + 'references': [] + }, + 'buffers': [] + } + patch_cds_msg(cds, msg) + assert msg == expected diff --git a/panel/tests/io/test_notebook.py b/panel/tests/io/test_notebook.py new file mode 100644 index 0000000000..6980ad9df1 --- /dev/null +++ b/panel/tests/io/test_notebook.py @@ -0,0 +1,38 @@ +from panel.io.notebook import ipywidget +from panel.pane import Str + +from ..util import jb_available + +@jb_available +def test_ipywidget(): + pane = Str('A') + widget = ipywidget(pane) + + assert widget._view_count == 0 + assert len(pane._models) == 1 + + init_id = list(pane._models)[0] + + widget._view_count = 1 + + assert widget._view_count == 1 + assert init_id in pane._models + + widget._view_count = 0 + + assert len(pane._models) == 0 + + widget._view_count = 1 + + assert len(pane._models) == 1 + prev_id = list(pane._models)[0] + + widget.notify_change({'new': 1, 'old': 1, 'name': '_view_count', + 'type': 'change', 'model': widget}) + assert prev_id in pane._models + assert len(pane._models) == 1 + + widget._view_count = 2 + + assert prev_id in pane._models + assert len(pane._models) == 1 diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py new file mode 100644 index 0000000000..5fa7b59da1 --- /dev/null +++ b/panel/tests/models/test_location.py @@ -0,0 +1,32 @@ +from panel.models.location import Location + +def test_constructor(): + # When + actual = Location() + # Then + assert actual.href == "" + assert actual.hostname == "" + assert actual.pathname == "" + assert actual.protocol == "" + assert actual.port == "" + assert actual.search == "" + assert actual.hash == "" + assert actual.reload == True + + +def test_constructor_with__href(): + # Given + href = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" + # When + actual = Location( + href="https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" + ) + # Then + assert actual.href == href + assert actual.hostname == "" + assert actual.pathname == "" + assert actual.protocol == "" + assert actual.port == "" + assert actual.search == "" + assert actual.hash == "" + assert actual.reload == True diff --git a/panel/tests/test_reactive.py b/panel/tests/test_reactive.py index dea2ff6db9..fad762c6d9 100644 --- a/panel/tests/test_reactive.py +++ b/panel/tests/test_reactive.py @@ -4,7 +4,8 @@ from bokeh.models import Div from panel.layout import Tabs, WidgetBox -from panel.viewable import Layoutable, Reactive +from panel.reactive import Reactive +from panel.viewable import Layoutable from panel.widgets import Checkbox, StaticText, TextInput diff --git a/panel/util.py b/panel/util.py index 6b9ebff36d..e70093b0e5 100644 --- a/panel/util.py +++ b/panel/util.py @@ -5,12 +5,15 @@ import datetime as dt import inspect +import json import numbers import os import re import sys +import urllib.parse as urlparse from collections import defaultdict, OrderedDict +from contextlib import contextmanager from datetime import datetime from six import string_types @@ -254,3 +257,51 @@ def value_as_date(value): elif isinstance(value, datetime): value = value.date() return value + + +def is_number(s): + try: + float(s) + return True + except ValueError: + return False + + +def parse_query(query): + """ + Parses a url query string, e.g. ?a=1&b=2.1&c=string, converting + numeric strings to int or float types. + """ + query = dict(urlparse.parse_qsl(query[1:])) + for k, v in list(query.items()): + if v.isdigit(): + query[k] = int(v) + elif is_number(v): + query[k] = float(v) + elif v.startswith('[') or v.startswith('{'): + query[k] = json.loads(v) + return query + +# This functionality should be contributed to param +# See https://github.com/holoviz/param/issues/379 +@contextmanager +def edit_readonly(parameterized): + """ + Temporarily set parameters on Parameterized object to readonly=False + to allow editing them. + """ + params = parameterized.param.objects("existing").values() + readonlys = [p.readonly for p in params] + constants = [p.constant for p in params] + for p in params: + p.readonly = False + p.constant = False + try: + yield + except Exception: + raise + finally: + for (p, readonly) in zip(params, readonlys): + p.readonly = readonly + for (p, constant) in zip(params, constants): + p.constant = constant diff --git a/panel/viewable.py b/panel/viewable.py index d462ef45bc..bc1860ad8c 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -1,18 +1,18 @@ """ -Defines the Viewable and Reactive baseclasses allow all panel objects -to display themselves, communicate with a Python process and react in -response to changes to parameters and the underlying bokeh models. +Defines the baseclasses that make a component render to a bokeh model +and become viewable including: + +* Layoutable: Defines parameters concerned with layout and style +* ServableMixin: Mixin class that defines methods to serve object on server +* Renderable: Defines methods to render a component as a bokeh model +* Viewable: Defines methods to view the component in the + notebook, on the server or in static exports """ -from __future__ import absolute_import, division, unicode_literals - -import difflib import logging import sys -import threading import traceback import uuid -from collections import namedtuple from functools import partial import param @@ -20,25 +20,24 @@ from bokeh.document.document import Document as _Document from bokeh.io import curdoc as _curdoc from pyviz_comms import JupyterCommManager -from tornado import gen -from .callbacks import PeriodicCallback from .config import config, panel_extension from .io.embed import embed_state -from .io.model import add_to_doc, hold, patch_cds_msg +from .io.model import add_to_doc, patch_cds_msg from .io.notebook import ( - ipywidget, push, render_mimebundle, render_model, show_embed, show_server + ipywidget, render_mimebundle, render_model, show_embed, show_server ) from .io.save import save from .io.state import state -from .io.server import StoppableThread, get_server, unlocked +from .io.server import StoppableThread, get_server from .util import escape, param_reprs -LinkWatcher = namedtuple("Watcher","inst cls fn mode onlychanged parameter_names what queued target links transformed") - - class Layoutable(param.Parameterized): + """ + Layoutable defines shared style and layout related parameters + for all Panel components with a visual representation. + """ align = param.ObjectSelector(default='start', objects=['start', 'end', 'center'], doc=""" @@ -201,6 +200,8 @@ class Layoutable(param.Parameterized): provided aspect ratio. """) + __abstract = True + def __init__(self, **params): if (params.get('width', None) is not None and params.get('height', None) is not None and @@ -215,18 +216,21 @@ def __init__(self, **params): class ServableMixin(object): + """ + Mixin to define methods shared by objects which can served. + """ - def _modify_doc(self, server_id, title, doc): + def _modify_doc(self, server_id, title, doc, location): """ Callback to handle FunctionHandler document creation. """ if server_id: state._servers[server_id][2].append(doc) - return self.server_doc(doc, title) + return self.server_doc(doc, title, location) def _get_server(self, port=0, websocket_origin=None, loop=None, show=False, start=False, title=None, verbose=False, - **kwargs): + location=True, **kwargs): return get_server(self, port, websocket_origin, loop, show, start, title, verbose, **kwargs) @@ -271,17 +275,19 @@ def _on_stdout(self, ref, stdout): # Public API #---------------------------------------------------------------- - def servable(self, title=None): + def servable(self, title=None, location=True): """ Serves the object if in a `panel serve` context and returns the Panel object to allow it to display itself in a notebook context. - Arguments --------- title : str A string title to give the Document (if served as an app) - + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. + Returns ------- The Panel object itself @@ -291,11 +297,11 @@ def servable(self, title=None): for handler in logger.handlers: if isinstance(handler, logging.StreamHandler): handler.setLevel(logging.WARN) - self.server_doc(title=title) + self.server_doc(title=title, location=True) return self def show(self, title=None, port=0, websocket_origin=None, threaded=False, - verbose=True, open=True, **kwargs): + verbose=True, open=True, location=True, **kwargs): """ Starts a Bokeh server and displays the Viewable in a new tab. @@ -317,6 +323,9 @@ def show(self, title=None, port=0, websocket_origin=None, threaded=False, Whether to print the address and port open : boolean (optional, default=True) Whether to open the server in a new browser tab + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. Returns ------- @@ -329,45 +338,34 @@ def show(self, title=None, port=0, websocket_origin=None, threaded=False, loop = IOLoop() server = StoppableThread( target=self._get_server, io_loop=loop, - args=(port, websocket_origin, loop, open, True, title, verbose), + args=(port, websocket_origin, loop, open, True, title, verbose, location), kwargs=kwargs) server.start() else: server = self._get_server( port, websocket_origin, show=open, start=True, - title=title, verbose=verbose, **kwargs + title=title, verbose=verbose, location=location, **kwargs ) return server - -class Viewable(Layoutable, ServableMixin): + +class Renderable(param.Parameterized): """ - Viewable is the baseclass all objects in the panel library are - built on. It defines the interface for declaring any object that - displays itself by transforming the object(s) being wrapped into - models that can be served using bokeh's layout engine. The class - also defines various methods that allow Viewable objects to be - displayed in the notebook and on bokeh server. + Baseclass for objects which can be rendered to a Bokeh model. + + It therefore declare APIs for initializing the models from + parameter values. """ __abstract = True - _preprocessing_hooks = [] - def __init__(self, **params): - super(Viewable, self).__init__(**params) + super(Renderable, self).__init__(**params) self._documents = {} self._models = {} self._comms = {} self._found_links = set() - def __repr__(self, depth=0): - return '{cls}({params})'.format(cls=type(self).__name__, - params=', '.join(param_reprs(self))) - - def __str__(self): - return self.__repr__() - def _get_model(self, doc, root=None, parent=None, comm=None): """ Converts the objects being wrapped by the viewable into a @@ -427,6 +425,52 @@ def _render_model(self, doc=None, comm=None): add_to_doc(model, doc) return model + def _init_properties(self): + return {k: v for k, v in self.param.get_param_values() + if v is not None} + + def get_root(self, doc=None, comm=None): + """ + Returns the root model and applies pre-processing hooks + + Arguments + --------- + doc: bokeh.Document + Bokeh document the bokeh model will be attached to. + comm: pyviz_comms.Comm + Optional pyviz_comms when working in notebook + + Returns + ------- + Returns the bokeh model corresponding to this panel object + """ + doc = doc or _curdoc() + root = self._get_model(doc, comm=comm) + self._preprocess(root) + ref = root.ref['id'] + state._views[ref] = (self, root, doc, comm) + return root + + +class Viewable(Renderable, Layoutable, ServableMixin): + """ + Viewable is the baseclass all visual components in the panel + library are built on. It defines the interface for declaring any + object that displays itself by transforming the object(s) being + wrapped into models that can be served using bokeh's layout + engine. The class also defines various methods that allow Viewable + objects to be displayed in the notebook and on bokeh server. + """ + + _preprocessing_hooks = [] + + def __repr__(self, depth=0): + return '{cls}({params})'.format(cls=type(self).__name__, + params=', '.join(param_reprs(self))) + + def __str__(self): + return self.__repr__() + def _repr_mimebundle_(self, include=None, exclude=None): loaded = panel_extension._loaded if not loaded and 'holoviews' in sys.modules: @@ -460,6 +504,13 @@ def _repr_mimebundle_(self, include=None, exclude=None): except Exception: pass + if not state._views: + # Initialize the global Location + from .io.location import Location + state._location = location = Location() + else: + location = None + from IPython.display import display from .models.comm_manager import CommManager @@ -483,50 +534,7 @@ def _repr_mimebundle_(self, include=None, exclude=None): if config.embed: return render_model(model) - return render_mimebundle(model, doc, comm, manager) - - def _comm_change(self, doc, ref, attr, old, new): - if attr in self._changing.get(ref, []): - self._changing[ref].remove(attr) - return - - with hold(doc): - self._process_events({attr: new}) - - def _server_change(self, doc, ref, attr, old, new): - if attr in self._changing.get(ref, []): - self._changing[ref].remove(attr) - return - - state._locks.clear() - self._events.update({attr: new}) - if not self._processing: - self._processing = True - if doc.session_context: - doc.add_timeout_callback(partial(self._change_coroutine, doc), self._debounce) - else: - self._change_event(doc) - - def _process_events(self, events): - self.param.set_param(**self._process_property_change(events)) - - @gen.coroutine - def _change_coroutine(self, doc=None): - self._change_event(doc) - - def _change_event(self, doc=None): - try: - state.curdoc = doc - thread = threading.current_thread() - thread_id = thread.ident if thread else None - state._thread_id = thread_id - events = self._events - self._events = {} - self._process_events(events) - finally: - self._processing = False - state.curdoc = None - state._thread_id = None + return render_mimebundle(model, doc, comm, manager, location) def _server_destroy(self, session_context): """ @@ -623,28 +631,6 @@ def embed(self, max_states=1000, max_opts=3, json=False, load_path, progress ) - def get_root(self, doc=None, comm=None): - """ - Returns the root model and applies pre-processing hooks - - Arguments - --------- - doc: bokeh.Document - Bokeh document the bokeh model will be attached to. - comm: pyviz_comms.Comm - Optional pyviz_comms when working in notebook - - Returns - ------- - Returns the bokeh model corresponding to this panel object - """ - doc = doc or _curdoc() - root = self._get_model(doc, comm=comm) - self._preprocess(root) - ref = root.ref['id'] - state._views[ref] = (self, root, doc, comm) - return root - def save(self, filename, title=None, resources=None, template=None, template_variables=None, embed=False, max_states=1000, max_opts=3, embed_json=False, json_prefix='', save_path='./', @@ -683,7 +669,7 @@ def save(self, filename, title=None, resources=None, template=None, template_variables, embed, max_states, max_opts, embed_json, json_prefix, save_path, load_path) - def server_doc(self, doc=None, title=None): + def server_doc(self, doc=None, title=None, location=True): """ Returns a serveable bokeh Document with the panel attached @@ -692,6 +678,9 @@ def server_doc(self, doc=None, title=None): doc : bokeh.Document (optional) The bokeh Document to attach the panel to as a root, defaults to bokeh.io.curdoc() + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. title : str A string title to give the Document @@ -700,6 +689,7 @@ def server_doc(self, doc=None, title=None): doc : bokeh.Document The bokeh document the panel was attached to """ + from .io.location import Location doc = doc or _curdoc() title = title or 'Panel Application' doc.title = title @@ -708,409 +698,14 @@ def server_doc(self, doc=None, title=None): doc.on_session_destroyed(self._server_destroy) self._documents[doc] = model add_to_doc(model, doc) - return doc - - -class Reactive(Viewable): - """ - Reactive is a Viewable object that also supports syncing between - the objects parameters and the underlying bokeh model either via - the defined pyviz_comms.Comm type or when using bokeh server. - - In order to bi-directionally link parameters with bokeh model - instances the _link_params and _link_props methods define - callbacks triggered when either the parameter or bokeh property - values change. Since there may not be a 1-to-1 mapping between - parameter and the model property the _process_property_change and - _process_param_change may be overridden to apply any necessary - transformations. - """ - - # Timeout if a notebook comm message is swallowed - _timeout = 20000 - - # Timeout before the first event is processed - _debounce = 50 - - # Mapping from parameter name to bokeh model property name - _rename = {} - - # Allows defining a mapping from model property name to a JS code - # snippet that transforms the object before serialization - _js_transforms = {} - - # Transforms from input value to bokeh property value - _source_transforms = {} - _target_transforms = {} - - def __init__(self, **params): - # temporary flag denotes panes created for temporary, internal - # use which should be garbage collected once they have been used - super(Reactive, self).__init__(**params) - self._processing = False - self._events = {} - self._callbacks = [] - self._links = [] - self._link_params() - self._changing = {} - - #---------------------------------------------------------------- - # Callback API - #---------------------------------------------------------------- - - def _update_model(self, events, msg, root, model, doc, comm): - self._changing[root.ref['id']] = [ - attr for attr, value in msg.items() - if not model.lookup(attr).property.matches(getattr(model, attr), value) - ] - try: - model.update(**msg) - finally: - del self._changing[root.ref['id']] - - def param_change(self, *events): - msgs = [] - for event in events: - msg = self._process_param_change({event.name: event.new}) - if msg: - msgs.append(msg) - - events = {event.name: event for event in events} - msg = {k: v for msg in msgs for k, v in msg.items()} - if not msg: - return - - for ref, (model, parent) in self._models.items(): - if ref not in state._views or ref in state._fake_roots: - continue - viewable, root, doc, comm = state._views[ref] - if comm or not doc.session_context or state._unblocked(doc): - with unlocked(): - self._update_model(events, msg, root, model, doc, comm) - if comm and 'embedded' not in root.tags: - push(doc, comm) + if location: + if isinstance(location, Location): + loc = location + elif doc in state._locations: + loc = state.location else: - cb = partial(self._update_model, events, msg, root, model, doc, comm) - doc.add_next_tick_callback(cb) - - def _link_params(self): - params = self._synced_params() - if params: - watcher = self.param.watch(self.param_change, params) - self._callbacks.append(watcher) - - def _link_props(self, model, properties, doc, root, comm=None): - ref = root.ref['id'] - if config.embed: - return - - for p in properties: - if isinstance(p, tuple): - _, p = p - if comm: - model.on_change(p, partial(self._comm_change, doc, ref)) - else: - model.on_change(p, partial(self._server_change, doc, ref)) - - #---------------------------------------------------------------- - # Model API - #---------------------------------------------------------------- - - def _init_properties(self): - return {k: v for k, v in self.param.get_param_values() - if v is not None} - - @property - def _linkable_params(self): - return [p for p in self._synced_params() - if self._source_transforms.get(p, False) is not None] - - def _synced_params(self): - return list(self.param) - - def _process_property_change(self, msg): - """ - Transform bokeh model property changes into parameter updates. - Should be overridden to provide appropriate mapping between - parameter value and bokeh model change. By default uses the - _rename class level attribute to map between parameter and - property names. - """ - inverted = {v: k for k, v in self._rename.items()} - return {inverted.get(k, k): v for k, v in msg.items()} - - def _process_param_change(self, msg): - """ - Transform parameter changes into bokeh model property updates. - Should be overridden to provide appropriate mapping between - parameter value and bokeh model change. By default uses the - _rename class level attribute to map between parameter and - property names. - """ - properties = {self._rename.get(k, k): v for k, v in msg.items() - if self._rename.get(k, False) is not None} - if 'width' in properties and self.sizing_mode is None: - properties['min_width'] = properties['width'] - if 'height' in properties and self.sizing_mode is None: - properties['min_height'] = properties['height'] - return properties - - def _cleanup(self, root): - super(Reactive, self)._cleanup(root) - ref = root.ref['id'] - self._models.pop(ref, None) - comm, client_comm = self._comms.pop(ref, (None, None)) - if comm: - try: - comm.close() - except Exception: - pass - if client_comm: - try: - client_comm.close() - except Exception: - pass - - #---------------------------------------------------------------- - # Public API - #---------------------------------------------------------------- - - def controls(self, parameters=[], jslink=True): - """ - Creates a set of widgets which allow manipulating the parameters - on this instance. By default all parameters which support - linking are exposed, but an explicit list of parameters can - be provided. - - Arguments - --------- - parameters: list(str) - An explicit list of parameters to return controls for. - jslink: bool - Whether to use jslinks instead of Python based links. - This does not allow using all types of parameters. - - Returns - ------- - A layout of the controls - """ - from .param import Param - from .layout import Tabs, WidgetBox - from .widgets import LiteralInput - - if parameters: - linkable = parameters - elif jslink: - linkable = self._linkable_params - else: - linkable = list(self.param) - - params = [p for p in linkable if p not in Layoutable.param] - controls = Param(self.param, parameters=params, default_layout=WidgetBox, - name='Controls') - layout_params = [p for p in linkable if p in Layoutable.param] - if 'name' not in layout_params and self._rename.get('name', False) is not None and not parameters: - layout_params.insert(0, 'name') - style = Param(self.param, parameters=layout_params, default_layout=WidgetBox, - name='Layout') - if jslink: - for p in params: - widget = controls._widgets[p] - widget.jslink(self, value=p, bidirectional=True) - if isinstance(widget, LiteralInput): - widget.serializer = 'json' - for p in layout_params: - widget = style._widgets[p] - widget.jslink(self, value=p, bidirectional=True) - if isinstance(widget, LiteralInput): - widget.serializer = 'json' - - if params and layout_params: - return Tabs(controls.layout[0], style.layout[0]) - elif params: - return controls.layout[0] - return style.layout[0] - - def link(self, target, callbacks=None, **links): - """ - Links the parameters on this object to attributes on another - object in Python. Supports two modes, either specify a mapping - between the source and target object parameters as keywords or - provide a dictionary of callbacks which maps from the source - parameter to a callback which is triggered when the parameter - changes. - - Arguments - --------- - target: object - The target object of the link. - callbacks: dict - Maps from a parameter in the source object to a callback. - **links: dict - Maps between parameters on this object to the parameters - on the supplied object. - """ - if links and callbacks: - raise ValueError('Either supply a set of parameters to ' - 'link as keywords or a set of callbacks, ' - 'not both.') - elif not links and not callbacks: - raise ValueError('Declare parameters to link or a set of ' - 'callbacks, neither was defined.') - - _updating = [] - def link(*events): - for event in events: - if event.name in _updating: continue - _updating.append(event.name) - try: - if callbacks: - callbacks[event.name](target, event) - else: - setattr(target, links[event.name], event.new) - except Exception: - raise - finally: - _updating.pop(_updating.index(event.name)) - params = list(callbacks) if callbacks else list(links) - cb = self.param.watch(link, params) - link = LinkWatcher(*tuple(cb)+(target, links, callbacks is not None)) - self._links.append(link) - return cb - - 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. - """ - cb = PeriodicCallback(callback=callback, period=period, - count=count, timeout=timeout) - if start: - cb.start() - return cb - - def jscallback(self, args={}, **callbacks): - """ - Allows defining a JS callback to be triggered when a property - changes on the source object. The keyword arguments define the - properties that trigger a callback and the JS code that gets - executed. - - Arguments - ---------- - args: dict - A mapping of objects to make available to the JS callback - **callbacks: dict - A mapping between properties on the source model and the code - to execute when that property changes - - Returns - ------- - callback: Callback - The Callback which can be used to disable the callback. - """ - - from .links import Callback - for k, v in list(callbacks.items()): - callbacks[k] = self._rename.get(v, v) - return Callback(self, code=callbacks, args=args) - - def jslink(self, target, code=None, args=None, bidirectional=False, **links): - """ - Links properties on the source object to those on the target - object in JS code. Supports two modes, either specify a - mapping between the source and target model properties as - keywords or provide a dictionary of JS code snippets which - maps from the source parameter to a JS code snippet which is - executed when the property changes. - - Arguments - ---------- - target: HoloViews object or bokeh Model or panel Viewable - The target to link the value to. - code: dict - Custom code which will be executed when the widget value - changes. - bidirectional: boolean - Whether to link source and target bi-directionally - **links: dict - A mapping between properties on the source model and the - target model property to link it to. - - Returns - ------- - link: GenericLink - The GenericLink which can be used unlink the widget and - the target model. - """ - if links and code: - raise ValueError('Either supply a set of properties to ' - 'link as keywords or a set of JS code ' - 'callbacks, not both.') - elif not links and not code: - raise ValueError('Declare parameters to link or a set of ' - 'callbacks, neither was defined.') - if args is None: - args = {} - - mapping = code or links - for k in mapping: - if k.startswith('event:'): - continue - elif k not in self.param and k not in list(self._rename.values()): - matches = difflib.get_close_matches(k, list(self.param)) - if matches: - matches = ' Similar parameters include: %r' % matches - else: - matches = '' - raise ValueError("Could not jslink %r parameter (or property) " - "on %s object because it was not found.%s" - % (k, type(self).__name__, matches)) - elif (self._source_transforms.get(k, False) is None or - self._rename.get(k, False) is None): - raise ValueError("Cannot jslink %r parameter on %s object, " - "the parameter requires a live Python kernel " - "to have an effect." % (k, type(self).__name__)) - - if isinstance(target, Reactive) and code is None: - for k, p in mapping.items(): - if k.startswith('event:'): - continue - elif p not in target.param and p not in list(target._rename.values()): - matches = difflib.get_close_matches(p, list(target.param)) - if matches: - matches = ' Similar parameters include: %r' % matches - else: - matches = '' - raise ValueError("Could not jslink %r parameter (or property) " - "on %s object because it was not found.%s" - % (p, type(self).__name__, matches)) - elif (target._source_transforms.get(p, False) is None or - target._rename.get(p, False) is None): - raise ValueError("Cannot jslink %r parameter on %s object " - "to %r parameter on %s object. It requires " - "a live Python kernel to have an effect." - % (k, type(self).__name__, p, type(target).__name__)) - - from .links import Link - return Link(self, target, properties=links, code=code, args=args, - bidirectional=bidirectional) + loc = Location() + state._locations[doc] = loc + loc_model = loc._get_model(doc, model) + doc.add_root(loc_model) + return doc diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 6bdb4fe7ea..74744c9587 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -5,13 +5,21 @@ from __future__ import absolute_import, division, unicode_literals from .ace import Ace # noqa -from .base import Widget, CompositeWidget # noqa -from .button import Button, Toggle # noqa -from .file_selector import FileSelector # noqa -from .input import (# noqa - ColorPicker, Checkbox, DatetimeInput, DatePicker, FileInput, - LiteralInput, StaticText, TextInput, Spinner, PasswordInput, - TextAreaInput +from .base import Widget, CompositeWidget # noqa +from .button import Button, Toggle # noqa +from .file_selector import FileSelector # noqa +from .input import ( # noqa + ColorPicker, + Checkbox, + DatetimeInput, + DatePicker, + FileInput, + LiteralInput, + StaticText, + TextInput, + Spinner, + PasswordInput, + TextAreaInput, ) from .misc import Audio, FileDownload, Progress, VideoStream # noqa from .player import DiscretePlayer, Player # noqa @@ -24,4 +32,4 @@ MultiChoice, MultiSelect, RadioButtonGroup, RadioBoxGroup, Select, ToggleGroup ) -from .tables import DataFrame # noqa +from .tables import DataFrame # noqa diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 2a7ef8f7c7..1367304b25 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -11,8 +11,8 @@ from ..layout import Row from ..io import push, state, unlocked -from ..viewable import Reactive, Layoutable - +from ..reactive import Reactive +from ..viewable import Layoutable class Widget(Reactive):