From 4fe16c6061fbf254f60dc8f752a5fb6c3ac381b4 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 16 Feb 2020 07:07:28 +0100 Subject: [PATCH 01/28] added .vscode folder --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From dcd3272971dcc860b4a5eb800d28112dac474561 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 16 Feb 2020 08:17:57 +0100 Subject: [PATCH 02/28] boiler plate code to make things concrete --- panel/io/state.py | 50 +++++++++++++++++++++------- panel/models/location.py | 71 ++++++++++++++++++++++++++++++++++++++++ panel/models/location.ts | 53 ++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 panel/models/location.py create mode 100644 panel/models/location.ts diff --git a/panel/io/state.py b/panel/io/state.py index c65f02d312..e3693508db 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -12,6 +12,24 @@ from bokeh.document import Document from bokeh.io import curdoc as _curdoc from pyviz_comms import CommManager as _CommManager +from panel.models.location import Location + + +class _Window(param.Parameterized): + """ + Provides selected functionality of the JS window api + """ + + location = param.Parameter( + default=Location(), + doc=""" + Provides access to selected functionality of the JS window.location api""", + ) + + def __init__(self, **params): + if "default" not in params: + params["default"] = Location() + super().__init__(**params) class _state(param.Parameterized): @@ -20,17 +38,27 @@ class _state(param.Parameterized): apps to indicate their state to a user. """ - 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.""") + 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.""", + ) + + webdriver = param.Parameter( + default=None, + doc=""" + Selenium webdriver used to export bokeh models to pngs.""", + ) - webdriver = param.Parameter(default=None, doc=""" - Selenium webdriver used to export bokeh models to pngs.""") + window = param.Parameter(default=_Window()) - _curdoc = param.ClassSelector(class_=Document, doc=""" + _curdoc = param.ClassSelector( + class_=Document, + doc=""" The bokeh Document for which a server event is currently being - processed.""") + processed.""", + ) # Whether to hold comm events _hold = False @@ -58,8 +86,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 +105,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): diff --git a/panel/models/location.py b/panel/models/location.py new file mode 100644 index 0000000000..ff9e12fc7f --- /dev/null +++ b/panel/models/location.py @@ -0,0 +1,71 @@ +"""This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" +from bokeh.models import Model +from bokeh.core.properties import Bool, String, Int +import param +from typing import Optional, List + + +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( + help="""The full url, e.g. \ + 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""" + ) + hostname = String(help="hostname in window.location e.g. 'panel.holoviz.org'") + pathname = String(help="pathname in window.location e.g. 'user_guide/Interact.html'") + protocol = String(help="protocol in window.location e.g. 'https:'") + port = Int(help="port in window.location e.g. 80") + search = String(help="search in window.location e.g. '?color=blue'") + hash_ = String(help="hash in window.location e.g. '#interact'") + + refresh = Bool( + default=False, + help="""Refresh 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""", + ) + + # Maybe Later ? + # - Add assign function to open a new window location. + # - Add reload function to force a reload + # - Add replace function to + + # Maybe ? + # we should provide the below helper functionality to easily + # keep a Parameterized class in sync with the search string + + # maybe ? + # we can only keep a dictionary in sync and the user has to specify how + # to serialize the Parameterized Class? + + # Maybe the param_class and parameters should be parameters on the model? + + def sync_search(self, param_class: param.Parameterized, parameters: List[str] = None): + """Updates the search string from the specified parameters + + Parameters + ---------- + param_class : param.Parameterized + The Parameterized Class containing the Parameters + parameters : [type], optional + The parameters to provide in the search string. If None is provided then all, by default None + """ + raise NotImplementedError() + + def sync_param_class(self, parameters=None): + """Updates the Parameterized Class from the parameters + + Parameters + ---------- + param_class : param.Parameterized + The Parameterized Class containing the Parameters + parameters : [type], optional + [description], by default None + """ + raise NotImplementedError() diff --git a/panel/models/location.ts b/panel/models/location.ts new file mode 100644 index 0000000000..85d05f41f1 --- /dev/null +++ b/panel/models/location.ts @@ -0,0 +1,53 @@ +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 +} + +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 + refresh: p.Property + } +} + + + +export interface Location extends Location.Attrs { } + +export class Location extends Model { + properties: Location.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static __module__ = "panel.models.location" + + 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.Number], + search: [p.String], + hash_: [p.String], + refresh: [p.Boolean], + }) + } +} + + From 34d25fcf751d9f0664026d7cd001a08f6c133d1c Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sat, 22 Feb 2020 06:17:47 +0100 Subject: [PATCH 03/28] The bokeh model can now instantiate with the correct values --- panel/io/state.py | 5 -- panel/models/location.py | 61 ++++++---------------- panel/models/location.ts | 76 +++++++++++++++++++--------- panel/tests/models/test_location.py | 63 +++++++++++++++++++++++ panel/tests/widgets/test_location.py | 32 ++++++++++++ panel/widgets/location.py | 62 +++++++++++++++++++++++ 6 files changed, 226 insertions(+), 73 deletions(-) create mode 100644 panel/tests/models/test_location.py create mode 100644 panel/tests/widgets/test_location.py create mode 100644 panel/widgets/location.py diff --git a/panel/io/state.py b/panel/io/state.py index e3693508db..ccc2101d0d 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -26,11 +26,6 @@ class _Window(param.Parameterized): Provides access to selected functionality of the JS window.location api""", ) - def __init__(self, **params): - if "default" not in params: - params["default"] = Location() - super().__init__(**params) - class _state(param.Parameterized): """ diff --git a/panel/models/location.py b/panel/models/location.py index ff9e12fc7f..54dd0509e8 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -1,11 +1,19 @@ """This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" -from bokeh.models import Model -from bokeh.core.properties import Bool, String, Int +import pathlib +from typing import List, Optional + import param -from typing import Optional, List +from bokeh.core.properties import Bool, Instance, Int, String +from bokeh.layouts import column +from bokeh.models import HTMLBox, Model, Slider + +import panel as pn + +LOCATION_TS = pathlib.Path(__file__).parent / "location.ts" +LOCATION_TS_STR = str(LOCATION_TS.resolve()) -class Location(Model): +class Location(HTMLBox): """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 @@ -14,6 +22,8 @@ class Location(Model): shareable link. """ + __implementation__ = LOCATION_TS_STR + href = String( help="""The full url, e.g. \ 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""" @@ -21,51 +31,12 @@ class Location(Model): hostname = String(help="hostname in window.location e.g. 'panel.holoviz.org'") pathname = String(help="pathname in window.location e.g. 'user_guide/Interact.html'") protocol = String(help="protocol in window.location e.g. 'https:'") - port = Int(help="port in window.location e.g. 80") + port = String(help="port in window.location e.g. 80") search = String(help="search in window.location e.g. '?color=blue'") hash_ = String(help="hash in window.location e.g. '#interact'") refresh = Bool( - default=False, + default=True, help="""Refresh 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""", ) - - # Maybe Later ? - # - Add assign function to open a new window location. - # - Add reload function to force a reload - # - Add replace function to - - # Maybe ? - # we should provide the below helper functionality to easily - # keep a Parameterized class in sync with the search string - - # maybe ? - # we can only keep a dictionary in sync and the user has to specify how - # to serialize the Parameterized Class? - - # Maybe the param_class and parameters should be parameters on the model? - - def sync_search(self, param_class: param.Parameterized, parameters: List[str] = None): - """Updates the search string from the specified parameters - - Parameters - ---------- - param_class : param.Parameterized - The Parameterized Class containing the Parameters - parameters : [type], optional - The parameters to provide in the search string. If None is provided then all, by default None - """ - raise NotImplementedError() - - def sync_param_class(self, parameters=None): - """Updates the Parameterized Class from the parameters - - Parameters - ---------- - param_class : param.Parameterized - The Parameterized Class containing the Parameters - parameters : [type], optional - [description], by default None - """ - raise NotImplementedError() diff --git a/panel/models/location.ts b/panel/models/location.ts index 85d05f41f1..624456b1e9 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -1,53 +1,83 @@ -import * as p from "@bokehjs/core/properties" -import { View } from "@bokehjs/core/view" -import { Model } from "@bokehjs/model" +import { HTMLBox, HTMLBoxView } from "models/layouts/html_box" -export class LocationView extends View { +import { div } from "core/dom" +import * as p from "core/properties" + +export class LocationView extends HTMLBoxView { model: Location + + connect_signals(): void { + console.info("connect_signals"); + super.connect_signals(); + + this.render(); + + // this.connect(this.model.slider.change, () => { + // console.info("slider change call back"); + // this.render(); + // }) + } + + render(): void { + console.info("render"); + super.render() + + var myString = ` + ${this.model.href} + ${this.model.hostname} + ${this.model.pathname} + ${this.model.protocol} + ${this.model.port} + ${this.model.search} + ${this.model.hash_} + ${this.model.refresh} + `; + + this.el.appendChild(div({ + style: { + padding: '2px', + color: '#b88d8e', + backgroundColor: '#2a3153', + }, + }, myString)) + } } export namespace Location { export type Attrs = p.AttrsOf - - export type Props = Model.Props & { + export type Props = HTMLBox.Props & { href: p.Property hostname: p.Property pathname: p.Property protocol: p.Property - port: p.Property + port: p.Property search: p.Property hash_: p.Property refresh: p.Property } } - - export interface Location extends Location.Attrs { } -export class Location extends Model { +export class Location extends HTMLBox { properties: Location.Props constructor(attrs?: Partial) { super(attrs) } - static __module__ = "panel.models.location" - static init_Location(): void { - this.prototype.default_view = LocationView + this.prototype.default_view = LocationView; this.define({ - href: [p.String], - hostname: [p.String], - pathname: [p.String], - protocol: [p.String], - port: [p.Number], - search: [p.String], - hash_: [p.String], - refresh: [p.Boolean], + href: [p.String, window.location.href], + hostname: [p.String, window.location.hostname], + pathname: [p.String, window.location.pathname], + protocol: [p.String, window.location.protocol], + port: [p.String, window.location.port], + search: [p.String, window.location.search], + hash_: [p.String, window.location.hash], + refresh: [p.Boolean, true], }) } } - - diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py new file mode 100644 index 0000000000..36caa75f82 --- /dev/null +++ b/panel/tests/models/test_location.py @@ -0,0 +1,63 @@ +"""In this module we test the Bokeh Location Model""" + +import pytest +from panel.widgets.location import Location +import panel as pn +from panel.models.location import Location as BKLocation + + +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.refresh == 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.refresh == True + + +def test_manual(): + location = Location() + parameters = [ + "href", + "hostname", + "pathname", + "protocol", + "port", + "search", + "hash_", + "refresh", + ] + bkmodel = pn.pane.Bokeh(BKLocation()) + app = pn.Column(bkmodel, pn.Param(location, parameters=parameters)) + return app + + +if __name__.startswith("bk"): + import ptvsd + + ptvsd.enable_attach(address=("localhost", 5678)) + ptvsd.wait_for_attach() # Only include this line if you always wan't to attach the debugger + test_manual().servable() diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py new file mode 100644 index 0000000000..158f7f93b8 --- /dev/null +++ b/panel/tests/widgets/test_location.py @@ -0,0 +1,32 @@ +from panel.widgets.location import Location + +HREF = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" +HOSTNAME = "panel.holoviz.org" +PATHNAME = "user_guide/Interact.html" +PROTOCOL = "https" +PORT = "80" +SEARCH = "?color=blue" +HASH_ = "#interact" + + +def test_location(document, comm): + # given + + # When + location = Location(href=HREF, name="Location") + + widget = location.get_root(document, comm=comm) + + assert isinstance(widget, Location._widget_type) + assert widget.href == HREF + # assert widget.title == 'Slider' + # assert widget.step == 0.1 + # assert widget.start == 0.1 + # assert widget.end == 0.5 + # assert widget.value == 0.4 + + # slider._comm_change({'value': 0.2}) + # assert slider.value == 0.2 + + # slider.value = 0.3 + # assert widget.value == 0.3 diff --git a/panel/widgets/location.py b/panel/widgets/location.py new file mode 100644 index 0000000000..7adc3b6cab --- /dev/null +++ b/panel/widgets/location.py @@ -0,0 +1,62 @@ +""" +Defines the Location widget which allows changing the href of the window. +""" +import param + +from panel.models.location import Location as _BkLocation + +from .base import Widget +import param + +from typing import Optional, List, Dict + + +class Location(Widget): + href = param.String( + doc="""The full url, e.g. \ + 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""" + ) + hostname = param.String(doc="hostname in window.location e.g. 'panel.holoviz.org'") + pathname = param.String(doc="pathname in window.location e.g. 'user_guide/Interact.html'") + protocol = param.String(doc="protocol in window.location e.g. 'https:'") + port = param.String(doc="port in window.location e.g. '80'") + search = param.String(doc="search in window.location e.g. '?color=blue'") + hash_ = param.String(doc="hash in window.location e.g. '#interact'") + + refresh = param.Boolean( + default=True, + doc="""Refresh 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""", + ) + + _widget_type = _BkLocation # type: ignore + + # Mapping from parameter name to bokeh model property name + _rename: Dict[str, str] = {} + + def update_search( + self, param_class: param.Parameterized, parameters: Optional[List[str]] = None + ): + """Updates the search string from the specified parameters + + Parameters + ---------- + param_class : param.Parameterized + The Parameterized Class containing the Parameters + parameters : [type], optional + The parameters to provide in the search string. If None is provided then all, by default None + """ + raise NotImplementedError() + + def update_param_class(self, parameters=None): + """Updates the Parameterized Class from the parameters + + Parameters + ---------- + param_class : param.Parameterized + The Parameterized Class containing the Parameters + parameters : [type], optional + [description], by default None + """ + raise NotImplementedError() + From 90acde0aab9ff805f22baf8f5d3b4d189cceb1d8 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 23 Feb 2020 15:48:59 +0100 Subject: [PATCH 04/28] connected signals and improved tests --- panel/models/location.ts | 48 +++++++++++++++++++++++++--- panel/tests/widgets/test_location.py | 6 ++++ panel/widgets/location.py | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/panel/models/location.ts b/panel/models/location.ts index 624456b1e9..3de7eb2769 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -12,13 +12,53 @@ export class LocationView extends HTMLBoxView { this.render(); - // this.connect(this.model.slider.change, () => { - // console.info("slider change call back"); - // this.render(); - // }) + this.connect(this.model.properties.href.change, () => this.update_href()); + + this.connect(this.model.properties.hostname.change, () => this.update_part_by_part()); + this.connect(this.model.properties.pathname.change, () => this.update_part_by_part()); + this.connect(this.model.properties.protocol.change, () => this.update_part_by_part()); + this.connect(this.model.properties.port.change, () => this.update_part_by_part()); + this.connect(this.model.properties.search.change, () => this.update_part_by_part()); + this.connect(this.model.properties.hash_.change, () => this.update_part_by_part()); + } + + update_href(): void { + console.log("update_href") + if (this.model.refresh) { + window.history.pushState({}, '', this.model.href); + } else { + window.location.href = this.model.href; + } + + this.model.hostname = window.location.hostname; + this.model.pathname = window.location.pathname; + this.model.protocol = window.location.protocol; + this.model.port = window.location.port; + this.model.search = window.location.search; + this.model.hash_ = window.location.hash; + } + update_part_by_part(): void { + console.log("update_by_part") + if (this.model.refresh) { + window.history.pushState( + {}, + '', + `${this.model.pathname}:${this.model.port}${this.model.search}${this.model.hash_}` + ); + } else { + window.location.hostname = this.model.hostname; + window.location.pathname = this.model.pathname; + window.location.protocol = this.model.protocol; + window.location.port = this.model.port; + window.location.search = this.model.search; + window.location.hash = this.model.hash_; + } + + this.model.href = window.location.href; } render(): void { + // Todo: remove content. Here for manual testing console.info("render"); super.render() diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index 158f7f93b8..d2fa3b9d1b 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -1,3 +1,4 @@ +import panel as pn from panel.widgets.location import Location HREF = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" @@ -30,3 +31,8 @@ def test_location(document, comm): # slider.value = 0.3 # assert widget.value == 0.3 + + +if __name__.startswith("bk"): + location = Location._widget_type() + pn.pane.Bokeh(location).servable() diff --git a/panel/widgets/location.py b/panel/widgets/location.py index 7adc3b6cab..755f989124 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -32,7 +32,7 @@ class Location(Widget): _widget_type = _BkLocation # type: ignore # Mapping from parameter name to bokeh model property name - _rename: Dict[str, str] = {} + _rename: Dict[str, str] = {"name": None} def update_search( self, param_class: param.Parameterized, parameters: Optional[List[str]] = None From 97efe1add52324ae3235e4be8a16f2ff0111b875 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 23 Feb 2020 20:19:53 +0100 Subject: [PATCH 05/28] got test_model.py for widget working by upgrading pyviz_comms to 0.7.3 --- panel/models/location.ts | 16 ++++++++-------- panel/tests/models/test_location.py | 22 +++------------------- panel/tests/widgets/test_location.py | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/panel/models/location.ts b/panel/models/location.ts index 3de7eb2769..a553fb31c6 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -25,17 +25,17 @@ export class LocationView extends HTMLBoxView { update_href(): void { console.log("update_href") if (this.model.refresh) { - window.history.pushState({}, '', this.model.href); - } else { window.location.href = this.model.href; + } else { + window.history.pushState({}, '', this.model.href); } - this.model.hostname = window.location.hostname; - this.model.pathname = window.location.pathname; - this.model.protocol = window.location.protocol; - this.model.port = window.location.port; - this.model.search = window.location.search; - this.model.hash_ = window.location.hash; + // this.model.hostname = window.location.hostname; + // this.model.pathname = window.location.pathname; + // this.model.protocol = window.location.protocol; + // this.model.port = window.location.port; + // this.model.search = window.location.search; + // this.model.hash_ = window.location.hash; } update_part_by_part(): void { console.log("update_by_part") diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py index 36caa75f82..025c0db43e 100644 --- a/panel/tests/models/test_location.py +++ b/panel/tests/models/test_location.py @@ -1,9 +1,8 @@ """In this module we test the Bokeh Location Model""" import pytest -from panel.widgets.location import Location import panel as pn -from panel.models.location import Location as BKLocation +from panel.models.location import Location def test_constructor(): @@ -39,25 +38,10 @@ def test_constructor_with_href(): def test_manual(): - location = Location() - parameters = [ - "href", - "hostname", - "pathname", - "protocol", - "port", - "search", - "hash_", - "refresh", - ] - bkmodel = pn.pane.Bokeh(BKLocation()) - app = pn.Column(bkmodel, pn.Param(location, parameters=parameters)) + bkmodel = pn.pane.Bokeh(Location()) + app = pn.Column(bkmodel) return app if __name__.startswith("bk"): - import ptvsd - - ptvsd.enable_attach(address=("localhost", 5678)) - ptvsd.wait_for_attach() # Only include this line if you always wan't to attach the debugger test_manual().servable() diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index d2fa3b9d1b..caac1b3892 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -34,5 +34,17 @@ def test_location(document, comm): if __name__.startswith("bk"): - location = Location._widget_type() - pn.pane.Bokeh(location).servable() + location = Location(refresh=False) + parameters = [ + "href", + "hostname", + "pathname", + "protocol", + "port", + "search", + "hash_", + "refresh", + ] + pn.Column( + "test", location, pn.Param(location, parameters=parameters), sizing_mode="stretch_width" + ).servable() From 3675051be4050d0733a1967efef6df287b625311 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Mon, 24 Feb 2020 05:53:53 +0100 Subject: [PATCH 06/28] added many tests. added readonly and regex to Location class --- panel/tests/widgets/test_location.py | 140 ++++++++++++++++++++++++--- panel/widgets/location.py | 30 ++++-- 2 files changed, 144 insertions(+), 26 deletions(-) diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index caac1b3892..216aede524 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -1,5 +1,6 @@ import panel as pn from panel.widgets.location import Location +import pytest HREF = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" HOSTNAME = "panel.holoviz.org" @@ -10,30 +11,139 @@ HASH_ = "#interact" -def test_location(document, comm): - # given +@pytest.fixture +def href(): + return "https://panel.holoviz.org/user_guide/Interact.html:5006?color=blue#interact" + +@pytest.fixture +def hostname(): + return "panel.holoviz.org" + + +@pytest.fixture +def pathname(): + return "user_guide/Interact.html" + + +@pytest.fixture +def protocol(): + return "https" + + +@pytest.fixture +def port(): + return "5006" + + +@pytest.fixture +def search(): + return "?color=blue" + + +@pytest.fixture +def hash_(): + return "#interact" + + +@pytest.fixture +def refresh(): + return False + + +def test_constructor(): # When - location = Location(href=HREF, name="Location") + actual = Location() + # Then/pyv + assert actual.href == "" + assert actual.hostname == "" + assert actual.pathname == "" + assert actual.protocol == "" + assert actual.port == "" + assert actual.search == "" + assert actual.hash_ == "" + assert actual.refresh == False + + +def test_href_is_readonly(href): + # When/ Then + with pytest.raises(TypeError): + Location(href=href) + + +def test_hostname_is_readonly(hostname): + # When/ Then + with pytest.raises(TypeError): + Location(hostname=hostname) + +def test_protocol_is_readonly(protocol): + # When/ Then + with pytest.raises(TypeError): + Location(protocol=protocol) + + +def test_port_is_readonly(port): + # When/ Then + with pytest.raises(TypeError): + Location(port=port) + + +def test_attributes_are_not_readonly(pathname, search, hash_, refresh): + # When + location = Location(pathname=pathname, search=search, hash_=hash_, refresh=refresh) + # Then + assert location.pathname == pathname + assert location.search == search + assert location.hash_ == hash_ + assert location.refresh == refresh + + +@pytest.mark.parametrize(["invalid"], [("/",), ("/app",), ("/app/",), ("app/",),]) +def test_pathname_raises_valueerror_if_string_invalid(invalid): + "The pathname should be '' or (not start or end with '/')" + with pytest.raises(ValueError): + Location(search="a=b") + + +def test_search_raises_valueerror_if_string_invalid(): + "The search string should be '' or start with '?'" + with pytest.raises(ValueError): + Location(search="a=b") + + +def test_hash_raises_valueerror_if_string_invalid(): + "The hash string should be '' or start with '#'" + # When/ Then + with pytest.raises(ValueError): + Location(hash="section2") + + +def test_location_comm(document, comm, pathname, search, hash_, refresh): + # Given + location = Location() + + # When widget = location.get_root(document, comm=comm) + # Then assert isinstance(widget, Location._widget_type) - assert widget.href == HREF - # assert widget.title == 'Slider' - # assert widget.step == 0.1 - # assert widget.start == 0.1 - # assert widget.end == 0.5 - # assert widget.value == 0.4 - # slider._comm_change({'value': 0.2}) - # assert slider.value == 0.2 + location._comm_change({"pathname": pathname}) + assert location.pathname == pathname + + location._comm_change({"search": search}) + assert location.search == search + + location._comm_change({"hash_": hash_}) + assert location.hash_ == hash_ - # slider.value = 0.3 - # assert widget.value == 0.3 + location._comm_change({"refresh": refresh}) + assert location.refresh == refresh if __name__.startswith("bk"): + pn.config.sizing_mode = "stretch_width" location = Location(refresh=False) parameters = [ "href", @@ -45,6 +155,4 @@ def test_location(document, comm): "hash_", "refresh", ] - pn.Column( - "test", location, pn.Param(location, parameters=parameters), sizing_mode="stretch_width" - ).servable() + pn.Column(location, pn.Param(location, parameters=parameters)).servable() diff --git a/panel/widgets/location.py b/panel/widgets/location.py index 755f989124..ad692053d6 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -8,23 +8,33 @@ from .base import Widget import param -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Optional + +import param +from panel.widgets.base import Widget class Location(Widget): href = param.String( + readonly=True, doc="""The full url, e.g. \ - 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""" + 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""", + ) + hostname = param.String( + readonly=True, doc="hostname in window.location e.g. 'panel.holoviz.org'" + ) + # Todo: Find the corect regex for pathname + pathname = param.String( + regex="([^\/])(.*)([^\/])", + doc="pathname in window.location e.g. 'user_guide/Interact.html'", ) - hostname = param.String(doc="hostname in window.location e.g. 'panel.holoviz.org'") - pathname = param.String(doc="pathname in window.location e.g. 'user_guide/Interact.html'") - protocol = param.String(doc="protocol in window.location e.g. 'https:'") - port = param.String(doc="port in window.location e.g. '80'") - search = param.String(doc="search in window.location e.g. '?color=blue'") - hash_ = param.String(doc="hash in window.location e.g. '#interact'") + protocol = param.String(readonly=True, doc="protocol in window.location e.g. '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'") refresh = param.Boolean( - default=True, + default=False, doc="""Refresh 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""", ) @@ -32,7 +42,7 @@ class Location(Widget): _widget_type = _BkLocation # type: ignore # Mapping from parameter name to bokeh model property name - _rename: Dict[str, str] = {"name": None} + _rename: Dict[str, Optional[str]] = {"name": None} def update_search( self, param_class: param.Parameterized, parameters: Optional[List[str]] = None From 8e5e7bd500fb5e7fff44f536482a61ebc511b32c Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Mon, 24 Feb 2020 06:17:17 +0100 Subject: [PATCH 07/28] improved regex and associated testing --- panel/tests/widgets/test_location.py | 2 +- panel/widgets/location.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index 216aede524..907536e546 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -103,7 +103,7 @@ def test_attributes_are_not_readonly(pathname, search, hash_, refresh): def test_pathname_raises_valueerror_if_string_invalid(invalid): "The pathname should be '' or (not start or end with '/')" with pytest.raises(ValueError): - Location(search="a=b") + Location(pathname=invalid) def test_search_raises_valueerror_if_string_invalid(): diff --git a/panel/widgets/location.py b/panel/widgets/location.py index ad692053d6..ef42c2bb77 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -25,7 +25,7 @@ class Location(Widget): ) # Todo: Find the corect regex for pathname pathname = param.String( - regex="([^\/])(.*)([^\/])", + 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. 'https:'") From f47226d51ed988562ab72e4ce8246195d3416a48 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Mon, 24 Feb 2020 20:22:05 +0100 Subject: [PATCH 08/28] added regex and tests --- panel/tests/widgets/test_location.py | 2 +- panel/widgets/location.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index 907536e546..d9994e2ec3 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -116,7 +116,7 @@ def test_hash_raises_valueerror_if_string_invalid(): "The hash string should be '' or start with '#'" # When/ Then with pytest.raises(ValueError): - Location(hash="section2") + Location(hash_="section2") def test_location_comm(document, comm, pathname, search, hash_, refresh): diff --git a/panel/widgets/location.py b/panel/widgets/location.py index ef42c2bb77..c79c1b3d7e 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -25,13 +25,13 @@ class Location(Widget): ) # Todo: Find the corect regex for pathname pathname = param.String( - regex=r"^$|([^\/])(.*)([^\/])", + 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. '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'") + 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'") refresh = param.Boolean( default=False, From 041423275fb85a0aeadacdbcdffee46e5789c79a Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Tue, 25 Feb 2020 05:15:01 +0100 Subject: [PATCH 09/28] more work --- panel/models/location.ts | 48 ++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/panel/models/location.ts b/panel/models/location.ts index a553fb31c6..3d410e8a7f 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -6,55 +6,45 @@ import * as p from "core/properties" export class LocationView extends HTMLBoxView { model: Location + initialize(): void { + super.initialize(); + + this.model.href = window.location.href; + this.model.hostname = window.location.hostname; + this.model.pathname = window.location.pathname; + this.model.protocol = window.location.protocol; + this.model.port = window.location.port; + this.model.search = window.location.search; + this.model.hash_ = window.location.hash; + this.model.refresh = true; + } + connect_signals(): void { console.info("connect_signals"); super.connect_signals(); this.render(); - this.connect(this.model.properties.href.change, () => this.update_href()); - - this.connect(this.model.properties.hostname.change, () => this.update_part_by_part()); - this.connect(this.model.properties.pathname.change, () => this.update_part_by_part()); - this.connect(this.model.properties.protocol.change, () => this.update_part_by_part()); - this.connect(this.model.properties.port.change, () => this.update_part_by_part()); - this.connect(this.model.properties.search.change, () => this.update_part_by_part()); - this.connect(this.model.properties.hash_.change, () => this.update_part_by_part()); + this.connect(this.model.properties.pathname.change, () => this.update()); + this.connect(this.model.properties.search.change, () => this.update()); + this.connect(this.model.properties.hash_.change, () => this.update()); } - update_href(): void { - console.log("update_href") - if (this.model.refresh) { - window.location.href = this.model.href; - } else { - window.history.pushState({}, '', this.model.href); - } - - // this.model.hostname = window.location.hostname; - // this.model.pathname = window.location.pathname; - // this.model.protocol = window.location.protocol; - // this.model.port = window.location.port; - // this.model.search = window.location.search; - // this.model.hash_ = window.location.hash; - } - update_part_by_part(): void { - console.log("update_by_part") + update(): void { if (this.model.refresh) { window.history.pushState( {}, '', - `${this.model.pathname}:${this.model.port}${this.model.search}${this.model.hash_}` + `${this.model.pathname}${this.model.search}${this.model.hash_}` ); } else { - window.location.hostname = this.model.hostname; window.location.pathname = this.model.pathname; - window.location.protocol = this.model.protocol; - window.location.port = this.model.port; window.location.search = this.model.search; window.location.hash = this.model.hash_; } this.model.href = window.location.href; + this.render(); } render(): void { From 72ce33b84d918a3c07b5b689b3bfa5fa9ee05a95 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Tue, 25 Feb 2020 05:29:59 +0100 Subject: [PATCH 10/28] fixed regex error on pathname --- panel/models/location.py | 4 ++-- panel/models/location.ts | 11 ++++++----- panel/tests/widgets/test_location.py | 6 +++--- panel/widgets/location.py | 3 +-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/panel/models/location.py b/panel/models/location.py index 54dd0509e8..73dccc5c8e 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -29,8 +29,8 @@ class Location(HTMLBox): 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""" ) hostname = String(help="hostname in window.location e.g. 'panel.holoviz.org'") - pathname = String(help="pathname in window.location e.g. 'user_guide/Interact.html'") - protocol = String(help="protocol in window.location e.g. 'https:'") + pathname = String(help="pathname in window.location e.g. '/user_guide/Interact.html'") + protocol = String(help="protocol in window.location e.g. 'https'") port = String(help="port in window.location e.g. 80") search = String(help="search in window.location e.g. '?color=blue'") hash_ = String(help="hash in window.location e.g. '#interact'") diff --git a/panel/models/location.ts b/panel/models/location.ts index 3d410e8a7f..60e451fea5 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -9,14 +9,15 @@ export class LocationView extends HTMLBoxView { 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.pathname = window.location.pathname; this.model.protocol = window.location.protocol; this.model.port = window.location.port; - this.model.search = window.location.search; - this.model.hash_ = window.location.hash; - this.model.refresh = true; } connect_signals(): void { @@ -107,7 +108,7 @@ export class Location extends HTMLBox { port: [p.String, window.location.port], search: [p.String, window.location.search], hash_: [p.String, window.location.hash], - refresh: [p.Boolean, true], + refresh: [p.Boolean, false], }) } } diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index d9994e2ec3..d84f03036c 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -4,7 +4,7 @@ HREF = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" HOSTNAME = "panel.holoviz.org" -PATHNAME = "user_guide/Interact.html" +PATHNAME = "/user_guide/Interact.html" PROTOCOL = "https" PORT = "80" SEARCH = "?color=blue" @@ -23,7 +23,7 @@ def hostname(): @pytest.fixture def pathname(): - return "user_guide/Interact.html" + return "/user_guide/Interact.html" @pytest.fixture @@ -99,7 +99,7 @@ def test_attributes_are_not_readonly(pathname, search, hash_, refresh): assert location.refresh == refresh -@pytest.mark.parametrize(["invalid"], [("/",), ("/app",), ("/app/",), ("app/",),]) +@pytest.mark.parametrize(["invalid"], [("app",), ("app/",),]) def test_pathname_raises_valueerror_if_string_invalid(invalid): "The pathname should be '' or (not start or end with '/')" with pytest.raises(ValueError): diff --git a/panel/widgets/location.py b/panel/widgets/location.py index c79c1b3d7e..f508c7d0c3 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -25,8 +25,7 @@ class Location(Widget): ) # Todo: Find the corect regex for pathname pathname = param.String( - regex=r"^$|[^\/].*[^\/]$", - doc="pathname in window.location e.g. 'user_guide/Interact.html'", + 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. 'https:'") port = param.String(readonly=True, doc="port in window.location e.g. '80'") From 0867b01573a7ee387a464e88de44b813f5d4300d Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Tue, 25 Feb 2020 07:04:34 +0100 Subject: [PATCH 11/28] fixed init and readonly problems --- panel/models/location.py | 17 +++--- panel/models/location.ts | 32 ++---------- panel/tests/models/test_location.py | 2 +- panel/tests/widgets/test_location.py | 29 +++++++---- panel/widgets/location.py | 77 +++++++++++++++++++++++++--- 5 files changed, 101 insertions(+), 56 deletions(-) diff --git a/panel/models/location.py b/panel/models/location.py index 73dccc5c8e..426caf56df 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -25,15 +25,18 @@ class Location(HTMLBox): __implementation__ = LOCATION_TS_STR href = String( + default="", help="""The full url, e.g. \ - 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""" + 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""", ) - hostname = String(help="hostname in window.location e.g. 'panel.holoviz.org'") - pathname = String(help="pathname in window.location e.g. '/user_guide/Interact.html'") - protocol = String(help="protocol in window.location e.g. 'https'") - port = String(help="port in window.location e.g. 80") - search = String(help="search in window.location e.g. '?color=blue'") - hash_ = String(help="hash in window.location e.g. '#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'") refresh = Bool( default=True, diff --git a/panel/models/location.ts b/panel/models/location.ts index 60e451fea5..e33e8b1ff9 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -1,6 +1,5 @@ import { HTMLBox, HTMLBoxView } from "models/layouts/html_box" -import { div } from "core/dom" import * as p from "core/properties" export class LocationView extends HTMLBoxView { @@ -32,7 +31,7 @@ export class LocationView extends HTMLBoxView { } update(): void { - if (this.model.refresh) { + if (!this.model.refresh) { window.history.pushState( {}, '', @@ -45,33 +44,8 @@ export class LocationView extends HTMLBoxView { } this.model.href = window.location.href; - this.render(); - } - - render(): void { - // Todo: remove content. Here for manual testing - console.info("render"); - super.render() - - var myString = ` - ${this.model.href} - ${this.model.hostname} - ${this.model.pathname} - ${this.model.protocol} - ${this.model.port} - ${this.model.search} - ${this.model.hash_} - ${this.model.refresh} - `; - - this.el.appendChild(div({ - style: { - padding: '2px', - color: '#b88d8e', - backgroundColor: '#2a3153', - }, - }, myString)) } + // render(): void { } } export namespace Location { @@ -108,7 +82,7 @@ export class Location extends HTMLBox { port: [p.String, window.location.port], search: [p.String, window.location.search], hash_: [p.String, window.location.hash], - refresh: [p.Boolean, false], + refresh: [p.Boolean, true], }) } } diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py index 025c0db43e..09daaac475 100644 --- a/panel/tests/models/test_location.py +++ b/panel/tests/models/test_location.py @@ -19,7 +19,7 @@ def test_constructor(): assert actual.refresh == True -def test_constructor_with_href(): +def test_constructor_with__href(): # Given href = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" # When diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index d84f03036c..a2a0273673 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -2,14 +2,6 @@ from panel.widgets.location import Location import pytest -HREF = "https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact" -HOSTNAME = "panel.holoviz.org" -PATHNAME = "/user_guide/Interact.html" -PROTOCOL = "https" -PORT = "80" -SEARCH = "?color=blue" -HASH_ = "#interact" - @pytest.fixture def href(): @@ -28,7 +20,7 @@ def pathname(): @pytest.fixture def protocol(): - return "https" + return "https:" @pytest.fixture @@ -48,7 +40,7 @@ def hash_(): @pytest.fixture def refresh(): - return False + return True def test_constructor(): @@ -62,7 +54,7 @@ def test_constructor(): assert actual.port == "" assert actual.search == "" assert actual.hash_ == "" - assert actual.refresh == False + assert actual.refresh == True def test_href_is_readonly(href): @@ -119,6 +111,21 @@ def test_hash_raises_valueerror_if_string_invalid(): Location(hash_="section2") +def test_readonly_workaround_works(href, hostname, protocol, port): + # Given + location = Location() + # When + location._href = href + location._hostname = hostname + location._protocol = protocol + location._port = port + # Then + location.href == href + location.hostname == hostname + location.protocol == protocol + location.port == port + + def test_location_comm(document, comm, pathname, search, hash_, refresh): # Given location = Location() diff --git a/panel/widgets/location.py b/panel/widgets/location.py index f508c7d0c3..82391af131 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -1,17 +1,42 @@ """ Defines the Location widget which allows changing the href of the window. """ +from contextlib import contextmanager +from typing import Dict, List, Optional + +# In order to enable syncing readonly values like href we define a _href helper parameter import param +import panel as pn from panel.models.location import Location as _BkLocation +from panel.widgets.base import Widget from .base import Widget -import param -from typing import Optional, List, Dict, Optional -import param -from panel.widgets.base import Widget +# 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: + raise + finally: + for (p, readonly) in zip(params, readonlys): + p.readonly = readonly + for (p, constant) in zip(params, constants): + p.constant = constant class Location(Widget): @@ -27,21 +52,58 @@ class Location(Widget): 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. 'https:'") + 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'") refresh = param.Boolean( - default=False, + default=True, doc="""Refresh 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""", ) + _href = param.String() + _hostname = param.String() + _protocol = param.String() + _port = param.String() + _widget_type = _BkLocation # type: ignore # Mapping from parameter name to bokeh model property name - _rename: Dict[str, Optional[str]] = {"name": None} + _rename: Dict[str, Optional[str]] = { + "name": None, + "hostname": None, + "href": None, + "port": None, + "protocol": None, + "_hostname": "hostname", + "_href": "href", + "_port": "port", + "_protocol": "protocol", + } + + @param.depends("_href", watch=True) + def _update_href(self): + with edit_readonly(self): + self.href = self._href + + @param.depends("_hostname", watch=True) + def _update_hostname(self): + with edit_readonly(self): + self.hostname = self._hostname + + @param.depends("_protocol", watch=True) + def _update_protocol(self): + with edit_readonly(self): + self.protocol = self._protocol + + @param.depends("_port", watch=True) + def _update_port(self): + with edit_readonly(self): + self.port = self._port def update_search( self, param_class: param.Parameterized, parameters: Optional[List[str]] = None @@ -68,4 +130,3 @@ def update_param_class(self, parameters=None): [description], by default None """ raise NotImplementedError() - From 8f4723f9a9b7965ccbc167e3de8756e1340a95cb Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Tue, 25 Feb 2020 20:13:58 +0100 Subject: [PATCH 12/28] renamed refresh to reload --- panel/models/location.py | 4 ++-- panel/models/location.ts | 8 +++----- panel/tests/models/test_location.py | 4 ++-- panel/tests/widgets/test_location.py | 20 ++++++++++---------- panel/widgets/__init__.py | 25 +++++++++++++++++-------- panel/widgets/location.py | 4 ++-- 6 files changed, 36 insertions(+), 29 deletions(-) diff --git a/panel/models/location.py b/panel/models/location.py index 426caf56df..0bf1d5faae 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -38,8 +38,8 @@ class Location(HTMLBox): search = String(default="", help="search in window.location e.g. '?color=blue'") hash_ = String(default="", help="hash in window.location e.g. '#interact'") - refresh = Bool( + reload = Bool( default=True, - help="""Refresh the page when the location is updated. For multipage apps this should be \ + 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 index e33e8b1ff9..9590a56e24 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -23,15 +23,13 @@ export class LocationView extends HTMLBoxView { console.info("connect_signals"); super.connect_signals(); - this.render(); - this.connect(this.model.properties.pathname.change, () => this.update()); this.connect(this.model.properties.search.change, () => this.update()); this.connect(this.model.properties.hash_.change, () => this.update()); } update(): void { - if (!this.model.refresh) { + if (!this.model.reload) { window.history.pushState( {}, '', @@ -58,7 +56,7 @@ export namespace Location { port: p.Property search: p.Property hash_: p.Property - refresh: p.Property + reload: p.Property } } @@ -82,7 +80,7 @@ export class Location extends HTMLBox { port: [p.String, window.location.port], search: [p.String, window.location.search], hash_: [p.String, window.location.hash], - refresh: [p.Boolean, true], + reload: [p.Boolean, true], }) } } diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py index 09daaac475..52c0efac81 100644 --- a/panel/tests/models/test_location.py +++ b/panel/tests/models/test_location.py @@ -16,7 +16,7 @@ def test_constructor(): assert actual.port == "" assert actual.search == "" assert actual.hash_ == "" - assert actual.refresh == True + assert actual.reload == True def test_constructor_with__href(): @@ -34,7 +34,7 @@ def test_constructor_with__href(): assert actual.port == "" assert actual.search == "" assert actual.hash_ == "" - assert actual.refresh == True + assert actual.reload == True def test_manual(): diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index a2a0273673..7b8afd4b49 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -39,7 +39,7 @@ def hash_(): @pytest.fixture -def refresh(): +def reload(): return True @@ -54,7 +54,7 @@ def test_constructor(): assert actual.port == "" assert actual.search == "" assert actual.hash_ == "" - assert actual.refresh == True + assert actual.reload == True def test_href_is_readonly(href): @@ -81,14 +81,14 @@ def test_port_is_readonly(port): Location(port=port) -def test_attributes_are_not_readonly(pathname, search, hash_, refresh): +def test_attributes_are_not_readonly(pathname, search, hash_, reload): # When - location = Location(pathname=pathname, search=search, hash_=hash_, refresh=refresh) + location = Location(pathname=pathname, search=search, hash_=hash_, reload=reload) # Then assert location.pathname == pathname assert location.search == search assert location.hash_ == hash_ - assert location.refresh == refresh + assert location.reload == reload @pytest.mark.parametrize(["invalid"], [("app",), ("app/",),]) @@ -126,7 +126,7 @@ def test_readonly_workaround_works(href, hostname, protocol, port): location.port == port -def test_location_comm(document, comm, pathname, search, hash_, refresh): +def test_location_comm(document, comm, pathname, search, hash_, reload): # Given location = Location() @@ -145,13 +145,13 @@ def test_location_comm(document, comm, pathname, search, hash_, refresh): location._comm_change({"hash_": hash_}) assert location.hash_ == hash_ - location._comm_change({"refresh": refresh}) - assert location.refresh == refresh + location._comm_change({"reload": reload}) + assert location.reload == reload if __name__.startswith("bk"): pn.config.sizing_mode = "stretch_width" - location = Location(refresh=False) + location = Location(reload=False) parameters = [ "href", "hostname", @@ -160,6 +160,6 @@ def test_location_comm(document, comm, pathname, search, hash_, refresh): "port", "search", "hash_", - "refresh", + "reload", ] pn.Column(location, pn.Param(location, parameters=parameters)).servable() diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 6bdb4fe7ea..701e83ac08 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -5,14 +5,23 @@ 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 .location import Location from .misc import Audio, FileDownload, Progress, VideoStream # noqa from .player import DiscretePlayer, Player # noqa from .slider import (# noqa @@ -24,4 +33,4 @@ MultiChoice, MultiSelect, RadioButtonGroup, RadioBoxGroup, Select, ToggleGroup ) -from .tables import DataFrame # noqa +from .tables import DataFrame # noqa diff --git a/panel/widgets/location.py b/panel/widgets/location.py index 82391af131..a8c6f625c9 100644 --- a/panel/widgets/location.py +++ b/panel/widgets/location.py @@ -59,9 +59,9 @@ class Location(Widget): 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'") - refresh = param.Boolean( + reload = param.Boolean( default=True, - doc="""Refresh the page when the location is updated. For multipage apps this should be \ + 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""", ) From 17aaabd9c5ea44d59643d32f7d5e87691abb0b0a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 12 Mar 2020 22:56:30 +0100 Subject: [PATCH 13/28] Cleaned up Location model --- panel/models/__init__.py | 1 + panel/models/index.ts | 1 + panel/models/location.py | 85 ++++++++++++------------- panel/models/location.ts | 132 +++++++++++++++++++-------------------- panel/models/state.py | 2 +- 5 files changed, 109 insertions(+), 112 deletions(-) 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 index 0bf1d5faae..b57102bea0 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -1,45 +1,40 @@ -"""This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" -import pathlib -from typing import List, Optional - -import param -from bokeh.core.properties import Bool, Instance, Int, String -from bokeh.layouts import column -from bokeh.models import HTMLBox, Model, Slider - -import panel as pn - -LOCATION_TS = pathlib.Path(__file__).parent / "location.ts" -LOCATION_TS_STR = str(LOCATION_TS.resolve()) - - -class Location(HTMLBox): - """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. - """ - - __implementation__ = LOCATION_TS_STR - - href = String( - default="", - help="""The full url, e.g. \ - 'https://panel.holoviz.org/user_guide/Interact.html: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""", - ) +"""This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" +import pathlib +from typing import List, Optional + +import param +from bokeh.core.properties import Bool, Instance, Int, String +from bokeh.layouts import column +from bokeh.models import Model + +import panel as pn + + +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://panel.holoviz.org/user_guide/Interact.html: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 index 9590a56e24..cca7996a9d 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -1,86 +1,86 @@ -import { HTMLBox, HTMLBoxView } from "models/layouts/html_box" +import * as p from "@bokehjs/core/properties" +import {View} from "@bokehjs/core/view" +import {Model} from "@bokehjs/model" -import * as p from "core/properties" +export class LocationView extends View { + model: Location -export class LocationView extends HTMLBoxView { - model: Location + initialize(): void { + super.initialize(); - initialize(): void { - super.initialize(); + this.model.pathname = window.location.pathname; + this.model.search = window.location.search; + this.model.hash_ = window.location.hash; - 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; + } - // 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(); - connect_signals(): void { - console.info("connect_signals"); - super.connect_signals(); + this.connect(this.model.properties.pathname.change, () => this.update()); + this.connect(this.model.properties.search.change, () => this.update()); + this.connect(this.model.properties.hash_.change, () => this.update()); + } - this.connect(this.model.properties.pathname.change, () => this.update()); - this.connect(this.model.properties.search.change, () => this.update()); - this.connect(this.model.properties.hash_.change, () => this.update()); + update(): void { + if (!this.model.reload) { + window.history.pushState( + {}, + '', + `${this.model.pathname}${this.model.search}${this.model.hash_}` + ); + } else { + window.location.pathname = (this.model.pathname as string); + window.location.search = (this.model.search as string); + window.location.hash = (this.model.hash_ as string); } - update(): void { - if (!this.model.reload) { - window.history.pushState( - {}, - '', - `${this.model.pathname}${this.model.search}${this.model.hash_}` - ); - } else { - window.location.pathname = this.model.pathname; - window.location.search = this.model.search; - window.location.hash = this.model.hash_; - } - - this.model.href = window.location.href; - } - // render(): void { } + this.model.href = window.location.href; + } } export namespace Location { - export type Attrs = p.AttrsOf - export type Props = HTMLBox.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 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 HTMLBox { - properties: Location.Props +export class Location extends Model { + properties: Location.Props - constructor(attrs?: Partial) { - super(attrs) - } + static __module__ = "panel.models.widgets" - static init_Location(): void { - this.prototype.default_view = LocationView; + constructor(attrs?: Partial) { + super(attrs) + } - this.define({ - href: [p.String, window.location.href], - hostname: [p.String, window.location.hostname], - pathname: [p.String, window.location.pathname], - protocol: [p.String, window.location.protocol], - port: [p.String, window.location.port], - search: [p.String, window.location.search], - hash_: [p.String, window.location.hash], - reload: [p.Boolean, true], - }) - } + static init_Location(): void { + this.prototype.default_view = LocationView; + + this.define({ + href: [p.String, window.location.href], + hostname: [p.String, window.location.hostname], + pathname: [p.String, window.location.pathname], + protocol: [p.String, window.location.protocol], + port: [p.String, window.location.port], + search: [p.String, window.location.search], + hash_: [p.String, window.location.hash], + reload: [p.Boolean, true], + }) + } } 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): From fe2f9ad2ae37967e30a791f2391d79d8fc364487 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 13 Mar 2020 12:33:00 +0100 Subject: [PATCH 14/28] Refactored Location component --- panel/io/server.py | 27 ++- panel/io/state.py | 7 + panel/models/location.py | 17 +- panel/models/location.ts | 174 ++++++++-------- panel/util.py | 26 +++ panel/viewable.py | 404 +++++++++++++++++++++----------------- panel/widgets/__init__.py | 1 - panel/widgets/location.py | 132 ------------- 8 files changed, 368 insertions(+), 420 deletions(-) delete mode 100644 panel/widgets/location.py diff --git a/panel/io/server.py b/panel/io/server.py index 58b2006441..b33f4d95a0 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 @@ -201,6 +208,7 @@ def get_server(panel, port=0, websocket_origin=None, loop=None, server_id = kwargs.pop('server_id', uuid.uuid4().hex) kwargs['extra_patterns'] = extra_patterns = kwargs.get('extra_patterns', []) if isinstance(panel, dict): +<<<<<<< HEAD apps = {} for slug, app in panel.items(): slug = slug if slug.startswith('/') else '/'+slug @@ -216,8 +224,13 @@ def get_server(panel, port=0, websocket_origin=None, loop=None, dict(fallback=wsgi, proxy=slug))) continue apps[slug] = partial(_eval_panel, app, server_id, title) +======= + apps = {slug if slug.startswith('/') else '/'+slug: + partial(_eval_panel, p, server_id, title, location) + for slug, p in panel.items()} +>>>>>>> Refactored Location component 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 ccc2101d0d..51b7880326 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -63,6 +63,9 @@ class _state(param.Parameterized): _comm_manager = _CommManager + # Locations + _locations = {} + # An index of all currently active views _views = {} @@ -125,5 +128,9 @@ def headers(self): def session_args(self): return self.curdoc.session_context.request.arguments if self.curdoc else {} + @property + def location(self): + return self._locations.get(self.curdoc) if self.curdoc else None + state = _state() diff --git a/panel/models/location.py b/panel/models/location.py index b57102bea0..b3cfca5d72 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -1,14 +1,14 @@ """This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" import pathlib + from typing import List, Optional import param + from bokeh.core.properties import Bool, Instance, Int, String from bokeh.layouts import column from bokeh.models import Model -import panel as pn - class Location(Model): """A python wrapper around the JS `window.location` api. See @@ -19,15 +19,12 @@ class Location(Model): shareable link. """ - href = String( - default="", - help="""The full url, e.g. \ - 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""", - ) + 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'" - ) + + 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'") diff --git a/panel/models/location.ts b/panel/models/location.ts index cca7996a9d..9c619edf5e 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -1,86 +1,88 @@ -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()); - this.connect(this.model.properties.search.change, () => this.update()); - this.connect(this.model.properties.hash_.change, () => this.update()); - } - - update(): void { - if (!this.model.reload) { - window.history.pushState( - {}, - '', - `${this.model.pathname}${this.model.search}${this.model.hash_}` - ); - } else { - window.location.pathname = (this.model.pathname as string); - window.location.search = (this.model.search as string); - window.location.hash = (this.model.hash_ as string); - } - - this.model.href = window.location.href; - } -} - -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.widgets" - - constructor(attrs?: Partial) { - super(attrs) - } - - static init_Location(): void { - this.prototype.default_view = LocationView; - - this.define({ - href: [p.String, window.location.href], - hostname: [p.String, window.location.hostname], - pathname: [p.String, window.location.pathname], - protocol: [p.String, window.location.protocol], - port: [p.String, window.location.port], - search: [p.String, window.location.search], - hash_: [p.String, window.location.hash], - reload: [p.Boolean, true], - }) - } -} +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')); + } + + update(change: string): void { + if (!this.model.reload) { + window.history.pushState( + {}, + '', + `${this.model.pathname}${this.model.search}${this.model.hash_}` + ); + this.model.href = window.location.href; + } 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, window.location.href], + hostname: [p.String, window.location.hostname], + pathname: [p.String, window.location.pathname], + protocol: [p.String, window.location.protocol], + port: [p.String, window.location.port], + search: [p.String, window.location.search], + hash_: [p.String, window.location.hash], + reload: [p.Boolean, true], + }) + } +} diff --git a/panel/util.py b/panel/util.py index 6b9ebff36d..eb59778479 100644 --- a/panel/util.py +++ b/panel/util.py @@ -11,6 +11,7 @@ import sys from collections import defaultdict, OrderedDict +from contextlib import contextmanager from datetime import datetime from six import string_types @@ -254,3 +255,28 @@ def value_as_date(value): elif isinstance(value, datetime): value = value.date() return value + + +# 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: + 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..1b5a1d44cd 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -32,7 +32,7 @@ from .io.save import save from .io.state import state from .io.server import StoppableThread, get_server, unlocked -from .util import escape, param_reprs +from .util import edit_readonly, escape, param_reprs LinkWatcher = namedtuple("Watcher","inst cls fn mode onlychanged parameter_names what queued target links transformed") @@ -216,17 +216,17 @@ def __init__(self, **params): class ServableMixin(object): - 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 +271,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 +293,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 +319,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 +334,26 @@ 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): - """ - 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. - """ - - __abstract = True - _preprocessing_hooks = [] +class Renderable(param.Parameterized): 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 +413,72 @@ def _render_model(self, doc=None, comm=None): add_to_doc(model, doc) return model + 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 + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + def clone(self, **params): + """ + Makes a copy of the object sharing the same parameters. + + Arguments + --------- + params: Keyword arguments override the parameters on the clone. + + Returns + ------- + Cloned Viewable object + """ + return type(self)(**dict(self.param.get_param_values(), **params)) + + def pprint(self): + """ + Prints a compositional repr of the class. + """ + print(self) + + +class Viewable(Renderable, Layoutable, ServableMixin): + """ + 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. + """ + + _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: @@ -540,26 +592,6 @@ def _server_destroy(self, session_context): # Public API #---------------------------------------------------------------- - def clone(self, **params): - """ - Makes a copy of the object sharing the same parameters. - - Arguments - --------- - params: Keyword arguments override the parameters on the clone. - - Returns - ------- - Cloned Viewable object - """ - return type(self)(**dict(self.param.get_param_values(), **params)) - - def pprint(self): - """ - Prints a compositional repr of the class. - """ - print(self) - def select(self, selector=None): """ Iterates over the Viewable and any potential children in the @@ -623,28 +655,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 +693,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 +702,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 +713,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,23 +722,18 @@ def server_doc(self, doc=None, title=None): doc.on_session_destroyed(self._server_destroy) self._documents[doc] = model add_to_doc(model, doc) + if location: + if isinstance(location, Location): + loc = location + else: + loc = Location() + state._locations[doc] = loc + loc_model = loc._get_model(doc, model) + doc.add_root(loc_model) 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. - """ +class Syncable(Renderable): # Timeout if a notebook comm message is swallowed _timeout = 20000 @@ -735,18 +744,12 @@ class Reactive(Viewable): # 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 = {} + __abstract = True - # Transforms from input value to bokeh property value - _source_transforms = {} - _target_transforms = {} + events = [] 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) + super(Syncable, self).__init__(**params) self._processing = False self._events = {} self._callbacks = [] @@ -754,6 +757,14 @@ def __init__(self, **params): 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 = {} + #---------------------------------------------------------------- # Callback API #---------------------------------------------------------------- @@ -812,13 +823,9 @@ def _link_props(self, model, properties, doc, root, comm=None): 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} + def _process_events(self, events): + with edit_readonly(self): + self.param.set_param(**self._process_property_change(events)) @property def _linkable_params(self): @@ -855,8 +862,17 @@ def _process_param_change(self, msg): properties['min_height'] = properties['height'] return properties + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- + + def _init_properties(self): + return {k: v for k, v in self.param.get_param_values() + if v is not None} + + def _cleanup(self, root): - super(Reactive, self)._cleanup(root) + super(Syncable, self)._cleanup(root) ref = root.ref['id'] self._models.pop(ref, None) comm, client_comm = self._comms.pop(ref, (None, None)) @@ -875,62 +891,6 @@ def _cleanup(self, root): # 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 @@ -978,35 +938,61 @@ def link(*events): self._links.append(link) return cb - def add_periodic_callback(self, callback, period=500, count=None, - timeout=None, start=True): + def controls(self, parameters=[], jslink=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. + 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 --------- - 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. + 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 ------- - Return a PeriodicCallback object with start and stop methods. + A layout of the controls """ - cb = PeriodicCallback(callback=callback, period=period, - count=count, timeout=timeout) - if start: - cb.start() - return cb + 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): """ @@ -1091,7 +1077,7 @@ def jslink(self, target, code=None, args=None, bidirectional=False, **links): "the parameter requires a live Python kernel " "to have an effect." % (k, type(self).__name__)) - if isinstance(target, Reactive) and code is None: + if isinstance(target, Syncable) and code is None: for k, p in mapping.items(): if k.startswith('event:'): continue @@ -1114,3 +1100,53 @@ def jslink(self, target, code=None, args=None, bidirectional=False, **links): from .links import Link return Link(self, target, properties=links, code=code, args=args, bidirectional=bidirectional) + + +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 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. + """ + + #---------------------------------------------------------------- + # 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 diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 701e83ac08..74744c9587 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -21,7 +21,6 @@ PasswordInput, TextAreaInput, ) -from .location import Location from .misc import Audio, FileDownload, Progress, VideoStream # noqa from .player import DiscretePlayer, Player # noqa from .slider import (# noqa diff --git a/panel/widgets/location.py b/panel/widgets/location.py deleted file mode 100644 index a8c6f625c9..0000000000 --- a/panel/widgets/location.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Defines the Location widget which allows changing the href of the window. -""" -from contextlib import contextmanager -from typing import Dict, List, Optional - -# In order to enable syncing readonly values like href we define a _href helper parameter -import param - -import panel as pn -from panel.models.location import Location as _BkLocation -from panel.widgets.base import Widget - -from .base import Widget - - -# 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: - raise - finally: - for (p, readonly) in zip(params, readonlys): - p.readonly = readonly - for (p, constant) in zip(params, constants): - p.constant = constant - - -class Location(Widget): - href = param.String( - readonly=True, - doc="""The full url, e.g. \ - 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'""", - ) - hostname = param.String( - readonly=True, doc="hostname in window.location e.g. 'panel.holoviz.org'" - ) - # Todo: Find the corect regex for pathname - 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=True, - 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""", - ) - - _href = param.String() - _hostname = param.String() - _protocol = param.String() - _port = param.String() - - _widget_type = _BkLocation # type: ignore - - # Mapping from parameter name to bokeh model property name - _rename: Dict[str, Optional[str]] = { - "name": None, - "hostname": None, - "href": None, - "port": None, - "protocol": None, - "_hostname": "hostname", - "_href": "href", - "_port": "port", - "_protocol": "protocol", - } - - @param.depends("_href", watch=True) - def _update_href(self): - with edit_readonly(self): - self.href = self._href - - @param.depends("_hostname", watch=True) - def _update_hostname(self): - with edit_readonly(self): - self.hostname = self._hostname - - @param.depends("_protocol", watch=True) - def _update_protocol(self): - with edit_readonly(self): - self.protocol = self._protocol - - @param.depends("_port", watch=True) - def _update_port(self): - with edit_readonly(self): - self.port = self._port - - def update_search( - self, param_class: param.Parameterized, parameters: Optional[List[str]] = None - ): - """Updates the search string from the specified parameters - - Parameters - ---------- - param_class : param.Parameterized - The Parameterized Class containing the Parameters - parameters : [type], optional - The parameters to provide in the search string. If None is provided then all, by default None - """ - raise NotImplementedError() - - def update_param_class(self, parameters=None): - """Updates the Parameterized Class from the parameters - - Parameters - ---------- - param_class : param.Parameterized - The Parameterized Class containing the Parameters - parameters : [type], optional - [description], by default None - """ - raise NotImplementedError() From 79b08d6e6be6d7eb8a9f97a17d7e0d36132903b2 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 13 Mar 2020 12:35:43 +0100 Subject: [PATCH 15/28] Minor cleanup --- panel/io/state.py | 36 ++---------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/panel/io/state.py b/panel/io/state.py index 51b7880326..1ff7a641d5 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -12,19 +12,6 @@ from bokeh.document import Document from bokeh.io import curdoc as _curdoc from pyviz_comms import CommManager as _CommManager -from panel.models.location import Location - - -class _Window(param.Parameterized): - """ - Provides selected functionality of the JS window api - """ - - location = param.Parameter( - default=Location(), - doc=""" - Provides access to selected functionality of the JS window.location api""", - ) class _state(param.Parameterized): @@ -33,27 +20,8 @@ class _state(param.Parameterized): apps to indicate their state to a user. """ - 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.""", - ) - - webdriver = param.Parameter( - default=None, - doc=""" - Selenium webdriver used to export bokeh models to pngs.""", - ) - - window = param.Parameter(default=_Window()) - - _curdoc = param.ClassSelector( - class_=Document, - doc=""" - The bokeh Document for which a server event is currently being - processed.""", - ) + webdriver = param.Parameter(default=None, doc=""" + Selenium webdriver used to export bokeh models to pngs.""") # Whether to hold comm events _hold = False From 57580c9299fe0f909debcd6a260655d31982ba39 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 13 Mar 2020 12:51:58 +0100 Subject: [PATCH 16/28] Add Location section to docs --- examples/user_guide/Deploy_and_Export.ipynb | 45 +++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) 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:" From a7ba958637219bd52cf7d0ce67cdbe7900b926f6 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 31 Mar 2020 01:30:02 +0200 Subject: [PATCH 17/28] Fixed flakes --- panel/io/state.py | 2 + panel/models/location.py | 48 +++++++++-------- panel/tests/models/test_location.py | 82 ++++++++++++----------------- panel/util.py | 2 +- 4 files changed, 64 insertions(+), 70 deletions(-) diff --git a/panel/io/state.py b/panel/io/state.py index 1ff7a641d5..deba2ef041 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -49,6 +49,8 @@ class _state(param.Parameterized): # Stores a set of locked Websockets, reset after every change event _locks = WeakSet() + _curdoc = None + def __repr__(self): server_info = [] for server, panel, docs in self._servers.values(): diff --git a/panel/models/location.py b/panel/models/location.py index b3cfca5d72..f220f881f6 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -1,37 +1,41 @@ """This module provides a Bokeh Location Model as a wrapper around the JS window.location api""" -import pathlib -from typing import List, Optional - -import param - -from bokeh.core.properties import Bool, Instance, Int, String -from bokeh.layouts import column +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 + """ + 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. + 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'") + 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'""") - hostname = String(default="", help="hostname in window.location e.g. 'panel.holoviz.org'") + port = String(default="", help=""" + port in window.location e.g. 80""") - pathname = String(default="", help="pathname in window.location e.g. '/user_guide/Interact.html'") + search = String(default="", help=""" + search in window.location e.g. '?color=blue'""") - 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'") + 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""", - ) + 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/tests/models/test_location.py b/panel/tests/models/test_location.py index 52c0efac81..b8ccaea639 100644 --- a/panel/tests/models/test_location.py +++ b/panel/tests/models/test_location.py @@ -1,47 +1,35 @@ -"""In this module we test the Bokeh Location Model""" - -import pytest -import panel as pn -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 - - -def test_manual(): - bkmodel = pn.pane.Bokeh(Location()) - app = pn.Column(bkmodel) - return app - - -if __name__.startswith("bk"): - test_manual().servable() +import panel as pn + +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/util.py b/panel/util.py index eb59778479..50cbad4cb5 100644 --- a/panel/util.py +++ b/panel/util.py @@ -273,7 +273,7 @@ def edit_readonly(parameterized): p.constant = False try: yield - except: + except Exception: raise finally: for (p, readonly) in zip(params, readonlys): From d3833cca1e9a61737b74d729b8aecf19822e29fb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 31 Mar 2020 14:15:53 +0200 Subject: [PATCH 18/28] Fixed flakes --- panel/io/state.py | 10 ++++++++-- panel/tests/models/test_location.py | 3 --- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/panel/io/state.py b/panel/io/state.py index deba2ef041..beed7e5eb3 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -20,9 +20,17 @@ class _state(param.Parameterized): apps to indicate their state to a user. """ + 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.""") + webdriver = param.Parameter(default=None, doc=""" 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 + processed.""") + # Whether to hold comm events _hold = False @@ -49,8 +57,6 @@ class _state(param.Parameterized): # Stores a set of locked Websockets, reset after every change event _locks = WeakSet() - _curdoc = None - def __repr__(self): server_info = [] for server, panel, docs in self._servers.values(): diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py index b8ccaea639..1df6df1989 100644 --- a/panel/tests/models/test_location.py +++ b/panel/tests/models/test_location.py @@ -1,8 +1,5 @@ -import panel as pn - from panel.models.location import Location - def test_constructor(): # When actual = Location() From 56f39084e388bf56c44156c291fc9dbc2e593555 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 13:00:44 +0200 Subject: [PATCH 19/28] Split viewable and reactive modules --- panel/io/embed.py | 4 +- panel/layout/base.py | 2 +- panel/layout/spacer.py | 2 +- panel/links.py | 3 +- panel/pane/base.py | 3 +- panel/reactive.py | 474 +++++++++++++++++++++++++++++ panel/tests/test_reactive.py | 3 +- panel/viewable.py | 564 ++++------------------------------- panel/widgets/base.py | 4 +- 9 files changed, 545 insertions(+), 514 deletions(-) create mode 100644 panel/reactive.py 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/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/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..5fe80d9629 --- /dev/null +++ b/panel/reactive.py @@ -0,0 +1,474 @@ +x""" +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. +""" + +import difflib +import threading + +from functools import partial + +from tornado import gen + +from .callbacks import PeriodicCallback +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 + + +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 = [ + attr for attr, value in msg.items() + if not model.lookup(attr).property.matches(getattr(model, attr), value) + ] + try: + model.update(**msg) + finally: + self._changing = [] + + 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: + 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: + self._changing.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: + self._changing.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/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/viewable.py b/panel/viewable.py index 1b5a1d44cd..341b686b06 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -1,14 +1,15 @@ """ -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 @@ -20,25 +21,27 @@ 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 .util import edit_readonly, escape, param_reprs +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 +204,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,6 +220,9 @@ 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, location): """ @@ -346,6 +354,14 @@ def show(self, title=None, port=0, websocket_origin=None, threaded=False, class Renderable(param.Parameterized): + """ + Baseclass for objects which can be rendered to a Bokeh model. + + It therefore declare APIs for initializing the models from + parameter values. + """ + + __abstract = True def __init__(self, **params): super(Renderable, self).__init__(**params) @@ -413,6 +429,10 @@ 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 @@ -435,39 +455,15 @@ def get_root(self, doc=None, comm=None): state._views[ref] = (self, root, doc, comm) return root - #---------------------------------------------------------------- - # Public API - #---------------------------------------------------------------- - - def clone(self, **params): - """ - Makes a copy of the object sharing the same parameters. - - Arguments - --------- - params: Keyword arguments override the parameters on the clone. - - Returns - ------- - Cloned Viewable object - """ - return type(self)(**dict(self.param.get_param_values(), **params)) - - def pprint(self): - """ - Prints a compositional repr of the class. - """ - print(self) - class Viewable(Renderable, Layoutable, ServableMixin): """ - 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. + 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 = [] @@ -537,49 +533,6 @@ def _repr_mimebundle_(self, include=None, exclude=None): 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 - def _server_destroy(self, session_context): """ Server lifecycle hook triggered when session is destroyed. @@ -592,6 +545,26 @@ def _server_destroy(self, session_context): # Public API #---------------------------------------------------------------- + def clone(self, **params): + """ + Makes a copy of the object sharing the same parameters. + + Arguments + --------- + params: Keyword arguments override the parameters on the clone. + + Returns + ------- + Cloned Viewable object + """ + return type(self)(**dict(self.param.get_param_values(), **params)) + + def pprint(self): + """ + Prints a compositional repr of the class. + """ + print(self) + def select(self, selector=None): """ Iterates over the Viewable and any potential children in the @@ -731,422 +704,3 @@ def server_doc(self, doc=None, title=None, location=True): loc_model = loc._get_model(doc, model) doc.add_root(loc_model) return doc - - -class Syncable(Renderable): - - # 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 = {} - - #---------------------------------------------------------------- - # 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) - 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)) - - def _process_events(self, events): - with edit_readonly(self): - self.param.set_param(**self._process_property_change(events)) - - @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 - - #---------------------------------------------------------------- - # Model API - #---------------------------------------------------------------- - - def _init_properties(self): - return {k: v for k, v in self.param.get_param_values() - if v is not None} - - - 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 - - #---------------------------------------------------------------- - # Public API - #---------------------------------------------------------------- - - 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) - - -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 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. - """ - - #---------------------------------------------------------------- - # 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 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): From 9b4241fdcfe75f9aca5cd00a1294c197aea6acaf Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 13:00:58 +0200 Subject: [PATCH 20/28] Add Syncable location component --- panel/io/location.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 panel/io/location.py diff --git a/panel/io/location.py b/panel/io/location.py new file mode 100644 index 0000000000..03f6cb35c2 --- /dev/null +++ b/panel/io/location.py @@ -0,0 +1,59 @@ +""" +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 ..viewable import Syncable + + +class Location(Syncable): + + 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=True, 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 _get_model(self, doc, root, parent=None, comm=None): + model = _BkLocation(**self._process_param_change(self._init_properties())) + 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 + + @property + def query_params(self): + return dict(urlparse.parse_qsl(self.search[1:])) + + def update_query(self, **kwargs): + query = self.query_params + query.update(kwargs) + self.search = '?' + urlparse.urlencode(query) From 2dfe85b8d988099efda93ab02dab17f5899f5ef8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 13:03:12 +0200 Subject: [PATCH 21/28] Fixed merge conflict --- panel/io/server.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/panel/io/server.py b/panel/io/server.py index b33f4d95a0..da579f8d82 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -208,7 +208,6 @@ def get_server(panel, port=0, websocket_origin=None, loop=None, server_id = kwargs.pop('server_id', uuid.uuid4().hex) kwargs['extra_patterns'] = extra_patterns = kwargs.get('extra_patterns', []) if isinstance(panel, dict): -<<<<<<< HEAD apps = {} for slug, app in panel.items(): slug = slug if slug.startswith('/') else '/'+slug @@ -223,12 +222,7 @@ 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 if slug.startswith('/') else '/'+slug: - partial(_eval_panel, p, server_id, title, location) - for slug, p in panel.items()} ->>>>>>> Refactored Location component + apps[slug] = partial(_eval_panel, app, server_id, title, location) else: apps = {'/': partial(_eval_panel, panel, server_id, title, location)} From ec0617be2f2bcc8bcf797c18c0b0ae84250e3fa9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 14:16:34 +0200 Subject: [PATCH 22/28] Add ability to sync parameters --- panel/io/location.py | 40 +++++++++++++++++++++++++--- panel/io/state.py | 11 +++++--- panel/models/location.py | 2 +- panel/models/location.ts | 31 +++++++++++---------- panel/reactive.py | 9 ++++--- panel/tests/models/test_location.py | 4 +-- panel/tests/widgets/test_location.py | 14 +++++----- panel/util.py | 24 +++++++++++++++++ panel/viewable.py | 2 ++ 9 files changed, 102 insertions(+), 35 deletions(-) diff --git a/panel/io/location.py b/panel/io/location.py index 03f6cb35c2..acebf6ad76 100644 --- a/panel/io/location.py +++ b/panel/io/location.py @@ -7,10 +7,16 @@ import param from ..models.location import Location as _BkLocation -from ..viewable import Syncable +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'""") @@ -30,10 +36,10 @@ class Location(Syncable): search = param.String(regex=r"^$|\?", doc=""" search in window.location e.g. '?color=blue'""") - hash_ = param.String(regex=r"^$|#", doc=""" + hash = param.String(regex=r"^$|#", doc=""" hash in window.location e.g. '#interact'""") - reload = param.Boolean(default=True, doc=""" + 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""") @@ -41,6 +47,12 @@ class Location(Syncable): # 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, parent=None, comm=None): model = _BkLocation(**self._process_param_change(self._init_properties())) values = dict(self.param.get_param_values()) @@ -49,11 +61,31 @@ def _get_model(self, doc, root, parent=None, comm=None): self._link_props(model, properties, doc, root, comm) return model + def _update_synced(self, event): + if self._syncing: + return + query_params = self.query_params + for p in self._synced: + p.param.set_param(**{k: v for k, v in query_params.items() + if k in p.param}) + + def _update_query(self, *events): + self._syncing = True + try: + self.update_query(**{e.name: e.new for e in events}) + finally: + self._syncing = False + @property def query_params(self): - return dict(urlparse.parse_qsl(self.search[1:])) + 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): + self._synced.append(parameterized) + parameters = parameters or [p for p in parameterized.param if p != 'name'] + parameterized.param.watch(self._update_query, parameters) diff --git a/panel/io/state.py b/panel/io/state.py index beed7e5eb3..3eaf887627 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 @@ -40,7 +40,7 @@ class _state(param.Parameterized): _comm_manager = _CommManager # Locations - _locations = {} + _locations = WeakKeyDictionary() # An index of all currently active views _views = {} @@ -106,7 +106,12 @@ def session_args(self): @property def location(self): - return self._locations.get(self.curdoc) if self.curdoc else None + if self.curdoc and self.curdoc not in self._locations: + from .location import Location + self._locations[self.curdoc] = loc = Location() + return loc + else: + return self._locations.get(self.curdoc) if self.curdoc else None state = _state() diff --git a/panel/models/location.py b/panel/models/location.py index f220f881f6..3717eaeb0e 100644 --- a/panel/models/location.py +++ b/panel/models/location.py @@ -32,7 +32,7 @@ class Location(Model): search = String(default="", help=""" search in window.location e.g. '?color=blue'""") - hash_ = String(default="", help=""" + hash = String(default="", help=""" hash in window.location e.g. '#interact'""") reload = Bool(default=True, help=""" diff --git a/panel/models/location.ts b/panel/models/location.ts index 9c619edf5e..d18445ea0d 100644 --- a/panel/models/location.ts +++ b/panel/models/location.ts @@ -10,7 +10,7 @@ export class LocationView extends View { this.model.pathname = window.location.pathname; this.model.search = window.location.search; - this.model.hash_ = window.location.hash; + this.model.hash = window.location.hash; // Readonly parameters on python side this.model.href = window.location.href; @@ -24,24 +24,27 @@ export class LocationView extends View { 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.hash.change, () => this.update('hash')); + this.connect(this.model.properties.reload.change, () => this.update('reload')); } update(change: string): void { - if (!this.model.reload) { + if (!this.model.reload || (change === 'reload')) { window.history.pushState( {}, '', - `${this.model.pathname}${this.model.search}${this.model.hash_}` + `${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); + window.location.hash = (this.model.hash as string); } } } @@ -55,7 +58,7 @@ export namespace Location { protocol: p.Property port: p.Property search: p.Property - hash_: p.Property + hash: p.Property reload: p.Property } } @@ -75,14 +78,14 @@ export class Location extends Model { this.prototype.default_view = LocationView; this.define({ - href: [p.String, window.location.href], - hostname: [p.String, window.location.hostname], - pathname: [p.String, window.location.pathname], - protocol: [p.String, window.location.protocol], - port: [p.String, window.location.port], - search: [p.String, window.location.search], - hash_: [p.String, window.location.hash], - reload: [p.Boolean, true], + 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/reactive.py b/panel/reactive.py index 5fe80d9629..2ec134b0ca 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -1,7 +1,7 @@ -x""" -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. +""" +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 @@ -12,6 +12,7 @@ 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 diff --git a/panel/tests/models/test_location.py b/panel/tests/models/test_location.py index 1df6df1989..5fa7b59da1 100644 --- a/panel/tests/models/test_location.py +++ b/panel/tests/models/test_location.py @@ -10,7 +10,7 @@ def test_constructor(): assert actual.protocol == "" assert actual.port == "" assert actual.search == "" - assert actual.hash_ == "" + assert actual.hash == "" assert actual.reload == True @@ -28,5 +28,5 @@ def test_constructor_with__href(): assert actual.protocol == "" assert actual.port == "" assert actual.search == "" - assert actual.hash_ == "" + assert actual.hash == "" assert actual.reload == True diff --git a/panel/tests/widgets/test_location.py b/panel/tests/widgets/test_location.py index 7b8afd4b49..03a592f90e 100644 --- a/panel/tests/widgets/test_location.py +++ b/panel/tests/widgets/test_location.py @@ -53,7 +53,7 @@ def test_constructor(): assert actual.protocol == "" assert actual.port == "" assert actual.search == "" - assert actual.hash_ == "" + assert actual.hash == "" assert actual.reload == True @@ -83,11 +83,11 @@ def test_port_is_readonly(port): def test_attributes_are_not_readonly(pathname, search, hash_, reload): # When - location = Location(pathname=pathname, search=search, hash_=hash_, reload=reload) + location = Location(pathname=pathname, search=search, hash=hash_, reload=reload) # Then assert location.pathname == pathname assert location.search == search - assert location.hash_ == hash_ + assert location.hash == hash_ assert location.reload == reload @@ -108,7 +108,7 @@ def test_hash_raises_valueerror_if_string_invalid(): "The hash string should be '' or start with '#'" # When/ Then with pytest.raises(ValueError): - Location(hash_="section2") + Location(hash="section2") def test_readonly_workaround_works(href, hostname, protocol, port): @@ -142,8 +142,8 @@ def test_location_comm(document, comm, pathname, search, hash_, reload): location._comm_change({"search": search}) assert location.search == search - location._comm_change({"hash_": hash_}) - assert location.hash_ == hash_ + location._comm_change({"hash": hash_}) + assert location.hash == hash_ location._comm_change({"reload": reload}) assert location.reload == reload @@ -159,7 +159,7 @@ def test_location_comm(document, comm, pathname, search, hash_, reload): "protocol", "port", "search", - "hash_", + "hash", "reload", ] pn.Column(location, pn.Param(location, parameters=parameters)).servable() diff --git a/panel/util.py b/panel/util.py index 50cbad4cb5..599aca8432 100644 --- a/panel/util.py +++ b/panel/util.py @@ -9,6 +9,7 @@ import os import re import sys +import urllib.parse as urlparse from collections import defaultdict, OrderedDict from contextlib import contextmanager @@ -257,6 +258,29 @@ def value_as_date(value): 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 diff --git a/panel/viewable.py b/panel/viewable.py index 341b686b06..b8907179d3 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -698,6 +698,8 @@ def server_doc(self, doc=None, title=None, location=True): if location: if isinstance(location, Location): loc = location + elif doc in state._locations: + loc = state.location else: loc = Location() state._locations[doc] = loc From aab5ae5475c1489a8e6bc68c5fae66e40a799aad Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 14:48:23 +0200 Subject: [PATCH 23/28] Fixed flakes --- panel/reactive.py | 3 +++ panel/util.py | 1 + panel/viewable.py | 4 ---- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/panel/reactive.py b/panel/reactive.py index 2ec134b0ca..0e3477423d 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -7,6 +7,7 @@ import difflib import threading +from collections import namedtuple from functools import partial from tornado import gen @@ -20,6 +21,8 @@ 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): """ diff --git a/panel/util.py b/panel/util.py index 599aca8432..e70093b0e5 100644 --- a/panel/util.py +++ b/panel/util.py @@ -5,6 +5,7 @@ import datetime as dt import inspect +import json import numbers import os import re diff --git a/panel/viewable.py b/panel/viewable.py index b8907179d3..0c7b22b739 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -13,7 +13,6 @@ import traceback import uuid -from collections import namedtuple from functools import partial import param @@ -34,9 +33,6 @@ 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 From 15624d99e80462f2a8f32035b80148bc948c63eb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 14:48:44 +0200 Subject: [PATCH 24/28] Improved support for syncing query params --- panel/io/location.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/panel/io/location.py b/panel/io/location.py index acebf6ad76..41c8bbfaa7 100644 --- a/panel/io/location.py +++ b/panel/io/location.py @@ -65,14 +65,23 @@ def _update_synced(self, event): if self._syncing: return query_params = self.query_params - for p in self._synced: - p.param.set_param(**{k: v for k, v in query_params.items() - if k in p.param}) + 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): + if self._syncing: + return + query = {} + 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(**{e.name: e.new for e in events}) + self.update_query(**query) finally: self._syncing = False @@ -86,6 +95,22 @@ def update_query(self, **kwargs): self.search = '?' + urlparse.urlencode(query) def sync(self, parameterized, parameters=None): - self._synced.append(parameterized) + """ + 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'] - parameterized.param.watch(self._update_query, parameters) + if not isinstance(parameters, dict): + parameters = dict(zip(parameters, parameters)) + self._synced.append((parameterized, parameters)) + parameterized.param.watch(self._update_query, list(parameters)) From 9a0205bba891b3901fef5ff4e67815978e6e8cca Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 21:14:30 +0200 Subject: [PATCH 25/28] Allow syncing Parameterized with query string --- examples/user_guide/Overview.ipynb | 11 ++++- examples/user_guide/Param.ipynb | 77 ++++++++++++++++++++++++++++-- panel/io/location.py | 14 ++++-- panel/io/notebook.py | 5 +- panel/io/state.py | 5 +- panel/viewable.py | 7 ++- 6 files changed, 107 insertions(+), 12 deletions(-) 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/location.py b/panel/io/location.py index 41c8bbfaa7..2161d9a0b7 100644 --- a/panel/io/location.py +++ b/panel/io/location.py @@ -53,15 +53,16 @@ def __init__(self, **params): self._syncing = False self.param.watch(self._update_synced, ['search']) - def _get_model(self, doc, root, parent=None, comm=None): + 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): + def _update_synced(self, event=None): if self._syncing: return query_params = self.query_params @@ -70,10 +71,10 @@ def _update_synced(self, event): p.param.set_param(**{mapping[k]: v for k, v in query_params.items() if k in mapping}) - def _update_query(self, *events): + def _update_query(self, *events, query=None): if self._syncing: return - query = {} + 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: @@ -81,7 +82,7 @@ def _update_query(self, *events): query[matches[0][e.name]] = e.new self._syncing = True try: - self.update_query(**query) + self.update_query(**{k: v for k, v in query.items() if v is not None}) finally: self._syncing = False @@ -114,3 +115,6 @@ def sync(self, parameterized, parameters=None): 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/state.py b/panel/io/state.py index 3eaf887627..311ae739b8 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -40,7 +40,8 @@ class _state(param.Parameterized): _comm_manager = _CommManager # Locations - _locations = WeakKeyDictionary() + _location = None # Global location, e.g. for notebook context + _locations = WeakKeyDictionary() # Server locations indexed by document # An index of all currently active views _views = {} @@ -110,6 +111,8 @@ def location(self): 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 diff --git a/panel/viewable.py b/panel/viewable.py index 0c7b22b739..8c9b9b901c 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -504,6 +504,11 @@ 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() + from IPython.display import display from .models.comm_manager import CommManager @@ -527,7 +532,7 @@ def _repr_mimebundle_(self, include=None, exclude=None): if config.embed: return render_model(model) - return render_mimebundle(model, doc, comm, manager) + return render_mimebundle(model, doc, comm, manager, location) def _server_destroy(self, session_context): """ From 87f4f96382875540714676f69a5b0648c1df55d8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 22:01:04 +0200 Subject: [PATCH 26/28] Refactored location tests --- panel/tests/io/__init__.py | 0 panel/tests/{test_io.py => io/test_embed.py} | 81 +-------- panel/tests/io/test_location.py | 81 +++++++++ panel/tests/io/test_model.py | 42 +++++ panel/tests/io/test_notebook.py | 38 +++++ panel/tests/widgets/test_location.py | 165 ------------------- 6 files changed, 162 insertions(+), 245 deletions(-) create mode 100644 panel/tests/io/__init__.py rename panel/tests/{test_io.py => io/test_embed.py} (86%) create mode 100644 panel/tests/io/test_location.py create mode 100644 panel/tests/io/test_model.py create mode 100644 panel/tests/io/test_notebook.py delete mode 100644 panel/tests/widgets/test_location.py 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/widgets/test_location.py b/panel/tests/widgets/test_location.py deleted file mode 100644 index 03a592f90e..0000000000 --- a/panel/tests/widgets/test_location.py +++ /dev/null @@ -1,165 +0,0 @@ -import panel as pn -from panel.widgets.location import Location -import pytest - - -@pytest.fixture -def href(): - return "https://panel.holoviz.org/user_guide/Interact.html:5006?color=blue#interact" - - -@pytest.fixture -def hostname(): - return "panel.holoviz.org" - - -@pytest.fixture -def pathname(): - return "/user_guide/Interact.html" - - -@pytest.fixture -def protocol(): - return "https:" - - -@pytest.fixture -def port(): - return "5006" - - -@pytest.fixture -def search(): - return "?color=blue" - - -@pytest.fixture -def hash_(): - return "#interact" - - -@pytest.fixture -def reload(): - return True - - -def test_constructor(): - # When - actual = Location() - # Then/pyv - 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_href_is_readonly(href): - # When/ Then - with pytest.raises(TypeError): - Location(href=href) - - -def test_hostname_is_readonly(hostname): - # When/ Then - with pytest.raises(TypeError): - Location(hostname=hostname) - - -def test_protocol_is_readonly(protocol): - # When/ Then - with pytest.raises(TypeError): - Location(protocol=protocol) - - -def test_port_is_readonly(port): - # When/ Then - with pytest.raises(TypeError): - Location(port=port) - - -def test_attributes_are_not_readonly(pathname, search, hash_, reload): - # When - location = Location(pathname=pathname, search=search, hash=hash_, reload=reload) - # Then - assert location.pathname == pathname - assert location.search == search - assert location.hash == hash_ - assert location.reload == reload - - -@pytest.mark.parametrize(["invalid"], [("app",), ("app/",),]) -def test_pathname_raises_valueerror_if_string_invalid(invalid): - "The pathname should be '' or (not start or end with '/')" - with pytest.raises(ValueError): - Location(pathname=invalid) - - -def test_search_raises_valueerror_if_string_invalid(): - "The search string should be '' or start with '?'" - with pytest.raises(ValueError): - Location(search="a=b") - - -def test_hash_raises_valueerror_if_string_invalid(): - "The hash string should be '' or start with '#'" - # When/ Then - with pytest.raises(ValueError): - Location(hash="section2") - - -def test_readonly_workaround_works(href, hostname, protocol, port): - # Given - location = Location() - # When - location._href = href - location._hostname = hostname - location._protocol = protocol - location._port = port - # Then - location.href == href - location.hostname == hostname - location.protocol == protocol - location.port == port - - -def test_location_comm(document, comm, pathname, search, hash_, reload): - # Given - location = Location() - - # When - widget = location.get_root(document, comm=comm) - - # Then - assert isinstance(widget, Location._widget_type) - - location._comm_change({"pathname": pathname}) - assert location.pathname == pathname - - location._comm_change({"search": search}) - assert location.search == search - - location._comm_change({"hash": hash_}) - assert location.hash == hash_ - - location._comm_change({"reload": reload}) - assert location.reload == reload - - -if __name__.startswith("bk"): - pn.config.sizing_mode = "stretch_width" - location = Location(reload=False) - parameters = [ - "href", - "hostname", - "pathname", - "protocol", - "port", - "search", - "hash", - "reload", - ] - pn.Column(location, pn.Param(location, parameters=parameters)).servable() From 1f645cf4ac8cd3074c91c4fd72c55b24818803b8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 22:21:55 +0200 Subject: [PATCH 27/28] Fixed issues in reactive module --- panel/reactive.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/panel/reactive.py b/panel/reactive.py index 0e3477423d..8f65fe45eb 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -59,7 +59,7 @@ def __init__(self, **params): self._callbacks = [] self._links = [] self._link_params() - self._changing = [] + self._changing = {} # Allows defining a mapping from model property name to a JS code # snippet that transforms the object before serialization @@ -118,7 +118,7 @@ def _link_props(self, model, properties, doc, root, comm=None): 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() @@ -128,14 +128,14 @@ def _synced_params(self): return list(self.param) def _update_model(self, events, msg, root, model, doc, comm): - self._changing = [ + 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: - self._changing = [] + del self._changing[root.ref['id']] def _cleanup(self, root): super(Syncable, self)._cleanup(root) @@ -166,7 +166,7 @@ def _param_change(self, *events): return for ref, (model, parent) in self._models.items(): - if ref not in state._views: + 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): @@ -201,18 +201,18 @@ def _change_event(self, doc=None): state._thread_id = None def _comm_change(self, doc, ref, attr, old, new): - if attr in self._changing: - self._changing.remove(attr) + 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: - self._changing.remove(attr) + 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: From ce93bf0500df00f67bf3219ca1c330e4b3f61d31 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 15 Apr 2020 22:38:20 +0200 Subject: [PATCH 28/28] Fixed undefined variable --- panel/viewable.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/panel/viewable.py b/panel/viewable.py index 8c9b9b901c..bc1860ad8c 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -508,6 +508,8 @@ def _repr_mimebundle_(self, include=None, exclude=None): # 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