From 6b44a1822048d442b56c1794d7d407e6b5e9b153 Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Mon, 26 Aug 2024 14:48:53 +0200 Subject: [PATCH 001/164] Improve ESM layout how-to guide (#7088) --- .../custom_components/esm/custom_layout.md | 543 ++++++++++-------- panel/models/react_component.ts | 2 +- panel/styles/models/esm.less | 4 + 3 files changed, 306 insertions(+), 243 deletions(-) diff --git a/doc/how_to/custom_components/esm/custom_layout.md b/doc/how_to/custom_components/esm/custom_layout.md index 112148636a..c8c9ccd080 100644 --- a/doc/how_to/custom_components/esm/custom_layout.md +++ b/doc/how_to/custom_components/esm/custom_layout.md @@ -1,108 +1,94 @@ # Create Custom Layouts -In this guide we will show you how to build custom, reusable layouts using `Viewer`, `JSComponent` or `ReactComponent`. +In this guide, we will demonstrate how to build custom, reusable layouts using [`JSComponent`](../../reference/panes/JSComponent.md) or [`ReactComponent`](../../reference/panes/ReactComponent.md). -## Layout a single Panel Component +Please note that it is currently not possible to create layouts using the [`AnyWidgetComponent`](../../reference/panes/AnyWidgetComponent.md), as the underlying [`AnyWidget`](https://anywidget.dev/) API does not support this. -You can layout a single `object` as follows. +## Layout Two Objects + +This example will show you how to create a *split* layout containing two objects. We will be using the [Split.js](https://split.js.org/) library. ::::{tab-set} -:::{tab-item} `Viewer` +:::{tab-item} `JSComponent` ```{pyodide} import panel as pn -from panel.custom import Child -from panel.viewable import Viewer, Layoutable - -pn.extension() - -class SingleObjectLayout(Viewer, Layoutable): - object = Child(allow_refs=False) +from panel.custom import Child, JSComponent - def __init__(self, **params): - super().__init__(**params) +CSS = """ +.split { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; +} - header = """ -# Temperature -## A Measurement from the Sensor - """ +.gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; +} - layoutable_params = {name: self.param[name] for name in Layoutable.param} - self._layout = pn.Column( - pn.pane.Markdown(header, height=100, sizing_mode="stretch_width"), - self._object, - **layoutable_params, - ) +.gutter.gutter-horizontal { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=='); + cursor: col-resize; +} +""" - def __panel__(self): - return self._layout - @pn.depends("object") - def _object(self): - return self.object +class SplitJS(JSComponent): + left = Child() + right = Child() -dial = pn.widgets.Dial( - name="°C", - value=37, - format="{value}", - colors=[(0.40, "green"), (1, "red")], - bounds=(0, 100), -) -py_layout = SingleObjectLayout( - object=dial, - name="Temperature", - styles={"border": "2px solid lightgray"}, - sizing_mode="stretch_width", -) -py_layout.servable() -``` + _esm = """ + import Split from 'https://esm.sh/split.js@1.6.5' -::: + export function render({ model }) { + const splitDiv = document.createElement('div'); + splitDiv.className = 'split'; -:::{tab-item} `JSComponent` + const split0 = document.createElement('div'); + splitDiv.appendChild(split0); -```{pyodide} -import panel as pn -from panel.custom import JSComponent, Child + const split1 = document.createElement('div'); + splitDiv.appendChild(split1); -pn.extension() + const split = Split([split0, split1]) -class SingleObjectLayout(JSComponent): - object = Child(allow_refs=False) + model.on('remove', () => split.destroy()) - _esm = """ -export function render({ model }) { - const containerID = `id-${crypto.randomUUID()}`;; - const div = document.createElement("div"); - div.innerHTML = ` -
-

Temperature

-

A measurement from the sensor

-
...
-
`; - const container = div.querySelector(`#${containerID}`); - container.appendChild(model.get_child("object")) - return div; -} -""" + split0.append(model.get_child("left")) + split1.append(model.get_child("right")) + return splitDiv + }""" -dial = pn.widgets.Dial( - name="°C", - value=37, - format="{value}", - colors=[(0.40, "green"), (1, "red")], - bounds=(0, 100), -) -js_layout = SingleObjectLayout( - object=dial, - name="Temperature", - styles={"border": "2px solid lightgray"}, + _stylesheets = [CSS] + + +pn.extension("codeeditor") + +split_js = SplitJS( + left=pn.widgets.CodeEditor( + value="Left!", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", + ), + right=pn.widgets.CodeEditor( + value="Right", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", + ), + height=500, sizing_mode="stretch_width", ) -js_layout.servable() +split_js.servable() ``` ::: @@ -114,91 +100,111 @@ import panel as pn from panel.custom import Child, ReactComponent -pn.extension() +CSS = """ +.split { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; +} -class SingleObjectLayout(ReactComponent): - object = Child(allow_refs=False) +.gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; +} - _esm = """ -export function render({ model }) { - return ( -
-

Temperature

-

A measurement from the sensor

-
- {model.get_child("object")} -
-
- ); +.gutter.gutter-horizontal { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=='); + cursor: col-resize; } """ -dial = pn.widgets.Dial( - name="°C", - value=37, - format="{value}", - colors=[(0.40, "green"), (1, "red")], - bounds=(0, 100), -) -react_layout = SingleObjectLayout( - object=dial, - name="Temperature", - styles={"border": "2px solid lightgray"}, + +class SplitReact(ReactComponent): + + left = Child() + right = Child() + + _esm = """ + import Split from 'https://esm.sh/react-split@2.0.14' + + export function render({ model }) { + return ( + + {model.get_child("left")} + {model.get_child("right")} + + ) + } + """ + + _stylesheets = [CSS] + + +pn.extension("codeeditor") + +split_react = SplitReact( + left=pn.widgets.CodeEditor( + value="Left!", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", + ), + right=pn.widgets.CodeEditor( + value="Right", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", + ), + height=500, sizing_mode="stretch_width", ) -react_layout.servable() +split_react.servable() ``` ::: :::: -Lets verify the layout will automatically update when the `object` is changed. +Let's verify that the layout will automatically update when the `object` is changed. ::::{tab-set} -:::{tab-item} `Viewer` +:::{tab-item} `JSComponent` ```{pyodide} -html = pn.pane.Markdown("A **markdown** pane!", name="Markdown") -radio_button_group = pn.widgets.RadioButtonGroup( - options=["Dial", "Markdown"], - value="Dial", - name="Select the object to display", - button_type="success", button_style="outline" -) +split_js.right=pn.pane.Markdown("Hi. I'm a `Markdown` pane replacing the `CodeEditor` widget!", sizing_mode="stretch_both") +``` + +::: -@pn.depends(radio_button_group, watch=True) -def update(value): - if value == "Dial": - py_layout.object = dial - else: - py_layout.object = html +:::{tab-item} `ReactComponent` -radio_button_group.servable() +```{pyodide} +split_react.right=pn.pane.Markdown("Hi. I'm a `Markdown` pane replacing the `CodeEditor` widget!", sizing_mode="stretch_both") ``` ::: +:::: + +Now, let's change it back: + +::::{tab-set} + :::{tab-item} `JSComponent` ```{pyodide} -html = pn.pane.Markdown("A **markdown** pane!", name="Markdown") -radio_button_group = pn.widgets.RadioButtonGroup( - options=["Dial", "Markdown"], - value="Dial", - name="Select the object to display", - button_type="success", button_style="outline" +split_js.right=pn.widgets.CodeEditor( + value="Right", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", ) - -@pn.depends(radio_button_group, watch=True) -def update(value): - if value == "Dial": - js_layout.object = dial - else: - js_layout.object = html - -radio_button_group.servable() ``` ::: @@ -206,22 +212,13 @@ radio_button_group.servable() :::{tab-item} `ReactComponent` ```{pyodide} -html = pn.pane.Markdown("A **markdown** pane!", name="Markdown") -radio_button_group = pn.widgets.RadioButtonGroup( - options=["Dial", "Markdown"], - value="Dial", - name="Select the object to display", - button_type="success", button_style="outline" +split_react.right=pn.widgets.CodeEditor( + value="Right", + sizing_mode="stretch_both", + margin=0, + theme="monokai", + language="python", ) - -@pn.depends(radio_button_group, watch=True) -def update(value): - if value == "Dial": - react_layout.object = dial - else: - react_layout.object = html - -radio_button_group.servable() ``` ::: @@ -230,153 +227,215 @@ radio_button_group.servable() ## Layout a List of Objects -A Panel `Column` or `Row` works as a list of objects. It is *list-like*. In this section will show you how to create your own *list-like* layout using Panels `NamedListLike` class. +A Panel `Column` or `Row` works as a list of objects. It is *list-like*. In this section, we will show you how to create your own *list-like* layout using Panel's `NamedListLike` class. ::::{tab-set} -:::{tab-item} `Viewer` +:::{tab-item} `JSComponent` ```{pyodide} import panel as pn -from panel.viewable import Viewer, Layoutable -from panel.custom import Children -from panel.layout.base import NamedListLike +import param + +from panel.custom import JSComponent + +from panel.layout.base import ListLike + +CSS = """ +.gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; +} +.gutter.gutter-vertical { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='); + cursor: row-resize; +} +""" + + +class GridJS(ListLike, JSComponent): -pn.extension() + _esm = """ + import Split from 'https://esm.sh/split.js@1.6.5' + + export function render({ model}) { + const objects = model.get_child("objects") + const splitDiv = document.createElement('div'); + splitDiv.className = 'split'; + splitDiv.style.height = `calc(100% - ${(objects.length - 1) * 10}px)`; -class ListLikeLayout(NamedListLike, Layoutable, Viewer): - objects = Children() + let splits = []; - def __init__(self, *args, **params): - super().__init__(*args, **params) + objects.forEach((object, index) => { + const split = document.createElement('div'); + splits.push(split) - layoutable_params = {name: self.param[name] for name in Layoutable.param} - self._layout = pn.Column( - **layoutable_params, - ) - self._objects() + splitDiv.appendChild(split); + split.appendChild(object); + }) - def __panel__(self): - return self._layout + Split(splits, {direction: 'vertical'}) - @pn.depends("objects", watch=True) - def _objects(self): - objects = [] - for object in self.objects: - objects.append(object) - objects.append( - pn.pane.HTML( - styles={"width": "calc(100% - 15px)", "border-top": "3px dotted #bbb"}, - height=10, - ) - ) + return splitDiv + }""" - self._layout[:] = objects + _stylesheets = [CSS] -ListLikeLayout( - "I love beat boxing", - "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", - "Yes I do!", +pn.extension("codeeditor") + +grid_js = GridJS( + pn.widgets.CodeEditor( + value="I love beatboxing\n" * 10, theme="monokai", sizing_mode="stretch_both" + ), + pn.panel( + "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", + sizing_mode="stretch_width", + height=100, + ), + pn.widgets.CodeEditor( + value="Yes, I do!\n" * 10, theme="monokai", sizing_mode="stretch_both" + ), styles={"border": "2px solid lightgray"}, + height=800, + width=500, + sizing_mode="fixed", ).servable() ``` -You must list `NamedListLike, Layoutable, Viewer` in exactly that order when you define the class! Other combinations might not work. +You must list `ListLike, JSComponent` in exactly that order when you define the class! Reversing the order to `JSComponent, ListLike` will not work. ::: -:::{tab-item} `JSComponent` +:::{tab-item} `ReactComponent` ```{pyodide} import panel as pn import param -from panel.custom import JSComponent -from panel.layout.base import NamedListLike -pn.extension() +from panel.custom import ReactComponent +from panel.layout.base import ListLike +CSS = """ +.gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; +} +.gutter.gutter-vertical { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='); -class ListLikeLayout(NamedListLike, JSComponent): - objects = param.List() - _esm = """ - export function render({ model }) { - const div = document.createElement('div') - let objects = model.get_child("objects") + cursor: row-resize; +} +""" - objects.forEach((object, index) => { - div.appendChild(object); - - // If it's not the last object, add a divider - if (index < objects.length - 1) { - const divider = document.createElement("div"); - divider.className = "divider"; - div.appendChild(divider); - } - }); - return div + +class GridReact(ListLike, ReactComponent): + + _esm = """ + import Split from 'https://esm.sh/react-split@2.0.14' + + export function render({ model}) { + const objects = model.get_child("objects") + const calculatedHeight = `calc( 100% - ${(objects.length - 1) * 10}px )`; + + return ( + {...objects} + ) }""" - _stylesheets = [ - """ -.divider {border-top: 3px dotted #bbb}; -""" - ] + _stylesheets = [CSS] + +pn.extension("codeeditor") -ListLikeLayout( - "I love beat boxing", - "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", - "Yes I do!", +grid_react = GridReact( + pn.widgets.CodeEditor( + value="I love beatboxing\n" * 10, theme="monokai", sizing_mode="stretch_both" + ), + pn.panel( + "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", + sizing_mode="stretch_width", + height=100, + ), + pn.widgets.CodeEditor( + value="Yes, I do!\n" * 10, theme="monokai", sizing_mode="stretch_both" + ), styles={"border": "2px solid lightgray"}, -).servable() + height=800, + width=500, + sizing_mode="fixed", +) +grid_react.servable() ``` -You must list `NamedListLike, JSComponent` in exactly that order when you define the class! The other -way around `JSComponent, NamedListLike` will not work. +::: + +:::: + +:::{note} +You must list `ListLike, ReactComponent` in exactly that order when you define the class! Reversing the order to `ReactComponent, ListLike` will not work. +::: + +You can now use `[...]` indexing and methods like `.append`, `.insert`, `pop`, etc., as you would expect: + +::::{tab-set} + +:::{tab-item} `JSComponent` + +```{pyodide} +grid_js.append( + pn.widgets.CodeEditor( + value="Another one bites the dust\n" * 10, + theme="monokai", + sizing_mode="stretch_both", + ) +) +``` ::: :::{tab-item} `ReactComponent` ```{pyodide} -import panel as pn +grid_react.append( + pn.widgets.CodeEditor( + value="Another one bites the dust\n" * 10, + theme="monokai", + sizing_mode="stretch_both", + ) +) +``` -from panel.custom import Children, ReactComponent +::: -class Example(ReactComponent): +:::: - objects = Children() +Let's remove it again: - _esm = """ - export function render({ model }) { - let objects = model.get_child("objects") - return ( -
- {objects.map((object, index) => ( - - {object} - {index < objects.length - 1 &&
} -
- ))} -
- ); - }""" +::::{tab-set} +:::{tab-item} `JSComponent` -Example( - objects=[pn.panel("A **Markdown** pane!"), pn.widgets.Button(name="Click me!"), {"text": "I'm shown as a JSON Pane"}] -).servable() +```{pyodide} +grid_js.pop(-1) ``` ::: -:::: +:::{tab-item} `ReactComponent` + +```{pyodide} +grid_react.pop(-1) +``` -:::{note} -You must list `ListLike, ReactComponent` in exactly that order when you define the class! The other way around `ReactComponent, ListLike` will not work. ::: -You can now use `[...]` indexing and the `.append`, `.insert`, `pop`, ... methods that you would expect. +:::: diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts index ca16b90373..89a8d123c0 100644 --- a/panel/models/react_component.ts +++ b/panel/models/react_component.ts @@ -178,7 +178,7 @@ class ErrorBoundary extends React.Component { if (this.state.hasError) { return React.createElement('div') } - return React.createElement('div', {}, this.props.children); + return React.createElement('div', {className: "error-wrapper"}, this.props.children); } } diff --git a/panel/styles/models/esm.less b/panel/styles/models/esm.less index 41f1739dfa..03a614c0e7 100644 --- a/panel/styles/models/esm.less +++ b/panel/styles/models/esm.less @@ -1,3 +1,7 @@ +.error-wrapper { + display: contents; +} + .error { padding: 0.75rem 1.25rem; border: 1px solid transparent; From 577f8d94210dd2cef909144f0153b20930dd6043 Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Mon, 26 Aug 2024 05:57:36 -0700 Subject: [PATCH 002/164] Add TimePicker from Bokeh (#7013) --- examples/reference/widgets/TimePicker.ipynb | 104 ++++++++++++++++++++ panel/models/__init__.py | 1 + panel/models/index.ts | 1 + panel/models/time_picker.py | 7 ++ panel/models/time_picker.ts | 67 +++++++++++++ panel/tests/ui/widgets/test_time_picker.py | 58 +++++++++++ panel/tests/widgets/test_input.py | 20 +++- panel/widgets/__init__.py | 2 + panel/widgets/input.py | 75 +++++++++++++- 9 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 examples/reference/widgets/TimePicker.ipynb create mode 100644 panel/models/time_picker.py create mode 100644 panel/models/time_picker.ts create mode 100644 panel/tests/ui/widgets/test_time_picker.py diff --git a/examples/reference/widgets/TimePicker.ipynb b/examples/reference/widgets/TimePicker.ipynb new file mode 100644 index 0000000000..b4d0f66a14 --- /dev/null +++ b/examples/reference/widgets/TimePicker.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import datetime as dt\n", + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``TimePicker`` widget allows entering a time value as text or `datetime.time`. \n", + "\n", + "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\n", + "\n", + "#### Parameters:\n", + "\n", + "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "\n", + "##### Core\n", + "\n", + "* **``value``** (str | datetime.time): The current value\n", + "* **``start``** (str | datetime.time): Inclusive lower bound of the allowed time selection\n", + "* **``end``** (str | datetime.time): Inclusive upper bound of the allowed time selection\n", + "\n", + " ```\n", + " +---+------------------------------------+------------+\n", + " | H | Hours (24 hours) | 00 to 23 |\n", + " | h | Hours | 1 to 12 |\n", + " | G | Hours, 2 digits with leading zeros | 1 to 12 |\n", + " | i | Minutes | 00 to 59 |\n", + " | S | Seconds, 2 digits | 00 to 59 |\n", + " | s | Seconds | 0, 1 to 59 |\n", + " | K | AM/PM | AM or PM |\n", + " +---+------------------------------------+------------+\n", + " ```\n", + " See also https://flatpickr.js.org/formatting/#date-formatting-tokens.\n", + "\n", + "\n", + "\n", + "##### Display\n", + "\n", + "* **``disabled``** (boolean): Whether the widget is editable\n", + "* **``name``** (str): The title of the widget\n", + "* **``format``** (str): Formatting specification for the display of the picked date.\n", + "* **``hour_increment``** (int): Defines the granularity of hour value increments in the UI. Default is 1.\n", + "* **``minute_increment``** (int): Defines the granularity of minute value increments in the UI. Default is 1.\n", + "* **``second_increment``** (int): Defines the granularity of second value increments in the UI. Default is 1.\n", + "* **``seconds``** (bool): Allows to select seconds. By default, only hours and minutes are selectable, and AM/PM depending on the `clock` option. Default is False.\n", + "* **``clock``** (bool): Whether to use 12 hour or 24 hour clock.\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `TimePicker` widget allows selecting a time of day." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "time_picker = pn.widgets.TimePicker(name='Time Picker', value=dt.datetime.now().time(), format='H:i K')\n", + "time_picker" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Either `datetime.time` or `str` can be used as input and `TimePicker` can be bounded by a start and end time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "time_picker = pn.widgets.TimePicker(name='Time Picker', value=\"08:28\", start='00:00', end='12:00')\n", + "time_picker" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/models/__init__.py b/panel/models/__init__.py index c00045f5bd..a5616ffbfb 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -15,6 +15,7 @@ from .markup import HTML, JSON, PDF # noqa from .reactive_html import ReactiveHTML # noqa from .state import State # noqa +from .time_picker import TimePicker # noqa from .trend import TrendIndicator # noqa from .widgets import ( # noqa Audio, Button, CheckboxButtonGroup, CustomMultiSelect, CustomSelect, diff --git a/panel/models/index.ts b/panel/models/index.ts index ba9cf6e1fd..013635d924 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -45,6 +45,7 @@ export {Terminal} from "./terminal" export {TextAreaInput} from "./textarea_input" export {TextInput} from "./text_input" export {TextToSpeech} from "./text_to_speech" +export {TimePicker} from "./time_picker" export {ToggleIcon} from "./toggle_icon" export {TooltipIcon} from "./tooltip_icon" export {TrendIndicator} from "./trend" diff --git a/panel/models/time_picker.py b/panel/models/time_picker.py new file mode 100644 index 0000000000..d211dc5e61 --- /dev/null +++ b/panel/models/time_picker.py @@ -0,0 +1,7 @@ +from bokeh.models import TimePicker as BkTimePicker + + +class TimePicker(BkTimePicker): + """ + A custom Panel version of the Bokeh TimePicker model which fixes timezones. + """ diff --git a/panel/models/time_picker.ts b/panel/models/time_picker.ts new file mode 100644 index 0000000000..3ee91e473c --- /dev/null +++ b/panel/models/time_picker.ts @@ -0,0 +1,67 @@ +import {TimePicker as BkTimePicker, TimePickerView as BkTimePickerView} from "@bokehjs/models/widgets/time_picker" +import type * as p from "@bokehjs/core/properties" +import type flatpickr from "flatpickr" + +export class TimePickerView extends BkTimePickerView { + declare model: TimePicker + + private _offset_time(value: string | number): number { + const baseDate = new Date(value) + const timeZoneOffset = baseDate.getTimezoneOffset() * 60 * 1000 + return baseDate.getTime() + timeZoneOffset + } + + private _setDate(date: string | number): void { + date = this._offset_time(date) + this.picker.setDate(date) + } + + protected override get flatpickr_options(): flatpickr.Options.Options { + // on init + const options = super.flatpickr_options + if (options.defaultDate != null) { options.defaultDate = this._offset_time(options.defaultDate as string) } + return options + } + + override connect_signals(): void { + super.connect_signals() + + const {value} = this.model.properties + this.connect(value.change, () => { + const {value} = this.model + if (value != null && typeof value === "number") { + // we need to handle it when programmatically changed thru Python, e.g. + // time_picker.value = "4:08" or time_picker.value = "datetime.time(4, 8)" + // else, when changed in the UI, e.g. by typing in the input field + // no special handling is needed + this._setDate(value) + } + }) + } + +} + +export namespace TimePicker { + export type Attrs = p.AttrsOf + export type Props = BkTimePicker.Props & { + } +} + +export interface TimePicker extends TimePicker.Attrs { } + +export class TimePicker extends BkTimePicker { + declare properties: TimePicker.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static override __module__ = "panel.models.time_picker" + + static { + this.prototype.default_view = TimePickerView + + this.define(({ }) => ({ + })) + } +} diff --git a/panel/tests/ui/widgets/test_time_picker.py b/panel/tests/ui/widgets/test_time_picker.py new file mode 100644 index 0000000000..b8688958ae --- /dev/null +++ b/panel/tests/ui/widgets/test_time_picker.py @@ -0,0 +1,58 @@ +import datetime + +import pytest + +from panel.tests.util import serve_component, wait_until +from panel.widgets import TimePicker + +pytestmark = pytest.mark.ui + + +def test_time_picker(page): + + time_picker = TimePicker(value="18:08", format="H:i") + + serve_component(page, time_picker) + + # test init corrected timezone + locator = page.locator("#input") + assert locator.get_attribute("value") == "18:08:00" + + # test UI change + locator = page.locator("input.bk-input.form-control.input") + locator.click() + wait_until(lambda: page.locator("input.numInput.flatpickr-hour").is_visible()) + locator = page.locator("input.numInput.flatpickr-hour") + locator.press("ArrowDown") + locator.press("Enter") + wait_until(lambda: time_picker.value == datetime.time(17, 8)) + + # test str value change + time_picker.value = "04:08" + wait_until(lambda: time_picker.value == "04:08") + locator = page.locator("#input") + assert locator.get_attribute("value") == "04:08:00" + + # test datetime.time value change + time_picker.value = datetime.time(18, 8) + wait_until(lambda: time_picker.value == datetime.time(18, 8)) + locator = page.locator("#input") + assert locator.get_attribute("value") == "18:08:00" + + +@pytest.mark.parametrize("timezone_id", [ + "America/New_York", + "Europe/Berlin", + "UTC", +]) +def test_time_picker_timezone_different(page, timezone_id): + context = page.context.browser.new_context( + timezone_id=timezone_id, + ) + page = context.new_page() + + time_picker = TimePicker(value="18:08", format="H:i") + serve_component(page, time_picker) + + locator = page.locator("#input") + assert locator.get_attribute("value") == "18:08:00" diff --git a/panel/tests/widgets/test_input.py b/panel/tests/widgets/test_input.py index c36b0d03d3..54e1d9ebbe 100644 --- a/panel/tests/widgets/test_input.py +++ b/panel/tests/widgets/test_input.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, time as dt_time from pathlib import Path import numpy as np @@ -10,7 +10,7 @@ from panel.widgets import ( ArrayInput, Checkbox, DatePicker, DateRangePicker, DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, FileInput, - FloatInput, IntInput, LiteralInput, StaticText, TextInput, + FloatInput, IntInput, LiteralInput, StaticText, TextInput, TimePicker, ) @@ -185,6 +185,22 @@ def test_datetime_range_picker(document, comm): datetime_range_picker._process_events({'value': '2018-09-10 00:00:01'}) +def test_time_picker(document, comm): + time_picker = TimePicker(name='Time Picker', value=dt_time(hour=18), format='H:i K') + assert time_picker.value == dt_time(hour=18) + assert time_picker.format == 'H:i K' + assert time_picker.start is None + assert time_picker.end is None + + +def test_time_picker_str(document, comm): + time_picker = TimePicker(name='Time Picker', value="08:28", start='00:00', end='12:00') + assert time_picker.value == "08:28" + assert time_picker.format == 'H:i' + assert time_picker.start == "00:00" + assert time_picker.end == "12:00" + + def test_file_input(document, comm): file_input = FileInput(accept='.txt') diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 5a8c08d459..02d8180038 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -47,6 +47,7 @@ DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, FileDropper, FileInput, FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, Spinner, StaticText, Switch, TextAreaInput, TextInput, + TimePicker, ) from .misc import FileDownload, JSONEditor, VideoStream # noqa from .player import DiscretePlayer, Player # noqa @@ -136,6 +137,7 @@ "TextEditor", "TextInput", "TextToSpeech", + "TimePicker", "Toggle", "ToggleGroup", "ToggleIcon", diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 625decae03..d8addf1620 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -8,7 +8,7 @@ import json from base64 import b64decode -from datetime import date, datetime +from datetime import date, datetime, time as dt_time from typing import ( TYPE_CHECKING, Any, ClassVar, Iterable, Mapping, Optional, ) @@ -31,7 +31,7 @@ from ..layout import Column, Panel from ..models import ( DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput, - TextInput as _BkTextInput, + TextInput as _BkTextInput, TimePicker as _BkTimePicker, ) from ..util import ( escape, lazy_load, param_reprs, try_datetime64_to_datetime, @@ -808,6 +808,77 @@ def _deserialize_value(self, value): return value +class _TimeCommon(Widget): + + hour_increment = param.Integer(default=1, doc=""" + Defines the granularity of hour value increments in the UI. + """) + + minute_increment = param.Integer(default=1, doc=""" + Defines the granularity of minute value increments in the UI. + """) + + second_increment = param.Integer(default=1, doc=""" + Defines the granularity of second value increments in the UI. + """) + + seconds = param.Boolean(default=False, doc=""" + Allows to select seconds. By default only hours and minutes are + selectable, and AM/PM depending on the `clock` option. + """) + + clock = param.String(default='12h', doc=""" + Whether to use 12 hour or 24 hour clock.""") + + __abstract = True + + +class TimePicker(_TimeCommon): + """ + The `TimePicker` allows selecting a `time` value using a text box + and a time-picking utility. + + Reference: https://panel.holoviz.org/reference/widgets/TimePicker.html + + :Example: + + >>> TimePicker( + ... value="12:59:31", start="09:00:00", end="18:00:00", name="Time" + ... ) + """ + + value = param.ClassSelector(default=None, class_=(dt_time, str), doc=""" + The current value""") + + start = param.ClassSelector(default=None, class_=(dt_time, str), doc=""" + Inclusive lower bound of the allowed time selection""") + + end = param.ClassSelector(default=None, class_=(dt_time, str), doc=""" + Inclusive upper bound of the allowed time selection""") + + format = param.String(default='H:i', doc=""" + Formatting specification for the display of the picked date. + + +---+------------------------------------+------------+ + | H | Hours (24 hours) | 00 to 23 | + | h | Hours | 1 to 12 | + | G | Hours, 2 digits with leading zeros | 1 to 12 | + | i | Minutes | 00 to 59 | + | S | Seconds, 2 digits | 00 to 59 | + | s | Seconds | 0, 1 to 59 | + | K | AM/PM | AM or PM | + +---+------------------------------------+------------+ + + See also https://flatpickr.js.org/formatting/#date-formatting-tokens. + """) + + _rename: ClassVar[Mapping[str, str | None]] = { + 'start': 'min_time', 'end': 'max_time', 'format': 'time_format' + } + + _widget_type: ClassVar[type[Model]] = _BkTimePicker + + class ColorPicker(Widget): """ The `ColorPicker` widget allows selecting a hexadecimal RGB color value From 0eea037d569aca0eb8197f6c9db4ff5ca93de248 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 26 Aug 2024 14:58:07 +0200 Subject: [PATCH 003/164] Detect WebGL support on BrowserInfo (#6931) --- panel/io/browser.py | 3 +++ panel/models/browser.py | 2 ++ panel/models/browser.ts | 23 +++++++++++++++++------ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/panel/io/browser.py b/panel/io/browser.py index 2f22bed1d4..66fad402aa 100644 --- a/panel/io/browser.py +++ b/panel/io/browser.py @@ -46,6 +46,9 @@ class BrowserInfo(Syncable): webdriver = param.Boolean(default=None, doc=""" Indicates whether the user agent is controlled by automation.""") + webgl = param.Boolean(default=None, doc=""" + Indicates whether the browser has WebGL support.""") + # Mapping from parameter name to bokeh model property name _rename: ClassVar[Mapping[str, str | None]] = {"name": None} diff --git a/panel/models/browser.py b/panel/models/browser.py index 2709ab283c..c4f970e858 100644 --- a/panel/models/browser.py +++ b/panel/models/browser.py @@ -24,3 +24,5 @@ class BrowserInfo(Model): timezone_offset = Nullable(Float()) webdriver = Nullable(Bool()) + + webgl = Nullable(Bool()) diff --git a/panel/models/browser.ts b/panel/models/browser.ts index 311830c5d7..8aa69ccb81 100644 --- a/panel/models/browser.ts +++ b/panel/models/browser.ts @@ -24,6 +24,15 @@ export class BrowserInfoView extends View { if (timezone_offset != null) { this.model.timezone_offset = timezone_offset } + try { + const canvas = document.createElement("canvas") + this.model.webgl = !!( + window.WebGLRenderingContext && + (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")) + ) + } catch (e) { + this.model.webgl = false + } this._has_finished = true this.notify_finished() } @@ -38,6 +47,7 @@ export namespace BrowserInfo { timezone: p.Property timezone_offset: p.Property webdriver: p.Property + webgl: p.Property } } @@ -56,12 +66,13 @@ export class BrowserInfo extends Model { this.prototype.default_view = BrowserInfoView this.define(({Bool, Nullable, Float, Str}) => ({ - dark_mode: [ Nullable(Bool), null ], - device_pixel_ratio: [ Nullable(Float), null ], - language: [ Nullable(Str), null ], - timezone: [ Nullable(Str), null ], - timezone_offset: [ Nullable(Float), null ], - webdriver: [ Nullable(Bool), null ], + dark_mode: [ Nullable(Bool), null ], + device_pixel_ratio: [ Nullable(Float), null ], + language: [ Nullable(Str), null ], + timezone: [ Nullable(Str), null ], + timezone_offset: [ Nullable(Float), null ], + webdriver: [ Nullable(Bool), null ], + webgl: [ Nullable(Bool), null ], })) } } From 625d955abb052544e7bb01634f23919c879baedf Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Mon, 26 Aug 2024 16:12:25 +0200 Subject: [PATCH 004/164] Document esm panes (#7029) --- .../custom_components/esm/custom_panes.md | 537 ++++++++++++++++++ doc/how_to/custom_components/index.md | 1 + .../custom_components/JSComponent.ipynb | 11 +- .../custom_components/ReactComponent.ipynb | 13 +- panel/models/react_component.ts | 8 +- panel/models/reactive_esm.ts | 19 +- panel/tests/ui/test_custom.py | 37 +- 7 files changed, 612 insertions(+), 14 deletions(-) create mode 100644 doc/how_to/custom_components/esm/custom_panes.md diff --git a/doc/how_to/custom_components/esm/custom_panes.md b/doc/how_to/custom_components/esm/custom_panes.md new file mode 100644 index 0000000000..a4192b2599 --- /dev/null +++ b/doc/how_to/custom_components/esm/custom_panes.md @@ -0,0 +1,537 @@ +# Create Panes with ReactiveHTML + +In this guide we will show you how to efficiently implement custom panes using `JSComponent`, `ReactComponent` and `AnyWidgetComponent` to get input from the user. + +## Creating a ChartJS Pane + +This example will show you the basics of creating a [ChartJS](https://www.chartjs.org/docs/latest/) pane. + +::::{tab-set} + +:::{tab-item} `JSComponent` + +```{pyodide} +import panel as pn +import param +from panel.custom import JSComponent + + +class ChartJSComponent(JSComponent): + object = param.Dict() + + _esm = """ +import { Chart } from "https://esm.sh/chart.js/auto" + +export function render({ model, el }) { + const canvasEl = document.createElement('canvas') + // Add DOM node before creating the chart + el.append(canvasEl) + const create_chart = () => new Chart(canvasEl.getContext('2d'), model.object) + let chart = create_chart() + model.on("object", () => { + chart.destroy() + chart = create_chart() + }) + model.on('remove', () => chart.destroy()); +} +""" + + +def plot(chart_type="line"): + return { + "type": chart_type, + "data": { + "labels": ["January", "February", "March", "April", "May", "June", "July"], + "datasets": [ + { + "label": "Data", + "backgroundColor": "rgb(255, 99, 132)", + "borderColor": "rgb(255, 99, 132)", + "data": [0, 10, 5, 2, 20, 30, 45], + } + ], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + }, + } + + +chart_type = pn.widgets.RadioBoxGroup( + name="Chart Type", options=["bar", "line"], inline=True +) +chart = ChartJSComponent( + object=pn.bind(plot, chart_type), height=400, sizing_mode="stretch_width" +) +pn.Column(chart_type, chart).servable() +``` + +Note how we had to add the `canvasEl` to the `el` before we could render the chart. Some libraries will require the element to be attached to the DOM before we could render it. Dealing with layout issues like this sometimes requires a bit of iteration. If you get stuck, share your question and minimum, reproducible code example on [Discourse](https://discourse.holoviz.org/). + +::: + +::: {tab-item} `ReactComponent` + +```{pyodide} +import panel as pn +import param + +from panel.custom import ReactComponent + + +class ChartReactComponent(ReactComponent): + + object = param.Dict() + + _esm = """ +import { Chart } from 'https://esm.sh/react-chartjs-2@4.3.1'; +import { Chart as ChartJS, registerables } from "https://esm.sh/chart.js@3.9.1"; + +ChartJS.register(...registerables); + +export function render({ model }) { + const [plot] = model.useState('object') + return +}; +""" + + +def data(chart_type="line"): + return { + "type": chart_type, + "data": { + "labels": ["January", "February", "March", "April", "May", "June", "July"], + "datasets": [ + { + "label": "Data", + "backgroundColor": "rgb(255, 99, 132)", + "borderColor": "rgb(255, 99, 132)", + "data": [0, 10, 5, 2, 20, 30, 45], + } + ], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + }, + } + + +chart_type = pn.widgets.RadioBoxGroup( + name="Chart Type", options=["bar", "line"], inline=True +) +chart = ChartReactComponent( + object=pn.bind(data, chart_type), height=600, sizing_mode="stretch_width" +) +pn.Column(chart_type, chart).servable() +``` + +::: + +::: {tab-item} `AnyWidgetComponent` + +```{pyodide} +import panel as pn +import param +from panel.custom import AnyWidgetComponent + + +class AnyWidgetComponent(AnyWidgetComponent): + object = param.Dict() + + _esm = """ +import { Chart } from "https://esm.sh/chart.js/auto" + +function render({ model, el }) { + const canvasEl = document.createElement('canvas') + // Add DOM node before creating the chart + el.append(canvasEl) + const create_chart = () => new Chart(canvasEl.getContext('2d'), model.object) + let chart = create_chart() + model.on("object", () => { + chart.destroy() + chart = create_chart() + }) +} + +export default { render }; +""" + +def data(chart_type="line"): + return { + "type": chart_type, + "data": { + "labels": ["January", "February", "March", "April", "May", "June", "July"], + "datasets": [ + { + "label": "Data", + "backgroundColor": "rgb(255, 99, 132)", + "borderColor": "rgb(255, 99, 132)", + "data": [0, 10, 5, 2, 20, 30, 45], + } + ], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + }, + } + + +chart_type = pn.widgets.RadioBoxGroup( + name="Chart Type", options=["bar", "line"], inline=True +) +chart = AnyWidgetComponent( + object=pn.bind(data, chart_type), height=400, sizing_mode="stretch_width" +) +pn.Column(chart_type, chart).servable() +``` + +Note, again, that we have to append the `canvasEl` to the `el` before we create the chart. +::: + +:::: + +## Creating a Cytoscape Pane + +This example will show you how to build a more advanced [CytoscapeJS](https://js.cytoscape.org/) pane. + +::::{tab-set} + +:::{tab-item} `JSComponent` + +```{pyodide} +import param +import panel as pn + +from panel.custom import JSComponent + + +class CytoscapeJS(JSComponent): + + object = param.List() + + layout = param.Selector( + default="cose", + objects=[ + "breadthfirst", + "circle", + "concentric", + "cose", + "grid", + "preset", + "random", + ], + ) + style = param.String("", doc="Use to set the styles of the nodes/edges") + + zoom = param.Number(1, bounds=(1, 100)) + pan = param.Dict({"x": 0, "y": 0}) + + data = param.List(doc="Use to send node's data/attributes to Cytoscape") + + selected_nodes = param.List() + selected_edges = param.List() + + _esm = """ +import { default as cytoscape} from "https://esm.sh/cytoscape" +let cy = null; + +function removeCy() { + if (cy) { cy.destroy() } +} + +export function render({ model }) { + removeCy(); + + const div = document.createElement('div'); + div.style.width = "100%"; + div.style.height = "100%"; + // Cytoscape raises warning of position is static + div.style.position = "relative"; + + model.on('after_render', () => { + cy = cytoscape({ + container: div, + layout: {name: model.layout}, + elements: model.object, + zoom: model.zoom, + pan: model.pan + }) + cy.style().resetToDefault().append(model.style).update() + cy.on('select unselect', function (evt) { + model.selected_nodes = cy.elements('node:selected').map(el => el.id()) + model.selected_edges = cy.elements('edge:selected').map(el => el.id()) + }); + + model.on('object', () => {cy.json({elements: model.object});cy.resize().fit()}) + model.on('layout', () => {cy.layout({name: model.layout}).run()}) + model.on('zoom', () => {cy.zoom(model.zoom)}) + model.on('pan', () => {cy.pan(model.pan)}) + model.on('style', () => {cy.style().resetToDefault().append(model.style).update()}) + + window.addEventListener('resize', function(event){ + cy.center(); + cy.resize().fit(); + }); + model.on('remove', removeCy) + }) + + return div +} +""" + + +pn.extension(sizing_mode="stretch_width") + +elements = [ + {"data": {"id": "A", "label": "A"}}, + {"data": {"id": "B", "label": "B"}}, + {"data": {"id": "A-B", "source": "A", "target": "B"}}, +] +graph = CytoscapeJS( + object=elements, + sizing_mode="stretch_width", + height=600, + styles={"border": "1px solid black"}, +) +pn.Row( + pn.Param( + graph, + parameters=[ + "object", + "zoom", + "pan", + "layout", + "style", + "selected_nodes", + "selected_edges", + ], + sizing_mode="fixed", + width=300, + ), + graph, +).servable() +``` + +::: + +:::{tab-item} `ReactComponent` + +```{pyodide} +import param +import panel as pn + +from panel.custom import ReactComponent + + +class CytoscapeReact(ReactComponent): + + object = param.List() + + layout = param.Selector( + default="cose", + objects=[ + "breadthfirst", + "circle", + "concentric", + "cose", + "grid", + "preset", + "random", + ], + ) + style = param.String("", doc="Use to set the styles of the nodes/edges") + + zoom = param.Number(1, bounds=(1, 100)) + pan = param.Dict({"x": 0, "y": 0}) + + data = param.List(doc="Use to send node's data/attributes to Cytoscape") + + selected_nodes = param.List() + selected_edges = param.List() + + _esm = """ +import CytoscapeComponent from 'https://esm.sh/react-cytoscapejs'; + +export function render({ model }) { + function configure(cy){ + cy.on('select unselect', function (evt) { + model.selected_nodes = cy.elements('node:selected').map(el => el.id()) + model.selected_edges = cy.elements('edge:selected').map(el => el.id()) + }); + } + + + const [layout] = model.useState('layout') + const [object] = model.useState('object') + const [pan] = model.useState('pan') + const [style] = model.useState('style') + const [zoom] = model.useState('zoom') + + return ( + + ); +} +""" + +pn.extension(sizing_mode="stretch_width") + +elements = [ + {"data": {"id": "A", "label": "A"}}, + {"data": {"id": "B", "label": "B"}}, + {"data": {"id": "A-B", "source": "A", "target": "B"}}, +] +graph = CytoscapeReact( + object=elements, + sizing_mode="stretch_width", + height=600, + styles={"border": "1px solid black"}, +) +pn.Row( + pn.Param( + graph, + parameters=[ + "object", + "zoom", + "pan", + "layout", + "style", + "selected_nodes", + "selected_edges", + "height", + ], + sizing_mode="fixed", + width=300, + ), + graph, +).servable() +``` + +::: + +::: `AnyWidgetComponent` + +```{pyodide} +import param +import panel as pn + +from panel.custom import AnyWidgetComponent + + +class CytoscapeAnyWidget(AnyWidgetComponent): + + object = param.List() + + layout = param.Selector( + default="cose", + objects=[ + "breadthfirst", + "circle", + "concentric", + "cose", + "grid", + "preset", + "random", + ], + ) + style = param.String("", doc="Use to set the styles of the nodes/edges") + + zoom = param.Number(1, bounds=(1, 100)) + pan = param.Dict({"x": 0, "y": 0}) + + data = param.List(doc="Use to send node's data/attributes to Cytoscape") + + selected_nodes = param.List() + selected_edges = param.List() + + _esm = """ +import { default as cytoscape} from "https://esm.sh/cytoscape" +let cy = null; + +function removeCy() { + if (cy) { cy.destroy() } +} + +function render({ model, el }) { + removeCy(); + + cy = cytoscape({ + container: el, + layout: {name: model.get('layout')}, + elements: model.get('object'), + zoom: model.get('zoom'), + pan: model.get('pan') + }) + cy.style().resetToDefault().append(model.get('style')).update() + cy.on('select unselect', function (evt) { + model.set("selected_nodes", cy.elements('node:selected').map(el => el.id())) + model.set("selected_edges", cy.elements('edge:selected').map(el => el.id())) + model.save_changes() + }); + + model.on('change:object', () => {cy.json({elements: model.get('object')});cy.resize().fit()}) + model.on('change:layout', () => {cy.layout({name: model.get('layout')}).run()}) + model.on('change:zoom', () => {cy.zoom(model.get('zoom'))}) + model.on('change:pan', () => {cy.pan(model.get('pan'))}) + model.on('change:style', () => {cy.style().resetToDefault().append(model.get('style')).update()}) + + window.addEventListener('resize', function(event){ + cy.center(); + cy.resize().fit(); + }); +} + +export default { render }; +""" + _stylesheets=[""" +.__________cytoscape_container { + position: relative; +} +"""] + + +pn.extension("cytoscape", sizing_mode="stretch_width") + +elements = [ + {"data": {"id": "A", "label": "A"}}, + {"data": {"id": "B", "label": "B"}}, + {"data": {"id": "A-B", "source": "A", "target": "B"}}, +] +graph = CytoscapeAnyWidget( + object=elements, + sizing_mode="stretch_width", + height=600, + styles={"border": "1px solid black"}, +) +pn.Row( + pn.Param( + graph, + parameters=[ + "object", + "zoom", + "pan", + "layout", + "style", + "selected_nodes", + "selected_edges", + ], + sizing_mode="fixed", + width=300, + ), + graph, +).servable() +``` + +::: + +:::: diff --git a/doc/how_to/custom_components/index.md b/doc/how_to/custom_components/index.md index 9c284a099d..081ecd7588 100644 --- a/doc/how_to/custom_components/index.md +++ b/doc/how_to/custom_components/index.md @@ -134,6 +134,7 @@ Build custom components wrapping Material UI using `ReactComponent`. esm/build esm/callbacks +esm/custom_panes esm/custom_widgets esm/custom_layout esm/dataframe diff --git a/examples/reference/custom_components/JSComponent.ipynb b/examples/reference/custom_components/JSComponent.ipynb index fc87414d43..fcbdc4eb67 100644 --- a/examples/reference/custom_components/JSComponent.ipynb +++ b/examples/reference/custom_components/JSComponent.ipynb @@ -103,11 +103,16 @@ "- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once.\n", "- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", "\n", + "The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", + "\n", "#### Lifecycle Hooks\n", "\n", - "- `after_render`: Called once after the component has been fully rendered.\n", - "- `after_resize`: Called after the component has been resized.\n", - "- `remove`: Called when the component view is being removed from the DOM.\n", + "- `.on('after_layout', callback)`: Called whenever the layout around the component is changed.\n", + "- `.on('after_render', callback)`: Called once after the component has been fully rendered.\n", + "- `.on('resize', callback)`: Called after the component has been resized.\n", + "- `.on('remove', callback)`: Called when the component view is being removed from the DOM.\n", + "\n", + "The `lifecycle:` prefix allows disambiguating lifecycle hooks from change events should a parameter name and lifecycle hook overlap.\n", "\n", "## Usage\n", "\n", diff --git a/examples/reference/custom_components/ReactComponent.ipynb b/examples/reference/custom_components/ReactComponent.ipynb index ca5648b2fc..f774dd8e0c 100644 --- a/examples/reference/custom_components/ReactComponent.ipynb +++ b/examples/reference/custom_components/ReactComponent.ipynb @@ -73,9 +73,7 @@ "- **`_stylesheets`** (List[str | PurePath] | None): This optional attribute accepts a list of CSS strings or paths to CSS files. It supports automatic reloading in development environments.\n", "\n", ":::note\n", - "\n", "You may specify a path to a file as a string instead of a PurePath. The path should be specified relative to the file its specified in.\n", - "\n", ":::\n", "\n", "#### `render` Function\n", @@ -109,11 +107,16 @@ "- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once.\n", "- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", "\n", + "The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", + "\n", "#### Lifecycle Hooks\n", "\n", - "- `after_render`: Called once after the component has been fully rendered.\n", - "- `after_resize`: Called after the component has been resized.\n", - "- `remove`: Called when the component view is being removed from the DOM.\n", + "- `.on('after_layout', callback)`: Called whenever the layout around the component is changed.\n", + "- `.on('after_render', callback)`: Called once after the component has been fully rendered.\n", + "- `.on('resize', callback)`: Called after the component has been resized.\n", + "- `.on('remove', callback)`: Called when the component view is being removed from the DOM.\n", + "\n", + "The `lifecycle:` prefix allows disambiguating lifecycle hooks from change events should a parameter name and lifecycle hook overlap.\n", "\n", "## Usage\n", "\n", diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts index 89a8d123c0..6909cc64c6 100644 --- a/panel/models/react_component.ts +++ b/panel/models/react_component.ts @@ -24,6 +24,11 @@ export class ReactComponentView extends ReactiveESMView { for (const cb of handlers) { cb() } + if (!this._rendered) { + for (const cb of (this._lifecycle_handlers.get("after_layout") || [])) { + cb() + } + } this._rendered = true } @@ -37,8 +42,6 @@ export class ReactComponentView extends ReactiveESMView { } catch(e) { view.render_error(e) } - view._changing = false - view.after_rendered() }` let import_code = ` import * as React from "react" @@ -185,6 +188,7 @@ class ErrorBoundary extends React.Component { class Component extends React.Component { componentDidMount() { + this.props.view._changing = false this.props.view.after_rendered() } diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts index e600a6d884..f20e790368 100644 --- a/panel/models/reactive_esm.ts +++ b/panel/models/reactive_esm.ts @@ -152,8 +152,9 @@ export class ReactiveESMView extends HTMLBoxView { _child_rendered: Map = new Map() _event_handlers: ((event: ESMEvent) => void)[] = [] _lifecycle_handlers: Map void)[]> = new Map([ + ["after_layout", []], ["after_render", []], - ["after_resize", []], + ["resize", []], ["remove", []], ]) _rendered: boolean = false @@ -306,6 +307,11 @@ export default {render}` } this.render_children() this.model_proxy.on(this.accessed_children, () => { this._stale_children = true }) + if (!this._rendered) { + for (const cb of (this._lifecycle_handlers.get("after_layout") || [])) { + cb() + } + } this._rendered = true } @@ -352,7 +358,16 @@ export default {render}` override after_resize(): void { super.after_resize() if (this._rendered && !this._changing) { - for (const cb of (this._lifecycle_handlers.get("after_resize") || [])) { + for (const cb of (this._lifecycle_handlers.get("resize") || [])) { + cb() + } + } + } + + override after_layout(): void { + super.after_layout() + if (this._rendered && !this._changing) { + for (const cb of (this._lifecycle_handlers.get("after_layout") || [])) { cb() } } diff --git a/panel/tests/ui/test_custom.py b/panel/tests/ui/test_custom.py index 81769b321f..241fc56eef 100644 --- a/panel/tests/ui/test_custom.py +++ b/panel/tests/ui/test_custom.py @@ -489,6 +489,39 @@ def test_after_render_lifecycle_hooks(page, component): expect(page.locator('h1')).to_have_text("rendered") +class JSLifecycleAfterLayout(JSComponent): + + _esm = """ + export function render({ model }) { + const h1 = document.createElement('h1') + model.on('after_layout', () => { h1.textContent = 'layouted' }) + return h1 + }""" + + +class ReactLifecycleAfterLayout(ReactComponent): + + _esm = """ + import {useState} from "react" + + export function render({ model }) { + const [text, setText] = useState("") + model.on('after_layout', () => { setText('layouted') }) + return

{text}

+ }""" + + +@pytest.mark.parametrize('component', [JSLifecycleAfterLayout, ReactLifecycleAfterLayout]) +def test_after_layout_lifecycle_hooks(page, component): + example = component() + + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + expect(page.locator('h1')).to_have_text("layouted") + + class JSLifecycleAfterResize(JSComponent): _esm = """ @@ -496,7 +529,7 @@ class JSLifecycleAfterResize(JSComponent): const h1 = document.createElement('h1') h1.textContent = "0" let count = 0 - model.on('after_resize', () => { count += 1; h1.textContent = `${count}`; }) + model.on('resize', () => { count += 1; h1.textContent = `${count}`; }) return h1 }""" @@ -507,7 +540,7 @@ class ReactLifecycleAfterResize(ReactComponent): export function render({ model }) { const [count, setCount] = useState(0) - model.on('after_resize', () => { setCount(count+1); }) + model.on('resize', () => { setCount(count+1); }) return

{count}

}""" From 3697335ec3659bed9e26bcf32d0cb9c08d0f8ea7 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 26 Aug 2024 16:57:03 +0200 Subject: [PATCH 005/164] Bump panel.js version to 1.5.0-b.6 --- panel/package-lock.json | 4 ++-- panel/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/package-lock.json b/panel/package-lock.json index 3b49cf4b41..e449d83c1a 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.5", + "version": "1.5.0-b.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.0-b.5", + "version": "1.5.0-b.6", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.5.1", diff --git a/panel/package.json b/panel/package.json index a3e45944e4..108a36611e 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.5", + "version": "1.5.0-b.6", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { From 09efaddeda5f9f6d9802a89cc464e07120ad9e9a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 10:49:29 +0200 Subject: [PATCH 006/164] Fix tabs issues in ESM custom pane docs (#7187) --- doc/how_to/custom_components/esm/custom_panes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/how_to/custom_components/esm/custom_panes.md b/doc/how_to/custom_components/esm/custom_panes.md index a4192b2599..988d048024 100644 --- a/doc/how_to/custom_components/esm/custom_panes.md +++ b/doc/how_to/custom_components/esm/custom_panes.md @@ -71,7 +71,7 @@ Note how we had to add the `canvasEl` to the `el` before we could render the cha ::: -::: {tab-item} `ReactComponent` +:::{tab-item} `ReactComponent` ```{pyodide} import panel as pn From fb5ef8f219a9491f7a663cc4a10459123f341d23 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 10:56:45 +0200 Subject: [PATCH 007/164] Fix issue with template variables when saving Template (#7189) --- panel/io/save.py | 2 ++ panel/tests/io/test_save.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/panel/io/save.py b/panel/io/save.py index dd51985feb..79b81f6f46 100644 --- a/panel/io/save.py +++ b/panel/io/save.py @@ -248,6 +248,7 @@ def save( elif isinstance(resources, BkResources): mode = resources.mode + template_variables = dict(template_variables) if template_variables else {} comm = Comm() with config.set(embed=embed): if isinstance(panel, Document): @@ -255,6 +256,7 @@ def save( elif isinstance(panel, BaseTemplate): with set_resource_mode(mode): panel._init_doc(doc, title=title) + template_variables.update(doc._template_variables) model = doc else: model = panel.get_root(doc, comm) diff --git a/panel/tests/io/test_save.py b/panel/tests/io/test_save.py index 1256aaf385..dff455b72e 100644 --- a/panel/tests/io/test_save.py +++ b/panel/tests/io/test_save.py @@ -11,6 +11,7 @@ from panel.io.resources import CDN_DIST from panel.models.vega import VegaPlot from panel.pane import Alert, Vega +from panel.template import BootstrapTemplate from panel.tests.util import hv_available vega_example = { @@ -72,3 +73,21 @@ def test_static_path_in_holoviews_save(tmpdir): content = out_file.read_text() assert 'src="/static/js/bokeh' in content and 'src="static/js/bokeh' not in content + + +def test_save_template(): + template = BootstrapTemplate( + title="Hello World", + sidebar=["# Hello Sidebar"], + main=['# Hello Main'], + ) + + sio = StringIO() + template.save(sio) + + sio.seek(0) + html = sio.read() + + for doc in template._documents: + for root in doc.roots: + assert f"data-root-id=\"{root.ref['id']}\"" in html From 9c95e58a72a3d46815f60c6b959d54f97357bfe9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 11:05:42 +0200 Subject: [PATCH 008/164] Fix LaTeX pane MathJax rendering (#7188) * Fix LaTeX pane MathJax rendering * Update test --- panel/models/mathjax.py | 17 ++--------------- panel/models/mathjax.ts | 16 +++++++++++++++- panel/pane/equation.py | 25 ++++--------------------- panel/tests/pane/test_equation.py | 2 +- panel/tests/ui/pane/test_equation.py | 24 ++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 panel/tests/ui/pane/test_equation.py diff --git a/panel/models/mathjax.py b/panel/models/mathjax.py index cec8bbb490..a448ed315c 100644 --- a/panel/models/mathjax.py +++ b/panel/models/mathjax.py @@ -1,23 +1,10 @@ """ Defines a custom MathJax bokeh model to render text using MathJax. """ -from bokeh.models import Markup +from bokeh.models import Div -class MathJax(Markup): +class MathJax(Div): """ A bokeh model that renders text using MathJax. """ - - __javascript__ = [ - "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-MML-AM_CHTML" - ] - - __js_skip__ = {'MathJax': __javascript__} - - __js_require__ = { - 'paths': { - 'mathjax': "//cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-AMS_HTML" - }, - 'shim': {'mathjax': {'exports': "MathJax"}} - } diff --git a/panel/models/mathjax.ts b/panel/models/mathjax.ts index 027e9a0c2a..cd300f5897 100644 --- a/panel/models/mathjax.ts +++ b/panel/models/mathjax.ts @@ -14,7 +14,21 @@ export class MathJaxView extends PanelMarkupView { override render(): void { super.render() - this.container.innerHTML = this.has_math_disabled() ? this.model.text : this.process_tex(this.model.text) + const text = this.model.text + const tex_parts = this.provider.MathJax.find_tex(text) + const processed_text: string[] = [] + + let last_index: number | undefined = 0 + for (const part of tex_parts) { + processed_text.push(text.slice(last_index, part.start.n)) + processed_text.push(this.provider.MathJax.tex2svg(part.math, {display: part.display}).outerHTML) + last_index = part.end.n + } + if (last_index! < text.length) { + processed_text.push(text.slice(last_index)) + } + + this.container.innerHTML = processed_text.join("") } } diff --git a/panel/pane/equation.py b/panel/pane/equation.py index afcd59f6e3..d988415ead 100644 --- a/panel/pane/equation.py +++ b/panel/pane/equation.py @@ -4,7 +4,6 @@ """ from __future__ import annotations -import re import sys from typing import ( @@ -24,15 +23,6 @@ from bokeh.model import Model -def is_sympy_expr(obj: Any) -> bool: - """Test for sympy.Expr types without usually needing to import sympy""" - if 'sympy' in sys.modules and 'sympy' in str(type(obj).__class__): - import sympy # type: ignore - if isinstance(obj, sympy.Expr): - return True - return False - - class LaTeX(ModelPane): r""" The `LaTeX` pane allows rendering LaTeX equations. It uses either @@ -71,20 +61,13 @@ class LaTeX(ModelPane): @classmethod def applies(cls, obj: Any) -> float | bool | None: - if is_sympy_expr(obj) or hasattr(obj, '_repr_latex_'): + if hasattr(obj, '_repr_latex_'): return 0.05 elif isinstance(obj, str): return None else: return False - def _process_param_change(self, params) -> dict[str, Any]: - if self.renderer == "mathjax": - # Replace $$math$$ with \[math\] and $math$ with \(math\) - msg = re.sub(r"(\$\$)(.*?)(\$\$)", r"\[\2\]", params["object"]) - params["object"] = re.sub(r"(\$)(.*?)(\$)", r"\(\2\)", msg) - return super()._process_param_change(params) - def _get_model_type(self, root: Model, comm: Comm | None) -> type[Model]: module = self.renderer if module is None: @@ -92,6 +75,7 @@ def _get_model_type(self, root: Model, comm: Comm | None) -> type[Model]: module = 'mathjax' else: module = 'katex' + self.renderer = module model = 'KaTeX' if module == 'katex' else 'MathJax' return lazy_load(f'panel.models.{module}', model, isinstance(comm, JupyterComm), root) @@ -110,7 +94,6 @@ def _transform_object(self, obj: Any) -> dict[str, Any]: obj = '' elif hasattr(obj, '_repr_latex_'): obj = obj._repr_latex_() - elif is_sympy_expr(obj): - import sympy - obj = r'$'+sympy.latex(obj)+'$' + if self.renderer == 'mathjax' and obj.startswith('$') and not obj.startswith('$$'): + obj = f'${obj}$' return dict(object=obj) diff --git a/panel/tests/pane/test_equation.py b/panel/tests/pane/test_equation.py index 54c2bd5b80..e1468566b8 100644 --- a/panel/tests/pane/test_equation.py +++ b/panel/tests/pane/test_equation.py @@ -24,7 +24,7 @@ def test_latex_mathjax_pane(document, comm): assert pane._models[model.ref['id']][0] is model assert type(model).__name__ == 'MathJax' # assert model.text == r"$\frac{p^3}{q}$" - assert model.text == r"\(\frac{p^3}{q}\)" + assert model.text == r"$$\frac{p^3}{q}$$" # Cleanup pane._cleanup(model) diff --git a/panel/tests/ui/pane/test_equation.py b/panel/tests/ui/pane/test_equation.py new file mode 100644 index 0000000000..e31dd53e00 --- /dev/null +++ b/panel/tests/ui/pane/test_equation.py @@ -0,0 +1,24 @@ +import pytest + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +from panel.pane import LaTeX +from panel.tests.util import serve_component + +pytestmark = pytest.mark.ui + +def test_latex_mathjax_renderer(page): + ltx = LaTeX('$1+1$', renderer='mathjax') + + serve_component(page, ltx) + + expect(page.locator('mjx-container')).to_have_count(1) + +def test_latex_katex_renderer(page): + ltx = LaTeX('$1+1$', renderer='katex') + + serve_component(page, ltx) + + expect(page.locator('.katex-html')).to_have_count(1) From 1f81c71490c0a2603419f3b4ced4bdcf5a36c97b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 11:49:46 +0200 Subject: [PATCH 009/164] Add panel.custom to default namespace (#7193) --- panel/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/panel/__init__.py b/panel/__init__.py index 0d9006e8c8..f07dd0e957 100644 --- a/panel/__init__.py +++ b/panel/__init__.py @@ -71,6 +71,7 @@ from .template import Template # noqa from .widgets import indicators, widget # noqa +from . import custom # isort:skip noqa has to be after widgets from . import chat # isort:skip noqa has to be after widgets __all__ = ( @@ -79,6 +80,7 @@ "Card", "chat", "Column", + "custom", "Feed", "FlexBox", "FloatPanel", From 49070119275b8b1fd9a626834a68a61d69d84f64 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 13:10:52 +0200 Subject: [PATCH 010/164] Ensure OAuth expiry is numeric and can be compared (#7191) --- panel/auth.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/panel/auth.py b/panel/auth.py index a68a50c41a..28c206c11a 100644 --- a/panel/auth.py +++ b/panel/auth.py @@ -1026,6 +1026,12 @@ async def get_user(handler): except Exception: pass + # Try casting expiry to float as it may be stored as bytes + try: + expiry = float(expiry) + except Exception: + expiry = None + if expiry is None: expiry = handler.get_secure_cookie('oauth_expiry', max_age_days=config.oauth_expiry) if expiry is None: From da83299450fa3cfc6fd4507f259eb928cd505ac8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 15:20:30 +0200 Subject: [PATCH 011/164] Ensure DiscretePlayer handles dict options (#7192) --- panel/widgets/player.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/panel/widgets/player.py b/panel/widgets/player.py index 86a60dc74f..93d94b656a 100644 --- a/panel/widgets/player.py +++ b/panel/widgets/player.py @@ -168,6 +168,9 @@ class DiscretePlayer(PlayerBase, SelectBase): interval = param.Integer(default=500, doc="Interval between updates") + show_value = param.Boolean(default=True, doc=""" + Whether to show the widget value""") + value = param.Parameter(doc="Current player value") value_throttled = param.Parameter(constant=True, doc="Current player value") @@ -185,6 +188,7 @@ def _process_param_change(self, msg): msg['end'] = len(values) - 1 if values and not isIn(self.value, values): self.value = values[0] + msg['options'] = self.labels if 'value' in msg: value = msg['value'] if isIn(value, values): From 0ca1a86b5552320da9794ae8b0ea2cb8bb05d90c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 15:41:08 +0200 Subject: [PATCH 012/164] Documentation fixes for 1.5.0b7 (#7195) * Load pyodide wheels from CDN root wheels directory * Update gallery links * Clean up tabs --- doc/conf.py | 6 ++-- doc/gallery/index.md | 30 +++++++++---------- .../custom_components/esm/custom_layout.md | 21 ------------- .../custom_components/esm/custom_panes.md | 13 +------- .../custom_components/esm/custom_widgets.md | 6 ---- 5 files changed, 19 insertions(+), 57 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 185ebc643b..cbdfa33cb7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -21,7 +21,7 @@ BOKEH_VERSION, MINIMUM_VERSIONS, PY_VERSION, PYODIDE_VERSION, PYSCRIPT_VERSION, ) -from panel.io.resources import CDN_DIST +from panel.io.resources import CDN_ROOT PANEL_ROOT = pathlib.Path(panel.__file__).parent @@ -150,8 +150,8 @@ panel_req = f'./wheels/panel-{py_version}-py3-none-any.whl' bokeh_req = f'./wheels/bokeh-{BOKEH_VERSION}-py3-none-any.whl' else: - panel_req = f'{CDN_DIST}wheels/panel-{PY_VERSION}-py3-none-any.whl' - bokeh_req = f'{CDN_DIST}wheels/bokeh-{BOKEH_VERSION}-py3-none-any.whl' + panel_req = f'{CDN_ROOT}wheels/panel-{PY_VERSION}-py3-none-any.whl' + bokeh_req = f'{CDN_ROOT}wheels/bokeh-{BOKEH_VERSION}-py3-none-any.whl' def get_requirements(): with open('pyodide_dependencies.json') as deps: diff --git a/doc/gallery/index.md b/doc/gallery/index.md index 6f85075392..1051277d95 100644 --- a/doc/gallery/index.md +++ b/doc/gallery/index.md @@ -8,7 +8,7 @@ These Panel applications demonstrate what you can build with Panel and how to do :::{grid-item-card} Portfolio Optimizer ```{image} https://assets.holoviz.org/panel/gallery/portfolio_optimizer.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/portfolio_optimizer +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/portfolio_optimizer :width: 100% ``` @@ -20,7 +20,7 @@ Stock portfolio optimization by exploring the efficient frontier and optimizing :::{grid-item-card} Streaming Videostream ```{image} https://assets.holoviz.org/panel/gallery/streaming_videostream.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/streaming_videostream +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/streaming_videostream :width: 100% ``` @@ -32,7 +32,7 @@ Applying face detection and other image transforms on your webcam input using sc :::{grid-item-card} Windturbines Explorer ```{image} https://assets.holoviz.org/panel/gallery/windturbines.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/windturbines +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/windturbines :width: 100% ``` @@ -44,7 +44,7 @@ Visually explore a dataset of US Windturbines. :::{grid-item-card} Portfolio Analyzer ```{image} https://assets.holoviz.org/panel/gallery/portfolio_analyzer.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/portfolio_analyzer +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/portfolio_analyzer :width: 100% ``` @@ -56,7 +56,7 @@ Analyze a stock portfolio using Plotly and Tabulator components. :::{grid-item-card} OGGM Glaciers ```{image} https://assets.holoviz.org/panel/gallery/glaciers.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/glaciers +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/glaciers :width: 100% ``` @@ -68,7 +68,7 @@ Visually explore the worlds glaciers in this application built in collaboration :::{grid-item-card} VTK Slicer ```{image} https://assets.holoviz.org/panel/gallery/vtk_slicer.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/vtk_slicer +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/vtk_slicer :width: 100% ``` @@ -80,7 +80,7 @@ Visualizing MRI brain scans and their cross-sections using a VTK volume and link :::{grid-item-card} Deck.GL: NYC Taxi ```{image} https://assets.holoviz.org/panel/gallery/nyc_deckgl.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/nyc_deckgl +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/nyc_deckgl :width: 100% ``` @@ -92,7 +92,7 @@ NYC Taxi trips visualized and animated using Deck.GL. :::{grid-item-card} Gapminders ```{image} https://assets.holoviz.org/panel/gallery/gapminders.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/gapminders +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/gapminders :width: 100% ``` @@ -104,7 +104,7 @@ Visualizing the Gapminders data using the most common Python plotting libraries. :::{grid-item-card} VTK: St. Helens ```{image} https://assets.holoviz.org/panel/gallery/vtk_interactive.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/vtk_interactive +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/vtk_interactive :width: 100% ``` @@ -116,7 +116,7 @@ Visualizing the surface of Mount St. Helens using VTK and linked widgets to cont :::{grid-item-card} Penguin Crossfiltering ```{image} https://assets.holoviz.org/panel/gallery/penguin_crossfilter.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/penguin_crossfilter +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/penguin_crossfilter :width: 100% ``` @@ -128,7 +128,7 @@ Palmer Penguins data visualized using a set of linked cross-filtering plots. :::{grid-item-card} Deck.GL: Game of Life ```{image} https://assets.holoviz.org/panel/gallery/deckgl_game_of_life.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/deckgl_game_of_life +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/deckgl_game_of_life :width: 100% ``` @@ -140,7 +140,7 @@ Game of Life simulation rendered on a 3D plane using Deck.gl. :::{grid-item-card} hvPlot Explorer ```{image} https://assets.holoviz.org/panel/gallery/hvplot_explorer.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/hvplot_explorer +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/hvplot_explorer :width: 100% ``` @@ -152,7 +152,7 @@ Use the hvPlot explorer to interactive visualize your dataset. :::{grid-item-card} Iris KMeans Clustering ```{image} https://assets.holoviz.org/panel/gallery/iris_kmeans.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/iris_kmeans +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/iris_kmeans :width: 100% ``` @@ -164,7 +164,7 @@ Interactively apply KMeans clustering on the Iris dataset. :::{grid-item-card} XGBoost Classifier ```{image} https://assets.holoviz.org/panel/gallery/xgboost_classifier.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/xgboost_classifier +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/xgboost_classifier :width: 100% ``` @@ -176,7 +176,7 @@ Interactively apply a XGBoost classifier on the Iris dataset. :::{grid-item-card} Altair Brushing ```{image} https://assets.holoviz.org/panel/gallery/altair_brushing.png -:target: https://|gallery-endpoint|.holoviz.dsp.anaconda.com/altair_brushing +:target: https://|gallery-endpoint|.holoviz-demo.anaconda.com/altair_brushing :width: 100% ``` diff --git a/doc/how_to/custom_components/esm/custom_layout.md b/doc/how_to/custom_components/esm/custom_layout.md index c8c9ccd080..6ce1f71b5d 100644 --- a/doc/how_to/custom_components/esm/custom_layout.md +++ b/doc/how_to/custom_components/esm/custom_layout.md @@ -11,7 +11,6 @@ This example will show you how to create a *split* layout containing two objects ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} import panel as pn @@ -164,7 +163,6 @@ split_react = SplitReact( ) split_react.servable() ``` - ::: :::: @@ -174,19 +172,15 @@ Let's verify that the layout will automatically update when the `object` is chan ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} split_js.right=pn.pane.Markdown("Hi. I'm a `Markdown` pane replacing the `CodeEditor` widget!", sizing_mode="stretch_both") ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} split_react.right=pn.pane.Markdown("Hi. I'm a `Markdown` pane replacing the `CodeEditor` widget!", sizing_mode="stretch_both") ``` - ::: :::: @@ -196,7 +190,6 @@ Now, let's change it back: ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} split_js.right=pn.widgets.CodeEditor( value="Right", @@ -206,11 +199,9 @@ split_js.right=pn.widgets.CodeEditor( language="python", ) ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} split_react.right=pn.widgets.CodeEditor( value="Right", @@ -220,7 +211,6 @@ split_react.right=pn.widgets.CodeEditor( language="python", ) ``` - ::: :::: @@ -232,7 +222,6 @@ A Panel `Column` or `Row` works as a list of objects. It is *list-like*. In this ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} import panel as pn import param @@ -306,11 +295,9 @@ grid_js = GridJS( ``` You must list `ListLike, JSComponent` in exactly that order when you define the class! Reversing the order to `JSComponent, ListLike` will not work. - ::: :::{tab-item} `ReactComponent` - ```{pyodide} import panel as pn import param @@ -375,7 +362,6 @@ grid_react = GridReact( ) grid_react.servable() ``` - ::: :::: @@ -399,11 +385,9 @@ grid_js.append( ) ) ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} grid_react.append( pn.widgets.CodeEditor( @@ -413,7 +397,6 @@ grid_react.append( ) ) ``` - ::: :::: @@ -423,19 +406,15 @@ Let's remove it again: ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} grid_js.pop(-1) ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} grid_react.pop(-1) ``` - ::: :::: diff --git a/doc/how_to/custom_components/esm/custom_panes.md b/doc/how_to/custom_components/esm/custom_panes.md index 988d048024..167c4e93ee 100644 --- a/doc/how_to/custom_components/esm/custom_panes.md +++ b/doc/how_to/custom_components/esm/custom_panes.md @@ -9,7 +9,6 @@ This example will show you the basics of creating a [ChartJS](https://www.chartj ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} import panel as pn import param @@ -68,11 +67,9 @@ pn.Column(chart_type, chart).servable() ``` Note how we had to add the `canvasEl` to the `el` before we could render the chart. Some libraries will require the element to be attached to the DOM before we could render it. Dealing with layout issues like this sometimes requires a bit of iteration. If you get stuck, share your question and minimum, reproducible code example on [Discourse](https://discourse.holoviz.org/). - ::: :::{tab-item} `ReactComponent` - ```{pyodide} import panel as pn import param @@ -126,11 +123,9 @@ chart = ChartReactComponent( ) pn.Column(chart_type, chart).servable() ``` - ::: ::: {tab-item} `AnyWidgetComponent` - ```{pyodide} import panel as pn import param @@ -200,7 +195,6 @@ This example will show you how to build a more advanced [CytoscapeJS](https://js ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} import param import panel as pn @@ -314,11 +308,9 @@ pn.Row( graph, ).servable() ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} import param import panel as pn @@ -416,11 +408,9 @@ pn.Row( graph, ).servable() ``` - ::: -::: `AnyWidgetComponent` - +:::{tab-item} `AnyWidgetComponent` ```{pyodide} import param import panel as pn @@ -531,7 +521,6 @@ pn.Row( graph, ).servable() ``` - ::: :::: diff --git a/doc/how_to/custom_components/esm/custom_widgets.md b/doc/how_to/custom_components/esm/custom_widgets.md index 4a94389259..a8db677078 100644 --- a/doc/how_to/custom_components/esm/custom_widgets.md +++ b/doc/how_to/custom_components/esm/custom_widgets.md @@ -9,7 +9,6 @@ This example we will show you to create an `ImageButton`. ::::{tab-set} :::{tab-item} `JSComponent` - ```{pyodide} import panel as pn import param @@ -74,11 +73,9 @@ button = ImageButton( ) pn.Column(button, button.param.clicks,).servable() ``` - ::: :::{tab-item} `ReactComponent` - ```{pyodide} import panel as pn import param @@ -136,11 +133,9 @@ button = ImageButton( ) pn.Column(button, button.param.clicks).servable() ``` - ::: :::{tab-item} `AnyWidgetComponent` - ```{pyodide} import panel as pn import param @@ -207,7 +202,6 @@ button = ImageButton( pn.Column(button, button.param.clicks).servable() ``` - ::: :::: From 40b942ecf2a3157fc8735cc7eb5c6a04f25c4188 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 16:18:42 +0200 Subject: [PATCH 013/164] Bump panel.js version to 1.5.0-b.7 --- panel/package-lock.json | 4 ++-- panel/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/package-lock.json b/panel/package-lock.json index e449d83c1a..3a5793421c 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.6", + "version": "1.5.0-b.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.0-b.6", + "version": "1.5.0-b.7", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.5.1", diff --git a/panel/package.json b/panel/package.json index 108a36611e..4e050919f5 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.6", + "version": "1.5.0-b.7", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { From d0976d87b2578032305fd0eb542665854d665c92 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 16:28:57 +0200 Subject: [PATCH 014/164] Correctly detect max depth of NestedSelect if level is empty (#7194) --- panel/tests/widgets/test_select.py | 11 ++++++++++- panel/widgets/select.py | 14 +++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/panel/tests/widgets/test_select.py b/panel/tests/widgets/test_select.py index 603791f7f2..51e78cb5e2 100644 --- a/panel/tests/widgets/test_select.py +++ b/panel/tests/widgets/test_select.py @@ -300,6 +300,15 @@ def test_nested_select_init_empty(document, comm): assert select.options is None assert select.levels == [] +def test_nested_select_max_depth_empty_first_sublevel(document, comm): + select = NestedSelect(options={'foo': ['a', 'b'], 'bar': []}) + + assert select._max_depth == 2 + +def test_nested_select_max_depth_empty_second_sublevel(document, comm): + select = NestedSelect(options={'foo': {'0': ['a', 'b'], '1': []}, 'bar': {'0': []}}) + + assert select._max_depth == 3 def test_nested_select_init_levels(document, comm): options = { @@ -500,7 +509,7 @@ def test_nested_select_partial_options_set(document, comm): select.options = {"Ben": []} assert select._widgets[0].value == 'Ben' assert select._widgets[0].visible - assert select.value == {0: 'Ben'} + assert select.value == {0: 'Ben', 1: None} def test_nested_select_partial_value_init(document, comm): diff --git a/panel/widgets/select.py b/panel/widgets/select.py index d3bda7ddd8..bbccae86b5 100644 --- a/panel/widgets/select.py +++ b/panel/widgets/select.py @@ -422,19 +422,11 @@ def _uses_callable(self, d): return False def _find_max_depth(self, d, depth=1): - if d is None or len(d) == 0: - return 0 - elif not isinstance(d, dict): - return depth - + if isinstance(d, list) or d is None: + return depth-1 max_depth = depth for value in d.values(): - if isinstance(value, dict): - max_depth = max(max_depth, self._find_max_depth(value, depth + 1)) - # dict means it's a level, so it's not the last level - # list means it's a leaf, so it's the last level - if isinstance(value, list) and len(value) == 0 and max_depth > 0: - max_depth -= 1 + max_depth = max(max_depth, self._find_max_depth(value, depth + 1)) return max_depth def _resolve_callable_options(self, i, options) -> dict | list: From 1335db57cedd6e888adf16b1ae249d3eb5d11a22 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 16:54:49 +0200 Subject: [PATCH 015/164] Fix BootstrapTemplate header_color issue (#7196) --- panel/template/bootstrap/bootstrap.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/template/bootstrap/bootstrap.css b/panel/template/bootstrap/bootstrap.css index 4fcff5ca93..64babe8f96 100644 --- a/panel/template/bootstrap/bootstrap.css +++ b/panel/template/bootstrap/bootstrap.css @@ -91,7 +91,7 @@ a.navbar-brand { } #header .title { - color: var(--bs-primary-color); + color: var(--header-color); text-decoration: none; text-decoration-line: none; text-decoration-style: initial; From f2e3e5fd4ff636367f0743385aa4cc5a3dcf32e7 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 18:40:10 +0200 Subject: [PATCH 016/164] Various improvements for ESM components (#7197) * Various improvements for ESM components * Small fixes * Further typing fix --- .../custom_components/esm/custom_layout.md | 2 +- .../custom_components/esm/custom_panes.md | 13 ++-- .../custom_components/esm/custom_widgets.md | 2 +- panel/custom.py | 2 + panel/models/anywidget_component.ts | 14 ++-- panel/models/esm.py | 2 + panel/models/reactive_esm.ts | 64 +++++++++++-------- panel/util/__init__.py | 8 +++ 8 files changed, 68 insertions(+), 39 deletions(-) diff --git a/doc/how_to/custom_components/esm/custom_layout.md b/doc/how_to/custom_components/esm/custom_layout.md index 6ce1f71b5d..d5ad7bc692 100644 --- a/doc/how_to/custom_components/esm/custom_layout.md +++ b/doc/how_to/custom_components/esm/custom_layout.md @@ -1,4 +1,4 @@ -# Create Custom Layouts +# Create Custom Layouts using ESM Components In this guide, we will demonstrate how to build custom, reusable layouts using [`JSComponent`](../../reference/panes/JSComponent.md) or [`ReactComponent`](../../reference/panes/ReactComponent.md). diff --git a/doc/how_to/custom_components/esm/custom_panes.md b/doc/how_to/custom_components/esm/custom_panes.md index 167c4e93ee..c03e2b267b 100644 --- a/doc/how_to/custom_components/esm/custom_panes.md +++ b/doc/how_to/custom_components/esm/custom_panes.md @@ -1,4 +1,4 @@ -# Create Panes with ReactiveHTML +# Create Panes using ESM Components In this guide we will show you how to efficiently implement custom panes using `JSComponent`, `ReactComponent` and `AnyWidgetComponent` to get input from the user. @@ -142,12 +142,13 @@ function render({ model, el }) { const canvasEl = document.createElement('canvas') // Add DOM node before creating the chart el.append(canvasEl) - const create_chart = () => new Chart(canvasEl.getContext('2d'), model.object) + const create_chart = () => new Chart(canvasEl.getContext('2d'), model.get("object")) let chart = create_chart() model.on("object", () => { - chart.destroy() - chart = create_chart() - }) + chart.destroy() + chart = create_chart() + }) + return () => chart.destroy() } export default { render }; @@ -490,7 +491,7 @@ export default { render }; """] -pn.extension("cytoscape", sizing_mode="stretch_width") +pn.extension(sizing_mode="stretch_width") elements = [ {"data": {"id": "A", "label": "A"}}, diff --git a/doc/how_to/custom_components/esm/custom_widgets.md b/doc/how_to/custom_components/esm/custom_widgets.md index a8db677078..279b77662a 100644 --- a/doc/how_to/custom_components/esm/custom_widgets.md +++ b/doc/how_to/custom_components/esm/custom_widgets.md @@ -1,4 +1,4 @@ -# Custom Widgets +# Create Custom Widgets using ESM Components In this guide we will show you how to efficiently implement custom widgets using `JSComponent`, `ReactComponent` and `AnyWidgetComponent` to get input from the user. diff --git a/panel/custom.py b/panel/custom.py index f606854484..4e0ad40b22 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -28,6 +28,7 @@ from .reactive import ( # noqa Reactive, ReactiveCustomBase, ReactiveHTML, ReactiveMetaBase, ) +from .util import camel_to_kebab from .util.checks import import_available from .viewable import ( # noqa Child, Children, Layoutable, Viewable, is_viewable_param, @@ -182,6 +183,7 @@ def _init_params(self) -> dict[str, Any]: data_params[k] = v data_props = self._process_param_change(data_params) params.update({ + 'class_name': camel_to_kebab(cls.__name__), 'data': self._data_model(**{p: v for p, v in data_props.items() if p not in ignored}), 'dev': config.autoreload or getattr(self, '_debug', False), 'esm': self._render_esm(), diff --git a/panel/models/anywidget_component.ts b/panel/models/anywidget_component.ts index 796a2aea8b..47ddd7b452 100644 --- a/panel/models/anywidget_component.ts +++ b/panel/models/anywidget_component.ts @@ -94,7 +94,7 @@ class AnyWidgetAdapter extends AnyWidgetModelAdapter { export class AnyWidgetComponentView extends ReactComponentView { declare model: AnyWidgetComponent adapter: AnyWidgetAdapter - destroyer: ((props: any) => void) | null + destroyer: Promise<((props: any) => void) | null> override initialize(): void { super.initialize() @@ -104,7 +104,7 @@ export class AnyWidgetComponentView extends ReactComponentView { override remove(): void { super.remove() if (this.destroyer) { - this.destroyer({model: this.adapter, el: this.container}) + this.destroyer.then((d: any) => d({model: this.adapter, el: this.container})) } } @@ -112,9 +112,15 @@ export class AnyWidgetComponentView extends ReactComponentView { return ` const view = Bokeh.index.find_one_by_id('${this.model.id}') -let props = {view, model: view.adapter, data: view.model.data, el: view.container} +function render() { + const out = Promise.resolve(view.render_fn({ + view, model: view.adapter, data: view.model.data, el: view.container + }) || null) + view.destroyer = out + out.then(() => view.after_rendered()) +} -view.destroyer = view.render_fn(props) || null` +export default {render}` } override after_rendered(): void { diff --git a/panel/models/esm.py b/panel/models/esm.py index 5308c55feb..d24bcfc932 100644 --- a/panel/models/esm.py +++ b/panel/models/esm.py @@ -27,6 +27,8 @@ def event_values(self) -> dict[str, Any]: class ReactiveESM(HTMLBox): + class_name = bp.String() + children = bp.List(bp.String) data = bp.Instance(DataModel) diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts index f20e790368..e3a839ee8b 100644 --- a/panel/models/reactive_esm.ts +++ b/panel/models/reactive_esm.ts @@ -183,11 +183,14 @@ export class ReactiveESMView extends HTMLBoxView { override connect_signals(): void { super.connect_signals() - const {esm, importmap} = this.model.properties + const {esm, importmap, class_name} = this.model.properties this.on_change([esm, importmap], async () => { this.compiled_module = await this.model.compiled_module this.invalidate_render() }) + this.on_change(class_name, () => { + this.container.className = this.model.class_name + }) const child_props = this.model.children.map((child: string) => this.model.data.properties[child]) this.on_change(child_props, () => { this.update_children() @@ -265,6 +268,7 @@ export class ReactiveESMView extends HTMLBoxView { this._rendered = false set_size(this.el, this.model) this.container = div() + this.container.className = this.model.class_name set_size(this.container, this.model, false) this.shadow_el.append(this.container) if (this.model.compile_error) { @@ -450,6 +454,7 @@ export namespace ReactiveESM { export type Props = HTMLBox.Props & { children: p.Property + class_name: p.Property data: p.Property dev: p.Property esm: p.Property @@ -588,7 +593,7 @@ export class ReactiveESM extends HTMLBox { this.compile_error = null const compiled = this.compile() if (compiled === null) { - this.compiled_module = null + this.compiled_module = Promise.resolve(null) return } this.compiled = compiled @@ -596,27 +601,31 @@ export class ReactiveESM extends HTMLBox { const url = URL.createObjectURL( new Blob([this.compiled], {type: "text/javascript"}), ) - try { - // @ts-ignore - this.compiled_module = importShim(url) - const mod = await this.compiled_module - let initialize - if (mod.initialize) { - initialize = mod.initialize - } else if (mod.default && mod.default.initialize) { - initialize = mod.default.initialize - } - if (initialize) { - this._run_initializer(initialize) - } - } catch (e: any) { - this.compiled_module = null - if (this.dev) { - this.compile_error = e - } else { - throw e + // @ts-ignore + this.compiled_module = importShim(url).then((mod: any) => { + try { + let initialize + if (mod.initialize) { + initialize = mod.initialize + } else if (mod.default && mod.default.initialize) { + initialize = mod.default.initialize + } else if (typeof mod.default === "function") { + const initialized = mod.default() + mod = {default: initialized} + initialize = initialized.initialize + } + if (initialize) { + this._run_initializer(initialize) + } + return mod + } catch (e: any) { + if (this.dev) { + this.compile_error = e + } + console.error(`Could not initialize module due to error: ${e}`) + return null } - } + }) } static override __module__ = "panel.models.esm" @@ -624,11 +633,12 @@ export class ReactiveESM extends HTMLBox { static { this.prototype.default_view = ReactiveESMView this.define(({Any, Array, Bool, String}) => ({ - children: [ Array(String), [] ], - data: [ Any ], - dev: [ Bool, false ], - esm: [ String, "" ], - importmap: [ Any, {} ], + children: [ Array(String), [] ], + class_name: [ String, "" ], + data: [ Any ], + dev: [ Bool, false ], + esm: [ String, "" ], + importmap: [ Any, {} ], })) } } diff --git a/panel/util/__init__.py b/panel/util/__init__.py index 0a074121cd..9fdaaaabdb 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -521,3 +521,11 @@ def prefix_length(a: str, b: str) -> int: else: right = mid - 1 return left + + +def camel_to_kebab(name): + # Use regular expressions to insert a hyphen before each uppercase letter not at the start, + # and between a lowercase and uppercase letter. + kebab_case = re.sub(r'([a-z0-9])([A-Z])', r'\1-\2', name) + kebab_case = re.sub(r'([A-Z]+)([A-Z][a-z0-9])', r'\1-\2', kebab_case) + return kebab_case.lower() From a87f80883369b36fe5d15a41b46428db459df444 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 27 Aug 2024 18:40:45 +0200 Subject: [PATCH 017/164] Bump panel.js version to 1.5.0-b.8 --- panel/package-lock.json | 4 ++-- panel/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/package-lock.json b/panel/package-lock.json index 3a5793421c..948a319ddf 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.7", + "version": "1.5.0-b.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.0-b.7", + "version": "1.5.0-b.8", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.5.1", diff --git a/panel/package.json b/panel/package.json index 4e050919f5..bfebaf6c85 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.7", + "version": "1.5.0-b.8", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { From 7b8fa405186abddc3dabb9669cea53efaf9cb455 Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Tue, 27 Aug 2024 14:31:31 -0700 Subject: [PATCH 018/164] revert input color (#7199) --- panel/theme/css/fast.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/theme/css/fast.css b/panel/theme/css/fast.css index d41f103dc0..232d482b2d 100644 --- a/panel/theme/css/fast.css +++ b/panel/theme/css/fast.css @@ -307,7 +307,7 @@ table.panel-df { background-color: var(--neutral-fill-input-rest); border: 1px solid var(--accent-fill-rest); border-radius: calc(var(--control-corner-radius) * 1px); - color: var(--foreground-on-accent-rest); + color: var(--neutral-foreground-rest); font-size: var(--type-ramp-base-font-size); height: calc( (var(--base-height-multiplier) + var(--density)) * var(--design-unit) * 1px From 764a4d9c14a7f8100cb6a93dd4d428ff32c811bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 28 Aug 2024 11:24:51 +0200 Subject: [PATCH 019/164] Add internet marker (#7201) --- panel/io/state.py | 6 +++-- panel/tests/chat/test_interface.py | 1 + panel/tests/chat/test_message.py | 1 + panel/tests/conftest.py | 18 +++++++++++++++ panel/tests/pane/test_image.py | 37 +++++++++++++++++++----------- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/panel/io/state.py b/panel/io/state.py index dbd71ec148..8beb3c9dd9 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -14,7 +14,7 @@ from collections import Counter, defaultdict from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from contextvars import ContextVar from functools import partial, wraps from typing import ( @@ -65,7 +65,9 @@ def set_curdoc(doc: Document): try: yield finally: - state._curdoc.reset(token) + # If _curdoc has been reset it will raise a ValueError + with suppress(ValueError): + state._curdoc.reset(token) def curdoc_locked() -> Document: try: diff --git a/panel/tests/chat/test_interface.py b/panel/tests/chat/test_interface.py index 3d53964eae..86e2809b51 100644 --- a/panel/tests/chat/test_interface.py +++ b/panel/tests/chat/test_interface.py @@ -42,6 +42,7 @@ def test_init_avatar_image(self, chat_interface): chat_interface.avatar = Image("https://panel.holoviz.org/_static/logo_horizontal.png") assert chat_interface.avatar.object == "https://panel.holoviz.org/_static/logo_horizontal.png" + @pytest.mark.internet @pytest.mark.parametrize("type_", [bytes, BytesIO]) def test_init_avatar_bytes(self, type_, chat_interface): with requests.get("https://panel.holoviz.org/_static/logo_horizontal.png") as resp: diff --git a/panel/tests/chat/test_message.py b/panel/tests/chat/test_message.py index e3b694e56c..8bbb04d2eb 100644 --- a/panel/tests/chat/test_message.py +++ b/panel/tests/chat/test_message.py @@ -371,6 +371,7 @@ def test_serialize_png_url(self): message = ChatMessage(PNG(PNG_FILE)) assert message.serialize() == "PNG='https://assets.holoviz.org/panel/samples/png_sample.png'" + @pytest.mark.internet def test_serialize_svg_embed(self): svg = SVG(SVG_FILE, embed=True, alt_text="abc") with BytesIO(svg._data(SVG_FILE)) as buf: diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index 1a0042ae68..1d57365aa2 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -15,6 +15,7 @@ import unittest from contextlib import contextmanager +from functools import cache from subprocess import PIPE, Popen import pandas as pd @@ -58,6 +59,16 @@ except (RuntimeError, DeprecationWarning): asyncio.set_event_loop(asyncio.new_event_loop()) +@cache +def internet_available(host="8.8.8.8", port=53, timeout=3): + """Check if the internet connection is available.""" + try: + socket.setdefaulttimeout(timeout) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as conn: + conn.connect((host, port)) + return True + except socket.error: + return False def port_open(port): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -141,6 +152,8 @@ def pytest_configure(config): if config.option.jupyter and not port_open(JUPYTER_PORT): start_jupyter() + config.addinivalue_line("markers", "internet: mark test as requiring an internet connection") + def pytest_collection_modifyitems(config, items): skipped, selected = [], [] @@ -160,6 +173,11 @@ def pytest_collection_modifyitems(config, items): items[:] = selected +def pytest_runtest_setup(item): + if "internet" in item.keywords and not internet_available(): + pytest.skip("Skipping test: No internet connection") + + @pytest.fixture def context(context): # Set the default timeout to 20 secs diff --git a/panel/tests/pane/test_image.py b/panel/tests/pane/test_image.py index 25eb240d0c..506e8874f7 100644 --- a/panel/tests/pane/test_image.py +++ b/panel/tests/pane/test_image.py @@ -19,6 +19,10 @@ SVG_FILE = 'https://assets.holoviz.org/panel/samples/svg_sample.svg' WEBP_FILE = 'https://assets.holoviz.org/panel/samples/webp_sample.webp' +embed_parametrize = pytest.mark.parametrize( + 'embed', [False, pytest.param(True, marks=pytest.mark.internet)] +) + def test_jpeg_applies(): assert JPG.applies(JPEG_FILE) assert JPG.applies(JPG_FILE) @@ -105,6 +109,7 @@ def test_load_from_stringio(): image_data = image_pane._data(memory) assert 'PNG' in image_data +@pytest.mark.internet def test_loading_a_image_from_url(): """Tests the loading of a image from a url""" url = 'https://raw.githubusercontent.com/holoviz/panel/main/doc/_static/logo.png' @@ -171,12 +176,14 @@ def test_png_native_size(document, comm): assert 'width: auto' in model.text assert 'height: auto' in model.text +@pytest.mark.internet def test_png_native_size_embed(document, comm): png = PNG(PNG_FILE, embed=True) model = png.get_root(document, comm) assert 'width: 800px' in model.text assert 'height: 600px' in model.text +@pytest.mark.internet def test_png_native_size_embed_with_width(document, comm): png = PNG(PNG_FILE, embed=True, width=200) model = png.get_root(document, comm) @@ -189,6 +196,7 @@ def test_png_native_size_with_width(document, comm): assert 'width: 200px' in model.text assert 'height: auto' in model.text +@pytest.mark.internet def test_png_native_size_embed_with_height(document, comm): png = PNG(PNG_FILE, embed=True, height=200) model = png.get_root(document, comm) @@ -201,6 +209,7 @@ def test_png_native_size_with_height(document, comm): assert 'width: auto' in model.text assert 'height: 200px' in model.text +@pytest.mark.internet def test_png_embed_scaled_fixed_size(document, comm): png = PNG(PNG_FILE, width=400, embed=True) model = png.get_root(document, comm) @@ -214,7 +223,7 @@ def test_png_scaled_fixed_size(document, comm): assert 'height: auto' in model.text @pytest.mark.parametrize('sizing_mode', ['scale_width', 'stretch_width', 'stretch_both', 'scale_both']) -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_png_scale_width(sizing_mode, embed, document, comm): png = PNG(PNG_FILE, sizing_mode=sizing_mode, fixed_aspect=True, embed=embed) model = png.get_root(document, comm) @@ -222,56 +231,56 @@ def test_png_scale_width(sizing_mode, embed, document, comm): assert 'height: auto' in model.text @pytest.mark.parametrize('sizing_mode', ['scale_height', 'stretch_height']) -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_png_scale_height(sizing_mode, embed, document, comm): png = PNG(PNG_FILE, sizing_mode=sizing_mode, fixed_aspect=True, embed=embed) model = png.get_root(document, comm) assert 'width: auto' in model.text assert 'height: 100%' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_png_stretch_width(embed, document, comm): png = PNG(PNG_FILE, sizing_mode='stretch_width', fixed_aspect=False, embed=embed, height=500) model = png.get_root(document, comm) assert 'width: 100%' in model.text assert 'height: 500px' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_png_stretch_height(embed, document, comm): png = PNG(PNG_FILE, sizing_mode='stretch_height', fixed_aspect=False, width=500, embed=embed) model = png.get_root(document, comm) assert 'width: 500px' in model.text assert 'height: 100%' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_png_stretch_both(embed, document, comm): png = PNG(PNG_FILE, sizing_mode='stretch_both', fixed_aspect=False, embed=embed) model = png.get_root(document, comm) assert 'width: 100%' in model.text assert 'height: 100%' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_native_size(embed, document, comm): svg = SVG(SVG_FILE, embed=embed) model = svg.get_root(document, comm) assert 'width: auto' in model.text assert 'height: auto' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_native_size_with_width(embed, document, comm): svg = SVG(SVG_FILE, embed=embed, width=200) model = svg.get_root(document, comm) assert 'width: 200px' in model.text assert 'height: auto' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_native_size_with_height(embed, document, comm): svg = SVG(SVG_FILE, embed=embed, height=200) model = svg.get_root(document, comm) assert 'width: auto' in model.text assert 'height: 200px' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_scaled_fixed_size(embed, document, comm): svg = SVG(SVG_FILE, width=400, embed=embed) model = svg.get_root(document, comm) @@ -279,7 +288,7 @@ def test_svg_scaled_fixed_size(embed, document, comm): assert 'height: auto' in model.text @pytest.mark.parametrize('sizing_mode', ['scale_width', 'stretch_width', 'stretch_both', 'scale_both']) -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_scale_width(sizing_mode, embed, document, comm): svg = SVG(SVG_FILE, sizing_mode=sizing_mode, fixed_aspect=True, embed=embed) model = svg.get_root(document, comm) @@ -287,28 +296,28 @@ def test_svg_scale_width(sizing_mode, embed, document, comm): assert 'height: auto' in model.text @pytest.mark.parametrize('sizing_mode', ['scale_height', 'stretch_height']) -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_scale_height(sizing_mode, embed, document, comm): svg = SVG(SVG_FILE, sizing_mode=sizing_mode, fixed_aspect=True, embed=embed) model = svg.get_root(document, comm) assert 'width: auto' in model.text assert 'height: 100%' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_stretch_width(embed, document, comm): svg = SVG(SVG_FILE, sizing_mode='stretch_width', fixed_aspect=False, embed=embed, height=500) model = svg.get_root(document, comm) assert 'width: 100%' in model.text assert 'height: 500px' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_stretch_height(embed, document, comm): svg = SVG(SVG_FILE, sizing_mode='stretch_height', fixed_aspect=False, width=500, embed=embed) model = svg.get_root(document, comm) assert 'width: 500px' in model.text assert 'height: 100%' in model.text -@pytest.mark.parametrize('embed', [False, True]) +@embed_parametrize def test_svg_stretch_both(embed, document, comm): svg = SVG(SVG_FILE, sizing_mode='stretch_both', fixed_aspect=False, embed=embed) model = svg.get_root(document, comm) From 3c2cc392b702dd020e23b7b7175985d824a88b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 28 Aug 2024 20:10:26 +0200 Subject: [PATCH 020/164] Make `--setup`/`--autoreload`/`--warm` work with `--num-procs` (#6913) --- panel/command/serve.py | 18 +++++++---------- panel/io/server.py | 11 +++++++++++ panel/io/state.py | 2 ++ panel/tests/command/test_serve.py | 33 ++++++++++++++++++++++++++++++- panel/tests/util.py | 18 +++++++++++------ 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/panel/command/serve.py b/panel/command/serve.py index cbd19b7eb8..67d44659f2 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -9,6 +9,7 @@ import logging import os import pathlib +import sys from glob import glob from types import ModuleType @@ -280,11 +281,11 @@ def customize_applications(self, args, applications): applications['/'] = applications[f'/{index}'] return super().customize_applications(args, applications) - def warm_applications(self, applications, reuse_sessions, error=True): + def warm_applications(self, applications, reuse_sessions, error=True, initialize_session=True): from ..io.session import generate_session for path, app in applications.items(): try: - session = generate_session(app) + session = generate_session(app, initialize=initialize_session) except Exception as e: if error: raise e @@ -358,27 +359,22 @@ def customize_kwargs(self, args, server_kwargs): watch(f) if args.setup: - setup_path = args.setup - with open(setup_path) as f: - setup_source = f.read() - nodes = ast.parse(setup_source, os.fspath(setup_path)) - code = compile(nodes, filename=setup_path, mode='exec', dont_inherit=True) module_name = 'panel_setup_module' module = ModuleType(module_name) - module.__dict__['__file__'] = fullpath(setup_path) - exec(code, module.__dict__) + module.__dict__['__file__'] = fullpath(args.setup) state._setup_module = module if args.warm or args.autoreload: argvs = {f: args.args for f in files} applications = build_single_handler_applications(files, argvs) + initialize_session = not (args.num_procs and sys.version_info < (3, 12)) if args.autoreload: with record_modules(list(applications.values())): self.warm_applications( - applications, args.reuse_sessions, error=False + applications, args.reuse_sessions, error=False, initialize_session=initialize_session ) else: - self.warm_applications(applications, args.reuse_sessions) + self.warm_applications(applications, args.reuse_sessions, initialize_session=initialize_session) if args.liveness: argvs = {f: args.args for f in files} diff --git a/panel/io/server.py b/panel/io/server.py index 5c90065982..e11812a610 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +import ast import asyncio import datetime as dt import importlib @@ -335,10 +336,20 @@ def __init__(self, *args, **kwargs): if state._admin_context: state._admin_context._loop = self._loop + def setup_file(self): + setup_path = state._setup_module.__dict__['__file__'] + with open(setup_path) as f: + setup_source = f.read() + nodes = ast.parse(setup_source, os.fspath(setup_path)) + code = compile(nodes, filename=setup_path, mode='exec', dont_inherit=True) + exec(code, state._setup_module.__dict__) + def start(self) -> None: super().start() if state._admin_context: self._loop.add_callback(state._admin_context.run_load_hook) + if state._setup_module: + self._loop.add_callback(self.setup_file) if config.autoreload: from .reload import setup_autoreload_watcher self._autoreload_stop_event = stop_event = asyncio.Event() diff --git a/panel/io/state.py b/panel/io/state.py index 8beb3c9dd9..876304bd71 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -7,6 +7,7 @@ import datetime as dt import inspect import logging +import os import shutil import sys import threading @@ -846,6 +847,7 @@ def schedule_task( Whether the callback should be run on a thread (requires config.nthreads to be set). """ + name = f"{os.getpid()}_{name}" if name in self._scheduled: if callback is not self._scheduled[name][1]: self.param.warning( diff --git a/panel/tests/command/test_serve.py b/panel/tests/command/test_serve.py index fbeedfaf36..0100c0a55b 100644 --- a/panel/tests/command/test_serve.py +++ b/panel/tests/command/test_serve.py @@ -1,11 +1,13 @@ import os +import re import tempfile import pytest import requests from panel.tests.util import ( - linux_only, run_panel_serve, wait_for_port, write_file, + linux_only, run_panel_serve, unix_only, wait_for_port, wait_for_regex, + write_file, ) @@ -103,3 +105,32 @@ def test_serve_markdown(): r = requests.get(f"http://localhost:{port}/") assert r.status_code == 200 assert 'My app' in r.content.decode('utf-8') + + +@unix_only +@pytest.mark.parametrize("arg", ["--warm", "--autoreload"]) +def test_serve_num_procs(arg, tmp_path): + app = "import panel as pn; pn.panel('Hello').servable()" + py = tmp_path / "app.py" + py.write_text(app) + + regex = re.compile(r'Starting Bokeh server with process id: (\d+)') + with run_panel_serve(["--port", "0", py, "--num-procs", 2, arg], cwd=tmp_path) as p: + pid1, pid2 = wait_for_regex(p.stdout, regex=regex, count=2) + assert pid1 != pid2 + + +@unix_only +def test_serve_num_procs_setup(tmp_path): + app = "import panel as pn; pn.panel('Hello').servable()" + py = tmp_path / "app.py" + py.write_text(app) + + setup_app = 'import os; print(f"Setup PID {os.getpid()}", flush=True)' + setup_py = tmp_path / "setup.py" + setup_py.write_text(setup_app) + + regex = re.compile(r'Setup PID (\d+)') + with run_panel_serve(["--port", "0", py, "--num-procs", 2, "--setup", setup_py], cwd=tmp_path) as p: + pid1, pid2 = wait_for_regex(p.stdout, regex=regex, count=2) + assert pid1 != pid2 diff --git a/panel/tests/util.py b/panel/tests/util.py index a6e2e45a74..8b8bc59ac8 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -59,6 +59,7 @@ ON_POSIX = 'posix' in sys.builtin_module_names linux_only = pytest.mark.skipif(platform.system() != 'Linux', reason="Only supported on Linux") +unix_only = pytest.mark.skipif(platform.system() == 'Windows', reason="Only supported on unix-like systems") from panel.pane.alert import Alert from panel.pane.markup import Markdown @@ -317,7 +318,7 @@ def wait_for_server(port, prefix=None, timeout=3): @contextlib.contextmanager def run_panel_serve(args, cwd=None): - cmd = [sys.executable, "-m", "panel", "serve"] + args + cmd = [sys.executable, "-m", "panel", "serve", *map(str, args)] p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=False, cwd=cwd, close_fds=ON_POSIX) try: yield p @@ -371,26 +372,31 @@ def readline(self, timeout=None): except Empty: return None -def wait_for_port(stdout): +def wait_for_regex(stdout, regex, count=1): nbsr = NBSR(stdout) m = None - output = [] + output, found = [], [] for _ in range(20): o = nbsr.readline(0.5) if not o: continue out = o.decode('utf-8') output.append(out) - m = APP_PATTERN.search(out) + m = regex.search(out) if m is not None: + found.append(m.group(1)) + if len(found) == count: break - if m is None: + if len(found) < count: output = '\n '.join(output) pytest.fail( "No matching log line in process output, following output " f"was captured:\n\n {output}" ) - return int(m.group(1)) + return found + +def wait_for_port(stdout): + return int(wait_for_regex(stdout, APP_PATTERN)[0]) def write_file(content, file_obj): file_obj.write(content) From 647db1ab876d7c8ae31eb7d099efe0bb3d6d0499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 29 Aug 2024 13:30:05 +0200 Subject: [PATCH 021/164] Remove deprecated function (#7202) * Remove deprecated function * Update import --- panel/depends.py | 12 ------------ panel/tests/test_depends.py | 4 +++- panel/widgets/tables.py | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/panel/depends.py b/panel/depends.py index 4b6a156f01..430b5c61f9 100644 --- a/panel/depends.py +++ b/panel/depends.py @@ -1,16 +1,4 @@ -from packaging.version import Version from param.depends import depends -from param.parameterized import transform_reference from param.reactive import bind -from .config import __version__ -from .util.warnings import deprecated - - -# Alias for backward compatibility -def param_value_if_widget(*args, **kwargs): - if Version(Version(__version__).base_version) > Version('1.2'): - deprecated("1.5", "param_value_if_widget", "transform_reference") - return transform_reference(*args, **kwargs) - __all__ = ["bind", "depends"] diff --git a/panel/tests/test_depends.py b/panel/tests/test_depends.py index ae8e7d5360..8488f534b2 100644 --- a/panel/tests/test_depends.py +++ b/panel/tests/test_depends.py @@ -1,6 +1,8 @@ import pytest -from panel.depends import bind, transform_reference +from param.parameterized import transform_reference + +from panel.depends import bind from panel.pane import panel from panel.param import ParamFunction from panel.widgets import IntSlider diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 77fa7e5245..0c4e400613 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -21,9 +21,9 @@ StringEditor, StringFormatter, SumAggregator, TableColumn, ) from bokeh.util.serialization import convert_datetime_array +from param.parameterized import transform_reference from pyviz_comms import JupyterComm -from ..depends import transform_reference from ..io.resources import CDN_DIST, CSS_URLS from ..io.state import state from ..reactive import Reactive, ReactiveData From 06ed9458f9cdd1d32f09995d8726a478ab1dc776 Mon Sep 17 00:00:00 2001 From: Coderambling <159031875+Coderambling@users.noreply.github.com> Date: Sat, 31 Aug 2024 23:32:40 +0200 Subject: [PATCH 022/164] Update FileInput.ipynb (#7216) --- examples/reference/widgets/FileInput.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/reference/widgets/FileInput.ipynb b/examples/reference/widgets/FileInput.ipynb index 43c360cb10..0467dd802a 100644 --- a/examples/reference/widgets/FileInput.ipynb +++ b/examples/reference/widgets/FileInput.ipynb @@ -15,7 +15,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The ``FileInput`` widget allows uploading one or more file from the frontend and makes the filename, file data and [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) available in Python.\n", + "The ``FileInput`` widget allows uploading one or more file from the frontend and makes the filename, file data and [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) available in Python. To upload large files, use the ``FileDropper`` widget.\n", "\n", "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\n", "\n", From b0ed7c641111ecb19153c3f7fd9a7c273b34829e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 1 Sep 2024 00:07:29 +0200 Subject: [PATCH 023/164] Ensure ReactiveESM components call initialize handler (#7210) * Ensure ReactiveESM components call initialize handler * Fix lint * Fix test --- panel/models/reactive_esm.ts | 14 ++- panel/tests/ui/test_custom.py | 174 ++++++++++++++++++++++++++++++---- 2 files changed, 169 insertions(+), 19 deletions(-) diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts index e3a839ee8b..4e6e3f036f 100644 --- a/panel/models/reactive_esm.ts +++ b/panel/models/reactive_esm.ts @@ -139,6 +139,15 @@ function init_model_getter(target: ReactiveESM, name: string) { } } +function init_model_setter(target: ReactiveESM, name: string, value: any): boolean { + if (Reflect.has(target.data, name)) { + return Reflect.set(target.data, name, value) + } else if (Reflect.has(target, name)) { + return Reflect.set(target, name, value) + } + return false +} + export class ReactiveESMView extends HTMLBoxView { declare model: ReactiveESM container: HTMLDivElement @@ -480,7 +489,10 @@ export class ReactiveESM extends HTMLBox { override initialize(): void { super.initialize() - this.model_proxy = new Proxy(this, {get: init_model_getter}) + this.model_proxy = new Proxy(this, { + get: init_model_getter, + set: init_model_setter, + }) this.recompile() } diff --git a/panel/tests/ui/test_custom.py b/panel/tests/ui/test_custom.py index 241fc56eef..f98c5fe375 100644 --- a/panel/tests/ui/test_custom.py +++ b/panel/tests/ui/test_custom.py @@ -59,7 +59,6 @@ class AnyWidgetUpdate(AnyWidgetComponent): } """ - @pytest.mark.parametrize('component', [JSUpdate, ReactUpdate, AnyWidgetUpdate]) def test_update(page, component): example = component(text='Hello World!') @@ -73,6 +72,67 @@ def test_update(page, component): expect(page.locator('h1')).to_have_text('Foo!') +class AnyWidgetInitialize(AnyWidgetComponent): + + count = param.Integer(default=0) + + _esm = """ + export function initialize({ model }) { + model.set('count', 1) + model.save_changes() + } + + export function render({ model, el }) { + const h1 = document.createElement('h1') + h1.textContent = `${model.get('count')}` + el.append(h1) + } + """ + +class JSInitialize(JSComponent): + + count = param.Integer(default=0) + + _esm = """ + export function initialize({ model }) { + model.count = 1 + } + + export function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = `${model.count}` + return h1 + } + """ + +class ReactInitialize(ReactComponent): + + count = param.Integer(default=0) + + _esm = """ + export function initialize({ model }) { + model.count = 1 + } + + export function render({ model }) { + const [count] = model.useState('count') + return

{count}

+ } + """ + +@pytest.mark.parametrize('component', [AnyWidgetInitialize, JSInitialize, ReactInitialize]) +def test_initialize(page, component): + example = Row(component()) + + serve_component(page, example) + + expect(page.locator('h1')).to_have_text('1') + + example[0] = component() + + expect(page.locator('h1')).to_have_text('1') + + class JSUnwatch(JSComponent): text = param.String() @@ -155,7 +215,6 @@ class JSInput(JSComponent): } """ - class ReactInput(ReactComponent): text = param.String() @@ -173,7 +232,6 @@ class ReactInput(ReactComponent): } """ - @pytest.mark.parametrize('component', [JSInput, ReactInput]) def test_gather_input(page, component): example = component(text='Hello World!') @@ -207,7 +265,6 @@ class JSSendEvent(JSComponent): def _handle_click(self, event): self.clicks += 1 - class ReactSendEvent(ReactComponent): clicks = param.Integer(default=0) @@ -221,7 +278,6 @@ class ReactSendEvent(ReactComponent): def _handle_click(self, event): self.clicks += 1 - @pytest.mark.parametrize('component', [JSSendEvent, ReactSendEvent]) def test_send_event(page, component): button = component() @@ -247,7 +303,6 @@ class JSChild(JSComponent): return button }""" - class ReactChild(ReactComponent): child = Child() @@ -260,7 +315,6 @@ class ReactChild(ReactComponent): return }""" - @pytest.mark.parametrize('component', [JSChild, ReactChild]) def test_child(page, component): example = component(child='A Markdown pane!') @@ -291,7 +345,6 @@ class JSChildren(ListLike, JSComponent): return div }""" - class JSChildrenNoReturn(JSChildren): _esm = """ @@ -303,7 +356,6 @@ class JSChildrenNoReturn(JSChildren): model.render_count += 1 }""" - class ReactChildren(ListLike, ReactComponent): objects = Children() @@ -316,7 +368,6 @@ class ReactChildren(ListLike, ReactComponent): return
{model.get_child("objects")}
}""" - @pytest.mark.parametrize('component', [JSChildren, JSChildrenNoReturn, ReactChildren]) def test_children(page, component): example = component(objects=['A Markdown pane!']) @@ -338,7 +389,6 @@ def test_children(page, component): assert example.render_count == (3 if issubclass(component, JSChildren) else 2) - @pytest.mark.parametrize('component', [JSChildren, JSChildrenNoReturn, ReactChildren]) def test_children_add_and_remove_without_error(page, component): example = component(objects=['A Markdown pane!']) @@ -358,7 +408,6 @@ def test_children_add_and_remove_without_error(page, component): assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == [] - @pytest.mark.parametrize('component', [JSChildren, JSChildrenNoReturn, ReactChildren]) def test_children_append_without_rerender(page, component): child = JSChild(child=Markdown( @@ -384,7 +433,6 @@ def test_children_append_without_rerender(page, component): assert example.render_count == 2 - JS_CODE_BEFORE = """ export function render() { const h1 = document.createElement('h1') @@ -465,7 +513,6 @@ class JSLifecycleAfterRender(JSComponent): return h1 }""" - class ReactLifecycleAfterRender(ReactComponent): _esm = """ @@ -477,7 +524,6 @@ class ReactLifecycleAfterRender(ReactComponent): return

{text}

}""" - @pytest.mark.parametrize('component', [JSLifecycleAfterRender, ReactLifecycleAfterRender]) def test_after_render_lifecycle_hooks(page, component): example = component() @@ -498,7 +544,6 @@ class JSLifecycleAfterLayout(JSComponent): return h1 }""" - class ReactLifecycleAfterLayout(ReactComponent): _esm = """ @@ -510,7 +555,6 @@ class ReactLifecycleAfterLayout(ReactComponent): return

{text}

}""" - @pytest.mark.parametrize('component', [JSLifecycleAfterLayout, ReactLifecycleAfterLayout]) def test_after_layout_lifecycle_hooks(page, component): example = component() @@ -544,7 +588,6 @@ class ReactLifecycleAfterResize(ReactComponent): return

{count}

}""" - @pytest.mark.parametrize('component', [JSLifecycleAfterResize, ReactLifecycleAfterResize]) def test_after_resize_lifecycle_hooks(page, component): example = component(sizing_mode='stretch_width') @@ -596,3 +639,98 @@ def test_remove_lifecycle_hooks(page, component): example.clear() wait_until(lambda: msg_info.value.args[0].json_value() == "Removed", page) + + +class JSDefaultExport(JSComponent): + + _esm = """ + function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = 'Hello' + return h1 + } + + export default {render} + """ + +class AnyWidgetDefaultExport(AnyWidgetComponent): + + _esm = """ + function render({ model, el }) { + const h1 = document.createElement('h1') + h1.textContent = 'Hello' + el.append(h1) + } + + export default {render} + """ + +class ReactDefaultExport(ReactComponent): + + _esm = """ + function render({ model }) { + return

Hello

+ } + + export default { render } + """ + +@pytest.mark.parametrize('component', [AnyWidgetDefaultExport, JSDefaultExport, ReactDefaultExport]) +def test_esm_component_default_export(page, component): + example = Row(component(sizing_mode='stretch_width')) + + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + expect(page.locator('h1')).to_have_text("Hello") + + +class JSDefaultFunctionExport(JSComponent): + + _esm = """ + export default () => { + function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = 'Hello' + return h1 + } + + return {render} + } + """ + +class AnyWidgetDefaultFunctionExport(AnyWidgetComponent): + + _esm = """ + export default () => { + function render({ model, el }) { + const h1 = document.createElement('h1') + h1.textContent = 'Hello' + el.append(h1) + } + + return {render} + } + """ + +class ReactDefaultFunctionExport(ReactComponent): + + _esm = """ + export default () => { + function render({ model }) { + return

Hello

+ } + return {render} + } + """ + +@pytest.mark.parametrize('component', [AnyWidgetDefaultFunctionExport, JSDefaultExport, ReactDefaultExport]) +def test_esm_component_default_function_export(page, component): + example = Row(component(sizing_mode='stretch_width')) + + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + expect(page.locator('h1')).to_have_text("Hello") From 82dcddd7623c4e7a1faa70c353ce267088c9902b Mon Sep 17 00:00:00 2001 From: Marc Skov Madsen Date: Sun, 1 Sep 2024 04:36:41 +0200 Subject: [PATCH 024/164] Describe usage of pyscript editor (#7017) --- doc/how_to/wasm/convert.md | 7 +- doc/how_to/wasm/standalone.md | 123 +++++++++++++++++++++++++--------- panel/command/convert.py | 2 +- 3 files changed, 97 insertions(+), 35 deletions(-) diff --git a/doc/how_to/wasm/convert.md b/doc/how_to/wasm/convert.md index cbe1807cea..83bf87a117 100644 --- a/doc/how_to/wasm/convert.md +++ b/doc/how_to/wasm/convert.md @@ -4,12 +4,13 @@ Writing an HTML file from scratch with all the Javascript and Python dependencie The ``panel convert`` command has the following options: +```bash positional arguments: DIRECTORY-OR-SCRIPT The app directories or scripts to serve (serve empty document if not specified) options: -h, --help show this help message and exit - --to TO The format to convert to, one of 'pyodide' (default), 'pyodide-worker' or 'pyscript' + --to TO The format to convert to, one of 'pyodide' (default), 'pyodide-worker', 'pyscript' or 'pyscript-worker' --compiled Whether to use the compiled and faster version of Pyodide. --out OUT The directory to write the file to. --title TITLE A custom title for the application(s). @@ -17,11 +18,13 @@ The ``panel convert`` command has the following options: --index Whether to create an index if multiple files are served. --pwa Whether to add files to serve applications as a Progressive Web App. --requirements REQUIREMENTS [REQUIREMENTS ...] - Explicit requirements to add to the converted file, a single requirements.txt file or a JSON file containing requirements per app. By default requirements are inferred from the code. + Explicit requirements to add to the converted file, a single requirements.txt file or a JSON file containing requirements per app. By default requirements + are inferred from the code. --disable-http-patch Whether to disable patching http requests using the pyodide-http library. --watch Watch the files --num-procs NUM_PROCS The number of processes to start in parallel to convert the apps. +``` ## Example diff --git a/doc/how_to/wasm/standalone.md b/doc/how_to/wasm/standalone.md index 6d917d63cc..078f89fae0 100644 --- a/doc/how_to/wasm/standalone.md +++ b/doc/how_to/wasm/standalone.md @@ -1,14 +1,10 @@ # Using Panel in Pyodide & PyScript -## Installing Panel in the browser +## Pyodide -To install Panel in the browser you merely have to use the installation mechanism provided by each supported runtime: +### Creating a Basic Panel Pyodide Application -### Pyodide - -Currently, the best supported mechanism for installing packages in Pyodide is `micropip`. - -To get started with Pyodide simply follow their [Getting started guide](https://pyodide.org/en/stable/usage/quickstart.html). Note that if you want to render Panel output you will also have to load [Bokeh.js](https://docs.bokeh.org/en/2.4.1/docs/first_steps/installation.html#install-bokehjs:~:text=Installing%20standalone%20BokehJS%C2%B6) and Panel.js from CDN. The most basic pyodide application therefore looks like this: +Create a file called **script.html** with the following content: ```html @@ -16,32 +12,27 @@ To get started with Pyodide simply follow their [Getting started guide](https:// - - + -
+ + + + + + + +
+ + + +``` + +Serve the app with: + +```bash +python -m http.server +``` + +Open the app in your browser at [http://localhost:8000/script.html](http://localhost:8000/script.html). -A basic, single file pyscript example looks like +The app should look like this: + +![Panel Pyodide App](../../_static/images/pyodide_app_simple.png) + +The [PyScript](https://docs.pyscript.net) documentation recommends separating your configuration and Python code into different files. Examples can be found in the [PyScript Examples Gallery](https://pyscript.com/@examples?q=panel). + +### Creating a Basic `py-editor` Example + +Create a file called **script.html** with the following content: ```html @@ -79,6 +125,7 @@ A basic, single file pyscript example looks like + @@ -88,8 +135,7 @@ A basic, single file pyscript example looks like -
- +
``` -The app should look identical to the one above. +Create a file called **mini-coi.js** with the content from [mini-coi.js](https://github.com/WebReflection/mini-coi/blob/main/mini-coi.js). -The [PyScript](https://docs.pyscript.net) documentation recommends you put your configuration and python code into separate files. You can find such examples in the [PyScript Examples Gallery](https://pyscript.com/@examples?q=panel). +Serve the app with: + +```bash +python -m http.server +``` + +Open the app in your browser at [http://localhost:8000/script.html](http://localhost:8000/script.html). + +Click the green *run* button that appears when you hover over the lower-right corner of the editor to see the application. + +:::note +In the example, we included **mini-coi.js**. This is not necessary if the [appropriate HTTP headers](https://docs.pyscript.net/2024.7.1/user-guide/workers/) are set on your server, such as on [pyscript.com](pyscript.com) or in Github pages. +::: -## Rendering Panel components in Pyodide or Pyscript +## Rendering Panel Components in Pyodide or PyScript -Rendering Panel components into the DOM is quite straightforward. You can simply use the `.servable()` method on any component and provide a target that should match the `id` of a DOM node: +Rendering Panel components into the DOM is straightforward. Use the `.servable()` method on any component and provide a target that matches the `id` of a DOM node: ```python import panel as pn @@ -121,16 +180,16 @@ slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') def callback(new): return f'Amplitude is: {new}' -pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app'); +pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app') ``` -This code will render this simple application into the `simple_app` DOM node: +This code will render the application into the `simple_app` DOM node: ```html
``` -Alternatively you can also use the `panel.io.pyodide.write` function to write into a particular DOM node: +Alternatively, you can use the `panel.io.pyodide.write` function to write into a specific DOM node: ```python await pn.io.pyodide.write('simple_app', component) diff --git a/panel/command/convert.py b/panel/command/convert.py index 58cbae7525..1af9e4309b 100644 --- a/panel/command/convert.py +++ b/panel/command/convert.py @@ -28,7 +28,7 @@ class Convert(Subcommand): ('--to', dict( action = 'store', type = str, - help = "The format to convert to, one of 'pyodide' (default), 'pyodide-worker' or 'pyscript'", + help = "The format to convert to, one of 'pyodide' (default), 'pyodide-worker', 'pyscript' or 'pyscript-worker'", default = 'pyodide' )), ('--compiled', dict( From 0274f69179ae5fb103cd032785ebd9c5f5bc2e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Sun, 1 Sep 2024 16:59:05 +0200 Subject: [PATCH 025/164] Continue if session crashes (#7223) --- panel/command/serve.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/panel/command/serve.py b/panel/command/serve.py index 67d44659f2..d2f8f04842 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -289,6 +289,8 @@ def warm_applications(self, applications, reuse_sessions, error=True, initialize except Exception as e: if error: raise e + else: + continue with set_curdoc(session.document): if config.session_key_func: reuse_sessions = False From cf8fd2e4c9a521c981d65048d543339bb68329c3 Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Sun, 1 Sep 2024 09:40:58 -0700 Subject: [PATCH 026/164] Tweak ChatMessage layout (#7209) --- panel/chat/icon.py | 7 +++++- panel/chat/message.py | 18 ++++++++++++---- panel/dist/css/chat_copy_icon.css | 2 +- panel/dist/css/chat_message.css | 7 +++--- panel/dist/css/chat_reaction_icons.css | 4 ++++ panel/dist/css/icon.css | 1 - panel/tests/chat/test_message.py | 30 +++++++++++++++++--------- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/panel/chat/icon.py b/panel/chat/icon.py index cb91895dfb..c9aea8cbda 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -72,7 +72,12 @@ def _render_icons(self): icon._reaction = option icon.param.watch(self._update_value, "value") self._rendered_icons[option] = icon - self._composite[:] = [self.default_layout(*list(self._rendered_icons.values()))] + self._composite[:] = [ + self.default_layout( + *list(self._rendered_icons.values()), + sizing_mode=self.param.sizing_mode, + stylesheets=self._stylesheets + self.param.stylesheets.rx(), + )] @param.depends("value", watch=True) def _update_icons(self): diff --git a/panel/chat/message.py b/panel/chat/message.py index 67d8cc5c71..d761a166c5 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -254,7 +254,7 @@ def __init__(self, object=None, **params): reaction_icons = params.get("reaction_icons", {"favorite": "heart"}) if isinstance(reaction_icons, dict): - params["reaction_icons"] = ChatReactionIcons(options=reaction_icons, default_layout=Row) + params["reaction_icons"] = ChatReactionIcons(options=reaction_icons, default_layout=Row, sizing_mode=None) self._internal = True super().__init__(object=object, **params) self.chat_copy_icon = ChatCopyIcon( @@ -294,18 +294,20 @@ def _build_layout(self): self.param.user, height=20, css_classes=["name"], visible=self.param.show_user, + sizing_mode=None, ) self._activity_dot = HTML( "●", css_classes=["activity-dot"], + margin=(5, 0), + sizing_mode=None, visible=self.param.show_activity_dot, ) meta_row = Row( self._user_html, self._activity_dot, - sizing_mode="stretch_width", css_classes=["meta"], stylesheets=self._stylesheets + self.param.stylesheets.rx(), ) @@ -344,8 +346,8 @@ def _build_layout(self): header_col, self._center_row, footer_col, - self._timestamp_html, self._icons_row, + self._timestamp_html, css_classes=["right"], sizing_mode=None, stylesheets=self._stylesheets + self.param.stylesheets.rx(), @@ -556,6 +558,11 @@ def _render_reaction_icons(self): def _update_reaction_icons(self, _): self._icons_row[-1] = self._render_reaction_icons() + self._icon_divider.visible = ( + len(self.reaction_icons.options) > 0 and + self.show_reaction_icons and + self.chat_copy_icon.visible + ) def _update(self, ref, old_models): """ @@ -598,7 +605,10 @@ def _update_chat_copy_icon(self): if isinstance(object_panel, str) and self.show_copy_icon: self.chat_copy_icon.value = object_panel self.chat_copy_icon.visible = True - self._icon_divider.visible = True + self._icon_divider.visible = ( + len(self.reaction_icons.options) > 0 and + self.show_reaction_icons + ) else: self.chat_copy_icon.value = "" self.chat_copy_icon.visible = False diff --git a/panel/dist/css/chat_copy_icon.css b/panel/dist/css/chat_copy_icon.css index a03a6a1605..6af21dc4ca 100644 --- a/panel/dist/css/chat_copy_icon.css +++ b/panel/dist/css/chat_copy_icon.css @@ -1,3 +1,3 @@ :host { - margin-top: 5px; + margin-top: 8px; } diff --git a/panel/dist/css/chat_message.css b/panel/dist/css/chat_message.css index 4d75872094..06ea28cbf7 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -59,6 +59,7 @@ .name { font-size: 1em; + width: fit-content; } .center { @@ -141,11 +142,9 @@ display: inline-block; animation: fadeOut 2s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55); color: #32cd32; - /* since 1.25em, fix margins to everything is perceived to be more aligned */ + /* since 1.25em, adjust line-height */ font-size: 1.25em; - margin-left: -2.5px; - margin-top: 2px; - margin-bottom: 5px; + line-height: 0.9em; } .divider { diff --git a/panel/dist/css/chat_reaction_icons.css b/panel/dist/css/chat_reaction_icons.css index e69de29bb2..ea0b116a10 100644 --- a/panel/dist/css/chat_reaction_icons.css +++ b/panel/dist/css/chat_reaction_icons.css @@ -0,0 +1,4 @@ +:host { + margin-top: 4px; + width: fit-content; +} diff --git a/panel/dist/css/icon.css b/panel/dist/css/icon.css index 788d12a395..dc47822162 100644 --- a/panel/dist/css/icon.css +++ b/panel/dist/css/icon.css @@ -18,5 +18,4 @@ .bk-IconRow { display: flex; align-items: center; - justify-content: center; } diff --git a/panel/tests/chat/test_message.py b/panel/tests/chat/test_message.py index 8bbb04d2eb..53c74320a2 100644 --- a/panel/tests/chat/test_message.py +++ b/panel/tests/chat/test_message.py @@ -54,7 +54,7 @@ def test_layout(self): assert isinstance(object_pane, Markdown) assert object_pane.object == "ABC" - icons = columns[1][5][2] + icons = columns[1][4][2] assert isinstance(icons, ChatReactionIcons) footer_col = columns[1][3] @@ -65,22 +65,29 @@ def test_layout(self): assert isinstance(footer_col[1], Markdown) assert footer_col[1].object == "Footer 2" - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] assert isinstance(timestamp_pane, HTML) def test_reactions_dynamic(self): - message = ChatMessage(reactions=["favorite"]) + message = ChatMessage("hi", reactions=["favorite"]) assert message.reaction_icons.value == ["favorite"] message.reactions = ["thumbs-up"] assert message.reaction_icons.value == ["thumbs-up"] def test_reaction_icons_dynamic(self): - message = ChatMessage(reaction_icons={"favorite": "heart"}) + message = ChatMessage("hi", reaction_icons={"favorite": "heart"}) assert message.reaction_icons.options == {"favorite": "heart"} message.reaction_icons = ChatReactionIcons(options={"like": "thumb-up"}) assert message._icons_row[-1] == message.reaction_icons + assert message._icon_divider.visible + + message.reaction_icons = ChatReactionIcons(options={}) + assert not message._icon_divider.visible + + message = ChatMessage("hi", reaction_icons={}) + assert not message._icon_divider.visible def test_reactions_link(self): # on init @@ -171,39 +178,39 @@ def test_update_object(self): def test_update_timestamp(self): message = ChatMessage() columns = message._composite.objects - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] assert isinstance(timestamp_pane, HTML) dt_str = datetime.datetime.now().strftime("%H:%M") assert timestamp_pane.object == dt_str message = ChatMessage(timestamp_tz="UTC") columns = message._composite.objects - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] assert isinstance(timestamp_pane, HTML) dt_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M") assert timestamp_pane.object == dt_str message = ChatMessage(timestamp_tz="US/Pacific") columns = message._composite.objects - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] assert isinstance(timestamp_pane, HTML) dt_str = datetime.datetime.now(tz=ZoneInfo("US/Pacific")).strftime("%H:%M") assert timestamp_pane.object == dt_str special_dt = datetime.datetime(2023, 6, 24, 15) message.timestamp = special_dt - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] dt_str = special_dt.strftime("%H:%M") assert timestamp_pane.object == dt_str mm_dd_yyyy = "%b %d, %Y" message.timestamp_format = mm_dd_yyyy - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] dt_str = special_dt.strftime(mm_dd_yyyy) assert timestamp_pane.object == dt_str message.show_timestamp = False - timestamp_pane = columns[1][4][0] + timestamp_pane = columns[1][5][0] assert not timestamp_pane.visible def test_does_not_turn_widget_into_str(self): @@ -322,17 +329,20 @@ def test_chat_copy_icon_text_widget(self, widget): message = ChatMessage(object=widget(value="testing")) assert message.chat_copy_icon.visible assert message.chat_copy_icon.value == "testing" + assert message._icon_divider.visible def test_chat_copy_icon_disabled(self): message = ChatMessage(object="testing", show_copy_icon=False) assert not message.chat_copy_icon.visible assert not message.chat_copy_icon.value + assert not message._icon_divider.visible @pytest.mark.parametrize("component", [Column, FileInput]) def test_chat_copy_icon_not_string(self, component): message = ChatMessage(object=component()) assert not message.chat_copy_icon.visible assert not message.chat_copy_icon.value + assert not message._icon_divider.visible def test_serialize_text_prefix_with_viewable_type(self): message = ChatMessage(Markdown("string")) From cf28fce7f439b930239b0682d20ab1c8e11f5a6e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 2 Sep 2024 00:12:31 +0200 Subject: [PATCH 027/164] Add panel compile command to bundle ESM components (#7204) --- doc/how_to/custom_components/esm/build.md | 197 ++++++++++- doc/how_to/custom_components/index.md | 4 +- panel/command/__init__.py | 10 +- panel/command/compile.py | 100 ++++++ panel/custom.py | 159 +++++++-- panel/io/compile.py | 409 ++++++++++++++++++++++ panel/models/anywidget_component.ts | 13 +- panel/models/esm.py | 4 +- panel/models/react_component.ts | 79 ++--- panel/models/reactive_esm.ts | 48 ++- panel/tests/io/test_compile.py | 92 +++++ panel/tests/ui/test_custom.py | 183 +++++++++- panel/viewable.py | 2 +- pixi.toml | 1 + 14 files changed, 1173 insertions(+), 128 deletions(-) create mode 100644 panel/command/compile.py create mode 100644 panel/io/compile.py create mode 100644 panel/tests/io/test_compile.py diff --git a/doc/how_to/custom_components/esm/build.md b/doc/how_to/custom_components/esm/build.md index 7c70cf6d2f..6e0241fae4 100644 --- a/doc/how_to/custom_components/esm/build.md +++ b/doc/how_to/custom_components/esm/build.md @@ -1,6 +1,6 @@ -# Handling of external resources +# Compile and Bundle ESM Components -The ESM components make it possible to load external libraries from NPM or GitHub easily using one of two approaches: +The ESM components make it possible to load external libraries from a CDN, NPM or GitHub using one of two approaches: 1. Directly importing from `esm.sh` or another CDN or by defining a so called importmap. 2. Bundling the resources using `npm` and `esbuild`. @@ -84,7 +84,7 @@ Let's say for instance you want to import libraries `A`, `B` and `C`. Both `B` a In order to avoid this we can ask `esm.sh` not to rewrite the imports using the `external` query parameter. This tells esm.sh that `A` will be provided externally (i.e. by us), ensuring that libraries `B` and `C` both import the version of `A` we declare: -``` +```python { "imports": { "A": "https://esm.sh/A@1.0.0", @@ -117,19 +117,186 @@ Import maps supports trailing slash that can not work with URL search params fri ``` ::: -## Bundling +## Compile & Bundling -Importing libraries directly from a CDN allows for extremely quick iteration but also means that the users of your components will have to have access to the internet to fetch the required modules. By bundling the component resources you can ship a self-contained module that includes all the dependencies, while also ensuring that you only fetch the parts of the libraries that are actually needed. +Importing libraries directly from a CDN allows for quick experimentation and iteration but also means that the users of your components will have to have access to the internet to fetch the required modules. By compiling and bundling the component and external resources you can ship a self-contained and optimized ESM module that includes all the dependencies, while also ensuring that you only fetch the parts of the libraries that are actually needed. The `panel compile` command provides a simple entrypoint to compile one or more components into a single component. -### Tooling +### Setup -The tooling we recommend to bundle your component resources include `esbuild` and `npm`, both can conveniently be installed with `conda`: +The compilation and bundling workflow depends on two JavaScript tools: `node.js` (or more specifically the node package manager `npm`) and `esbuild`. The most convenient way to install them is `conda` but you can also set up your own node environment using something like [`asdf`](https://asdf-vm.com/guide/getting-started.html), [`nvm`](https://github.com/nvm-sh/nvm?tab=readme-ov-file#installing-and-updating) or [`volta`](https://volta.sh/). + +::::{tab-set} +:::{tab-item} `conda` ```bash conda install esbuild npm ``` +::: + +:::{tab-item} Custom Node.js installation + +Once you have set up `node.js` you can install `esbuild` globally with: + +```bash +npm install -g esbuild +``` + +and confirm the installation with: + +```bash +esbuild --version +``` +::: + +:::: + +### Panel Compile Command + +Panel provides the `panel compile` command to automate the compilation of ESM components from the command line and bundle their resources. This functionality requires `npm` and `esbuild` to be installed globally on your system. + +#### Example Usage + +Let's consider a confetti.py module containing a custom JavaScript component: + +```python +# confetti.py +import panel as pn + +from panel.custom import JSComponent + +class ConfettiButton(JSComponent): + + _esm = """ +import confetti from "https://esm.sh/canvas-confetti@1.6.0"; + +export function render() { + const button = document.createElement('button') + button.addEventListener('click', () => confetti()) + button.append('Click me!') + return button +}""" +``` + +To compile this component, you can use the following command: + +```bash +panel compile confetti +``` + +:::{hint} +`panel compile` accepts file paths, e.g. `my_components/custom.py`, and dotted module name, e.g. `my_package.custom`. If you provide a module name it must be importable. +::: + +This will automatically discover the `ConfettiButton` but you can also explicitly request a single component by adding the class name: + +```bash +panel compile confetti:ConfettiButton +``` + +After running the command you should output that looks a like this, indicating the build succeeded: + +```bash +Running command: npm install + +npm output: + +added 1 package, and audited 2 packages in 649ms + +1 package is looking for funding + run `npm fund` for details -### Configuration +found 0 vulnerabilities + +Running command: esbuild /var/folders/7c/ww31pmxj2j18w_mn_qy52gdh0000gq/T/tmp9yhyqo55/index.js --bundle --format=esm --outfile=/ConfettiButton.bundle.js --minify + +esbuild output: + + ...../ConfettiButton.bundle.js 10.5kb + +⚡ Done in 9ms +``` + +The compiled JavaScript file will be automatically loaded if it remains alongside the component. If you rename the component or modify its code or `_importmap`, you must recompile the component. For ongoing development, consider using the `--autoreload` option to ignore the compiled file and automatically reload the development version when it changes. + +#### Compilation Steps + +The `panel compile` command performs the compilation and bundling in several steps: + +1. **Identify Components**: The first step is to discover the components in the provided module(s). +2. **Extract External Dependencies**: The command identifies external dependencies from the `_importmap` (if defined) or directly from the ESM code. These dependencies are written to a `package.json` file in a temporary build directory. The `.js(x)` files corresponding to each component are also placed in this directory. +3. **Install Dependencies**: The command runs `npm install` within the build directory to fetch all external dependencies specified in `package.json`. +4. **Bundle and Minify**: The command executes `esbuild index.js --bundle --format=esm --minify --outfile=ConfettiButton.bundle.js` to bundle the ESM code into a single minified JavaScript file. +5. **Output the Compiled Bundle(s)**: The final output is one or more compiled JavaScript bundle (`ConfettiButton.bundle.js`). + +#### Compiling Multiple Components + +If you intend to ship multiple components with shared dependencies, `panel compile` can generate a combined bundle, which ensures that the dependencies are only loaded once. By default it will generate one bundle per module or per component, but if you declare a `_bundle` attribute on the class, declared either as a string defining a relative path or a `pathlib.Path`, you can generate shared bundles across modules. These bundles can include as many components as needed and will be automatically loaded when you use the component. + +As an example, imagine you have a components declared across your package containing two distinct components. By declaring a path that resolves to the same location we can bundle them together: + +```python +# my_package/my_module.py +class ComponentA(JSComponent): + _bundle = './dist/custom.bundle.js' + +# my_package/subpackage/other_module.py +class ComponentB(JSComponent): + _bundle = '../dist/custom.bundle.js' +``` + +when you compile it with: + +```bash +panel compile my_package.my_module my_package.subpackage.other_module +``` + +you will end up with a single `custom.bundle.js` file placed in the `my_package/dist` directory. + +#### Using the `--build-dir` Option + +The `--build-dir` option allows you to specify a custom directory where the `package.json` and raw JavaScript/JSX modules will be written. This is useful if you need to manually modify the dependencies before the bundling process and/or debug issues while bundling. To use this feature, follow these steps: + +1. Run the compile command with the `--build-dir` option to generate the directory: + +```bash +panel compile confetti.py --build-dir ./custom_build_dir +``` + +2. Navigate to the specified build directory and manually edit the `package.json` file to adjust dependencies as needed. + +3. Once you've made your changes, you can manually run the `esbuild` command: + +```bash +esbuild custom_build_dir/index.js --format=esm --bundle --minify +``` + +Here is a typical structure of the build_dir directory: + +``` +custom_build_dir/ +├── index.js +├── package.json +├── .js +└── .js +``` + +The compiled JS file will now be loaded automatically as long as it remains alongside the component. If you rename the component you will have to delete and recompile the JS file. If you make changes to the code or `_importmap` you also have to recompile. During development we recommend using `--autoreload`, which ignores the compiled file. + +```{caution} +The `panel compile` CLI tool is still very new and experimental. In our testing it was able to compile and bundle most components but there are bound to be corner cases. + +We will continue to improve the tool and eventually allow you to bundle multiple components into a single bundle to allow sharing of resources. +``` + +### React Components + +React components automatically include `react` and `react-dom` in their bundles. The version of `React` that is loaded can be specified the `_react_version` attribute on the component class. We strongly suggest you pin a specific version on your component to ensure your component does not break should the version be bumped in Panel. + +### Manual Compilation + +If you have more complex requirements or the automatic compilation fails for whatever reason you can also manually compile the output. We generally strongly recommend that you start by generating the initial bundle structure by providing a `--build-dir` and then tweaking the resulting output. + +#### Configuration To run the bundling we will need one additional file, the `package.json`, which, just like the import maps, determines the required packages and their versions. The `package.json` is a complex file with tons of configuration options but all we will need are the [dependencies](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies). @@ -160,7 +327,7 @@ pn.extension() class ConfettiButton(JSComponent): - _esm = 'confetti.bundled.js' + _esm = 'confetti.js' ConfettiButton().servable() ``` @@ -190,15 +357,7 @@ npm install This will fetch the packages and install them into the local `node_modules` directory. Once that is complete we can run the bundling: ```bash -esbuild confetti.js --bundle --format=esm --minify --outfile=confetti.bundled.js +esbuild confetti.js --bundle --format=esm --minify --outfile=ConfettiButton.bundle.js ``` -This will create a new file called `confetti.bundled.js`, which includes all the dependencies (even CSS, image files and other static assets if you have imported them). - -The only thing left to do now is to update the `_esm` declaration to point to the new bundled file: - -```python -class ConfettiButton(JSComponent): - - _esm = 'confetti.bundled.js' -``` +This will create a new file called `ConfettiButton.bundle.js`, which includes all the dependencies (even CSS, image files and other static assets if you have imported them). diff --git a/doc/how_to/custom_components/index.md b/doc/how_to/custom_components/index.md index 081ecd7588..389e9c9dd7 100644 --- a/doc/how_to/custom_components/index.md +++ b/doc/how_to/custom_components/index.md @@ -59,11 +59,11 @@ Build custom components in Javascript using so called ESM components, which allo :gutter: 1 1 1 2 -:::{grid-item-card} {octicon}`tools;2.5em;sd-mr-1 sd-animate-grow50` Building and Bundling ESM components +:::{grid-item-card} {octicon}`tools;2.5em;sd-mr-1 sd-animate-grow50` Compile and Bundle ESM Components :link: esm/build :link-type: doc -How to specify and bundle external dependencies for ESM components. +How to specify external dependencies for ESM components and compile them into JS bundles. ::: :::{grid-item-card} {octicon}`pencil;2.5em;sd-mr-1 sd-animate-grow50` Add callbacks to ESM components diff --git a/panel/command/__init__.py b/panel/command/__init__.py index e30816964f..9ba7447ca3 100644 --- a/panel/command/__init__.py +++ b/panel/command/__init__.py @@ -12,6 +12,7 @@ from .. import __version__ from .bundle import Bundle +from .compile import Compile from .convert import Convert from .oauth_secret import OAuthSecret from .serve import Serve @@ -51,7 +52,7 @@ def transform_cmds(argv): def main(args=None): """Mirrors bokeh CLI and adds additional Panel specific commands """ from bokeh.command.subcommands import all as bokeh_commands - bokeh_commands = bokeh_commands + [OAuthSecret, Convert, Bundle] + bokeh_commands = bokeh_commands + [OAuthSecret, Compile, Convert, Bundle] parser = argparse.ArgumentParser( prog="panel", epilog="See ' --help' to read about a specific subcommand." @@ -66,6 +67,10 @@ def main(args=None): subparser = subs.add_parser(Serve.name, help=Serve.help) subcommand = Serve(parser=subparser) subparser.set_defaults(invoke=subcommand.invoke) + elif cls is Compile: + subparser = subs.add_parser(Compile.name, help=Compile.help) + subcommand = Compile(parser=subparser) + subparser.set_defaults(invoke=subcommand.invoke) elif cls is Convert: subparser = subs.add_parser(Convert.name, help=Convert.help) subcommand = Convert(parser=subparser) @@ -102,6 +107,9 @@ def main(args=None): elif sys.argv[1] == 'bundle': args = parser.parse_args(sys.argv[1:]) ret = Bundle(parser).invoke(args) + elif sys.argv[1] == 'compile': + args = parser.parse_args(sys.argv[1:]) + ret = Compile(parser).invoke(args) else: ret = bokeh_entry_point() else: diff --git a/panel/command/compile.py b/panel/command/compile.py new file mode 100644 index 0000000000..776a555a2f --- /dev/null +++ b/panel/command/compile.py @@ -0,0 +1,100 @@ +import os +import pathlib +import sys + +from collections import defaultdict + +from bokeh.command.subcommand import Argument, Subcommand + +from ..io.compile import RED, compile_components, find_components + + +class Compile(Subcommand): + ''' Subcommand to generate a new encryption key. + + ''' + + #: name for this subcommand + name = "compile" + + help = "Compiles an ESM component using node and esbuild" + + args = ( + ('modules', Argument( + metavar = 'DIRECTORY-OR-SCRIPT', + nargs = "*", + help = "The Python modules to compile. May optionally define a single class.", + default = None, + )), + ('--build-dir', dict( + action = 'store', + type = str, + help = "Where to write the build directory." + )), + ('--unminified', dict( + action = 'store_true', + help = "Whether to generate unminified output." + )), + ('--verbose', dict( + action = 'store_true', + help = "Whether to show verbose output. Note when setting --outfile only the result will be printed to stdout." + )), + ) + + def invoke(self, args): + bundles = defaultdict(list) + for module_spec in args.modules: + if ':' in module_spec: + *parts, cls = module_spec.split(':') + module = ':'.join(parts) + else: + module = module_spec + cls = '' + classes = cls.split(',') if cls else None + module_name, ext = os.path.splitext(os.path.basename(module)) + if ext not in ('', '.py'): + print( # noqa + f'{RED} Can only compile ESM components defined in Python ' + 'file or importable module.' + ) + return 1 + try: + components = find_components(module, classes) + except ValueError: + cls_error = f' and that class(es) {cls!r} are defined therein' if cls else '' + print( # noqa + f'{RED} Could not find any ESM components to compile, ensure ' + f'you provided the right module{cls_error}.' + ) + return 1 + if module in sys.modules: + module_path = sys.modules[module].__file__ + else: + module_path = module + module_path = pathlib.Path(module_path).parent + for component in components: + if component._bundle: + bundle_path = component._bundle + if isinstance(bundle_path, str): + path = (module_path / bundle_path).absolute() + else: + path = bundle_path.absolute() + bundles[str(path)].append(component) + elif len(components) > 1 and not classes: + component_module = module_name if ext else component.__module__ + bundles[module_path / f'{component_module}.bundle.js'].append(component) + else: + bundles[module_path / f'{component.__name__}.bundle.js'].append(component) + + errors = 0 + for bundle, components in bundles.items(): + out = compile_components( + components, + build_dir=args.build_dir, + minify=not args.unminified, + outfile=bundle, + verbose=args.verbose, + ) + if not out: + errors += 1 + return int(errors>0) diff --git a/panel/custom.py b/panel/custom.py index 4e0ad40b22..7de55b4a3a 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -1,9 +1,11 @@ from __future__ import annotations import asyncio +import hashlib import inspect import os import pathlib +import sys import textwrap from collections import defaultdict @@ -28,7 +30,7 @@ from .reactive import ( # noqa Reactive, ReactiveCustomBase, ReactiveHTML, ReactiveMetaBase, ) -from .util import camel_to_kebab +from .util import camel_to_kebab, classproperty from .util.checks import import_available from .viewable import ( # noqa Child, Children, Layoutable, Viewable, is_viewable_param, @@ -41,6 +43,8 @@ from bokeh.model import Model from pyviz_comms import Comm + ExportSpec = dict[str, list[str | tuple[str, ...]]] + class ReactiveESMMetaclass(ReactiveMetaBase): @@ -97,8 +101,16 @@ class CounterButton(pn.ReactiveESM): _bokeh_model = _BkReactiveESM + _bundle: ClassVar[str | os.PathLike | None] = None + _esm: ClassVar[str | os.PathLike] = "" + # Specifies exports to make available to JS in a bundled file + # 1. Default export: "" + # 2. Import all (`* as`): "*" + # 3. Named export (`{ , ... }`): ("", ...) + _exports__: ClassVar[ExportSpec] = {} + _importmap: ClassVar[dict[Literal['imports', 'scopes'], str]] = {} __abstract = True @@ -108,24 +120,72 @@ def __init__(self, **params): self._watching_esm = False self._event_callbacks = defaultdict(list) - @property - def _esm_path(self): - esm = self._esm + @classproperty + def _bundle_path(cls) -> os.PathLike | None: + if config.autoreload: + return + try: + mod_path = pathlib.Path(inspect.getfile(cls)).parent + except (OSError, TypeError, ValueError): + if not isinstance(cls._bundle, pathlib.PurePath): + return + if cls._bundle: + bundle = cls._bundle + if isinstance(bundle, pathlib.PurePath): + return bundle + elif bundle.endswith('.js'): + bundle_path = mod_path / bundle + if bundle_path.is_file(): + return bundle_path + return + else: + raise ValueError( + 'Could not resolve {cls.__name__}._bundle. Ensure ' + 'you provide either a string with a relative or absolute ' + 'path or a Path object to a .js file extension.' + ) + path = mod_path / f'{cls.__name__}.bundle.js' + if path.is_file(): + return path + module = cls.__module__ + path = mod_path / f'{module}.bundle.js' + if path.is_file(): + return path + elif module in sys.modules: + module = os.path.basename(sys.modules[module].__file__).replace('.py', '') + path = mod_path / f'{module}.bundle.js' + return path if path.is_file() else None + return None + + @classmethod + def _esm_path(cls, compiled: bool = True) -> os.PathLike | None: + if compiled: + bundle_path = cls._bundle_path + if bundle_path: + return bundle_path + esm = cls._esm if isinstance(esm, pathlib.PurePath): return esm + elif not esm.endswith(('.js', '.jsx', '.ts', '.tsx')): + return try: - esm_path = pathlib.Path(inspect.getfile(type(self))).parent / esm + if hasattr(cls, '__path__'): + mod_path = cls.__path__ + else: + mod_path = pathlib.Path(inspect.getfile(cls)).parent + esm_path = mod_path / esm if esm_path.is_file(): return esm_path except (OSError, TypeError, ValueError): pass - return None + return - def _render_esm(self): - if (esm_path:= self._esm_path): + @classmethod + def _render_esm(cls, compiled: bool | Literal['compiling'] = True): + if (esm_path:= cls._esm_path(compiled=compiled is True)): esm = esm_path.read_text(encoding='utf-8') else: - esm = self._esm + esm = cls._esm esm = textwrap.dedent(esm) return esm @@ -149,11 +209,12 @@ def _cleanup(self, root: Model | None) -> None: async def _watch_esm(self): import watchfiles - async for _ in watchfiles.awatch(self._esm_path, stop_event=self._watching_esm): + path = self._esm_path(compiled=False) + async for _ in watchfiles.awatch(path, stop_event=self._watching_esm): self._update_esm() def _update_esm(self): - esm = self._render_esm() + esm = self._render_esm(not config.autoreload) for ref, (model, _) in self._models.copy().items(): if esm == model.esm: continue @@ -181,18 +242,28 @@ def _init_params(self) -> dict[str, Any]: if k in params: params.pop(k) data_params[k] = v + bundle_path = self._bundle_path + if bundle_path: + bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest() + importmap = {} + else: + bundle_hash = None + importmap = self._process_importmap() data_props = self._process_param_change(data_params) params.update({ + 'bundle': bundle_hash, 'class_name': camel_to_kebab(cls.__name__), 'data': self._data_model(**{p: v for p, v in data_props.items() if p not in ignored}), 'dev': config.autoreload or getattr(self, '_debug', False), - 'esm': self._render_esm(), - 'importmap': self._process_importmap(), + 'esm': self._render_esm(not config.autoreload), + 'importmap': importmap, + 'name': cls.__name__ }) return params - def _process_importmap(self): - return self._importmap + @classmethod + def _process_importmap(cls): + return cls._importmap def _get_children(self, data_model, doc, root, parent, comm): children = {} @@ -219,7 +290,7 @@ def _setup_autoreload(self): if not ((config.autoreload or getattr(self, '_debug', False)) and import_available('watchfiles')): return super()._setup_autoreload() - if (self._esm_path and not self._watching_esm): + if (self._esm_path(compiled=False) and not self._watching_esm): self._watching_esm = asyncio.Event() state.execute(self._watch_esm) @@ -327,6 +398,8 @@ class CounterButton(JSComponent): CounterButton().servable() ''' + __abstract = True + class ReactComponent(ReactiveESM): ''' @@ -361,23 +434,55 @@ class CounterButton(ReactComponent): CounterButton().servable() ''' + __abstract = True _bokeh_model = _BkReactComponent _react_version = '18.3.1' - def _init_params(self) -> dict[str, Any]: - params = super()._init_params() - params['react_version'] = self._react_version - return params + @classproperty + def _exports__(cls) -> ExportSpec: + imports = cls._importmap.get('imports', {}) + exports = { + "react": ["*React"], + "react-dom/client": [("createRoot",)] + } + if any('@mui' in v for v in imports.values()): + exports.update({ + "@emotion/cache": "createCache", + "@emotion/react": ("CacheProvider",) + }) + return exports + + @classmethod + def _render_esm(cls, compiled: bool | Literal['compiling'] = True): + esm = super()._render_esm(compiled=compiled) + if compiled == 'compiling': + esm = 'import * as React from "react"\n' + esm + return esm - def _process_importmap(self): - imports = self._importmap.get('imports', {}) - imports_with_deps = {} - dev_suffix = '&dev' if config.autoreload else '' - suffix = f'deps=react@{self._react_version},react-dom@{self._react_version}&external=react{dev_suffix},react-dom' + @classmethod + def _process_importmap(cls): + imports = cls._importmap.get('imports', {}) + v_react = cls._react_version + if config.autoreload: + pkg_suffix, path_suffix = '?dev', '&dev' + else: + pkg_suffix = path_suffix = '' + imports_with_deps = { + "react": f"https://esm.sh/react@{v_react}{pkg_suffix}", + "react/": f"https://esm.sh/react@{v_react}{path_suffix}/", + "react-dom": f"https://esm.sh/react-dom@{v_react}?deps=react@{v_react}&external=react", + "react-dom/": f"https://esm.sh/react-dom@{v_react}&deps=react@{v_react}{path_suffix}&external=react/" + } + suffix = f'deps=react@{v_react},react-dom@{v_react}&external=react,react-dom' if any('@mui' in v for v in imports.values()): suffix += ',react-is,@emotion/react' + imports_with_deps.update({ + "react-is": f"https://esm.sh/react-is@{v_react}&external=react", + "@emotion/cache": f"https://esm.sh/@emotion/cache?deps=react@{v_react},react-dom@{v_react}", + "@emotion/react": f"https://esm.sh/@emotion/react?deps=react@{v_react},react-dom@{v_react}&external=react,react-is", + }) for k, v in imports.items(): if '?' not in v and 'esm.sh' in v: if v.endswith('/'): @@ -387,7 +492,7 @@ def _process_importmap(self): imports_with_deps[k] = v return { 'imports': imports_with_deps, - 'scopes': self._importmap.get('scopes', {}) + 'scopes': cls._importmap.get('scopes', {}) } class AnyWidgetComponent(ReactComponent): @@ -398,6 +503,8 @@ class AnyWidgetComponent(ReactComponent): as is, without having to adapt the callbacks to use Bokeh APIs. """ + __abstract = True + _bokeh_model = _BkAnyWidgetComponent def send(self, msg: dict): diff --git a/panel/io/compile.py b/panel/io/compile.py new file mode 100644 index 0000000000..70b105f29f --- /dev/null +++ b/panel/io/compile.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import importlib +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from bokeh.application.handlers.code_runner import CodeRunner + +from ..custom import ReactComponent, ReactiveESM + +if TYPE_CHECKING: + from .custom import ExportSpec + +GREEN, RED, RESET = "\033[0;32m", "\033[0;31m", "\033[0m" + +# Regex pattern to match import statements with URLs starting with https +_ESM_IMPORT_RE = re.compile( + r'import\s+.*?\s+from\s+["\']' # Match 'import ... from' with any content + r'(https:\/\/[^\/]+\/(?:npm\/)?' # Match the base URL (e.g., https://cdn.jsdelivr.net/) and ignore /npm if present + r'((?:@[\w\.\-]+\/)?[\w\.\-]+)' # Capture the package name, including optional scope + r'(?:@([\d\.\w-]+))?' # Optionally capture the version after @ + r'[^"\']*)["\']' # Match the rest of the URL up to the quote +) +_ESM_IMPORT_SUFFIX = re.compile(r'\/([^?"&\']*)') + +# Regex pattern to extract version specifiers from a URL +_ESM_URL_RE = re.compile( + r'(https:\/\/[^\/]+\/(?:npm\/)?' + r'((?:@[\w\.\-]+\/)?[\w\.\-]+)' + r'(?:@([\d\.\w-]+))?' + r'[^"\']*)' +) +_ESM_IMPORT_ALIAS_RE = re.compile(r'(import\s+(?:\*\s+as\s+\w+|\{[^}]*\}|[\w*\s,]+)\s+from\s+[\'"])(.*?)([\'"])') +_EXPORT_DEFAULT_RE = re.compile(r'\bexport\s+default\b') + + +@contextmanager +def setup_build_dir(build_dir: str | os.PathLike | None = None): + original_directory = os.getcwd() + if build_dir: + temp_dir = pathlib.Path(build_dir).absolute() + temp_dir.mkdir(parents=True, exist_ok=True) + else: + temp_dir = tempfile.mkdtemp() + try: + os.chdir(temp_dir) + yield temp_dir + finally: + os.chdir(original_directory) + if not build_dir: + shutil.rmtree(temp_dir) + + +def check_cli_tool(tool_name): + try: + result = subprocess.run([tool_name, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode == 0: + return True + else: + return False + except Exception: + return False + + +def find_components(module_or_file: str | os.PathLike, classes: list[str] | None = None) -> list[type[ReactiveESM]]: + """ + Creates a temporary module given a path-like object and finds all + the ReactiveESM components defined therein. + + Arguments + --------- + module_or_file : str | os.PathLike + The path to the Python module. + classes: list[str] | None + Names of classes to return. + + Returns + ------- + List of ReactiveESM components defined in the module. + """ + py_file = module_or_file.endswith('.py') + if py_file: + path_obj = pathlib.Path(module_or_file) + source = path_obj.read_text(encoding='utf-8') + runner = CodeRunner(source, module_or_file, []) + module = runner.new_module() + runner.run(module) + else: + module = importlib.import_module(module_or_file) + classes = classes or [] + components = [] + for v in module.__dict__.values(): + if ( + isinstance(v, type) and + issubclass(v, ReactiveESM) and + not v.abstract and + (not classes or v.__name__ in classes) + ): + if py_file: + v.__path__ = path_obj.parent.absolute() + components.append(v) + not_found = set(classes) - set(c.__name__ for c in components) + if classes and not_found: + clss = ', '.join(map(repr, not_found)) + raise ValueError(f'{clss} class(es) not found in {module_or_file!r}.') + return components + + +def packages_from_code(esm_code: str) -> dict[str, str]: + """ + Extracts package version definitions from ESM code. + + Arguments + --------- + esm_code : str + The ESM code to search for package imports. + + Returns + ------- + Dictionary of packages and their versions. + """ + packages = {} + for match in _ESM_IMPORT_RE.findall(esm_code): + url, package_name, version = match + packages[package_name] = f'^{version}' + after_slash_match = _ESM_IMPORT_SUFFIX.search(url.split('@')[-1]) + import_name = package_name + if after_slash_match: + suffix = after_slash_match.group(1) + if suffix != '+esm' and not suffix.endswith(('.js', '.mjs')): + # ESM specifier is used by some CDNs to load ESM bundle + import_name += f'/{suffix}' + esm_code = esm_code.replace(url, import_name) + return esm_code, packages + + +def replace_imports(esm_code: str, replacements: dict[str, str]) -> dict[str, str]: + """ + Replaces imports in the code which may be aliases with the actual + package names. + + Arguments + --------- + esm_code: str + The ESM code to replace import names in. + replacements: dict[str, str] + Mapping that defines replacements from aliased import names + to actual package names. + + Returns + ------- + modified_code: str + The code where imports have been replaced with package names. + """ + + def replace_match(match): + import_part = match.group(1) + module_path = match.group(2) + quote = match.group(3) + for old, new in replacements.items(): + if module_path.startswith(old): + module_path = module_path.replace(old, new, 1) + break + return f"{import_part}{module_path}{quote}" + + # Use the sub method to replace the matching parts of the import statements + modified_code = _ESM_IMPORT_ALIAS_RE.sub(replace_match, esm_code) + return modified_code + + +def packages_from_importmap(esm_code: str, imports: dict[str, str]) -> dict[str, str]: + """ + Extracts package version definitions from an import map. + + Arguments + --------- + esm_code: str + The ESM code to replace import names in. + imports : dict[str, str] + A dictionary representing the import map, where keys are package names and values are URLs. + + Returns + ------- + dict[str, str] + A dictionary where keys are package names and values are their corresponding versions. + """ + dependencies, replacements = {}, {} + for key, url in imports.items(): + match = _ESM_URL_RE.search(url) + if not match: + raise RuntimeError( + f'Could not determine package name from URL: {url!r}.' + ) + pkg_name = match.group(2) + version = match.group(3) + dependencies[pkg_name] = f"^{version}" if version else "latest" + replacements[key] = pkg_name+'/' if key.endswith('/') else pkg_name + esm_code = replace_imports(esm_code, replacements) + return esm_code, dependencies + + +def extract_dependencies(component: type[ReactiveESM]) -> tuple[str, dict[str, any]]: + """ + Extracts dependencies from a ReactiveESM component by parsing its + importmap and the associated code and replaces URL import + specifiers with package imports. + + Arguments + --------- + component: type[ReactiveESM] + The ReactiveESM component to extract a dependency definition from. + + Returns + ------- + code: str + Code where the URL imports have been replaced by package imports. + dependencies: dict[str, str] + A dictionary of package dependencies and their versions. + """ + importmap = component._process_importmap() + esm = component._render_esm(compiled='compiling') + esm, dependencies = packages_from_importmap(esm, importmap.get('imports', {})) + esm, packages = packages_from_code(esm) + dependencies.update(packages) + return esm, dependencies + + +def merge_exports(old: ExportSpec, new: ExportSpec): + """ + Appends the new exports to set of existing ones. + + Appropriately combines different kinds of exports including + default, import-all exports and named exports. + """ + for export, specs in new.items(): + if export not in old: + old[export] = list(specs) + continue + prev = old[export] + for spec in specs: + if isinstance(spec, tuple): + for i, p in enumerate(prev): + if isinstance(p, tuple): + prev[i] = tuple(dict.fromkeys(p+spec)) + break + else: + prev.append(spec) + elif spec not in prev: + prev.append(spec) + + +def generate_index(imports: str, exports: list[str], export_spec: ExportSpec): + index_js = imports + exports = list(exports) + for module, specs in export_spec.items(): + for spec in specs: + # TODO: Handle name clashes in exports + if isinstance(spec, tuple): + imports = f"{{{', '.join(spec)}}}" + exports.extend(spec) + elif spec.startswith('*'): + imports = f"* as {spec[1:]}" + exports.append(spec[1:]) + else: + imports = spec + exports.append(spec) + index_js += f'import {imports} from "{module}"\n' + + export_string = ', '.join(exports) + index_js += f"export default {{{export_string}}}" + return index_js + + +def generate_project( + components: list[type[ReactiveESM]], + path: str | os.PathLike, + project_config: dict[str, any] = None +): + """ + Converts a set of ESM components into a Javascript project with + an index.js, package.json and a T|JS(X) per component. + """ + path = pathlib.Path(path) + component_names = [] + dependencies, export_spec = {}, {} + index = '' + for component in components: + name = component.__name__ + esm_path = component._esm_path(compiled=False) + if esm_path: + ext = esm_path.suffix + else: + ext = 'jsx' if issubclass(component, ReactComponent) else 'js' + code, component_deps = extract_dependencies(component) + # Detect default export in component code and handle import accordingly + if _EXPORT_DEFAULT_RE.search(code): + index += f'import {name} from "./{name}"\n' + else: + index += f'import * as {name} from "./{name}"\n' + + with open(path / f'{name}.{ext}', 'w') as component_file: + component_file.write(code) + # TODO: Improve merging of dependencies + dependencies.update(component_deps) + merge_exports(export_spec, component._exports__) + component_names.append(name) + + # Create package.json and write to temp directory + package_json = {"dependencies": dependencies} + if project_config: + package_json.update(project_config) + with open(path / 'package.json', 'w') as package_json_file: + json.dump(package_json, package_json_file, indent=2) + + # Generate index file from component imports, exports and export specs + index_js = generate_index(index, component_names, export_spec) + with open(path / 'index.js', 'w') as index_js_file: + index_js_file.write(index_js) + + +def compile_components( + components: list[type[ReactiveESM]], + build_dir: str | os.PathLike = None, + outfile: str | os.PathLike = None, + minify: bool = True, + verbose: bool = True +) -> str | None: + """ + Compiles a list of ReactiveESM components into a single JavaScript bundle + including their Javascript dependencies. + + Arguments + --------- + components : list[type[ReactiveESM]] + A list of `ReactiveESM` component classes to compile. + build_dir : str | os.PathLike, optional + The directory where the build output will be saved. If None, a + temporary directory will be used. + outfile : str | os.PathLike, optional + The path to the output file where the compiled bundle will be saved. + If None the compiled output will be returned. + minify : bool, optional + If True, minifies the compiled JavaScript bundle. + verbose : bool, optional + If True, prints detailed logs during the compilation process. + + Returns + ------- + Returns the compiled bundle or None if outfile is provided. + """ + npm_cmd = 'npm.cmd' if sys.platform == 'win32' else 'npm' + if not check_cli_tool(npm_cmd): + raise RuntimeError( + 'Could not find `npm` or it generated an error. Ensure it is ' + 'installed and can be run with `npm --version`. You can get it ' + 'with conda or you favorite package manager or nodejs manager.' + ) + if not check_cli_tool('esbuild'): + raise RuntimeError( + 'Could not find `esbuild` or it generated an error. Ensure it ' + 'is installed and can be run with `esbuild --version`. You can ' + 'install it with conda or with `npm install -g esbuild`.' + ) + + out = str(pathlib.Path(outfile).absolute()) if outfile else None + with setup_build_dir(build_dir) as build_dir: + generate_project(components, build_dir) + extra_args = [] + if verbose: + extra_args.append('--log-level=debug') + install_cmd = [npm_cmd, 'install'] + extra_args + try: + if out: + print(f"Running command: {' '.join(install_cmd)}\n") # noqa + result = subprocess.run(install_cmd, check=True, capture_output=True, text=True) + if result.stdout and out: + print(f"npm output:\n{GREEN}{result.stdout}{RESET}") # noqa + if result.stderr: + print("npm errors:\n{RED}{result.stderr}{RESET}") # noqa + return None + except subprocess.CalledProcessError as e: + print(f"An error occurred while running npm install:\n{RED}{e.stderr}{RESET}") # noqa + return None + + if minify: + extra_args.append('--minify') + if out: + extra_args.append(f'--outfile={out}') + build_cmd = ['esbuild', 'index.js', '--bundle', '--format=esm'] + extra_args + try: + if verbose: + print(f"Running command: {' '.join(build_cmd)}\n") # noqa + result = subprocess.run(build_cmd+['--color=true'], check=True, capture_output=True, text=True) + if result.stderr: + print(f"esbuild output:\n{result.stderr}") # noqa + return None + except subprocess.CalledProcessError as e: + print(f"An error occurred while running esbuild: {e.stderr}") # noqa + return None + return 0 if outfile else result.stdout diff --git a/panel/models/anywidget_component.ts b/panel/models/anywidget_component.ts index 47ddd7b452..a580fe72d3 100644 --- a/panel/models/anywidget_component.ts +++ b/panel/models/anywidget_component.ts @@ -1,7 +1,6 @@ import type * as p from "@bokehjs/core/properties" -import {ReactiveESM} from "./reactive_esm" -import {ReactComponent, ReactComponentView} from "./react_component" +import {ReactiveESM, ReactiveESMView} from "./reactive_esm" class AnyWidgetModelAdapter { declare model: AnyWidgetComponent @@ -91,7 +90,7 @@ class AnyWidgetAdapter extends AnyWidgetModelAdapter { } -export class AnyWidgetComponentView extends ReactComponentView { +export class AnyWidgetComponentView extends ReactiveESMView { declare model: AnyWidgetComponent adapter: AnyWidgetAdapter destroyer: Promise<((props: any) => void) | null> @@ -132,12 +131,12 @@ export default {render}` export namespace AnyWidgetComponent { export type Attrs = p.AttrsOf - export type Props = ReactComponent.Props + export type Props = ReactiveESM.Props } export interface AnyWidgetComponent extends AnyWidgetComponent.Attrs {} -export class AnyWidgetComponent extends ReactComponent { +export class AnyWidgetComponent extends ReactiveESM { declare properties: AnyWidgetComponent.Props constructor(attrs?: Partial) { @@ -149,10 +148,6 @@ export class AnyWidgetComponent extends ReactComponent { initialize(props) } - override compile(): string | null { - return ReactiveESM.prototype.compile.call(this) - } - static override __module__ = "panel.models.esm" static { diff --git a/panel/models/esm.py b/panel/models/esm.py index d24bcfc932..2bdfad0752 100644 --- a/panel/models/esm.py +++ b/panel/models/esm.py @@ -27,6 +27,8 @@ def event_values(self) -> dict[str, Any]: class ReactiveESM(HTMLBox): + bundle = bp.Nullable(bp.String) + class_name = bp.String() children = bp.List(bp.String) @@ -53,8 +55,6 @@ class ReactComponent(ReactiveESM): Renders jsx/tsx based ESM bundles using React. """ - react_version = bp.String('18.3.1') - class AnyWidgetComponent(ReactComponent): """ diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts index 6909cc64c6..a9013e6e72 100644 --- a/panel/models/react_component.ts +++ b/panel/models/react_component.ts @@ -34,23 +34,36 @@ export class ReactComponentView extends ReactiveESMView { protected override _render_code(): string { let render_code = ` - if (rendered && view.model.usesReact) { - view._changing = true - const root = createRoot(view.container) - try { - root.render(rendered) - } catch(e) { - view.render_error(e) - } - }` - let import_code = ` +if (rendered && view.model.usesReact) { + view._changing = true + const root = createRoot(view.container) + try { + root.render(rendered) + } catch(e) { + view.render_error(e) + } +}` + let import_code + if (this.model.bundle) { + import_code = ` +const ns = await view._module_cache.get(view.model.bundle) +const {React, createRoot} = ns.default` + } else { + import_code = ` import * as React from "react" import { createRoot } from "react-dom/client"` + } if (this.model.usesMui) { - import_code = ` + if (this.model.bundle) { + import_code = ` +const ns = await view._module_cache.get(view.model.bundle) +const {CacheProvider, React, createCache, createRoot} = ns.default` + } else { + import_code = ` ${import_code} import createCache from "@emotion/cache" import { CacheProvider } from "@emotion/react"` + } render_code = ` if (rendered) { const cache = createCache({ @@ -63,10 +76,10 @@ import { CacheProvider } from "@emotion/react"` ${render_code}` } return ` -${import_code} - const view = Bokeh.index.find_one_by_id('${this.model.id}') +${import_code} + class Child extends React.Component { get view() { @@ -215,9 +228,7 @@ export default {render}` export namespace ReactComponent { export type Attrs = p.AttrsOf - export type Props = ReactiveESM.Props & { - react_version: p.Property - } + export type Props = ReactiveESM.Props } export interface ReactComponent extends ReactComponent.Attrs {} @@ -241,37 +252,11 @@ export class ReactComponent extends ReactiveESM { return this.compiled !== null && this.compiled.includes("React") } - protected override _declare_importmap(): void { - const react_version = this.react_version - const imports = this.importmap?.imports - const scopes = this.importmap?.scopes - const pkg_suffix = this.dev ? "?dev": "" - const path_suffix = this.dev ? "&dev": "" - const importMap = { - imports: { - react: `https://esm.sh/react@${react_version}${pkg_suffix}`, - "react/": `https://esm.sh/react@${react_version}${path_suffix}/`, - "react-dom": `https://esm.sh/react-dom@${react_version}?deps=react@${react_version}${pkg_suffix}&external=react`, - "react-dom/": `https://esm.sh/react-dom@${react_version}&deps=react@${react_version}${path_suffix}&external=react/`, - ...imports, - }, - scopes: scopes || {}, - } - if (this.usesMui) { - importMap.imports = { - ...importMap.imports, - "react-is": `https://esm.sh/react-is@${react_version}&external=react`, - "@emotion/cache": `https://esm.sh/@emotion/cache?deps=react@${react_version},react-dom@${react_version}`, - "@emotion/react": `https://esm.sh/@emotion/react?deps=react@${react_version},react-dom@${react_version}&external=react${path_suffix},react-is`, - } - } - // @ts-ignore - importShim.addImportMap(importMap) - } - override compile(): string | null { const compiled = super.compile() - if (compiled === null || !compiled.includes("React")) { + if (this.bundle) { + return compiled + } else if (compiled === null || !compiled.includes("React")) { return compiled } return ` @@ -284,9 +269,5 @@ ${compiled}` static { this.prototype.default_view = ReactComponentView - - this.define(({String}) => ({ - react_version: [ String, "18.3.1" ], - })) } } diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts index 4e6e3f036f..11cc5b7bfe 100644 --- a/panel/models/reactive_esm.ts +++ b/panel/models/reactive_esm.ts @@ -17,6 +17,8 @@ import {convertUndefined, formatError} from "./util" import error_css from "styles/models/esm.css" +const MODULE_CACHE = new Map() + @server_event("esm_event") export class ESMEvent extends ModelEvent { constructor(readonly model: ReactiveESM, readonly data: any) { @@ -166,6 +168,7 @@ export class ReactiveESMView extends HTMLBoxView { ["resize", []], ["remove", []], ]) + _module_cache: Map = MODULE_CACHE _rendered: boolean = false _stale_children: boolean = false @@ -462,6 +465,7 @@ export namespace ReactiveESM { export type Attrs = p.AttrsOf export type Props = HTMLBox.Props & { + bundle: p.Property children: p.Property class_name: p.Property data: p.Property @@ -582,6 +586,9 @@ export class ReactiveESM extends HTMLBox { } compile(): string | null { + if (this.bundle != null) { + return this.esm + } let compiled try { compiled = transform( @@ -610,13 +617,29 @@ export class ReactiveESM extends HTMLBox { } this.compiled = compiled this._declare_importmap() - const url = URL.createObjectURL( - new Blob([this.compiled], {type: "text/javascript"}), - ) - // @ts-ignore - this.compiled_module = importShim(url).then((mod: any) => { + let esm_module + const cache_key = this.bundle || `${this.class_name}-${this.esm.length}` + let resolve: (value: any) => void + if (!this.dev && MODULE_CACHE.has(cache_key)) { + esm_module = Promise.resolve(MODULE_CACHE.get(cache_key)) + } else { + if (!this.dev) { + MODULE_CACHE.set(cache_key, new Promise((res) => { resolve = res })) + } + const url = URL.createObjectURL( + new Blob([this.compiled], {type: "text/javascript"}), + ) + esm_module = (window as any).importShim(url) + } + this.compiled_module = (esm_module as Promise).then((mod: any) => { + if (resolve) { + resolve(mod) + } try { let initialize + if (this.bundle != null && (mod.default || {}).hasOwnProperty(this.name)) { + mod = mod.default[(this.name as any)] + } if (mod.initialize) { initialize = mod.initialize } else if (mod.default && mod.default.initialize) { @@ -644,13 +667,14 @@ export class ReactiveESM extends HTMLBox { static { this.prototype.default_view = ReactiveESMView - this.define(({Any, Array, Bool, String}) => ({ - children: [ Array(String), [] ], - class_name: [ String, "" ], - data: [ Any ], - dev: [ Bool, false ], - esm: [ String, "" ], - importmap: [ Any, {} ], + this.define(({Any, Array, Bool, Nullable, Str}) => ({ + bundle: [ Nullable(Str), null ], + children: [ Array(Str), [] ], + class_name: [ Str, "" ], + data: [ Any ], + dev: [ Bool, false ], + esm: [ Str, "" ], + importmap: [ Any, {} ], })) } } diff --git a/panel/tests/io/test_compile.py b/panel/tests/io/test_compile.py new file mode 100644 index 0000000000..a30c104399 --- /dev/null +++ b/panel/tests/io/test_compile.py @@ -0,0 +1,92 @@ +import pytest + +from panel.io.compile import ( + packages_from_code, packages_from_importmap, replace_imports, +) + + +def test_packages_from_code_esm_sh(): + code, pkgs = packages_from_code('import * from "https://esm.sh/confetti-canvas@1.0.0"') + assert code == 'import * from "confetti-canvas"' + assert pkgs == {'confetti-canvas': '^1.0.0'} + +def test_packages_from_code_unpkg(): + code, pkgs = packages_from_code('import * from "https://unpkg.com/esm@3.2.25/esm/loader.js"') + assert code == 'import * from "esm"' + assert pkgs == {'esm': '^3.2.25'} + +def test_packages_from_code_jsdelivr(): + code, pkgs = packages_from_code('import * as vg from "https://cdn.jsdelivr.net/npm/@uwdata/vgplot@0.8.0/+esm"') + assert code == 'import * as vg from "@uwdata/vgplot"' + assert pkgs == {'@uwdata/vgplot': '^0.8.0'} + +@pytest.mark.parametrize('dev', ['rc', 'a', 'b']) +def test_packages_from_code_dev(dev): + code, pkgs = packages_from_code(f'import * from "https://esm.sh/confetti-canvas@1.0.0-{dev}.1"') + assert code == 'import * from "confetti-canvas"' + assert pkgs == {'confetti-canvas': f'^1.0.0-{dev}.1'} + +@pytest.mark.parametrize('import_code', [ + 'import * from "https://esm.sh/chart.js@1.0.0/auto"', + 'import * from "https://esm.sh/chart.js@1.0.0/auto?deps=react"', + 'import * from "https://esm.sh/chart.js@1.0.0&external=react/auto"', +]) +def test_packages_from_code_path(import_code): + code, pkgs = packages_from_code(import_code) + assert code == 'import * from "chart.js/auto"' + assert pkgs == {'chart.js': '^1.0.0'} + +def test_replace_imports_single_quote(): + assert replace_imports("import {foo} from 'bar'", {'bar': 'pkg-bar'}) == "import {foo} from 'pkg-bar'" + +def test_replace_imports_double_quote(): + assert replace_imports('import {foo} from "bar"', {'bar': 'pkg-bar'}) == 'import {foo} from "pkg-bar"' + +def test_replace_imports_trailing_slash(): + assert replace_imports('import {foo} from "bar/baz"', {'bar': 'pkg-bar'}) == 'import {foo} from "pkg-bar/baz"' + +def test_replace_imports_only_from(): + assert replace_imports('import {bar} from "bar"', {'bar': 'pkg-bar'}) == 'import {bar} from "pkg-bar"' + +def test_packages_from_importmap_esm_sh(): + code, packages = packages_from_importmap( + 'import * from "confetti"', + {'confetti': 'https://esm.sh/confetti-canvas@1.0.0'} + ) + assert code == 'import * from "confetti-canvas"' + assert packages == {'confetti-canvas': '^1.0.0'} + +def test_packages_from_importmap_unpkg(): + code, pkgs = packages_from_importmap( + 'import * from "esm"', + {'esm': 'https://unpkg.com/esm@3.2.25/esm/loader.js'} + ) + assert code == 'import * from "esm"' + assert pkgs == {'esm': '^3.2.25'} + +def test_packages_from_importmap_jsdelivr(): + code, pkgs = packages_from_importmap( + 'import * as vg from "@uwdata/vgplot"', + {"@uwdata/vgplot": "https://cdn.jsdelivr.net/npm/@uwdata/vgplot@0.8.0/+esm"} + ) + assert code == 'import * as vg from "@uwdata/vgplot"' + assert pkgs == {'@uwdata/vgplot': '^0.8.0'} + +@pytest.mark.parametrize('dev', ['rc', 'a', 'b']) +def test_packages_from_importmap_dev(dev): + code, pkgs = packages_from_importmap( + 'import * from "confetti"', + {'confetti': f'https://esm.sh/confetti-canvas@1.0.0-{dev}.1'} + ) + assert code == 'import * from "confetti-canvas"' + assert pkgs == {'confetti-canvas': f'^1.0.0-{dev}.1'} + +@pytest.mark.parametrize('url', [ + "https://esm.sh/chart.js@1.0.0/", + "https://esm.sh/chart.js@1.0.0/?deps=react", + "https://esm.sh/chart.js@1.0.0&external=react/", +]) +def test_packages_from_importmap_path(url): + code, pkgs = packages_from_importmap('import * from "chart-js/auto"', {'chart-js/': url}) + assert code == 'import * from "chart.js/auto"' + assert pkgs == {'chart.js': '^1.0.0'} diff --git a/panel/tests/ui/test_custom.py b/panel/tests/ui/test_custom.py index f98c5fe375..bbb58af023 100644 --- a/panel/tests/ui/test_custom.py +++ b/panel/tests/ui/test_custom.py @@ -7,9 +7,11 @@ from playwright.sync_api import expect +from panel.config import config from panel.custom import ( AnyWidgetComponent, Child, Children, JSComponent, ReactComponent, ) +from panel.io.compile import compile_components from panel.layout import Row from panel.layout.base import ListLike from panel.pane import Markdown @@ -133,6 +135,76 @@ def test_initialize(page, component): expect(page.locator('h1')).to_have_text('1') +class AnyWidgetModuleCached(AnyWidgetComponent): + + count = param.Integer(default=0) + + _esm = """ + let count = 0 + + export function initialize({ model }) { + count += 1 + model.set('count', count) + model.save_changes() + } + + export function render({ model, el }) { + const h1 = document.createElement('h1') + h1.textContent = `${model.get('count')}` + el.append(h1) + } + """ + +class JSModuleCached(JSComponent): + + count = param.Integer(default=0) + + _esm = """ + let count = 0 + + export function initialize({ model }) { + count += 1 + model.count = count + } + + export function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = `${model.count}` + return h1 + } + """ + +class ReactModuleCached(ReactComponent): + + count = param.Integer(default=0) + + _esm = """ + let count = 0 + + export function initialize({ model }) { + count += 1 + model.count = count + } + + export function render({ model }) { + const [count] = model.useState('count') + return

{count}

+ } + """ + +@pytest.mark.parametrize('component', [AnyWidgetModuleCached, JSModuleCached, ReactModuleCached]) +def test_module_cached(page, component): + example = Row(component()) + + serve_component(page, example) + + expect(page.locator('h1')).to_have_text('1') + + example[0] = component() + + expect(page.locator('h1')).to_have_text('2') + + class JSUnwatch(JSComponent): text = param.String() @@ -470,16 +542,18 @@ class CustomReload(component): _esm = pathlib.Path(js_file.name) example = CustomReload() - serve_component(page, example) - expect(page.locator('h1')).to_have_text('foo') + with config.set(autoreload=True): + serve_component(page, example) - js_file.file.write(after) - js_file.file.flush() - js_file.file.seek(0) - example._update_esm() + expect(page.locator('h1')).to_have_text('foo') + + js_file.file.write(after) + js_file.file.flush() + js_file.file.seek(0) + example._update_esm() - expect(page.locator('h1')).to_have_text('bar') + expect(page.locator('h1')).to_have_text('bar') def test_anywidget_custom_event(page): @@ -734,3 +808,98 @@ def test_esm_component_default_function_export(page, component): expect(page.locator('h1')).to_have_count(1) expect(page.locator('h1')).to_have_text("Hello") + + +@pytest.mark.parametrize('component', [AnyWidgetInitialize, JSInitialize, ReactInitialize]) +def test_esm_compile_simple(page, component): + outfile = pathlib.Path(__file__).parent / f'{component.__name__}.bundle.js' + ret = compile_components([component], outfile=outfile) + if ret or not outfile.is_file(): + raise RuntimeError('Could not compile ESM component') + + assert component._bundle_path == outfile + + example = Row(component()) + + serve_component(page, example) + + expect(page.locator('h1')).to_have_text('1') + + example[0] = component() + + expect(page.locator('h1')).to_have_text('1') + + +class JSBase(JSComponent): + + _bundle = 'js.bundle.js' + + _esm = """ + export function render({model}) { + const h1 = document.createElement('h1') + h1.id = model.name + h1.textContent = "Rendered" + return h1 + } + """ + +class JS1(JSBase): + pass + +class JS2(JSBase): + pass + + +class AnyWidgetBase(AnyWidgetComponent): + + _bundle = 'anywidget.bundle.js' + + _esm = """ + export function render({model, el}) { + const h1 = document.createElement('h1') + h1.id = model.get("name") + h1.textContent = "Rendered" + el.append(h1) + } + """ + +class AnyWidget1(AnyWidgetBase): + pass + +class AnyWidget2(AnyWidgetBase): + pass + + +class ReactBase(ReactComponent): + + _bundle = 'react.bundle.js' + + _esm = """ + export function render({model, el}) { + return

Rendered

+ } + """ + +class React1(ReactBase): + pass + +class React2(ReactBase): + pass + +@pytest.mark.parametrize('components', [[JS1, JS2], [AnyWidget1, AnyWidget2], [React1, React2]]) +def test_esm_compile_shared(page, components): + component1, component2 = components + outfile = pathlib.Path(__file__).parent / component1._bundle + ret = compile_components([component1, component2], outfile=outfile) + if ret or not outfile.is_file(): + raise RuntimeError('Could not compile ESM component') + + assert component1._bundle_path == outfile + assert component2._bundle_path == outfile + + example = Row(component1(), component2()) + + serve_component(page, example) + + expect(page.locator(f'#{example[0].name}')).to_have_text('Rendered') + expect(page.locator(f'#{example[1].name}')).to_have_text('Rendered') diff --git a/panel/viewable.py b/panel/viewable.py index c75f61d20b..92e5ea4670 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -313,7 +313,7 @@ def _modify_doc( """ Callback to handle FunctionHandler document creation. """ - if server_id: + if server_id and server_id in state._servers: state._servers[server_id][2].append(doc) return self.server_doc(doc, title, location) # type: ignore diff --git a/pixi.toml b/pixi.toml index 152ad41196..e0a72fa368 100644 --- a/pixi.toml +++ b/pixi.toml @@ -140,6 +140,7 @@ playwright = { version = "*", channel = "microsoft" } pytest-playwright = "*" pytest-asyncio = "*" jupyter_server = "*" +esbuild = "*" packaging = "*" [feature.test-ui.tasks] From da68c8aa86ea64074648b3162cd4ab4ec051cb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 2 Sep 2024 13:49:03 +0200 Subject: [PATCH 028/164] Pass non-Param widget arguments to widget (#4959) --- panel/param.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/panel/param.py b/panel/param.py index ada3f7c2de..2e60e87a41 100644 --- a/panel/param.py +++ b/panel/param.py @@ -512,16 +512,9 @@ def widget(self, p_name): # Update kwargs onkeyup = kw_widget.pop('onkeyup', False) throttled = kw_widget.pop('throttled', False) - ignored_kws = [repr(k) for k in kw_widget if k not in widget_class.param] - if ignored_kws: - self.param.warning( - f'Param pane was given unknown keyword argument(s) for {p_name!r} ' - f'parameter with a widget of type {widget_class!r}. The following ' - f'keyword arguments could not be applied: {", ".join(ignored_kws)}.' - ) kw.update(kw_widget) - kwargs = {k: v for k, v in kw.items() if k in widget_class.param} + non_param_kwargs = {k: v for k, v in kw_widget.items() if k not in widget_class.param} if isinstance(widget_class, type) and issubclass(widget_class, Button): kwargs.pop('value', None) @@ -529,7 +522,7 @@ def widget(self, p_name): if isinstance(widget_class, WidgetBase): widget = widget_class else: - widget = widget_class(**kwargs) + widget = widget_class(**kwargs, **non_param_kwargs) widget._param_pane = self widget._param_name = p_name From d0744c50c66866396272e056282c3df38f33ecb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 2 Sep 2024 14:52:22 +0200 Subject: [PATCH 029/164] Speed up import time (#7207) --- panel/chat/__init__.py | 26 +++++++++++++++----------- panel/config.py | 13 ++++++++----- panel/pane/vega.py | 2 +- panel/tests/test_imports.py | 21 +++++++++++++++++++++ panel/tests/util.py | 2 +- panel/widgets/indicators.py | 4 +++- 6 files changed, 49 insertions(+), 19 deletions(-) create mode 100644 panel/tests/test_imports.py diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 729fbb531a..8cac6e04cd 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -29,7 +29,7 @@ https://panel.holoviz.org/reference/chat/ChatInterface.html """ -import importlib as _importlib +from typing import TYPE_CHECKING from .feed import ChatFeed # noqa from .icon import ChatReactionIcons # noqa @@ -38,16 +38,6 @@ from .message import ChatMessage # noqa from .step import ChatStep # noqa - -def __getattr__(name): - """ - Lazily import langchain module when accessed. - """ - if name == "langchain": - return _importlib.import_module("panel.chat.langchain") - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - __all__ = ( "ChatAreaInput", "ChatFeed", @@ -57,3 +47,17 @@ def __getattr__(name): "ChatStep", "langchain", ) + +def __getattr__(name): + """ + Lazily import langchain module when accessed. + """ + if name == "langchain": + from . import langchain + return langchain + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__dir__ = lambda: list(__all__) + +if TYPE_CHECKING: + from . import langchain diff --git a/panel/config.py b/panel/config.py index 5f7955d03f..1a41f89ea0 100644 --- a/panel/config.py +++ b/panel/config.py @@ -17,10 +17,6 @@ import param -from bokeh.core.has_props import _default_resolver -from bokeh.document import Document -from bokeh.model import Model -from bokeh.settings import settings as bk_settings from pyviz_comms import ( JupyterCommManager as _JupyterCommManager, extension as _pyviz_extension, ) @@ -41,6 +37,7 @@ #--------------------------------------------------------------------- _PATH = os.path.abspath(os.path.dirname(__file__)) +_config_uninitialized = True def validate_config(config, parameter, value): """ @@ -435,7 +432,7 @@ def __getattribute__(self, attr): ensure that even on first access mutable parameters do not end up being modified. """ - if attr in ('_param__private', '_globals', '_parameter_set', '__class__', 'param'): + if _config_uninitialized or attr in ('_param__private', '_globals', '_parameter_set', '__class__', 'param'): return super().__getattribute__(attr) from .io.state import state @@ -621,6 +618,7 @@ def theme(self): _config._parameter_set = set(_params) config = _config(**{k: None if p.allow_None else getattr(_config, k) for k, p in _params.items() if k != 'name'}) +_config_uninitialized = False class panel_extension(_pyviz_extension): """ @@ -703,6 +701,10 @@ class panel_extension(_pyviz_extension): _comms_detected_before = False def __call__(self, *args, **params): + from bokeh.core.has_props import _default_resolver + from bokeh.model import Model + from bokeh.settings import settings as bk_settings + from .reactive import ReactiveHTML, ReactiveHTMLMetaclass reactive_exts = { v._extension_name: v for k, v in param.concrete_descendents(ReactiveHTML).items() @@ -855,6 +857,7 @@ def __call__(self, *args, **params): @staticmethod def _display_globals(): if config.browser_info and state.browser_info: + from bokeh.document import Document doc = Document() comm = state._comm_manager.get_server_comm() model = state.browser_info._render_model(doc, comm) diff --git a/panel/pane/vega.py b/panel/pane/vega.py index 081ec5661f..59ef47d57c 100644 --- a/panel/pane/vega.py +++ b/panel/pane/vega.py @@ -8,7 +8,6 @@ ) import numpy as np -import pandas as pd import param from bokeh.models import ColumnDataSource @@ -27,6 +26,7 @@ def ds_as_cds(dataset): """ Converts Vega dataset into Bokeh ColumnDataSource data """ + import pandas as pd if isinstance(dataset, pd.DataFrame): return {k: dataset[k].values for k in dataset.columns} if len(dataset) == 0: diff --git a/panel/tests/test_imports.py b/panel/tests/test_imports.py new file mode 100644 index 0000000000..51ffa2c2a4 --- /dev/null +++ b/panel/tests/test_imports.py @@ -0,0 +1,21 @@ +import sys + +from subprocess import check_output +from textwrap import dedent + + +def test_no_blocklist_imports(): + check = """\ + import sys + import panel + + blocklist = {"pandas", "bokeh.plotting"} + mods = blocklist & set(sys.modules) + + if mods: + print(", ".join(mods), end="") + """ + + output = check_output([sys.executable, '-c', dedent(check)]) + + assert output == b"" diff --git a/panel/tests/util.py b/panel/tests/util.py index 8b8bc59ac8..bb5c14c89c 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -322,7 +322,7 @@ def run_panel_serve(args, cwd=None): p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=False, cwd=cwd, close_fds=ON_POSIX) try: yield p - except Exception as e: + except BaseException as e: p.terminate() p.wait() print("An error occurred: %s", e) # noqa: T201 diff --git a/panel/widgets/indicators.py b/panel/widgets/indicators.py index 920e2546af..c6babbc159 100644 --- a/panel/widgets/indicators.py +++ b/panel/widgets/indicators.py @@ -32,7 +32,6 @@ import param from bokeh.models import ColumnDataSource, FixedTicker, Tooltip -from bokeh.plotting import figure from .._param import Align from ..io.resources import CDN_DIST @@ -737,6 +736,7 @@ def _get_data(self, properties): return annulus_data, needle_data, threshold_data, text_data def _get_model(self, doc, root=None, parent=None, comm=None): + from bokeh.plotting import figure properties = self._get_properties(doc) model = figure( x_range=(-1,1), y_range=(-1,1), tools=[], @@ -953,6 +953,8 @@ def _get_data(self, properties): ) def _get_model(self, doc, root=None, parent=None, comm=None): + from bokeh.plotting import figure + params = self._get_properties(doc) model = figure( outline_line_color=None, toolbar_location=None, tools=[], From d49dafc94b2512c3c2be515a5d0545e00b414c32 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 2 Sep 2024 14:56:01 +0200 Subject: [PATCH 030/164] Make --dev an alias for --autoreload (#7224) * Make --dev an alias for --autoreload * Rename standard optional dependency specifier to dev * Update docs --- doc/explanation/develop_seamlessly.md | 4 ++-- doc/getting_started/build_app.md | 4 ++-- doc/getting_started/core_concepts.md | 6 +++--- doc/getting_started/installation.md | 2 +- doc/how_to/server/commandline.md | 8 ++++---- doc/how_to/streamlit_migration/get_started.md | 2 +- doc/how_to/wasm/convert.md | 2 +- doc/tutorials/basic/build_animation.md | 6 +++--- doc/tutorials/basic/build_chatbot.md | 2 +- .../basic/build_crossfilter_dashboard.md | 2 +- doc/tutorials/basic/build_image_classifier.md | 2 +- .../basic/build_monitoring_dashboard.md | 4 ++-- .../basic/build_streaming_dashboard.md | 2 +- doc/tutorials/basic/build_todo.md | 2 +- doc/tutorials/basic/caching.md | 10 +++++----- doc/tutorials/basic/design.md | 2 +- doc/tutorials/basic/develop_editor.md | 6 +++--- doc/tutorials/basic/indicators_activity.md | 2 +- doc/tutorials/basic/indicators_performance.md | 4 ++-- doc/tutorials/basic/layouts.md | 2 +- doc/tutorials/basic/panes.md | 4 ++-- doc/tutorials/basic/pn_panel.md | 4 ++-- doc/tutorials/basic/progressive_layouts.md | 2 +- doc/tutorials/basic/serve.md | 6 +++--- doc/tutorials/basic/size.md | 2 +- doc/tutorials/basic/style.md | 2 +- doc/tutorials/basic/templates.md | 4 ++-- doc/tutorials/basic/widgets.md | 2 +- doc/tutorials/expert/custom_components.md | 2 +- doc/tutorials/intermediate/develop_editor.md | 6 +++--- doc/tutorials/intermediate/serve.md | 4 ++-- .../intermediate/structure_data_store.md | 2 +- panel/command/serve.py | 17 +++++++++++++---- pyproject.toml | 2 +- 34 files changed, 71 insertions(+), 62 deletions(-) diff --git a/doc/explanation/develop_seamlessly.md b/doc/explanation/develop_seamlessly.md index 0f4e622d5b..f29d1723a6 100644 --- a/doc/explanation/develop_seamlessly.md +++ b/doc/explanation/develop_seamlessly.md @@ -146,10 +146,10 @@ template.servable() From the terminal run the following command. ```bash -panel serve app.py --show --autoreload +panel serve app.py --show --dev ``` -The `--show` flag will open a browser tab with the live app and the `--autoreload` flag ensures that the app reloads whenever you make a change to the Python source. `--autoreload` is key to your developer experience, you will see the app being updated live when you save your app file! In the image below the windows have been re-arranged the way web developers like, on one side the code and on the other side a live view of the app, just like the *Preview* functionality in Jupyterlab. +The `--show` flag will open a browser tab with the live app and the `--dev` flag ensures that the app reloads whenever you make a change to the Python source. `--dev` is key to your developer experience, you will see the app being updated live when you save your app file! In the image below the windows have been re-arranged the way web developers like, on one side the code and on the other side a live view of the app, just like the *Preview* functionality in Jupyterlab. ![VSCode Preview](../_static/images/vscode_preview.png) diff --git a/doc/getting_started/build_app.md b/doc/getting_started/build_app.md index 91cf34b6d9..8932f230ae 100644 --- a/doc/getting_started/build_app.md +++ b/doc/getting_started/build_app.md @@ -124,7 +124,7 @@ Save the notebook with the name `app.ipynb`. Finally, we'll serve the app with: ```bash -panel serve app.ipynb --autoreload +panel serve app.ipynb --dev ``` Now, open the app in your browser at [http://localhost:5006/app](http://localhost:5006/app). @@ -138,7 +138,7 @@ It should look like this: If you prefer developing in a Python Script using an editor, you can copy the code into a file `app.py` and serve it. ```bash -panel serve app.py --autoreload +panel serve app.py --dev ``` ::: diff --git a/doc/getting_started/core_concepts.md b/doc/getting_started/core_concepts.md index 13a73196d4..b2f4d9ec63 100644 --- a/doc/getting_started/core_concepts.md +++ b/doc/getting_started/core_concepts.md @@ -37,7 +37,7 @@ By placing a Panel component at the end of a notebook cell, it renders as part o To add Panel components to your app, mark them as `.servable()` and serve the app with: ```bash -panel serve app.ipynb --autoreload +panel serve app.ipynb --dev ``` You've already experimented with this while [building a simple app](build_app.md). @@ -47,10 +47,10 @@ You've already experimented with this while [building a simple app](build_app.md If you're working in an editor, declare the Panel components you want to display as `.servable()`, then serve the script with: ```bash -panel serve app.py --autoreload --show +panel serve app.py --dev --show ``` -Upon running that command, Panel launches a server that serves your app, opens a tab in your default browser (`--show`), and updates the application whenever you modify the code (`--autoreload`). +Upon running that command, Panel launches a server that serves your app, opens a tab in your default browser (`--show`), and updates the application whenever you modify the code (`--dev`). diff --git a/doc/getting_started/installation.md b/doc/getting_started/installation.md index 23706457b9..8a0cee4118 100644 --- a/doc/getting_started/installation.md +++ b/doc/getting_started/installation.md @@ -62,7 +62,7 @@ conda install panel watchfiles ::::: :::{tip} -We recommend also installing [`watchfiles`](https://watchfiles.helpmanual.io) while developing. This will provide a significantly better experience when using Panel's `--autoreload` feature. It's not needed for production. +We recommend also installing [`watchfiles`](https://watchfiles.helpmanual.io) while developing. This will provide a significantly better experience when using Panel's autoreload features when activating `--dev` mode. It's not needed for production. ::: :::{tip} diff --git a/doc/how_to/server/commandline.md b/doc/how_to/server/commandline.md index 10b2baa7bc..cd0eb5fa2b 100644 --- a/doc/how_to/server/commandline.md +++ b/doc/how_to/server/commandline.md @@ -12,13 +12,13 @@ or even serve a number of apps at once: panel serve apps/*.py -For development it can be particularly helpful to use the ``--autoreload`` option to `panel serve` as that will automatically reload the page whenever the application code or any of its imports change. +For development it can be particularly helpful to use the `--dev` option to `panel serve` as that will automatically reload the page whenever the application code or any of its imports change. ```{note} -We recommend installing `watchfiles`, which will provide a significantly better user experience when using `--autoreload`. +We recommend installing `watchfiles`, which will provide a significantly better user experience when using `--dev`. ``` -The ``panel serve`` command has the following options: +The `panel serve` command has the following options: ```bash positional arguments: @@ -142,6 +142,6 @@ options: Whether to add a global loading spinner to the application(s). ``` -To turn a notebook into a deployable app simply append ``.servable()`` to one or more Panel objects, which will add the app to Bokeh's ``curdoc``, ensuring it can be discovered by Bokeh server on deployment. In this way it is trivial to build dashboards that can be used interactively in a notebook and then seamlessly deployed on Bokeh server. +To turn a notebook into a deployable app simply append `.servable()` to one or more Panel objects, which will add the app to Bokeh's `curdoc`, ensuring it can be discovered by Bokeh server on deployment. In this way it is trivial to build dashboards that can be used interactively in a notebook and then seamlessly deployed on Bokeh server. When called on a notebook, `panel serve` first converts it to a python script using [`nbconvert.PythonExporter()`](https://nbconvert.readthedocs.io/en/stable/api/exporters.html), albeit with [IPython magics](https://ipython.readthedocs.io/en/stable/interactive/magics.html) stripped out. This means that non-code cells, such as raw cells, are entirely handled by `nbconvert` and [may modify the served app](https://nbsphinx.readthedocs.io/en/latest/raw-cells.html). diff --git a/doc/how_to/streamlit_migration/get_started.md b/doc/how_to/streamlit_migration/get_started.md index 8ba76650a3..89762a0ce3 100644 --- a/doc/how_to/streamlit_migration/get_started.md +++ b/doc/how_to/streamlit_migration/get_started.md @@ -54,7 +54,7 @@ pn.panel("Hello World").servable() You *serve* and *show* (i.e. open) the app in your browser with *autoreload* via ```bash -panel serve app.py --autoreload --show +panel serve app.py --dev --show ``` ![Panel Hello World Example](../../_static/images/panel_hello_world.png) diff --git a/doc/how_to/wasm/convert.md b/doc/how_to/wasm/convert.md index 83bf87a117..afa7f6a55d 100644 --- a/doc/how_to/wasm/convert.md +++ b/doc/how_to/wasm/convert.md @@ -79,7 +79,7 @@ You can now add the `script.html` (and `script.js` file if you used the `pyodide ## Tips & Tricks for development -- While developing you should run the script locally with *auto reload*: `panel serve script.py --autoreload`. +- While developing you should run the script locally in dev mode (enabling autoreload): `panel serve script.py --dev`. - You can also watch your script for changes and rebuild it if you make an edit with `panel convert ... --watch` - If the converted app does not work as expected, you can most often find the errors in the browser console. [This guide](https://balsamiq.com/support/faqs/browserconsole/) describes how to open the diff --git a/doc/tutorials/basic/build_animation.md b/doc/tutorials/basic/build_animation.md index d3e9d1d5ec..0f47580f67 100644 --- a/doc/tutorials/basic/build_animation.md +++ b/doc/tutorials/basic/build_animation.md @@ -146,7 +146,7 @@ VALUE = "p_cap" @pn.cache() def get_data(): - return pd.read_csv("https://assets.holoviz.org/panel/tutorials/turbines.csv.gz") + return pd.read_csv("https://datasets.holoviz.org/windturbines/v1/windturbines.csv.gz") data = get_data() min_year = int(data.p_year.min()) @@ -257,7 +257,7 @@ Now serve the app with: :sync: script ```bash -panel serve app.py --autoreload +panel serve app.py --dev ``` ::: @@ -266,7 +266,7 @@ panel serve app.py --autoreload :sync: notebook ```bash -panel serve app.ipynb --autoreload +panel serve app.ipynb --dev ``` ::: diff --git a/doc/tutorials/basic/build_chatbot.md b/doc/tutorials/basic/build_chatbot.md index a126f55fa6..82461d0208 100644 --- a/doc/tutorials/basic/build_chatbot.md +++ b/doc/tutorials/basic/build_chatbot.md @@ -3,7 +3,7 @@ In this tutorial, we will build a streaming *chat bot*. We will first use the *high-level* [`ChatInterface`](../../reference/chat/ChatInterface.md) to build a basic chat bot. Then we will add streaming. :::{note} -When we ask to *run the code* in the sections below, we may either execute the code directly in the Panel docs via the green *run* button, in a cell in a notebook, or in a file `app.py` that is served with `panel serve app.py --autoreload`. +When we ask to *run the code* in the sections below, we may either execute the code directly in the Panel docs via the green *run* button, in a cell in a notebook, or in a file `app.py` that is served with `panel serve app.py --dev`. ::: ## Build a Basic Chat Bot diff --git a/doc/tutorials/basic/build_crossfilter_dashboard.md b/doc/tutorials/basic/build_crossfilter_dashboard.md index 915bb0e4fe..dd6a6d17d4 100644 --- a/doc/tutorials/basic/build_crossfilter_dashboard.md +++ b/doc/tutorials/basic/build_crossfilter_dashboard.md @@ -234,7 +234,7 @@ pn.template.FastListTemplate( The [`FastListTemplate`](https://panel.holoviz.org/reference/templates/FastListTemplate.html) is a pre-built Panel template that provides a clean and modern layout for our dashboard. It takes our crossfiltering plot and other configurations as input, creating a cohesive and interactive web application. -Now serve the app with `panel serve app.py --autoreload`. It should look like +Now serve the app with `panel serve app.py --dev`. It should look like