diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 09b47c4e93..91753fa7d6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -55,6 +55,8 @@ jobs: name: conda path: dist/*tar.bz2 if-no-files-found: error + - name: Verify size + run: python scripts/verify_build_size.py conda conda_publish: name: Publish Conda @@ -106,6 +108,10 @@ jobs: name: pip path: dist/ if-no-files-found: error + - name: Verify sizes + run: | + python scripts/verify_build_size.py sdist + python scripts/verify_build_size.py whl pip_install: name: Install PyPI @@ -165,6 +171,8 @@ jobs: name: npm if-no-files-found: error path: ./${{ env.PACKAGE }}/${{ env.TARBALL }} + - name: Verify size + run: python scripts/verify_build_size.py npm npm_publish: name: Publish NPM diff --git a/.github/workflows/gallery.yaml b/.github/workflows/gallery.yaml index cc74ad3acd..761cc05733 100644 --- a/.github/workflows/gallery.yaml +++ b/.github/workflows/gallery.yaml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - shell: bash -e {0} + shell: bash -el {0} steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32e84421e8..6d1ae3d01c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: exclude: \.min\.js$ - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.0 hooks: - id: ruff files: panel/ @@ -47,7 +47,7 @@ repos: - id: oxipng stages: [manual] - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.12.0 + rev: v9.13.0 hooks: - id: eslint args: ['-c', 'panel/.eslintrc.js', 'panel/*.ts', 'panel/models/**/*.ts', '--fix'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b25aac1b1..1ecdb0b7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Releases +## Version 1.5.4 + +This release primarily focuses on improving the ESM components including fixes for serialization of parameter values, improvements for compiling bundles, and building custom layouts. Additionally this release includes the new `DatetimeSlider`, adds a copy button to codeblocks in `Markdown` panes, improves responsive sizing for Plotly and starts adding better support for Polars. Many thanks and a warm welcome to our new contributor @MP-MaximilianLattka as well as our maintainer team, including @Hoxbro, @thuydotm, @ahuang11, @MarcSkovMadsen and @philippjfr. + +### Enhancements + +- Add `DatetimeSlider` widget ([#7374](https://github.com/holoviz/panel/pull/7374)) +- Improve Jupyter preview error handling ([#7434](https://github.com/holoviz/panel/pull/7434)) +- Add copy button to `Markdown` codeblocks ([#7451](https://github.com/holoviz/panel/pull/7451)) +- Various improvements for writing ESM components ([#7462](https://github.com/holoviz/panel/pull/7462)) +- Log authorization callback errors ([#7463](https://github.com/holoviz/panel/pull/7463)) +- Support polars in `pn.cache` ([#7472](https://github.com/holoviz/panel/pull/7472)) +- Improve and document `hold` utility ([#7474](https://github.com/holoviz/panel/pull/7474)) +- Improve how `panel compile` collects bundles ([#7477](https://github.com/holoviz/panel/pull/7477)) + +### Bug fixes + +- Fix issues detecting changed property values during serialization ([#7432](https://github.com/holoviz/panel/pull/7432)) +- Ensure ESM compilation correctly detects file extension ([#7446](https://github.com/holoviz/panel/pull/7446)) +- Ensure parameter overrides are applied to ESM components ([#7452](https://github.com/holoviz/panel/pull/7452)) +- Ensure component `Children` parameter correctly resolves when multiple types are defined ([#7454](https://github.com/holoviz/panel/pull/7454)) +- Fix issues using Jupyter Preview with notifications enabled ([#7466](https://github.com/holoviz/panel/pull/7466)) +- Ensure `HTML`/`Markdown` streaming does not freeze during rapid updates ([#7480](https://github.com/holoviz/panel/pull/7480)) +- Ensure `Plotly` sizes correctly on initial render ([#7483](https://github.com/holoviz/panel/pull/7483)) + ## Version 1.5.3 This release fixes a number of smaller regressions related to `Tabulator` `row_content`, ensures `--dev`/`--autoreload` picks up on external modules correctly and resolves OAuth guest endpoints correctly. Additionally it introduces some enhancements and bug fixes for custom components, such as adding support for loading custom components ESM Javascript bundles from the inbuilt endpoint ensuring that the bundle can be cached by the browser. Many thanks and welcome to our new contributors @chryshumble and @haojungc, our returning contributors @TheoMathurin, @aktech and @Coderambling and the core maintainer team @Hoxbro, @ahuang11, @MarcSkovMadsen and @philippjfr for their contributions to this release. @@ -20,6 +45,8 @@ This release fixes a number of smaller regressions related to `Tabulator` `row_c - Prevent `Tabulator` from overlapping when `max_height` is set ([#7403](https://github.com/holoviz/panel/pull/7403)) - Do not mutate layout `Children` inplace ([#7417](https://github.com/holoviz/panel/pull/7403)) - Set `Tabulator` null formatter to empty string ([#7421](https://github.com/holoviz/panel/pull/7421)) +- Ensure Tabulator table content does not overflow ([#7425](https://github.com/holoviz/panel/pull/7425)) +- Ensure `cache` handles hashing of classes and instances correctly ([#7478](https://github.com/holoviz/panel/issues/7478)) ### Compatibility diff --git a/doc/about/releases.md b/doc/about/releases.md index 1972d30591..d1bf5601ac 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -2,6 +2,31 @@ See [the HoloViz blog](https://blog.holoviz.org/#category=panel) for a visual summary of the major features added in each release. +## Version 1.5.4 + +This release primarily focuses on improving the ESM components including fixes for serialization of parameter values, improvements for compiling bundles, and building custom layouts. Additionally this release includes the new `DatetimeSlider`, adds a copy button to codeblocks in `Markdown` panes, improves responsive sizing for Plotly and starts adding better support for Polars. Many thanks and a warm welcome to our new contributor @MP-MaximilianLattka as well as our maintainer team, including @Hoxbro, @thuydotm, @ahuang11, @MarcSkovMadsen and @philippjfr. + +### Enhancements + +- Add `DatetimeSlider` widget ([#7374](https://github.com/holoviz/panel/pull/7374)) +- Improve Jupyter preview error handling ([#7434](https://github.com/holoviz/panel/pull/7434)) +- Add copy button to `Markdown` codeblocks ([#7451](https://github.com/holoviz/panel/pull/7451)) +- Various improvements for writing ESM components ([#7462](https://github.com/holoviz/panel/pull/7462)) +- Log authorization callback errors ([#7463](https://github.com/holoviz/panel/pull/7463)) +- Support polars in `pn.cache` ([#7472](https://github.com/holoviz/panel/pull/7472)) +- Improve and document `hold` utility ([#7474](https://github.com/holoviz/panel/pull/7474)) +- Improve how `panel compile` collects bundles ([#7477](https://github.com/holoviz/panel/pull/7477)) + +### Bug fixes + +- Fix issues detecting changed property values during serialization ([#7432](https://github.com/holoviz/panel/pull/7432)) +- Ensure ESM compilation correctly detects file extension ([#7446](https://github.com/holoviz/panel/pull/7446)) +- Ensure parameter overrides are applied to ESM components ([#7452](https://github.com/holoviz/panel/pull/7452)) +- Ensure component `Children` parameter correctly resolves when multiple types are defined ([#7454](https://github.com/holoviz/panel/pull/7454)) +- Fix issues using Jupyter Preview with notifications enabled ([#7466](https://github.com/holoviz/panel/pull/7466)) +- Ensure `HTML`/`Markdown` streaming does not freeze during rapid updates ([#7480](https://github.com/holoviz/panel/pull/7480)) +- Ensure `Plotly` sizes correctly on initial render ([#7483](https://github.com/holoviz/panel/pull/7483)) + ## Version 1.5.3 This release fixes a number of smaller regressions related to `Tabulator` `row_content`, ensures `--dev`/`--autoreload` picks up on external modules correctly and resolves OAuth guest endpoints correctly. Additionally it introduces some enhancements and bug fixes for custom components, such as adding support for loading custom components ESM Javascript bundles from the inbuilt endpoint ensuring that the bundle can be cached by the browser. Many thanks and welcome to our new contributors @chryshumble and @haojungc, our returning contributors @TheoMathurin, @aktech and @Coderambling and the core maintainer team @Hoxbro, @ahuang11, @MarcSkovMadsen and @philippjfr for their contributions to this release. @@ -22,6 +47,8 @@ This release fixes a number of smaller regressions related to `Tabulator` `row_c - Prevent `Tabulator` from overlapping when `max_height` is set ([#7403](https://github.com/holoviz/panel/pull/7403)) - Do not mutate layout `Children` inplace ([#7417](https://github.com/holoviz/panel/pull/7403)) - Set `Tabulator` null formatter to empty string ([#7421](https://github.com/holoviz/panel/pull/7421)) +- Ensure Tabulator table content does not overflow ([#7425](https://github.com/holoviz/panel/pull/7425), [#7431](https://github.com/holoviz/panel/pull/7431)) +- Ensure `cache` handles hashing of classes and instances correctly ([#7478](https://github.com/holoviz/panel/issues/7478)) ### Compatibility diff --git a/doc/how_to/custom_components/esm/build.md b/doc/how_to/custom_components/esm/build.md index c2f888c150..d951ee6b49 100644 --- a/doc/how_to/custom_components/esm/build.md +++ b/doc/how_to/custom_components/esm/build.md @@ -184,7 +184,7 @@ 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. +`panel compile` accepts file paths, e.g. `my_components/custom.py`, or dotted module names, 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: @@ -216,7 +216,11 @@ esbuild output: ⚡ 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 `--dev` option to ignore the compiled file and automatically reload the development version when it changes. +If the supplied module or package contains multiple components they will all be bundled together by default. If instead you want to generate bundles for each file explicitly you must list them with the `:` syntax, e.g. `panel compile package.module:Component1,Component2`. You may also provide a glob pattern to request multiple components to be built individually without listing them all out, e.g. `panel compile "package.module:Component*"`. + +During runtime the compiled bundles will be resolved automatically, where bundles compiled for a specific component (i.e. `.bundle.js`) take highest precedence and we then search for module bundles up to the root package, e.g. for a component that lives in `package.module` we first search for `package.module.bundle.js` in the same directory as the component and then recursively search in parent directories until we reach the root of the package. + +If you rename the component or modify its code or `_importmap`, you must recompile the component. For ongoing development, consider using the `--dev` option to ignore the compiled file and automatically reload the development version when it changes. #### Compilation Steps diff --git a/doc/how_to/custom_components/examples/table_viewer.md b/doc/how_to/custom_components/examples/table_viewer.md index ddc43b3c07..35c5d27754 100644 --- a/doc/how_to/custom_components/examples/table_viewer.md +++ b/doc/how_to/custom_components/examples/table_viewer.md @@ -17,7 +17,7 @@ This example demonstrates Panel's reactive programming paradigm using the Param ```{pyodide} class ReactiveTables(Viewer): - dataset = param.ObjectSelector(default='iris', objects=['iris', 'autompg', 'population']) + dataset = param.Selector(default='iris', objects=['iris', 'autompg', 'population']) rows = param.Integer(default=10, bounds=(0, 19)) diff --git a/doc/how_to/deployment/pycafe.md b/doc/how_to/deployment/pycafe.md index c42999723e..f6bdd58649 100644 --- a/doc/how_to/deployment/pycafe.md +++ b/doc/how_to/deployment/pycafe.md @@ -1,12 +1,12 @@ -# PY.CAFE Guide +# PyCafe Guide -This guide demonstrates how to deploy a Panel app on [PY.CAFE](https://py.cafe/). +This guide demonstrates how to deploy a Panel app on [py.cafe](https://py.cafe/). -PY.CAFE is a platform for creating and sharing data apps online, powered by [Pyodide](https://pyodide.org/). It offers a free tier for users. +PyCafe is a platform for creating and sharing data apps online, powered by [Pyodide](https://pyodide.org/). It offers a free tier for users. You can find the official PyCafe-Panel guide [here](https://py.cafe/docs/apps/panel). ## 1. Log In -Visit [PY.CAFE](https://py.cafe/) and either sign in or sign up for an account if you want to save your projects to your personal gallery. +Visit [py.cafe](https://py.cafe/) and either sign in or sign up for an account if you want to save your projects to your personal gallery. ## 2. Choose the Panel Framework @@ -89,3 +89,7 @@ Explore the [`panel-org`](https://py.cafe/panel-org) gallery examples below: - [Penguin Crossfilter](https://py.cafe/panel-org/penguin-crossfilter) from the [Penguin Crossfilter Tutorial](../../gallery/penguin_crossfilter) - [Portfolio Analyzer](https://py.cafe/panel-org/portfolio-analyzer) from the [Portfolio Analyzer Tutorial](../../gallery/portfolio_analyzer). - [VideoStream Interface](https://py.cafe/panel-org/videostream) from the [VideoStream Interface Tutorial](../../gallery/streaming_videostream). + +---- + +For more examples check out [awesome-panel.org](https://py.cafe/awesome.panel.org). diff --git a/doc/how_to/param/dependencies.md b/doc/how_to/param/dependencies.md index e5e2da966d..c46b4a1d79 100644 --- a/doc/how_to/param/dependencies.md +++ b/doc/how_to/param/dependencies.md @@ -55,9 +55,9 @@ We also define a ``view`` method that returns an HTML iframe displaying the coun ```{pyodide} class GoogleMapViewer(param.Parameterized): - continent = param.ObjectSelector(default='Asia', objects=['Africa', 'Asia', 'Europe']) + continent = param.Selector(default='Asia', objects=['Africa', 'Asia', 'Europe']) - country = param.ObjectSelector(default='China', objects=['China', 'Thailand', 'Japan']) + country = param.Selector(default='China', objects=['China', 'Thailand', 'Japan']) _countries = {'Africa': ['Ghana', 'Togo', 'South Africa', 'Tanzania'], 'Asia' : ['China', 'Thailand', 'Japan'], diff --git a/doc/how_to/param/examples/subobjects.md b/doc/how_to/param/examples/subobjects.md index 3ba4f0c983..e96faf1f73 100644 --- a/doc/how_to/param/examples/subobjects.md +++ b/doc/how_to/param/examples/subobjects.md @@ -82,7 +82,7 @@ Having defined our basic domain model (of shapes in this case), we can now make ```{pyodide} class ShapeViewer(param.Parameterized): - shape = param.ObjectSelector(default=shapes[0], objects=shapes) + shape = param.Selector(default=shapes[0], objects=shapes) @param.depends('shape', 'shape.param') def view(self): diff --git a/doc/how_to/param/subobjects.md b/doc/how_to/param/subobjects.md index 47b6f06afb..e98ffdcf80 100644 --- a/doc/how_to/param/subobjects.md +++ b/doc/how_to/param/subobjects.md @@ -69,7 +69,7 @@ shapes = [NGon(), Circle()] class ShapeViewer(param.Parameterized): - shape = param.ObjectSelector(default=shapes[0], objects=shapes) + shape = param.Selector(default=shapes[0], objects=shapes) @param.depends('shape') def view(self): diff --git a/doc/how_to/param/uis.md b/doc/how_to/param/uis.md index 676094e8ad..05f0b37f21 100644 --- a/doc/how_to/param/uis.md +++ b/doc/how_to/param/uis.md @@ -81,8 +81,8 @@ class Example(BaseClass): date = param.Date(default=dt.datetime(2017, 1, 1), bounds=(dt.datetime(2017, 1, 1), dt.datetime(2017, 2, 1))) dataframe = param.DataFrame(default=pd.DataFrame({'A': [1, 2, 3]})) - select_string = param.ObjectSelector(default="yellow", objects=["red", "yellow", "green"]) - select_fn = param.ObjectSelector(default=list,objects=[list, set, dict]) + select_string = param.Selector(default="yellow", objects=["red", "yellow", "green"]) + select_fn = param.Selector(default=list,objects=[list, set, dict]) int_list = param.ListSelector(default=[3, 5], objects=[1, 3, 5, 7, 9], precedence=0.5) single_file = param.FileSelector(path='../../*/*.py*', precedence=0.5) multiple_files = param.MultiFileSelector(path='../../*/*.py?', precedence=0.5) diff --git a/doc/how_to/performance/hold.md b/doc/how_to/performance/hold.md new file mode 100644 index 0000000000..81d55ed400 --- /dev/null +++ b/doc/how_to/performance/hold.md @@ -0,0 +1,72 @@ +# Batching Updates with `hold` + +When working with interactive dashboards and applications in Panel, you might encounter situations where updating multiple components simultaneously causes unnecessary re-renders. This is because Panel generally dispatches any change to a parameter immediately. This can lead to performance issues and a less responsive user experience because each individual update may trigger re-renders on the frontend. The `hold` utility in Panel allows you to batch updates to the frontend, reducing the number of re-renders and improving performance. + +In this guide, we'll explore how to use `hold` both as a context manager and as a decorator to optimize your Panel applications. + +## What is `hold`? + +The `hold` function is a context manager and decorator that temporarily holds events on a Bokeh Document. When you update multiple components within a `hold` block, the events are collected and dispatched all at once when the block exits. This means that the frontend will only re-render once, regardless of how many updates were made, leading to a smoother and more efficient user experience. + +Let’s try first **without `hold`** to understand the difference `hold` can make: + +```{pyodide} +import panel as pn +from panel.io import hold + +def increment(e): + for obj in column_0: + obj.object = str(e.new) + +column_0 = pn.FlexBox(*['0']*100) +button = pn.widgets.Button(name='Increment', on_click=increment) + +pn.Column(column_0, button).servable() +``` + +## Using `hold` + +### As a Decorator + +If you have a function that updates components and you want to ensure that all updates are held, you can use `hold` as a decorator. For example, here we update 100 components at once. If you do not use `hold`, each of these events is sent and applied in series, potentially resulting in visible updates. + +```{pyodide} +import panel as pn +from panel.io import hold + +@hold() +def increment(e): + for obj in column_1: + obj.object = str(e.new) + +column_1 = pn.FlexBox(*['0']*100) +button = pn.widgets.Button(name='Increment', on_click=increment) + +pn.Column(column_1, button).servable() +``` + +Applying the `hold` decorator means all the updates are sent in a single WebSocket message and applied on the frontend simultaneously. + +### As a Context Manager + +Alternatively, the `hold` function can be used as a context manager, potentially giving you finer-grained control over which events are batched and which are not: + +```{pyodide} +import time + +import panel as pn +from panel.io import hold + +def increment(e): + with button.param.update(name='Incrementing...', disabled=True): + with hold(): + for obj in column_2: + obj.object = str(e.new) + +column_2 = pn.FlexBox(*['0']*100) +button = pn.widgets.Button(name='Increment', on_click=increment) + +pn.Column(column_2, button).servable() +``` + +Here the updates to the `Button` are dispatched immediately, while the updates to the counters are batched. diff --git a/doc/how_to/performance/index.md b/doc/how_to/performance/index.md index 622c462ce5..fdbef1bb59 100644 --- a/doc/how_to/performance/index.md +++ b/doc/how_to/performance/index.md @@ -19,6 +19,13 @@ Discover how to reuse sessions to improve the start render time. Discover how to enable throttling to reduce the number of events being processed. ::: +:::{grid-item-card} {octicon}`tab;2.5em;sd-mr-1 sd-animate-grow50` Batching Updates with `hold` +:link: hold +:link-type: doc + +Discover how to improve performance by using the `hold` context manager and decorator to batch updates to multiple components. +::: + :::: ```{toctree} @@ -28,4 +35,5 @@ Discover how to enable throttling to reduce the number of events being processed reuse_sessions throttling +hold ``` diff --git a/doc/how_to/styling/altair.md b/doc/how_to/styling/altair.md index 54cfc56836..337559e724 100644 --- a/doc/how_to/styling/altair.md +++ b/doc/how_to/styling/altair.md @@ -21,7 +21,7 @@ from vega_datasets import data pn.extension("vega") def plot(theme, color): - alt.themes.enable(theme) + alt.theme.enable(theme) return ( alt.Chart(data.cars()) @@ -41,7 +41,7 @@ def plot(theme, color): .interactive() ) -themes = sorted(alt.themes.names()) +themes = sorted(alt.theme.names()) theme = pn.widgets.Select(value="dark", options=themes, name="Theme") color = pn.widgets.ColorPicker(value="#F08080", name="Color") @@ -53,5 +53,5 @@ pn.Column( ).servable() ``` -Please note that the line `alt.themes.enable(theme)` will set the theme of all future generated plots +Please note that the line `alt.theme.enable(theme)` will set the theme of all future generated plots unless you specifically change it before usage in a `Vega` pane. diff --git a/doc/tutorials/basic/templates.md b/doc/tutorials/basic/templates.md index a4e0b624bf..116abfe9d9 100644 --- a/doc/tutorials/basic/templates.md +++ b/doc/tutorials/basic/templates.md @@ -91,9 +91,9 @@ ACCENT = "teal" image = pn.pane.JPG("https://assets.holoviz.org/panel/tutorials/wind_turbines_sunset.png") if pn.config.theme=="dark": - alt.themes.enable("dark") + alt.theme.enable("dark") else: - alt.themes.enable("default") + alt.theme.enable("default") @pn.cache # Add caching to only download data once def get_data(): @@ -153,7 +153,7 @@ Upon toggling, the app should switch to dark mode: In the code: - `pn.config.theme` determines the selected theme ("default" or "dark"). -- `alt.themes.enable("dark")` applies the "dark" theme to the plot. Panel doesn't do this automatically. +- `alt.theme.enable("dark")` applies the "dark" theme to the plot. Panel doesn't do this automatically. - `accent` sets the primary or accent color for the template, allowing quick branding of the app. - `main_layout` specifies a layout to wrap each object in the main list. Choose from `"card"` (default) or `None`. diff --git a/examples/apps/django_multi_apps/requirements.txt b/examples/apps/django_multi_apps/requirements.txt index 4f6ab137f3..1c09dd55fb 100644 --- a/examples/apps/django_multi_apps/requirements.txt +++ b/examples/apps/django_multi_apps/requirements.txt @@ -39,7 +39,7 @@ PyYAML==5.4 service-identity==18.1.0 six==1.13.0 sqlparse==0.5.0 -tornado==6.4.1 +tornado==6.4.2 Twisted==24.7.0 txaio==20.4.1 zope.interface==4.7.1 diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index bf117aeb7c..fbc8bd0ab7 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -701,7 +701,7 @@ "source": [ "with chat_feed.add_step(title=\"Execute the plan\", status=\"running\") as step:\n", " step.stream(\"\\n\\n...Executing plan...\")\n", - " await asyncio.sleep(1)\n", + " time.sleep(1)\n", " step.stream(\"\\n\\n...Handing over to SQL Agent\")" ] }, @@ -720,7 +720,7 @@ "source": [ "with chat_feed.add_step(title=\"Running SQL query\", user='SQL Agent') as step:\n", " step.stream('Querying...')\n", - " await asyncio.sleep(1)\n", + " time.sleep(1)\n", " step.stream('\\nSELECT * FROM TABLE')" ] }, diff --git a/examples/reference/panes/Vizzu.ipynb b/examples/reference/panes/Vizzu.ipynb index 204963d863..3d9678ca71 100644 --- a/examples/reference/panes/Vizzu.ipynb +++ b/examples/reference/panes/Vizzu.ipynb @@ -38,7 +38,7 @@ "* **`patch`**: Patches one or more rows in the data.\n", "___\n", "\n", - "The `Vizzu` pane is built on **version {{VIZZU_VERSION}}** of the [Vizzu Javascript](https://lib.vizzuhq.com/latest/) library." + "The `Vizzu` pane is built on **version {{VIZZU_VERSION}}** of the [Vizzu Javascript](https://lib.vizzuhq.com/{{VIZZU_VERSION}}/) library." ] }, { diff --git a/examples/reference/widgets/DatetimeSlider.ipynb b/examples/reference/widgets/DatetimeSlider.ipynb new file mode 100644 index 0000000000..1be9d8de08 --- /dev/null +++ b/examples/reference/widgets/DatetimeSlider.ipynb @@ -0,0 +1,102 @@ +{ + "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 ``DatetimeSlider`` widget allows selecting a datetime value within a set bounds using a slider.\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.md).\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", + "* **``start``** (date or datetime): The range's lower bound\n", + "* **``end``** (date or datetime): The range's upper bound\n", + "* **``value``** (date or datetime): The selected value as a datetime type\n", + "* **``value_throttled``** (datetime): The selected value as a datetime type throttled until mouseup\n", + "* **``step``** (number): The selected step of the slider in seconds, default is 1 minutes, i.e 60 seconds\n", + "\n", + "##### Display\n", + "\n", + "* **``bar_color``** (color): Color of the slider bar as a hexadecimal RGB value\n", + "* **``direction``** (str): Whether the slider should go from left to right ('ltr') or right to left ('rtl')\n", + "* **``disabled``** (boolean): Whether the widget is editable\n", + "* **``name``** (str): The title of the widget\n", + "* **``orientation``** (str): Whether the slider should be displayed in a 'horizontal' or 'vertical' orientation.\n", + "* **``tooltips``** (boolean): Whether to display tooltips on the slider handle\n", + "* **``format``** (string): The datetime's format\n", + "\n", + "___" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "datetime_slider = pn.widgets.DatetimeSlider(name='Datetime Slider', start=dt.datetime(2019, 1, 1), end=dt.datetime(2019, 6, 1), value=dt.datetime(2019, 2, 8, 15, 40, 30))\n", + "\n", + "datetime_slider" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "``DatetimeSlider.value`` returns a datetime type that can be read out or set like other widgets:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "datetime_slider.value" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Controls\n", + "\n", + "The `DatetimeSlider` widget exposes a number of options which can be changed from both Python and Javascript. Try out the effect of these parameters interactively:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.Row(datetime_slider.controls(jslink=True), datetime_slider)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/reference/widgets/FileDownload.ipynb b/examples/reference/widgets/FileDownload.ipynb index 6f4fd1dc58..f59dcebe48 100644 --- a/examples/reference/widgets/FileDownload.ipynb +++ b/examples/reference/widgets/FileDownload.ipynb @@ -26,7 +26,7 @@ "\n", "##### Core\n", "\n", - "* **`auto`** (boolean): Whether to download the file the initial click (if `True`) or when clicking a second time (or via the right-click Save file menu).\n", + "* **`auto`** (boolean): Whether to download the file with the first click (if `True`) or only after clicking a second time (if `False`, enables right-click -> Save as).\n", "* **`callback`** (callable): A callable that returns a file or file-like object (takes precedence over `file` if set). \n", "* **`embed`** (boolean): Whether to embed the data on initialization.\n", "* **`file`** (str, Path or file-like object): A path to a file or a file-like object.\n", diff --git a/panel/.eslintrc.js b/panel/.eslintrc.js index abebdc905b..40f4c7ddce 100644 --- a/panel/.eslintrc.js +++ b/panel/.eslintrc.js @@ -85,7 +85,6 @@ module.exports = { "no-floating-decimal": ["error"], "no-multiple-empty-lines": ["error", {"max": 1, "maxBOF": 0, "maxEOF": 0}], "no-new-wrappers": "error", - "no-template-curly-in-string": "error", "no-throw-literal": "error", "no-trailing-spaces": ["error"], "no-var": "error", @@ -116,7 +115,7 @@ module.exports = { "overrides": {}, }], "guard-for-in": ["warn"], - "quotes": ["error", "double", {"avoidEscape": true, "allowTemplateLiterals": false}], + "quotes": ["error", "double", {"avoidEscape": true, "allowTemplateLiterals": true}], "curly": ["error", "all"], "prefer-template": ["error"], "generator-star-spacing": ["error", { diff --git a/panel/chat/feed.py b/panel/chat/feed.py index a0924f46e7..da77313ef4 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -99,7 +99,7 @@ class ChatFeed(ListPanel): the previous message value `contents`, the previous `user` name, and the component `instance`.""") - callback_exception = param.ObjectSelector( + callback_exception = param.Selector( default="summary", objects=["raise", "summary", "verbose", "ignore"], doc=""" How to handle exceptions raised by the callback. If "raise", the exception will be raised. @@ -199,7 +199,7 @@ class ChatFeed(ListPanel): The placeholder wrapped in a ChatMessage object; primarily to prevent recursion error in _update_placeholder.""") - _callback_state = param.ObjectSelector(objects=list(CallbackState), doc=""" + _callback_state = param.Selector(objects=list(CallbackState), doc=""" The current state of the callback.""") _prompt_trigger = param.Event(doc="Triggers the prompt input.") diff --git a/panel/chat/input.py b/panel/chat/input.py index 78f995234b..602941613c 100644 --- a/panel/chat/input.py +++ b/panel/chat/input.py @@ -63,7 +63,7 @@ class ChatAreaInput(_PnTextAreaInput): of rows the input area can grow.""", ) - resizable = param.ObjectSelector( + resizable = param.Selector( default="height", objects=["both", "width", "height", False], doc=""" diff --git a/panel/chat/message.py b/panel/chat/message.py index af67993355..b69043cd16 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -698,3 +698,6 @@ def serialize( prefix_with_viewable_label=prefix_with_viewable_label, prefix_with_container_label=prefix_with_container_label, ) + + def __repr__(self, depth: int = 0) -> str: + return f"ChatMessage(object={self.object!r}, user={self.user!r}, reactions={self.reactions!r})" diff --git a/panel/chat/step.py b/panel/chat/step.py index a7384f13c2..6b93ae4f6a 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -43,7 +43,7 @@ class ChatStep(Card): collapsed_on_success = param.Boolean(default=True, doc=""" Whether to collapse the card on completion.""") - context_exception = param.ObjectSelector( + context_exception = param.Selector( default="raise", objects=["raise", "summary", "verbose", "ignore"], doc=""" How to handle exceptions raised upon exiting the context manager. If "raise", the exception will be raised. diff --git a/panel/chat/utils.py b/panel/chat/utils.py index 773d395f72..32fce1bffa 100644 --- a/panel/chat/utils.py +++ b/panel/chat/utils.py @@ -127,7 +127,7 @@ def get_obj_label(obj): Get the label for the object; defaults to specified object name; if unspecified, defaults to the type name. """ - label = obj.name + label = obj.name if hasattr(obj, "name") else "" type_name = type(obj).__name__ # If the name is just type + ID, simply use type # e.g. Column10241 -> Column diff --git a/panel/command/compile.py b/panel/command/compile.py index 776a555a2f..9b6421df83 100644 --- a/panel/command/compile.py +++ b/panel/command/compile.py @@ -1,12 +1,6 @@ -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 +from ..io.compile import RED, compile_components, find_module_bundles class Compile(Subcommand): @@ -42,52 +36,28 @@ class Compile(Subcommand): ) 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 + bundles = {} + for module in args.modules: 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}.' - ) + module_bundles = find_module_bundles(module) + except RuntimeError as e: + print(f'{RED} {e}') # noqa 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) + if not module_bundles: + print ( # noqa + f'{RED} Could not find any ESM components to compile ' + f'in {module}, ensure you provided the right module.' + ) + for bundle, components in module_bundles.items(): + if bundle in bundles: + bundles[bundle] += components else: - bundles[module_path / f'{component.__name__}.bundle.js'].append(component) + bundles[bundle] = components errors = 0 for bundle, components in bundles.items(): + component_names = '\n- '.join(c.name for c in components) + print(f"Building {bundle} containing the following components:\n\n- {component_names}\n") # noqa out = compile_components( components, build_dir=args.build_dir, diff --git a/panel/command/serve.py b/panel/command/serve.py index 35e9a6d16a..ae4fe49838 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -670,5 +670,10 @@ def invoke(self, args: argparse.Namespace): # Empty layout are valid and the Bokeh warning is silenced as usually # not relevant to Panel users. silence(EMPTY_LAYOUT, True) + # dask.distributed changes the logging level of Bokeh, we will overwrite it + # if the environment variable is not set to the default Bokeh level + # See https://github.com/holoviz/panel/issues/2302 + if "DASK_DISTRIBUTED__LOGGING__BOKEH" not in os.environ: + os.environ["DASK_DISTRIBUTED__LOGGING__BOKEH"] = "info" args.dev = None super().invoke(args) diff --git a/panel/compiler.py b/panel/compiler.py index 108b1b7095..d9152fc9e4 100644 --- a/panel/compiler.py +++ b/panel/compiler.py @@ -349,13 +349,14 @@ def bundle_icons(verbose=False, external=True, download_list=None): shutil.copyfile(icon, dest_dir / os.path.basename(icon)) def patch_tabulator(): - # https://github.com/olifolkerd/tabulator/issues/4421 path = BUNDLE_DIR / 'datatabulator' / 'tabulator-tables@6.3.0' / 'dist' / 'js' / 'tabulator.min.js' text = path.read_text() + # https://github.com/olifolkerd/tabulator/issues/4421 old = '"focus"!==this.options("editTriggerEvent")&&"click"!==this.options("editTriggerEvent")' new = '"click"!==this.options("editTriggerEvent")' assert text.count(old) == 1 text = text.replace(old, new) + # https://github.com/olifolkerd/tabulator/pull/4598 old = '(i=!0,this.subscribed("table-resize")?this.dispatch("table-resize"):this.redraw())' new = '(i=!0,this.redrawing||(this.redrawing=!0,this.subscribed("table-resize")?this.dispatch("table-resize"):this.redraw(),this.redrawing=!1))' assert text.count(old) == 1 diff --git a/panel/config.py b/panel/config.py index 2b7438cdfe..290a6d879b 100644 --- a/panel/config.py +++ b/panel/config.py @@ -24,7 +24,6 @@ from .__version import __version__ from .io.logging import panel_log_handler from .io.state import state -from .util import param_watchers _LOCAL_DEV_VERSION = ( any(v in __version__ for v in ('post', 'dirty')) @@ -203,12 +202,12 @@ class _config(_base_config): information about user sessions. A value of -1 indicates an unlimited history.""") - sizing_mode = param.ObjectSelector(default=None, objects=[ + sizing_mode = param.Selector(default=None, objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None], doc=""" Specify the default sizing mode behavior of panels.""") - template = param.ObjectSelector(default=None, doc=""" + template = param.Selector(default=None, doc=""" The default template to render served applications into.""") throttled = param.Boolean(default=False, doc=""" @@ -222,12 +221,12 @@ class _config(_base_config): default='DEBUG', objects=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], doc="Log level of the Admin Panel logger") - _comms = param.ObjectSelector( + _comms = param.Selector( default='default', objects=['default', 'ipywidgets', 'vscode', 'colab'], doc=""" Whether to render output in Jupyter with the default Jupyter extension or use the jupyter_bokeh ipywidget model.""") - _console_output = param.ObjectSelector(default='accumulate', allow_None=True, + _console_output = param.Selector(default='accumulate', allow_None=True, objects=['accumulate', 'replace', 'disable', False], doc=""" How to log errors and stdout output triggered by callbacks @@ -271,7 +270,7 @@ class _config(_base_config): or filepath containing JSON to use with the basic auth provider.""") - _oauth_provider = param.ObjectSelector( + _oauth_provider = param.Selector( default=None, allow_None=True, objects=[], doc=""" Select between a list of authentication providers.""") @@ -310,7 +309,7 @@ class _config(_base_config): Whether to inline JS and CSS resources. If disabled, resources are loaded from CDN if one is available.""") - _theme = param.ObjectSelector(default=None, objects=['default', 'dark'], allow_None=True, doc=""" + _theme = param.Selector(default=None, objects=['default', 'dark'], allow_None=True, doc=""" The theme to apply to components.""") # Global parameters that are shared across all sessions @@ -407,7 +406,7 @@ def __setattr__(self, attr, value): if state.curdoc not in self._session_config: self._session_config[state.curdoc] = {} self._session_config[state.curdoc][attr] = value - watchers = param_watchers(self).get(attr, {}).get('value', []) + watchers = self.param.watchers.get(attr, {}).get('value', []) for w in watchers: w.fn() elif f'_{attr}' in _config._parameter_set and hasattr(self, f'_{attr}_'): diff --git a/panel/custom.py b/panel/custom.py index f7d858ee67..136c8bc840 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -2,6 +2,7 @@ import asyncio import hashlib +import importlib import inspect import os import pathlib @@ -168,7 +169,7 @@ def __init__(mcs, name: str, bases: tuple[type, ...], dict_: Mapping[str, Any]): model_name = f'{name}{ReactiveMetaBase._name_counter[name]}' ignored = [p for p in Reactive.param if not issubclass(type(mcs.param[p].owner), ReactiveESMMetaclass)] mcs._data_model = construct_data_model( - mcs, name=model_name, ignore=ignored + mcs, name=model_name, ignore=ignored, extras={'esm_constants': param.Dict} ) @@ -216,6 +217,8 @@ class CounterButton(pn.custom.ReactiveESM): _bundle: ClassVar[str | os.PathLike | None] = None + _constants: ClassVar[dict[str, Any]] = {} + _esm: ClassVar[str | os.PathLike] = "" # Specifies exports to make available to JS in a bundled file @@ -235,15 +238,27 @@ def __init__(self, **params): self._msg__callbacks = [] @classproperty - def _bundle_path(cls) -> os.PathLike | None: - if config.autoreload and cls._esm: - return + def _module_path(cls): + if hasattr(cls, '__path__'): + return pathlib.Path(cls.__path__) try: - mod_path = pathlib.Path(inspect.getfile(cls)).parent + return pathlib.Path(inspect.getfile(cls)).parent except (OSError, TypeError, ValueError): if not isinstance(cls._bundle, pathlib.PurePath): return + + @classproperty + def _bundle_path(cls) -> os.PathLike | None: + if config.autoreload and cls._esm: + return + mod_path = cls._module_path + if mod_path is None: + return if cls._bundle: + for scls in cls.__mro__: + if issubclass(scls, ReactiveESM) and cls._bundle == scls._bundle: + cls = scls + mod_path = cls._module_path bundle = cls._bundle if isinstance(bundle, pathlib.PurePath): return bundle @@ -251,21 +266,34 @@ def _bundle_path(cls) -> os.PathLike | None: 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.' - ) + raise ValueError( + f'Could not resolve {cls.__name__}._bundle: {cls._bundle}. Ensure ' + 'you provide either a string with a relative or absolute ' + 'path or a Path object to a .js file extension.' + ) + + # Attempt resolving bundle for this component specifically path = mod_path / f'{cls.__name__}.bundle.js' if path.is_file(): return path + + # Attempt to resolve bundle in current module and parent modules module = cls.__module__ - path = mod_path / f'{module}.bundle.js' - if path.is_file(): - return path - elif module in sys.modules: + modules = module.split('.') + for i in reversed(range(len(modules))): + submodule = '.'.join(modules[:i+1]) + try: + mod = importlib.import_module(submodule) + except (ModuleNotFoundError, ImportError): + continue + if not hasattr(mod, '__file__'): + continue + submodule_path = pathlib.Path(mod.__file__).parent + path = submodule_path / f'{submodule}.bundle.js' + if path.is_file(): + return path + + if 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 @@ -300,7 +328,7 @@ def _render_esm(cls, compiled: bool | Literal['compiling'] = True, server: bool if esm_path: if esm_path == cls._bundle_path and cls.__module__ in sys.modules and server: base_cls = cls - for scls in cls.__mro__[1:][::-1]: + for scls in cls.__mro__[1:]: if not issubclass(scls, ReactiveESM): continue if esm_path == scls._esm_path(compiled=compiled is True): @@ -361,7 +389,14 @@ def _get_properties(self, doc: Document) -> dict[str, Any]: props = super()._get_properties(doc) cls = type(self) data_params = {} - ignored = [p for p in Reactive.param if not issubclass(cls.param[p].owner, ReactiveESM)] + # Split data model properties from ESM model properties + # Note that inherited parameters are generally treated + # as ESM model properties unless their type has changed + ignored = [ + p for p in Reactive.param + if not issubclass(cls.param[p].owner, ReactiveESM) or + (p in Viewable.param and p != 'name' and type(Reactive.param[p]) is type(cls.param[p])) + ] for k, v in self.param.values().items(): p = self.param[k] is_viewable = is_viewable_param(p) @@ -384,6 +419,7 @@ def _get_properties(self, doc: Document) -> dict[str, Any]: else: bundle_hash = None data_props = self._process_param_change(data_params) + data_props['esm_constants'] = self._constants props.update({ 'bundle': bundle_hash, 'class_name': camel_to_kebab(cls.__name__), @@ -399,24 +435,26 @@ def _get_properties(self, doc: Document) -> dict[str, Any]: def _process_importmap(cls): return cls._importmap + def _get_child_model(self, child, doc, root, parent, comm): + if child is None: + return None + ref = root.ref['id'] + if isinstance(child, list): + return [ + sv._models[ref][0] if ref in sv._models else sv._get_model(doc, root, parent, comm) + for sv in child + ] + elif ref in child._models: + return child._models[ref][0] + return child._get_model(doc, root, parent, comm) + def _get_children(self, data_model, doc, root, parent, comm): children = {} - ref = root.ref['id'] for k, v in self.param.values().items(): p = self.param[k] if not is_viewable_param(p): continue - if v is None: - children[k] = None - elif isinstance(v, list): - children[k] = [ - sv._models[ref][0] if ref in sv._models else sv._get_model(doc, root, parent, comm) - for sv in v - ] - elif ref in v._models: - children[k] = v._models[ref][0] - else: - children[k] = v._get_model(doc, root, parent, comm) + children[k] = self._get_child_model(v, doc, root, parent, comm) return children def _setup_autoreload(self): @@ -666,6 +704,7 @@ def _process_importmap(cls): "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", + "@emotion/styled": f"https://esm.sh/@emotion/styled?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: diff --git a/panel/io/cache.py b/panel/io/cache.py index af3bf5bb48..db51f41572 100644 --- a/panel/io/cache.py +++ b/panel/io/cache.py @@ -58,13 +58,13 @@ def clear(self, func_hashes: list[str | None]=[None]) -> None: bytes, str, float, int, bool, bytearray, type(None) ) -_NP_SIZE_LARGE = 100_000 +_ARRAY_SIZE_LARGE = 100_000 -_NP_SAMPLE_SIZE = 100_000 +_ARRAY_SAMPLE_SIZE = 100_000 -_PANDAS_ROWS_LARGE = 100_000 +_DATAFRAME_ROWS_LARGE = 100_000 -_PANDAS_SAMPLE_SIZE = 100_000 +_DATAFRAME_SAMPLE_SIZE = 100_000 if sys.platform == 'win32': _TIME_FN = time.perf_counter @@ -125,8 +125,8 @@ def _pandas_hash(obj): if not isinstance(obj, (pd.Series, pd.DataFrame)): obj = pd.Series(obj) - if len(obj) >= _PANDAS_ROWS_LARGE: - obj = obj.sample(n=_PANDAS_SAMPLE_SIZE, random_state=0) + if len(obj) >= _DATAFRAME_ROWS_LARGE: + obj = obj.sample(n=_DATAFRAME_SAMPLE_SIZE, random_state=0) try: if isinstance(obj, pd.DataFrame): return ((b"%s" % pd.util.hash_pandas_object(obj).sum()) @@ -138,13 +138,62 @@ def _pandas_hash(obj): # it contains unhashable objects. return b"%s" % pickle.dumps(obj, pickle.HIGHEST_PROTOCOL) +def _polars_combine_hash_expr(columns): + """ + Inspired by pd.core.util.hashing.combine_hash_arrays, + rewritten to a Polars expression. + """ + import polars as pl + + mult = pl.lit(1000003, dtype=pl.UInt64) + initial_value = pl.lit(0x345678, dtype=pl.UInt64) + increment = pl.lit(82520, dtype=pl.UInt64) + final_addition = pl.lit(97531, dtype=pl.UInt64) + + out = initial_value + num_items = len(columns) + for i, col_name in enumerate(columns): + col = pl.col(col_name).hash(seed=0) + inverse_i = pl.lit(num_items - i, dtype=pl.UInt64) + out = (out ^ col) * mult + mult = mult + (increment + inverse_i + inverse_i) + + return out + final_addition + +def _polars_hash(obj): + import polars as pl + + hash_type = type(obj).__name__.encode() + + if isinstance(obj, pl.Series): + obj = obj.to_frame() + + columns = obj.collect_schema().names() + hash_columns = _container_hash(columns) + + # LazyFrame does not support len and sample + if hash_type != b"LazyFrame" and len(obj) >= _DATAFRAME_ROWS_LARGE: + obj = obj.sample(n=_DATAFRAME_SAMPLE_SIZE, seed=0) + elif hash_type == b"LazyFrame": + count = obj.select(pl.col(columns[0]).count()).collect().item() + if count >= _DATAFRAME_ROWS_LARGE: + obj = obj.select(pl.all().sample(n=_DATAFRAME_SAMPLE_SIZE, seed=0)) + + hash_expr = _polars_combine_hash_expr(columns) + hash_data = obj.select(hash_expr).sum() + if hash_type == b"LazyFrame": + hash_data = hash_data.collect() + hash_data = _int_to_bytes(hash_data.item()) + + return hash_type + hash_data + hash_columns + def _numpy_hash(obj): h = hashlib.new("md5") h.update(_generate_hash(obj.shape)) - if obj.size >= _NP_SIZE_LARGE: + if obj.size >= _ARRAY_SIZE_LARGE: import numpy as np state = np.random.RandomState(0) - obj = state.choice(obj.flat, size=_NP_SAMPLE_SIZE) + obj = state.choice(obj.flat, size=_ARRAY_SAMPLE_SIZE) h.update(obj.tobytes()) return h.digest() @@ -180,6 +229,9 @@ def _io_hash(obj): 'builtins.dict_items' : lambda obj: _container_hash(dict(obj)), 'builtins.getset_descriptor' : lambda obj: obj.__qualname__.encode(), "numpy.ufunc" : lambda obj: obj.__name__.encode(), + "polars.series.series.Series": _polars_hash, + "polars.dataframe.frame.DataFrame": _polars_hash, + "polars.lazyframe.frame.LazyFrame": _polars_hash, # Functions inspect.isbuiltin : lambda obj: obj.__name__.encode(), inspect.ismodule : lambda obj: obj.__name__, @@ -214,7 +266,7 @@ def _generate_hash_inner(obj): f'{obj!r} with following error: {type(e).__name__}("{e}").' ) from e return output - if hasattr(obj, '__reduce__'): + if hasattr(obj, '__reduce__') and inspect.isclass(obj): h = hashlib.new("md5") try: reduce_data = obj.__reduce__() diff --git a/panel/io/compile.py b/panel/io/compile.py index 0df128571a..6e8beba647 100644 --- a/panel/io/compile.py +++ b/panel/io/compile.py @@ -1,5 +1,6 @@ from __future__ import annotations +import fnmatch import importlib import json import os @@ -10,6 +11,7 @@ import sys import tempfile +from collections import defaultdict from contextlib import contextmanager from typing import TYPE_CHECKING @@ -71,6 +73,62 @@ def check_cli_tool(tool_name): return False +def find_module_bundles(module_spec: str) -> dict[pathlib.PurePath, list[ReactiveESM]]: + """ + Takes module specifications and extracts a set of components to bundle. + + Arguments + --------- + module_spec: str + Module specification either as a dotted module or a path to a module. + + Returns + ------- + Dictionary containing the bundle paths and list of components to bundle. + """ + # Split module spec, while respecting Windows drive letters + if ':' in module_spec and (module_spec[1:3] != ':\\' or module_spec.count(':') > 1): + module, cls = module_spec.rsplit(':', 1) + else: + module = module_spec + cls = '' + classes = cls.split(',') if cls else None + if module.endswith('.py'): + module_name, _ = os.path.splitext(os.path.basename(module)) + else: + module_name = module + try: + components = find_components(module, classes) + except ValueError: + cls_error = f' and that class(es) {cls!r} are defined therein' if cls else '' + raise RuntimeError( # noqa + f'Could not find any ESM components to compile, ensure ' + f'you provided the right module{cls_error}.' + ) + if module in sys.modules: + module_path = sys.modules[module].__file__ + else: + module_path = module + + bundles = defaultdict(list) + 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 or component.__module__ + bundles[module_path / f'{component_module}.bundle.js'].append(component) + else: + bundles[component._module_path / f'{component.__name__}.bundle.js'].append(component) + + return bundles + + 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 @@ -94,6 +152,10 @@ def find_components(module_or_file: str | os.PathLike, classes: list[str] | None runner = CodeRunner(source, module_or_file, []) module = runner.new_module() runner.run(module) + if runner.error: + raise RuntimeError( + f'Compilation failed because supplied module errored on import:\n\n{runner.error}' + ) else: module = importlib.import_module(module_or_file) classes = classes or [] @@ -103,12 +165,12 @@ def find_components(module_or_file: str | os.PathLike, classes: list[str] | None isinstance(v, type) and issubclass(v, ReactiveESM) and not v.abstract and - (not classes or v.__name__ in classes) + (not classes or any(fnmatch.fnmatch(v.__name__, p) for p 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) + not_found = {cls for cls in classes if '*' not in cls} - 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}.') @@ -297,7 +359,7 @@ def generate_project( name = component.__name__ esm_path = component._esm_path(compiled=False) if esm_path: - ext = esm_path.suffix + ext = esm_path.suffix.lstrip('.') else: ext = 'jsx' if issubclass(component, ReactComponent) else 'js' code, component_deps = extract_dependencies(component) @@ -391,6 +453,8 @@ def compile_components( print(f"An error occurred while running npm install:\n{RED}{e.stderr}{RESET}") # noqa return None + if any(issubclass(c, ReactComponent) for c in components): + extra_args.append('--loader:.js=jsx') if minify: extra_args.append('--minify') if out: diff --git a/panel/io/datamodel.py b/panel/io/datamodel.py index 1489b012b3..b5a080ed03 100644 --- a/panel/io/datamodel.py +++ b/panel/io/datamodel.py @@ -115,7 +115,7 @@ def bytes_param(p, kwargs): } -def construct_data_model(parameterized, name=None, ignore=[], types={}): +def construct_data_model(parameterized, name=None, ignore=[], types={}, extras={}): """ Dynamically creates a Bokeh DataModel class from a Parameterized object. @@ -132,6 +132,8 @@ def construct_data_model(parameterized, name=None, ignore=[], types={}): types: dict A dictionary mapping from parameter name to a Parameter type, making it possible to override the default parameter types. + extras: dict + Additional properties to define on the DataModel. Returns ------- @@ -163,6 +165,10 @@ def construct_data_model(parameterized, name=None, ignore=[], types={}): for bkp, convert in accepts: bk_prop = bk_prop.accepts(bkp, convert) properties[pname] = bk_prop + for pname, ptype in extras.items(): + if issubclass(ptype, pm.Parameter): + ptype = PARAM_MAPPING.get(ptype)(None, {}) + properties[pname] = ptype name = name or parameterized.name return type(name, (DataModel,), properties) diff --git a/panel/io/document.py b/panel/io/document.py index ea03602e23..290e713d99 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -29,15 +29,16 @@ from bokeh.models import CustomJS from ..config import config -from ..util import param_watchers from .loading import LOADING_INDICATOR_CSS_CLASS -from .model import hold, monkeypatch_events # noqa: F401 API import +from .model import monkeypatch_events # noqa: F401 API import from .state import curdoc_locked, state if TYPE_CHECKING: + from bokeh.core.enums import HoldPolicyType from bokeh.core.has_props import HasProps from bokeh.protocol.message import Message from bokeh.server.connection import ServerConnection + from pyviz_comms import Comm logger = logging.getLogger(__name__) @@ -118,10 +119,10 @@ def _cleanup_doc(doc, destroy=True): pane._hooks = [] for p in pane.select(): p._hooks = [] - param_watchers(p, {}) + p.param.watchers = {} p._documents = {} p._internal_callbacks = {} - param_watchers(pane, {}) + pane.param.watchers = {} pane._documents = {} pane._internal_callbacks = {} else: @@ -431,11 +432,18 @@ def dispatch_django( return futures @contextmanager -def unlocked() -> Iterator: +def unlocked(policy: HoldPolicyType = 'combine') -> Iterator: """ Context manager which unlocks a Document and dispatches ModelChangedEvents triggered in the context body to all sockets on current sessions. + + Arguments + --------- + policy: Literal['combine' | 'collect'] + One of 'combine' or 'collect' determining whether events + setting the same property are combined or accumulated to be + dispatched when the context manager exits. """ curdoc = state.curdoc session_context = getattr(curdoc, 'session_context', None) @@ -457,7 +465,7 @@ def unlocked() -> Iterator: monkeypatch_events(curdoc.callbacks._held_events) return - curdoc.hold() + curdoc.hold(policy=policy) try: yield finally: @@ -518,6 +526,47 @@ def unlocked() -> Iterator: except RuntimeError: curdoc.add_next_tick_callback(partial(retrigger_events, curdoc, retriggered_events)) +@contextmanager +def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: Comm | None = None): + """ + Context manager that holds events on a particular Document + allowing them all to be collected and dispatched when the context + manager exits. This allows multiple events on the same object to + be combined if the policy is set to 'combine'. + + Arguments + --------- + doc: Document + The Bokeh Document to hold events on. + policy: HoldPolicyType + One of 'combine', 'collect' or None determining whether events + setting the same property are combined or accumulated to be + dispatched when the context manager exits. + comm: Comm + The Comm to dispatch events on when the context manager exits. + """ + doc = doc or state.curdoc + if doc is None: + yield + return + held = doc.callbacks.hold_value + try: + if policy is None: + doc.unhold() + yield + else: + with unlocked(policy=policy): + if not doc.callbacks.hold_value: + doc.hold(policy) + yield + finally: + if held: + doc.callbacks._hold = held + else: + if comm is not None: + from .notebook import push + push(doc, comm) + doc.unhold() @contextmanager def immediate_dispatch(doc: Document | None = None): diff --git a/panel/io/embed.py b/panel/io/embed.py index 791b5cccbb..15fb7e397a 100644 --- a/panel/io/embed.py +++ b/panel/io/embed.py @@ -16,7 +16,6 @@ from bokeh.models import CustomJS from param.parameterized import Watcher -from ..util import param_watchers from .model import add_to_doc, diff from .state import state @@ -82,7 +81,7 @@ def save_dict(state, key=(), depth=0, max_depth=None, save_path='', load_path=No def get_watchers(reactive): - return [w for pwatchers in param_watchers(reactive).values() + return [w for pwatchers in reactive.param.watchers.values() for awatchers in pwatchers.values() for w in awatchers] @@ -158,7 +157,7 @@ def links_to_jslinks(model, widget): mappings = [] for pname, tgt_spec in link.links.items(): - if Watcher(*link[:-4]) in param_watchers(widget)[pname]['value']: + if Watcher(*link[:-4]) in widget.param.watchers[pname]['value']: mappings.append((pname, tgt_spec)) if mappings: diff --git a/panel/io/jupyter_server_extension.py b/panel/io/jupyter_server_extension.py index d11a74e1a7..1c8a7a1f5a 100644 --- a/panel/io/jupyter_server_extension.py +++ b/panel/io/jupyter_server_extension.py @@ -389,6 +389,9 @@ async def open(self, path, *args, **kwargs) -> None: self.session_id = get_session_id(token) if self.session_id not in state._kernels: self.close() + msg = f"Session ID '{self.session_id}' does not correspond to any active kernel." + raise RuntimeError(msg) + kernel_info = state._kernels[self.session_id] self.kernel, self.comm_id, self.kernel_id, _ = kernel_info state._kernels[self.session_id] = kernel_info[:-1] + (True,) diff --git a/panel/io/model.py b/panel/io/model.py index 322af5748f..f7a02874b9 100644 --- a/panel/io/model.py +++ b/panel/io/model.py @@ -22,6 +22,7 @@ from bokeh.models import ColumnDataSource, FlexBox, Model from bokeh.protocol.messages.patch_doc import patch_doc +from ..util.warnings import deprecated from .state import state if TYPE_CHECKING: @@ -174,7 +175,7 @@ def bokeh_repr(obj: Model, depth: int = 0, ignored: Optional[Iterable[str]] = No return r @contextmanager -def hold(doc: Document, policy: HoldPolicyType = 'combine', comm: Comm | None = None): +def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: Comm | None = None): """ Context manager that holds events on a particular Document allowing them all to be collected and dispatched when the context @@ -192,22 +193,9 @@ def hold(doc: Document, policy: HoldPolicyType = 'combine', comm: Comm | None = comm: Comm The Comm to dispatch events on when the context manager exits. """ - doc = doc or state.curdoc - if doc is None: - yield - return - held = doc.callbacks.hold_value - try: - if policy is None: - doc.unhold() - else: - doc.hold(policy) - yield - finally: - if held: - doc.callbacks._hold = held - else: - if comm is not None: - from .notebook import push - push(doc, comm) - doc.unhold() + deprecated( + '1.7.0', 'panel.io.model.hold', 'panel.io.document.hold', + warn_version='1.6.0' + ) + from .document import hold + yield hold(doc, policy, comm) diff --git a/panel/io/resources.py b/panel/io/resources.py index 35776a214c..fdf5ac13d2 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -258,7 +258,11 @@ def component_resource_path(component, attr, path): component_path = COMPONENT_PATH if state.rel_path: component_path = f"{state.rel_path}/{component_path}" - rel_path = os.fspath(resolve_custom_path(component, path, relative=True)).replace(os.path.sep, '/') + custom_path = resolve_custom_path(component, path, relative=True) + if custom_path: + rel_path = os.fspath(custom_path).replace(os.path.sep, '/') + else: + rel_path = path return f'{component_path}{component.__module__}/{component.__name__}/{attr}/{rel_path}' def patch_stylesheet(stylesheet, dist_url): @@ -641,6 +645,8 @@ def extra_resources(self, resources, resource_type): if not (getattr(model, resource_type, None) and model._loaded()): continue for resource in getattr(model, resource_type, []): + if state.rel_path: + resource = resource.lstrip(state.rel_path+'/') if not isurl(resource) and not resource.lstrip('./').startswith('static/extensions'): resource = component_resource_path(model, resource_type, resource) if resource not in resources: @@ -815,6 +821,8 @@ def js_modules(self): if not (getattr(model, '__javascript_modules__', None) and model._loaded()): continue for js_module in model.__javascript_modules__: + if state.rel_path: + js_module = js_module.lstrip(state.rel_path+'/') if not isurl(js_module) and not js_module.startswith('static/extensions'): js_module = component_resource_path(model, '__javascript_modules__', js_module) if js_module not in modules: diff --git a/panel/io/server.py b/panel/io/server.py index 153a8c76cb..61bb6c1cb7 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -426,6 +426,7 @@ def _authorize(self, session=False): auth_error = None except Exception: auth_error = f'Authorization callback errored. Could not validate user {state.user}.' + logger.warning(auth_error) return authorized, auth_error def _render_auth_error(self, auth_error): @@ -455,6 +456,7 @@ async def get(self, *args, **kwargs): if authorized is None: return elif not authorized: + self.set_status(403) page = self._render_auth_error(auth_error) self.set_header("Content-Type", 'text/html') self.write(page) diff --git a/panel/layout/base.py b/panel/layout/base.py index d58286db4c..70160e3fba 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -15,12 +15,11 @@ from bokeh.models import Row as BkRow from param.parameterized import iscoroutinefunction, resolve_ref -from ..io.document import freeze_doc -from ..io.model import hold +from ..io.document import freeze_doc, hold from ..io.resources import CDN_DIST from ..models import Column as PnColumn from ..reactive import Reactive -from ..util import param_name, param_reprs, param_watchers +from ..util import param_name, param_reprs from ..viewable import Children if TYPE_CHECKING: @@ -564,13 +563,14 @@ def __init__(self, *items: list[Any | tuple[str, Any]], **params: Any): 'as positional arguments or as a keyword, not both.' ) items = params.pop('objects') - params['objects'], self._names = self._to_objects_and_names(items) + params['objects'], names = self._to_objects_and_names(items) super().__init__(**params) + self._names = names self._panels = defaultdict(dict) self.param.watch(self._update_names, 'objects') # ALERT: Ensure that name update happens first, should be # replaced by watch precedence support in param - param_watchers(self)['objects']['value'].reverse() + self.param.watchers['objects']['value'].reverse() def _to_object_and_name(self, item): from ..pane import panel @@ -848,7 +848,7 @@ class NamedListPanel(NamedListLike, Panel): active = param.Integer(default=0, bounds=(0, None), doc=""" Index of the currently displayed objects.""") - scroll = param.ObjectSelector( + scroll = param.Selector( default=False, objects=[False, True, "both-auto", "y-auto", "x-auto", "both", "x", "y"], doc="""Whether to add scrollbars if the content overflows the size diff --git a/panel/layout/grid.py b/panel/layout/grid.py index c594ae1244..0c1e9675e1 100644 --- a/panel/layout/grid.py +++ b/panel/layout/grid.py @@ -16,8 +16,7 @@ from bokeh.models import FlexBox as BkFlexBox, GridBox as BkGridBox -from ..io.document import freeze_doc -from ..io.model import hold +from ..io.document import freeze_doc, hold from ..io.resources import CDN_DIST from ..viewable import ChildDict from .base import ( @@ -261,7 +260,7 @@ class GridSpec(Panel): objects = ChildDict(default={}, doc=""" The dictionary of child objects that make up the grid.""") - mode = param.ObjectSelector(default='warn', objects=['warn', 'error', 'override'], doc=""" + mode = param.Selector(default='warn', objects=['warn', 'error', 'override'], doc=""" Whether to warn, error or simply override on overlapping assignment.""") ncols = param.Integer(default=None, bounds=(0, None), doc=""" diff --git a/panel/layout/spacer.py b/panel/layout/spacer.py index 4be5c40ade..a4a0051c2e 100644 --- a/panel/layout/spacer.py +++ b/panel/layout/spacer.py @@ -101,7 +101,7 @@ class Divider(Reactive): >>> ) """ - width_policy = param.ObjectSelector(default="fit", readonly=True) + width_policy = param.Selector(default="fit", readonly=True) _bokeh_model = BkDiv diff --git a/panel/layout/tabs.py b/panel/layout/tabs.py index 0dc43f37e3..e4a795d0f6 100644 --- a/panel/layout/tabs.py +++ b/panel/layout/tabs.py @@ -43,7 +43,7 @@ class Tabs(NamedListPanel): dynamic = param.Boolean(default=False, doc=""" Dynamically populate only the active tab.""") - tabs_location = param.ObjectSelector( + tabs_location = param.Selector( default='above', objects=['above', 'below', 'left', 'right'], doc=""" The location of the tabs relative to the tab contents.""") diff --git a/panel/models/__init__.py b/panel/models/__init__.py index a5616ffbfb..90750473b1 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -6,6 +6,7 @@ """ from .browser import BrowserInfo # noqa from .datetime_picker import DatetimePicker # noqa +from .datetime_slider import DatetimeSlider # noqa from .esm import AnyWidgetComponent, ReactComponent, ReactiveESM # noqa from .feed import Feed # noqa from .icon import ButtonIcon, ToggleIcon, _ClickableIcon # noqa diff --git a/panel/models/datetime_slider.py b/panel/models/datetime_slider.py new file mode 100644 index 0000000000..665e6519b3 --- /dev/null +++ b/panel/models/datetime_slider.py @@ -0,0 +1,10 @@ +from bokeh.core.properties import Override +from bokeh.models.widgets.sliders import DateSlider + + +class DatetimeSlider(DateSlider): + """ Slider-based datetime selection widget. """ + + step = Override(default=60) + + format = Override(default="%d %b %Y %H:%M:%S") diff --git a/panel/models/datetime_slider.ts b/panel/models/datetime_slider.ts new file mode 100644 index 0000000000..0fee835f00 --- /dev/null +++ b/panel/models/datetime_slider.ts @@ -0,0 +1,56 @@ +// adapted from bokeh +// https://github.com/bokeh/bokeh/blob/branch-3.7/bokehjs/src/lib/models/widgets/sliders/date_slider.ts + +import {DEFAULT_FORMATTERS} from "@bokehjs/core/util/templating" +import type {SliderSpec} from "@bokehjs/models/widgets/sliders/abstract_slider" +import {NumericalSlider, NumericalSliderView} from "@bokehjs/models/widgets/sliders/numerical_slider" +import type {TickFormatter} from "@bokehjs/models/formatters/tick_formatter" +import type * as p from "@bokehjs/core/properties" +import {isString} from "@bokehjs/core/util/types" + +export class DatetimeSliderView extends NumericalSliderView { + declare model: DatetimeSlider + + override behaviour = "tap" as const + override connected = [true, false] + + protected override _calc_to(): SliderSpec { + const spec = super._calc_to() + spec.step *= 1_000 // step size is in seconds + return spec + } + + protected _formatter(value: number, format: string | TickFormatter): string { + if (isString(format)) { + return DEFAULT_FORMATTERS.datetime(value, format, {}) + } else { + return format.compute(value) + } + } +} + +export namespace DatetimeSlider { + export type Attrs = p.AttrsOf + export type Props = NumericalSlider.Props +} + +export interface DatetimeSlider extends DatetimeSlider.Attrs {} + +export class DatetimeSlider extends NumericalSlider { + declare properties: DatetimeSlider.Props + declare __view_type__: DatetimeSliderView + + constructor(attrs?: Partial) { + super(attrs) + } + static override __module__ = "panel.models.datetime_slider" + + static { + this.prototype.default_view = DatetimeSliderView + + this.override({ + step: 60, + format: "%d %b %Y %H:%M:%S", + }) + } +} diff --git a/panel/models/file_download.ts b/panel/models/file_download.ts index 210964cc92..993225ad94 100644 --- a/panel/models/file_download.ts +++ b/panel/models/file_download.ts @@ -38,6 +38,7 @@ export class FileDownloadView extends InputWidgetView { anchor_el: HTMLAnchorElement button_el: HTMLButtonElement + label_el: Text declare input_el: HTMLInputElement // HACK: So this.input_el.id = "input" can be set in Bokeh 3.4 _downloadable: boolean = false _click_listener: any @@ -101,8 +102,9 @@ export class FileDownloadView extends InputWidgetView { this.anchor_el.appendChild(separator) this.icon_view.render() } + this.label_el = document.createTextNode(this.model.label) + this.anchor_el.appendChild(this.label_el) this._update_button_style() - this._update_label() // Changing the disabled property calls render() so it needs to be handled here. // This callback is inherited from ControlView in bokehjs. @@ -201,8 +203,7 @@ export class FileDownloadView extends InputWidgetView { } _update_label(): void { - const label = document.createTextNode(this.model.label) - this.anchor_el.appendChild(label) + this.label_el.data = this.model.label } _update_button_style(): void { diff --git a/panel/models/html.ts b/panel/models/html.ts index 53d7bc1fb1..42744b4e8d 100644 --- a/panel/models/html.ts +++ b/panel/models/html.ts @@ -1,10 +1,18 @@ import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" +import type {StyleSheetLike} from "@bokehjs/core/dom" import type * as p from "@bokehjs/core/properties" import type {Attrs, Dict} from "@bokehjs/core/types" import {entries} from "@bokehjs/core/util/object" import {Markup} from "@bokehjs/models/widgets/markup" import {PanelMarkupView} from "./layout" import {serializeEvent} from "./event-to-object" +import {throttle} from "./util" + +import html_css from "styles/models/html.css" + +const COPY_ICON = `` + +const CHECK_ICON = `` function searchAllDOMs(node: Element | ShadowRoot, selector: string): (Element | ShadowRoot)[] { let found: (Element | ShadowRoot)[] = [] @@ -72,29 +80,6 @@ export function run_scripts(node: Element): void { } } -function throttle(func: Function, limit: number): any { - let lastFunc: ReturnType | undefined - let lastRan: number - - return function(...args: any) { - // @ts-ignore - const context = this - - if (!lastRan) { - func.apply(context, args) - lastRan = Date.now() - } else { - clearTimeout(lastFunc) - lastFunc = setTimeout(function() { - if ((Date.now() - lastRan) >= limit) { - func.apply(context, args) - lastRan = Date.now() - } - }, limit - (Date.now() - lastRan)) - } - } -} - export class HTMLView extends PanelMarkupView { declare model: HTML _buffer: string | null = null @@ -134,6 +119,10 @@ export class HTMLView extends PanelMarkupView { }) } + override stylesheets(): StyleSheetLike[] { + return [...super.stylesheets(), html_css] + } + protected rerender() { this.render() this.invalidate_layout() @@ -148,6 +137,22 @@ export class HTMLView extends PanelMarkupView { run_scripts(this.container) } this._setup_event_listeners() + for (const codeblock of this.container.querySelectorAll(".codehilite")) { + const copy_button = document.createElement("button") + const pre = (codeblock.children[0] as HTMLPreElement) + copy_button.className = "copybtn" + copy_button.innerHTML = COPY_ICON + copy_button.addEventListener("click", () => { + const code = pre.innerText + navigator.clipboard.writeText(code).then(() => { + copy_button.innerHTML = CHECK_ICON + setTimeout(() => { + copy_button.innerHTML = COPY_ICON + }, 300) + }) + }) + codeblock.insertBefore(copy_button, pre) + } for (const anchor of this.container.querySelectorAll("a")) { const link = anchor.getAttribute("href") if (link && link.startsWith("#")) { diff --git a/panel/models/index.ts b/panel/models/index.ts index ac566893f5..220a13be8b 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -14,6 +14,7 @@ export {CustomSelect} from "./customselect" export {CustomMultiSelect} from "./multiselect" export {DataTabulator} from "./tabulator" export {DatetimePicker} from "./datetime_picker" +export {DatetimeSlider} from "./datetime_slider" export {DeckGLPlot} from "./deckgl" export {DiscretePlayer} from "./discrete_player" export {ECharts} from "./echarts" diff --git a/panel/models/plotly.ts b/panel/models/plotly.ts index 3f16accefd..900b5f515f 100644 --- a/panel/models/plotly.ts +++ b/panel/models/plotly.ts @@ -140,6 +140,12 @@ export class PlotlyPlotView extends HTMLBoxView { _end_relayouting = debounce(() => { this._relayouting = false }, 2000, false) + _throttled_resize: any + + override initialize(): void { + super.initialize() + this._throttled_resize = throttle(() => this.resize_layout(), 25) + } override connect_signals(): void { super.connect_signals() @@ -205,28 +211,33 @@ export class PlotlyPlotView extends HTMLBoxView { this.container = div() as PlotlyHTMLElement set_size(this.container, this.model) this._rendered = false - this.shadow_el.appendChild(this.container) this.watch_stylesheets() - this.plot().then(() => { + this.plot(true).then(() => { + this.shadow_el.appendChild(this.container) this._rendered = true + this.resize_layout() if (this.model.relayout != null) { (window as any).Plotly.relayout(this.container, this.model.relayout) } - (window as any).Plotly.Plots.resize(this.container) }) } override style_redraw(): void { - if (this._rendered && this.container != null) { - (window as any).Plotly.Plots.resize(this.container) + this.resize_layout() + } + + resize_layout(): void { + if (!this._rendered || this.container == null) { + return } + const width: number = Math.min(this.model.width || this.el.clientWidth, this.model.max_width || Infinity) + const height: number = Math.min(this.model.height || this.el.clientHeight, this.model.max_height || Infinity); + (window as any).Plotly.relayout(this.container, {width, height}) } override after_layout(): void { super.after_layout() - if (this._rendered && this.container != null) { - (window as any).Plotly.Plots.resize(this.container) - } + this._throttled_resize() } _trace_data(): any { diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 3cc745b2a3..51867a87b4 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -2,6 +2,7 @@ import {display, undisplay} from "@bokehjs/core/dom" import {sum} from "@bokehjs/core/util/arrayable" import {isArray, isBoolean, isString, isNumber} from "@bokehjs/core/util/types" import {ModelEvent} from "@bokehjs/core/bokeh_events" +import type {StyleSheetLike} from "@bokehjs/core/dom" import {div} from "@bokehjs/core/dom" import {Enum} from "@bokehjs/core/kinds" import type * as p from "@bokehjs/core/properties" @@ -17,6 +18,8 @@ import {transform_cds_to_records} from "./data" import {HTMLBox, HTMLBoxView} from "./layout" import {schedule_when} from "./util" +import tabulator_css from "styles/models/tabulator.css" + export class TableEditEvent extends ModelEvent { constructor(readonly column: string, readonly row: number, readonly pre: boolean) { super() @@ -547,6 +550,10 @@ export class DataTabulatorView extends HTMLBoxView { this.restore_scroll() } + override stylesheets(): StyleSheetLike[] { + return [...super.stylesheets(), tabulator_css] + } + setCSSClasses(el: HTMLDivElement): void { el.className = "pnx-tabulator tabulator" for (const cls of this.model.theme_classes) { diff --git a/panel/models/util.ts b/panel/models/util.ts index 50a70eba6e..5cdac07a98 100644 --- a/panel/models/util.ts +++ b/panel/models/util.ts @@ -11,13 +11,27 @@ export const get = (obj: any, path: string, defaultValue: any = undefined) => { return result === undefined || result === obj ? defaultValue : result } -export function throttle(func: any, timeFrame: number) { - let lastTime: number = 0 - return function() { - const now: number = Number(new Date()) - if (now - lastTime >= timeFrame) { - func() - lastTime = now +export function throttle(func: Function, limit: number): any { + let lastRan: number = 0 + let trailingCall: ReturnType | null = null + + return function(...args: any) { + // @ts-ignore + const context = this + const now = Date.now() + if (trailingCall) { + clearTimeout(trailingCall) + } + + if ((now - lastRan) >= limit) { + func.apply(context, args) + lastRan = Date.now() + } else { + trailingCall = setTimeout(function() { + func.apply(context, args) + lastRan = Date.now() + trailingCall = null + }, limit - (now - lastRan)) } } } diff --git a/panel/models/vizzu.py b/panel/models/vizzu.py index 4a682fba9c..6a8b5d3d48 100644 --- a/panel/models/vizzu.py +++ b/panel/models/vizzu.py @@ -11,7 +11,7 @@ from ..config import config from ..util import classproperty -VIZZU_VERSION = "0.9.3" +VIZZU_VERSION = "0.15" class VizzuEvent(ModelEvent): diff --git a/panel/package-lock.json b/panel/package-lock.json index bfbae0483d..e15f4d35f4 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,12 +1,12 @@ { "name": "@holoviz/panel", - "version": "1.5.3-b.1", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.3-b.1", + "version": "1.5.4", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.6.0", @@ -1112,10 +1112,9 @@ "license": "MIT" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "license": "MIT", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", diff --git a/panel/package.json b/panel/package.json index 248b178691..c5d53992f2 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.3-b.1", + "version": "1.5.4", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { diff --git a/panel/pane/_textual.py b/panel/pane/_textual.py index 33e34c210f..0029509eb0 100644 --- a/panel/pane/_textual.py +++ b/panel/pane/_textual.py @@ -66,8 +66,10 @@ def _disable_mouse_support(self) -> None: self.flush() def _process_input(self, event): + # Textual 0.86 changed from `process_event` to `process_message` + fn = self.process_event if hasattr(self, 'process_event') else self.process_message for parsed_event in self._parser.feed(event.new): - self.process_event(parsed_event) + fn(parsed_event) def disable_input(self): if self._input_watcher is None: diff --git a/panel/pane/alert.py b/panel/pane/alert.py index fb12e04990..b2d390bf0d 100644 --- a/panel/pane/alert.py +++ b/panel/pane/alert.py @@ -30,7 +30,7 @@ class Alert(Markdown): >>> Alert('Some important message', alert_type='warning') """ - alert_type = param.ObjectSelector(default="primary", objects=ALERT_TYPES) + alert_type = param.Selector(default="primary", objects=ALERT_TYPES) priority: ClassVar[float | bool | None] = 0 diff --git a/panel/pane/base.py b/panel/pane/base.py index 7a7c149633..1f5c758b70 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -28,7 +28,7 @@ from ..links import Link from ..models import ReactiveHTML as _BkReactiveHTML from ..reactive import Reactive -from ..util import param_reprs, param_watchers +from ..util import param_reprs from ..util.checks import is_dataframe, is_series from ..util.parameters import get_params_to_inherit from ..viewable import ( @@ -702,7 +702,7 @@ def _update_from_object(cls, object: Any, old_object: Any, was_internal: bool, i custom_watchers = [] if isinstance(object, Reactive): watchers = [ - w for pwatchers in param_watchers(object).values() + w for pwatchers in object.param.watchers.values() for awatchers in pwatchers.values() for w in awatchers ] custom_watchers = [ diff --git a/panel/pane/echarts.py b/panel/pane/echarts.py index 7a2d4384d5..22fd8d9ba1 100644 --- a/panel/pane/echarts.py +++ b/panel/pane/echarts.py @@ -44,10 +44,10 @@ class ECharts(ModelPane): the `objects` with a value containing a smaller number of series. """) - renderer = param.ObjectSelector(default="canvas", objects=["canvas", "svg"], doc=""" + renderer = param.Selector(default="canvas", objects=["canvas", "svg"], doc=""" Whether to render as HTML canvas or SVG""") - theme = param.ObjectSelector(default="default", objects=["default", "light", "dark"], doc=""" + theme = param.Selector(default="default", objects=["default", "light", "dark"], doc=""" Theme to apply to plots.""") priority: ClassVar[float | bool | None] = None diff --git a/panel/pane/equation.py b/panel/pane/equation.py index 163d4ead12..4af31999ce 100644 --- a/panel/pane/equation.py +++ b/panel/pane/equation.py @@ -42,7 +42,7 @@ class LaTeX(ModelPane): ... ) """ - renderer = param.ObjectSelector(default=None, allow_None=True, + renderer = param.Selector(default=None, allow_None=True, objects=['katex', 'mathjax'], doc=""" The JS renderer used to render the LaTeX expression. Defaults to katex.""") diff --git a/panel/pane/holoviews.py b/panel/pane/holoviews.py index f157072775..827d45eff8 100644 --- a/panel/pane/holoviews.py +++ b/panel/pane/holoviews.py @@ -58,7 +58,7 @@ class HoloViews(Pane): >>> HoloViews(some_holoviews_object) """ - backend = param.ObjectSelector( + backend = param.Selector( default=None, objects=['bokeh', 'matplotlib', 'plotly'], doc=""" The HoloViews backend used to render the plot (if None defaults to the currently selected renderer).""") @@ -81,18 +81,18 @@ class HoloViews(Pane): allow_None=True, doc=""" Bokeh theme to apply to the HoloViews plot.""") - widget_location = param.ObjectSelector(default='right_top', objects=[ + widget_location = param.Selector(default='right_top', objects=[ 'left', 'bottom', 'right', 'top', 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'left_top', 'left_bottom', 'right_top', 'right_bottom'], doc=""" The layout of the plot and the widgets. The value refers to the position of the widgets relative to the plot.""") - widget_layout = param.ObjectSelector( + widget_layout = param.Selector( objects=[WidgetBox, Row, Column], constant=True, default=WidgetBox, doc=""" The layout object to display the widgets in.""") - widget_type = param.ObjectSelector(default='individual', + widget_type = param.Selector(default='individual', objects=['individual', 'scrubber'], doc=""") Whether to generate individual widgets for each dimension or on global scrubber.""") diff --git a/panel/pane/ipywidget.py b/panel/pane/ipywidget.py index a8a9c522f5..a4ca2b08ae 100644 --- a/panel/pane/ipywidget.py +++ b/panel/pane/ipywidget.py @@ -84,7 +84,7 @@ def _get_model( class IPyLeaflet(IPyWidget): - sizing_mode = param.ObjectSelector(default='stretch_width', objects=[ + sizing_mode = param.Selector(default='stretch_width', objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None]) diff --git a/panel/pane/markup.py b/panel/pane/markup.py index 583903a932..30a1b9c7be 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -168,7 +168,7 @@ class DataFrame(HTML): index_names = param.Boolean(default=True, doc=""" Prints the names of the indexes.""") - justify = param.ObjectSelector(default=None, allow_None=True, objects=[ + justify = param.Selector(default=None, allow_None=True, objects=[ 'left', 'right', 'center', 'justify', 'justify-all', 'start', 'end', 'inherit', 'match-parent', 'initial', 'unset'], doc=""" How to justify the column labels.""") @@ -505,7 +505,7 @@ class JSON(HTMLBasePane): hover_preview = param.Boolean(default=False, doc=""" Whether to display a hover preview for collapsed nodes.""") - theme = param.ObjectSelector(default="dark", objects=["light", "dark"], doc=""" + theme = param.Selector(default="dark", objects=["light", "dark"], doc=""" Whether the JSON tree view is expanded by default.""") priority: ClassVar[float | bool | None] = None diff --git a/panel/pane/perspective.py b/panel/pane/perspective.py index aceb0cc67d..476a9e3904 100644 --- a/panel/pane/perspective.py +++ b/panel/pane/perspective.py @@ -305,7 +305,7 @@ class Perspective(ModelPane, ReactiveData): sort = param.List(default=None, doc=""" How to sort. For example[["x","desc"]]""") - plugin = param.ObjectSelector(default=Plugin.GRID.value, objects=Plugin.options(), doc=""" + plugin = param.Selector(default=Plugin.GRID.value, objects=Plugin.options(), doc=""" The name of a plugin to display the data. For example hypergrid or d3_xy_scatter.""") plugin_config = param.Dict(default={}, nested_refs=True, doc=""" @@ -314,7 +314,7 @@ class Perspective(ModelPane, ReactiveData): settings = param.Boolean(default=True, doc=""" Whether to show the settings menu.""") - theme = param.ObjectSelector(default='pro', objects=THEMES, doc=""" + theme = param.Selector(default='pro', objects=THEMES, doc=""" The style of the PerspectiveViewer. For example pro-dark""") title = param.String(default=None, doc=""" diff --git a/panel/pane/plot.py b/panel/pane/plot.py index 0ede8f7dcb..8b83769eb7 100644 --- a/panel/pane/plot.py +++ b/panel/pane/plot.py @@ -443,7 +443,7 @@ class Folium(HTML): The Folium pane wraps Folium map components. """ - sizing_mode = param.ObjectSelector(default='stretch_width', objects=[ + sizing_mode = param.Selector(default='stretch_width', objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None]) diff --git a/panel/pane/vega.py b/panel/pane/vega.py index 59ef47d57c..3ad207eeba 100644 --- a/panel/pane/vega.py +++ b/panel/pane/vega.py @@ -160,7 +160,7 @@ class Vega(ModelPane): show_actions = param.Boolean(default=False, doc=""" Whether to show Vega actions.""") - theme = param.ObjectSelector(default=None, allow_None=True, objects=[ + theme = param.Selector(default=None, allow_None=True, objects=[ 'excel', 'ggplot2', 'quartz', 'vox', 'fivethirtyeight', 'dark', 'latimes', 'urbaninstitute', 'googlecharts']) diff --git a/panel/reactive.py b/panel/reactive.py index 81b0a076ba..91a38f52b8 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -35,8 +35,7 @@ resolve_ref, resolve_value, ) -from .io.document import unlocked -from .io.model import hold +from .io.document import hold, unlocked from .io.notebook import push from .io.resources import ( CDN_DIST, loading_css, patch_stylesheet, process_raw_css, @@ -215,7 +214,7 @@ def _process_param_change(self, msg: dict[str, Any]) -> dict[str, Any]: stylesheets += properties['stylesheets'] wrapped = [] for stylesheet in stylesheets: - if isinstance(stylesheet, str) and stylesheet.split('?')[0].endswith('.css'): + if isinstance(stylesheet, str) and (stylesheet.split('?')[0].endswith('.css') or stylesheet.startswith('http')): cache = (state._stylesheets if state.curdoc else {}).get(state.curdoc, {}) if stylesheet in cache: stylesheet = cache[stylesheet] @@ -1582,16 +1581,32 @@ def _process_param_change(self, params): def _set_on_model(self, msg: Mapping[str, Any], root: Model, model: Model) -> None: if not msg: return - old = self._changing.get(root.ref['id'], []) - self._changing[root.ref['id']] = [ - attr for attr, value in msg.items() - if not model.lookup(attr).property.matches(getattr(model, attr), value) - ] + prev_changing = self._changing.get(root.ref['id'], []) + changing = [] + transformed = {} + for attr, value in msg.items(): + prop = model.lookup(attr).property + old = getattr(model, attr) + try: + matches = bool(prop.matches(old, value)) + except Exception: + for tp, converter in prop.alternatives: + if tp.is_valid(value): + value = converter(value) + break + try: + matches = bool(prop.matches(old, value)) + except Exception: + matches = False + if not matches: + transformed[attr] = value + changing.append(attr) + self._changing[root.ref['id']] = changing try: - model.update(**msg) + model.update(**transformed) finally: if old: - self._changing[root.ref['id']] = old + self._changing[root.ref['id']] = prev_changing else: del self._changing[root.ref['id']] diff --git a/panel/styles/models/html.less b/panel/styles/models/html.less new file mode 100644 index 0000000000..f26e8be056 --- /dev/null +++ b/panel/styles/models/html.less @@ -0,0 +1,34 @@ +.copybtn { + position: sticky; + display: flex; + top: 0; + left: 100%; + width: 1.7em; + height: 1.7em; + opacity: 0; + transition: opacity 0.3s, border .3s, background-color .3s; + user-select: none; + padding: 0; + border: none; + outline: none; + border-radius: 0.4em; + border: #1b1f2426 1px solid; + background-color: #f6f8fa; + color: #57606a; +} + +.codehilite pre { + margin-top: -1.7em; +} + +.copybtn svg { + stroke: currentColor; + width: 1.5em; + height: 1.5em; + padding: 0.1em; +} + +.codehilite:hover .copybtn { + opacity: 1; + cursor: pointer; +} diff --git a/panel/styles/models/tabulator.less b/panel/styles/models/tabulator.less new file mode 100644 index 0000000000..04c5dc6c46 --- /dev/null +++ b/panel/styles/models/tabulator.less @@ -0,0 +1,7 @@ +.tabulator-table { + max-width: 100%; + + .tabulator-row .row-content .bk-panel-models-markup-HTML { + white-space: normal; + } +} diff --git a/panel/template/base.py b/panel/template/base.py index 10c9f54d67..a47161dc5e 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -671,7 +671,7 @@ class BasicTemplate(BaseTemplate): Specifies the base URL for all relative URLs in a page. Default is '', i.e. not the domain.""") - base_target = param.ObjectSelector(default="_self", + base_target = param.Selector(default="_self", objects=["_blank", "_self", "_parent", "_top"], doc=""" Specifies the base Target for all relative URLs in a page.""") diff --git a/panel/template/react/__init__.py b/panel/template/react/__init__.py index 5971ac9250..b0378150d9 100644 --- a/panel/template/react/__init__.py +++ b/panel/template/react/__init__.py @@ -19,7 +19,7 @@ class ReactTemplate(BasicTemplate): ReactTemplate is built on top of React Grid Layout web components. """ - compact = param.ObjectSelector(default=None, objects=[None, 'vertical', 'horizontal', 'both']) + compact = param.Selector(default=None, objects=[None, 'vertical', 'horizontal', 'both']) cols = param.Dict(default={'lg': 12, 'md': 10, 'sm': 6, 'xs': 4, 'xxs': 2}) diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 64aa7304d4..b29dc5b0c5 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -608,6 +608,15 @@ def test_update_chat_log_params(self, chat_feed): assert chat_feed._chat_log.scroll_button_threshold == 10 assert chat_feed._chat_log.auto_scroll_limit == 10 + def test_repr(self, chat_feed): + chat_feed.send("A") + chat_feed.send("B") + assert repr(chat_feed) == ( + "ChatFeed(_placeholder=ChatMessage, sizing_mode='stretch_width')\n" + " [0] ChatMessage(object='A', user='User', reactions=[])\n" + " [1] ChatMessage(object='B', user='User', reactions=[])" + ) + @pytest.mark.xdist_group("chat") class TestChatFeedPromptUser: diff --git a/panel/tests/chat/test_message.py b/panel/tests/chat/test_message.py index 29870587bc..4d56c81e3f 100644 --- a/panel/tests/chat/test_message.py +++ b/panel/tests/chat/test_message.py @@ -390,3 +390,11 @@ def test_serialize_audio(self): def test_serialize_dataframe(self): message = ChatMessage(DataFrame(pd.DataFrame({'a': [1, 2, 3]}))) assert message.serialize() == "DataFrame= a\n0 1\n1 2\n2 3" + + def test_repr(self): + message = ChatMessage(object="Hello", user="User", avatar="A", reactions=["favorite"]) + assert repr(message) == "ChatMessage(object='Hello', user='User', reactions=['favorite'])" + + def test_repr_dataframe(self): + message = ChatMessage(pd.DataFrame({'a': [1, 2, 3]}), avatar="D") + assert repr(message) == "ChatMessage(object= a\n0 1\n1 2\n2 3, user='User', reactions=[])" diff --git a/panel/tests/command/test_compile.py b/panel/tests/command/test_compile.py new file mode 100644 index 0000000000..b562b790f6 --- /dev/null +++ b/panel/tests/command/test_compile.py @@ -0,0 +1,25 @@ +import pathlib +import subprocess +import sys + +from panel.custom import JSComponent + +CWD = pathlib.Path(__file__).parent + + +class JSTestComponent(JSComponent): + + _esm = "export function render() { console.log('foo') }" + + +def test_compile_component(py_file): + cmd = [sys.executable, "-m", "panel", "compile", "panel.tests.command.test_compile:JSTestComponent", "--unminified"] + p = subprocess.Popen(cmd, shell=False, cwd=CWD) + p.wait() + + bundle = CWD / "JSTestComponent.bundle.js" + try: + assert bundle.is_file() + assert 'function render() {\n console.log("foo");\n}' in bundle.read_text() + finally: + bundle.unlink() diff --git a/panel/tests/command/test_serve.py b/panel/tests/command/test_serve.py index f15c451420..74b121f832 100644 --- a/panel/tests/command/test_serve.py +++ b/panel/tests/command/test_serve.py @@ -3,12 +3,14 @@ import tempfile import time +from textwrap import dedent + import pytest import requests from panel.tests.util import ( - linux_only, run_panel_serve, unix_only, wait_for_port, wait_for_regex, - write_file, + NBSR, linux_only, run_panel_serve, unix_only, wait_for_port, + wait_for_regex, write_file, ) @@ -175,3 +177,25 @@ def test_serve_setup(tmp_path): with run_panel_serve(["--port", "0", py, "--setup", setup_py], cwd=tmp_path) as p: _, output = wait_for_regex(p.stdout, regex=regex, return_output=True) assert output[0].strip() == "Setup running before" + + +def test_serve_authorize_callback_exception(tmp_path): + app = "import panel as pn; pn.panel('Hello').servable()" + py = tmp_path / "app.py" + py.write_text(app) + + setup_app = """\ + import panel as pn + def auth(userinfo): + raise ValueError("This is an error") + pn.config.authorize_callback = auth""" + setup_py = tmp_path / "setup.py" + setup_py.write_text(dedent(setup_app)) + + regex = re.compile('(Authorization callback errored)') + with run_panel_serve(["--port", "0", py, "--setup", setup_py], cwd=tmp_path) as p: + nsbr = NBSR(p.stdout) + port = wait_for_port(nsbr) + resp = requests.get(f"http://localhost:{port}/") + wait_for_regex(nsbr, regex=regex) + assert resp.status_code == 403 diff --git a/panel/tests/io/test_cache.py b/panel/tests/io/test_cache.py index 6dd70168ec..9031702b27 100644 --- a/panel/tests/io/test_cache.py +++ b/panel/tests/io/test_cache.py @@ -17,9 +17,7 @@ diskcache = None diskcache_available = pytest.mark.skipif(diskcache is None, reason="requires diskcache") -from panel.io.cache import ( - _find_hash_func, _generate_hash, cache, is_equal, -) +from panel.io.cache import _generate_hash, cache, is_equal from panel.io.state import set_curdoc, state from panel.tests.util import serve_and_wait @@ -28,7 +26,7 @@ ################ def hashes_equal(v1, v2): - a, b = _find_hash_func(v1)(v1), _find_hash_func(v2)(v2) + a, b = _generate_hash(v1), _generate_hash(v2) return a == b def test_str_hash(): @@ -52,6 +50,11 @@ def test_none_hash(): assert hashes_equal(None, None) assert not hashes_equal(None, False) +def test_object_hash(): + obj1, obj2 = object(), object() + assert hashes_equal(obj1, obj1) + assert not hashes_equal(obj1, obj2) + def test_bytes_hash(): assert hashes_equal(b'0', b'0') assert not hashes_equal(b'0', b'1') @@ -70,10 +73,11 @@ def test_list_hash(): assert not hashes_equal([0], [1]) assert not hashes_equal(['a', ['b']], ['a', ['c']]) +def test_list_hash_recursive(): # Recursion l = [0] l.append(l) - assert hashes_equal(l, list(l)) + assert hashes_equal(list(l), list(l)) def test_tuple_hash(): assert hashes_equal((0,), (0,)) @@ -88,10 +92,10 @@ def test_dict_hash(): assert not hashes_equal({'a': 0}, {'a': 1}) assert not hashes_equal({'a': {'b': 0}}, {'a': {'b': 1}}) - # Recursion +def test_dict_hash_recursive(): d = {'a': {}} d['a'] = d - assert hashes_equal(d, dict(d)) + assert hashes_equal(dict(d), dict(d)) def test_stringio_hash(): sio1, sio2 = io.StringIO(), io.StringIO() @@ -151,6 +155,34 @@ def test_series_hash(): series2.iloc[0] = 3.14 assert not hashes_equal(series1, series2) +def test_polars_dataframe_hash(): + pl = pytest.importorskip("polars") + data = { + "A": [0.0, 1.0, 2.0, 3.0, 4.0], + "B": [0.0, 1.0, 0.0, 1.0, 0.0], + "C": ["foo1", "foo2", "foo3", "foo4", "foo5"], + } + # DataFrame + df1, df2 = pl.DataFrame(data), pl.DataFrame(data) + assert hashes_equal(df1, df2) + df2 = df2.with_columns(A=pl.col("A").sort(descending=True)) + assert not hashes_equal(df1, df2) + + # Lazy DataFrame + df1, df2 = pl.LazyFrame(data), pl.LazyFrame(data) + assert hashes_equal(df1, df2) + df2 = df2.with_columns(A=pl.col("A").sort(descending=True)) + assert not hashes_equal(df1, df2) + +def test_polars_series_hash(): + pl = pytest.importorskip("polars") + ser1 = pl.Series([0.0, 1.0, 2.0, 3.0, 4.0]) + ser2 = ser1.clone() + + assert hashes_equal(ser1, ser2) + ser2 = ser2.replace(0.0, 3.14) + assert not hashes_equal(ser1, ser2) + def test_ufunc_hash(): assert hashes_equal(np.absolute, np.absolute) assert not hashes_equal(np.sin, np.cos) diff --git a/panel/tests/io/test_compile.py b/panel/tests/io/test_compile.py index a30c104399..3db35446f7 100644 --- a/panel/tests/io/test_compile.py +++ b/panel/tests/io/test_compile.py @@ -1,10 +1,28 @@ +import pathlib + import pytest +from panel.custom import ReactiveESM from panel.io.compile import ( - packages_from_code, packages_from_importmap, replace_imports, + find_module_bundles, packages_from_code, packages_from_importmap, + replace_imports, ) +class ESM1(ReactiveESM): + pass + + +def test_find_module_bundles_as_dotted_module(): + assert find_module_bundles('panel.tests.io.test_compile') == {pathlib.Path(__file__).parent / 'ESM1.bundle.js': [ESM1]} + +def test_find_module_bundles_as_path(): + path = pathlib.Path(__file__).parent / 'ESM1.bundle.js' + bundles = find_module_bundles(__file__) + assert path in bundles + assert len(bundles[path]) == 1 + assert bundles[path][0].name == 'ESM1' + 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"' diff --git a/panel/tests/layout/test_base.py b/panel/tests/layout/test_base.py index 422cbce9fc..20097b282b 100644 --- a/panel/tests/layout/test_base.py +++ b/panel/tests/layout/test_base.py @@ -494,7 +494,7 @@ def test_widgetbox(document, comm): def test_layout_with_param_setitem(document, comm): import param class TestClass(param.Parameterized): - select = param.ObjectSelector(default=0, objects=[0,1]) + select = param.Selector(default=0, objects=[0,1]) def __init__(self, **params): super().__init__(**params) diff --git a/panel/tests/pane/test_base.py b/panel/tests/pane/test_base.py index 1173831a13..e9a5c59a57 100644 --- a/panel/tests/pane/test_base.py +++ b/panel/tests/pane/test_base.py @@ -17,7 +17,6 @@ Param, ParamFunction, ParamMethod, ParamRef, ReactiveExpr, ) from panel.tests.util import check_layoutable_properties -from panel.util import param_watchers SKIP_PANES = ( Bokeh, ChatMessage, HoloViews, Interactive, IPyWidget, Param, @@ -90,7 +89,7 @@ def test_pane_untracked_watchers(pane, document, comm): except ImportError: pytest.skip("Dependent library could not be imported.") watchers = [ - w for pwatchers in param_watchers(p).values() + w for pwatchers in p.param.watchers.values() for awatchers in pwatchers.values() for w in awatchers ] assert len([wfn for wfn in watchers if wfn not in p._internal_callbacks and not hasattr(wfn.fn, '_watcher_name')]) == 0 diff --git a/panel/tests/test_custom.py b/panel/tests/test_custom.py index 45ef95e0ae..c558134e84 100644 --- a/panel/tests/test_custom.py +++ b/panel/tests/test_custom.py @@ -1,3 +1,5 @@ +import numpy as np +import pandas as pd import param from panel.custom import PyComponent, ReactiveESM @@ -44,6 +46,25 @@ def test_py_component_cleanup(document, comm): assert not spy._view__._models +class ESMDataFrame(ReactiveESM): + + df = param.DataFrame() + + +def test_reactive_esm_sync_dataframe(document, comm): + esm_df = ESMDataFrame() + + model = esm_df.get_root(document, comm) + + esm_df.df = pd.DataFrame({"1": [2]}) + + assert isinstance(model.data.df, dict) + assert len(model.data.df) == 2 + expected = {"index": np.array([0]), "1": np.array([2])} + for col, values in model.data.df.items(): + np.testing.assert_array_equal(values, expected.get(col)) + + class ESMWithChildren(ReactiveESM): child = param.ClassSelector(class_=Viewable) @@ -122,3 +143,18 @@ def test_reactive_esm_children_models_cleanup_on_replace(document, comm): assert ref in md2._models md2_model, _ = md2._models[ref] assert model.data.children == [md2_model] + +class ESMOverride(ReactiveESM): + + width = param.Integer(default=42) + +def test_esm_parameter_override(document, comm): + esm = ESMOverride() + + model = esm.get_root(document, comm) + + assert model.width == 42 + + esm.width = 84 + + assert model.width == 84 diff --git a/panel/tests/test_param.py b/panel/tests/test_param.py index 02abc8503c..18dfb99b91 100644 --- a/panel/tests/test_param.py +++ b/panel/tests/test_param.py @@ -314,7 +314,7 @@ class Test(param.Parameterized): def test_object_selector_param(document, comm): class Test(param.Parameterized): - a = param.ObjectSelector(default='b', objects=[1, 'b', 'c']) + a = param.Selector(default='b', objects=[1, 'b', 'c']) test = Test() test_pane = Param(test) @@ -420,7 +420,7 @@ class Test(param.Parameterized): def test_object_selector_param_overrides(document, comm): class Test(param.Parameterized): - a = param.ObjectSelector(default='b', objects=[1, 'b', 'c']) + a = param.Selector(default='b', objects=[1, 'b', 'c']) test = Test() test_pane = Param(test, widgets={'a': {'options': ['b', 'c'], 'value': 'c'}}) @@ -953,7 +953,7 @@ class Test(param.Parameterized): def test_switch_param_subobject(document, comm): class Test(param.Parameterized): - a = param.ObjectSelector() + a = param.Selector() o1 = Test(name='Subobject 1') o2 = Test(name='Subobject 2') diff --git a/panel/tests/test_viewable.py b/panel/tests/test_viewable.py index 9aef6bcd62..43b83807a9 100644 --- a/panel/tests/test_viewable.py +++ b/panel/tests/test_viewable.py @@ -7,7 +7,9 @@ from panel.interact import interactive from panel.pane import Markdown, Str, panel from panel.param import ParamMethod -from panel.viewable import Viewable, Viewer +from panel.viewable import ( + Child, Children, Viewable, Viewer, is_viewable_param, +) from .util import jb_available @@ -117,3 +119,48 @@ def test_clone_with_non_defaults(): assert ([(k, v) for k, v in sorted(v.param.values().items()) if k not in ('name')] == [(k, v) for k, v in sorted(clone.param.values().items()) if k not in ('name')]) + +def test_is_viewable_parameter(): + class Example(param.Parameterized): + p_dict = param.Dict() + p_child = Child() + p_children = Children() + + # ClassSelector + c_viewable = param.ClassSelector(class_=Viewable) + c_viewables = param.ClassSelector(class_=(Viewable,)) + c_none = param.ClassSelector(class_=None) + c_tuple = param.ClassSelector(class_=tuple) + c_list_tuple = param.ClassSelector(class_=(list, tuple)) + + # List + l_no_item_type = param.List() + l_item_type_viewable = param.List(item_type=Viewable) + l_item_type_not_viewable = param.List(item_type=tuple) + + l_item_types_viewable = param.List(item_type=(Viewable,)) + l_item_types_not_viewable = param.List(item_type=(tuple,)) + l_item_types_not_viewable2 = param.List(item_type=(list, tuple,)) + + example = Example() + + assert not is_viewable_param(example.param.p_dict) + assert is_viewable_param(example.param.p_child) + assert is_viewable_param(example.param.p_children) + + # ClassSelector + assert is_viewable_param(example.param.c_viewable) + assert is_viewable_param(example.param.c_viewables) + assert not is_viewable_param(example.param.c_none) + assert not is_viewable_param(example.param.c_tuple) + assert not is_viewable_param(example.param.c_list_tuple) + + # List + assert not is_viewable_param(example.param.l_no_item_type) + assert not is_viewable_param(example.param.l_no_item_type) + assert is_viewable_param(example.param.l_item_type_viewable) + assert not is_viewable_param(example.param.l_item_type_not_viewable) + + assert is_viewable_param(example.param.l_item_types_viewable) + assert not is_viewable_param(example.param.l_item_types_not_viewable) + assert not is_viewable_param(example.param.l_item_types_not_viewable2) diff --git a/panel/tests/ui/widgets/test_misc.py b/panel/tests/ui/widgets/test_misc.py index 526e77bf85..f8807c907a 100644 --- a/panel/tests/ui/widgets/test_misc.py +++ b/panel/tests/ui/widgets/test_misc.py @@ -5,6 +5,10 @@ import param import pytest +pytest.importorskip("playwright") + +from playwright.sync_api import expect + from panel.layout import Column, Tabs from panel.tests.util import serve_component from panel.widgets import FileDownload, TextInput @@ -13,6 +17,18 @@ not_windows = pytest.mark.skipif(sys.platform=='win32', reason="Does not work on Windows") +def test_file_download_label_updates(page): + + download = FileDownload(filename='f.txt', embed=False, callback=lambda: io.StringIO()) + + serve_component(page, download) + + expect(page.locator('.bk-btn a')).to_have_text('Download f.txt') + + download.filename = 'g.txt' + + expect(page.locator('.bk-btn a')).to_have_text('Download g.txt') + @not_windows def test_file_download_updates_when_navigating_between_dynamic_tabs(page): text_input = TextInput(value='abc') diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 8663a597f2..c7643ed344 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -23,6 +23,7 @@ from panel.io.state import state from panel.layout.base import Column from panel.models.tabulator import _TABULATOR_THEMES_MAPPING +from panel.pane import Markdown from panel.tests.util import get_ctrl_modifier, serve_component, wait_until from panel.util import BOKEH_GE_3_6 from panel.widgets import Select, Tabulator, TextInput @@ -4079,3 +4080,17 @@ def test_tabulator_header_tooltips(page): page.wait_for_timeout(200) expect(page.locator('.tabulator-tooltip')).to_have_text("Test") + + +def test_tabulator_row_content_markup_wrap(page): + # https://github.com/holoviz/panel/issues/7388 + + df = pd.DataFrame({"col": ["foo"]}) + long_markdown = Markdown("xxxx " * 50) + widget = Tabulator(df, row_content=lambda row: long_markdown, expanded=[0], width=200) + + serve_component(page, widget) + + md = page.locator('.row-content .bk-panel-models-markup-HTML') + + assert md.bounding_box()['height'] >= 130 diff --git a/panel/tests/util.py b/panel/tests/util.py index e7d87b3ac7..f86f1a5cc1 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -395,7 +395,10 @@ def readline(self, timeout=None): return None def wait_for_regex(stdout, regex, count=1, return_output=False): - nbsr = NBSR(stdout) + if isinstance(stdout, NBSR): + nbsr = stdout + else: + nbsr = NBSR(stdout) m = None output, found = [], [] for _ in range(20): diff --git a/panel/tests/widgets/test_base.py b/panel/tests/widgets/test_base.py index 8f0ab1bfb4..1dcb172cd6 100644 --- a/panel/tests/widgets/test_base.py +++ b/panel/tests/widgets/test_base.py @@ -5,7 +5,6 @@ from panel.layout import Row from panel.links import CallbackGenerator from panel.tests.util import check_layoutable_properties -from panel.util import param_watchers from panel.widgets import ( CompositeWidget, Dial, FileDownload, FloatSlider, LinearGauge, LoadingSpinner, Terminal, TextInput, ToggleGroup, Tqdm, Widget, @@ -38,7 +37,7 @@ def test_widget_untracked_watchers(widget, document, comm): except ImportError: pytest.skip("Dependent library could not be imported.") watchers = [ - w for pwatchers in param_watchers(widg).values() + w for pwatchers in widg.param.watchers.values() for awatchers in pwatchers.values() for w in awatchers ] assert len([wfn for wfn in watchers if wfn not in widg._internal_callbacks and not hasattr(wfn.fn, '_watcher_name')]) == 0 diff --git a/panel/tests/widgets/test_slider.py b/panel/tests/widgets/test_slider.py index 6147c03d9f..a588805b5a 100644 --- a/panel/tests/widgets/test_slider.py +++ b/panel/tests/widgets/test_slider.py @@ -1,5 +1,6 @@ from datetime import date, datetime +import numpy as np import pytest from bokeh.models import ( @@ -8,9 +9,9 @@ from panel import config from panel.widgets import ( - DateRangeSlider, DateSlider, DatetimeRangeSlider, DiscreteSlider, - EditableFloatSlider, EditableIntSlider, EditableRangeSlider, FloatSlider, - IntSlider, RangeSlider, StaticText, + DateRangeSlider, DateSlider, DatetimeRangeSlider, DatetimeSlider, + DiscreteSlider, EditableFloatSlider, EditableIntSlider, + EditableRangeSlider, FloatSlider, IntSlider, RangeSlider, StaticText, ) @@ -156,6 +157,117 @@ def test_date_slider(document, comm): assert widget.value == 1620777600000 +@pytest.mark.parametrize("start", [date(2018, 9, 1), datetime(2018, 9, 1)]) +@pytest.mark.parametrize("end", [date(2018, 9, 10), datetime(2018, 9, 10)]) +@pytest.mark.parametrize("value", [date(2018, 9, 4), datetime(2018, 9, 4)]) +def test_datetime_slider(document, comm, value, start, end): + datetime_slider = DatetimeSlider( + name='DatetimeSlider', + value=value, + start=start, + end=end, + ) + assert datetime_slider.start == start + assert datetime_slider.end == end + assert datetime_slider.value == value + + widget = datetime_slider.get_root(document, comm=comm) + + assert isinstance(widget, datetime_slider._widget_type) + assert widget.title == 'DatetimeSlider' + assert widget.value == 1536019200000 + assert widget.start == 1535760000000.0 + assert widget.end == 1536537600000.0 + + epoch = datetime(1970, 1, 1) + epoch_time = lambda dt: (dt - epoch).total_seconds() * 1000 + widget.value = epoch_time(datetime(2018, 9, 3)) + datetime_slider._process_events({'value': widget.value}) + assert datetime_slider.value == datetime(2018, 9, 3) + datetime_slider._process_events({'value_throttled': epoch_time(datetime(2018, 9, 3))}) + assert datetime_slider.value_throttled == datetime(2018, 9, 3) + + # Test raw timestamp value: + datetime_slider._process_events({'value': epoch_time(datetime(2018, 9, 4))}) + assert datetime_slider.value == datetime(2018, 9, 4) + datetime_slider._process_events({'value_throttled': epoch_time(datetime(2018, 9, 4))}) + assert datetime_slider.value_throttled == datetime(2018, 9, 4) + + datetime_slider.value = datetime(2018, 9, 6) + assert widget.value == 1536192000000 + + # Testing throttled mode + with config.set(throttled=True): + datetime_slider._process_events({'value': epoch_time(datetime(2021, 5, 15))}) + assert datetime_slider.value == datetime(2018, 9, 6) # no change + datetime_slider._process_events({'value_throttled': epoch_time(datetime(2021, 5, 15))}) + assert datetime_slider.value == datetime(2021, 5, 15) + + datetime_slider.value = datetime(2021, 5, 12) + assert widget.value == 1620777600000 + + +def test_datetime_slider_np_datetime64(document, comm): + start = np.datetime64('2018-09-01') + end = np.datetime64('2018-09-10') + value = np.datetime64('2018-09-04') + + datetime_slider = DatetimeSlider( + name='DatetimeSlider', + value=value, + start=start, + end=end, + ) + assert datetime_slider.start == start + assert datetime_slider.end == end + assert datetime_slider.value == value + + widget = datetime_slider.get_root(document, comm=comm) + + assert isinstance(widget, datetime_slider._widget_type) + assert widget.title == 'DatetimeSlider' + assert widget.value == value + assert widget.start == start + assert widget.end == end + + widget.value = np.datetime64('2018-09-03') + datetime_slider._process_events({'value': widget.value}) + assert datetime_slider.value == np.datetime64('2018-09-03') + datetime_slider._process_events({'value_throttled': np.datetime64('2018-09-03')}) + assert datetime_slider.value_throttled == np.datetime64('2018-09-03') + + # Test raw timestamp value: + datetime_slider._process_events({'value': np.datetime64('2018-09-04')}) + assert datetime_slider.value == np.datetime64('2018-09-04') + datetime_slider._process_events({'value_throttled': np.datetime64('2018-09-04')}) + assert datetime_slider.value_throttled == np.datetime64('2018-09-04') + + datetime_slider.value = np.datetime64('2018-09-06') + assert widget.value == np.datetime64('2018-09-06') + + # Testing throttled mode + with config.set(throttled=True): + datetime_slider._process_events({'value': np.datetime64('2021-05-15')}) + assert datetime_slider.value == np.datetime64('2018-09-06') # no change + datetime_slider._process_events({'value_throttled': np.datetime64('2021-05-15')}) + assert datetime_slider.value == np.datetime64('2021-05-15') + + datetime_slider.value = np.datetime64('2021-05-12') + assert widget.value == np.datetime64('2021-05-12') + + +def test_datetime_slider_param_as_datetime_is_readonly(): + assert DatetimeSlider.param.as_datetime.readonly + assert DatetimeSlider().param.as_datetime.readonly + datetime_slider = DatetimeSlider( + name='DatetimeSlider', + value=datetime(2018, 9, 4), + start=datetime(2018, 9, 1), + end=datetime(2018, 9, 10), + ) + assert datetime_slider.param.as_datetime.readonly + + def test_date_range_slider(document, comm): date_slider = DateRangeSlider(name='DateRangeSlider', value=(datetime(2018, 9, 2), datetime(2018, 9, 4)), diff --git a/panel/theme/css/bootstrap.css b/panel/theme/css/bootstrap.css index 6a7bdbac5c..1792eddb85 100644 --- a/panel/theme/css/bootstrap.css +++ b/panel/theme/css/bootstrap.css @@ -869,15 +869,19 @@ div .tabulator .tabulator-header .tabulator-col { } /* Quill editor */ - .ql-editor, .ql-editor.ql-blank::before { color: var(--bs-body-color); } /* Float panel */ - .jsPanel .jsPanel-content { background-color: var(--bs-body-bg); color: var(--bs-body-color); } + +/* Copy Button */ +.copybtn { + background-color: var(--bs-body-bg); + color: var(--bs-body-color); +} diff --git a/panel/theme/css/fast.css b/panel/theme/css/fast.css index 232d482b2d..62997c4b23 100644 --- a/panel/theme/css/fast.css +++ b/panel/theme/css/fast.css @@ -1107,7 +1107,6 @@ table.panel-df { } /* Quill editor */ - .ql-editor { color: var(--neutral-foreground-rest); } @@ -1117,8 +1116,13 @@ table.panel-df { } /* Float panel */ - .jsPanel .jsPanel-content { background-color: var(--background-color); color: var(--neutral-foreground-rest); } + +/* Copy Button */ +.copybtn { + background-color: var(--neutral-fill-input-rest); + color: var(--neutral-foreground-rest); +} diff --git a/panel/theme/css/material.css b/panel/theme/css/material.css index 42d79fd9b3..5bca10494d 100644 --- a/panel/theme/css/material.css +++ b/panel/theme/css/material.css @@ -582,8 +582,13 @@ div .tabulator .tabulator-header .tabulator-col { } /* Float panel */ - .jsPanel .jsPanel-content { background-color: var(--mdc-theme-background); color: var(--mdc-theme-on-background); } + +/* Copy Button */ +.copybtn { + background-color: var(--mdc-theme-background); + color: var(--mdc-theme-on-background); +} diff --git a/panel/theme/css/native.css b/panel/theme/css/native.css index e46a86109a..d1a2ac0873 100644 --- a/panel/theme/css/native.css +++ b/panel/theme/css/native.css @@ -44,14 +44,12 @@ } /* Help Icon */ - .bk-description > .bk-icon { background-color: var(--background-text-color); opacity: 0.4; } /* Input widgets */ - textarea.bk-input { padding: 0.5em var(--padding-horizontal); } @@ -68,7 +66,6 @@ textarea.bk-input { } /* Quill editor */ - .ql-editor, .ql-editor.ql-blank::before { color: var(--background-text-color); @@ -90,8 +87,16 @@ textarea.bk-input { } /* Float panel */ - .jsPanel .jsPanel-content { background-color: var(--background-color); color: var(--background-text-color); } + +/* Copy Button */ +.copybtn { + background-color: #bbb; +} + +.copybtn:active { + background-color: #bbb; +} diff --git a/panel/util/__init__.py b/panel/util/__init__.py index a8820fcfe4..b7edd2cba9 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -37,7 +37,7 @@ is_series, isdatetime, isfile, isIn, isurl, ) from .parameters import ( # noqa - edit_readonly, extract_dependencies, get_method_owner, param_watchers, + edit_readonly, extract_dependencies, get_method_owner, recursive_parameterized, ) diff --git a/panel/util/parameters.py b/panel/util/parameters.py index ec7575926f..7474a91ae8 100644 --- a/panel/util/parameters.py +++ b/panel/util/parameters.py @@ -7,10 +7,6 @@ import param -from packaging.version import Version - -_unset = object() - def should_inherit(parameterized: param.Parameterized, p: str, v: Any) -> Any: pobj = parameterized.param[p] @@ -85,19 +81,6 @@ def extract_dependencies(function): return params -def param_watchers(parameterized: param.Parameterized, value=_unset): - if Version(param.__version__) <= Version('2.0.0a2'): - if value is not _unset: - parameterized._param_watchers = value - else: - return parameterized._param_watchers - else: - if value is not _unset: - parameterized.param.watchers = value - else: - return parameterized.param.watchers - - def recursive_parameterized(parameterized: param.Parameterized, objects=None) -> list[param.Parameterized]: """ Recursively searches a Parameterized object for other Parmeterized diff --git a/panel/util/warnings.py b/panel/util/warnings.py index 9c6938b1df..5284046715 100644 --- a/panel/util/warnings.py +++ b/panel/util/warnings.py @@ -59,14 +59,21 @@ def deprecated( remove_version: Version | str, old: str, new: str | None = None, + *, extra: str | None = None, + warn_version: Version | str | None = None ) -> None: + from .. import __version__ - import panel as pn - - current_version = Version(pn.__version__) + current_version = Version(__version__) base_version = Version(current_version.base_version) + if warn_version: + if isinstance(warn_version, str): + warn_version = Version(warn_version) + if base_version < warn_version: + return + if isinstance(remove_version, str): remove_version = Version(remove_version) diff --git a/panel/viewable.py b/panel/viewable.py index f346df18e6..d1786f9193 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -12,6 +12,7 @@ import asyncio import functools +import inspect import logging import os import sys @@ -86,7 +87,7 @@ class Layoutable(param.Parameterized): css_classes = param.List(default=[], nested_refs=True, doc=""" CSS classes to apply to the layout.""") - design = param.ObjectSelector(default=None, objects=[], doc=""" + design = param.Selector(default=None, objects=[], doc=""" The design system to use to style components.""") height = param.Integer(default=None, bounds=(0, None), doc=""" @@ -127,7 +128,7 @@ class Layoutable(param.Parameterized): The width of the component (in pixels). This can be either fixed or preferred width, depending on width sizing policy.""") - width_policy = param.ObjectSelector( + width_policy = param.Selector( default="auto", objects=['auto', 'fixed', 'fit', 'min', 'max'], doc=""" Describes how the component should maintain its width. @@ -159,7 +160,7 @@ class Layoutable(param.Parameterized): management and other factors. """) - height_policy = param.ObjectSelector( + height_policy = param.Selector( default="auto", objects=['auto', 'fixed', 'fit', 'min', 'max'], doc=""" Describes how the component should maintain its height. @@ -191,7 +192,7 @@ class Layoutable(param.Parameterized): management and other factors. """) - sizing_mode = param.ObjectSelector(default=None, objects=[ + sizing_mode = param.Selector(default=None, objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None], doc=""" How the component should size itself. @@ -600,9 +601,9 @@ def _preprocess(self, root: 'Model', changed=None, old_models=None) -> None: changed = self if changed is None else changed hooks = self._preprocessing_hooks+self._hooks for hook in hooks: - try: + if len(inspect.signature(hook).parameters) >= 4: hook(self, root, changed, old_models) - except TypeError: + else: hook(self, root) def _render_model(self, doc: Optional[Document] = None, comm: Optional[Comm] = None) -> 'Model': @@ -1193,34 +1194,42 @@ def __set__(self, obj, val): +def _is_viewable_class_selector(class_selector: param.ClassSelector) -> bool: + if not class_selector.class_: + return False + if isinstance(class_selector.class_, tuple): + return all(issubclass(cls, Viewable) for cls in class_selector.class_) + return issubclass(class_selector.class_, Viewable) + +def _is_viewable_list(param_list: param.List) -> bool: + if not param_list.item_type: + return False + if isinstance(param_list.item_type, tuple): + return all(issubclass(cls, Viewable) for cls in param_list.item_type) + return issubclass(param_list.item_type, Viewable) + + def is_viewable_param(parameter: param.Parameter) -> bool: """ - Detects whether the Parameter uniquely identifies a Viewable - type. + Determines if a parameter uniquely identifies a Viewable type. Arguments --------- - parameter: param.Parameter + parameter : param.Parameter + The parameter to evaluate. Returns ------- - Whether the Parameter specieis a Parameter type + bool + True if the parameter specifies a Viewable type, False otherwise. """ - p = parameter - if ( - isinstance(p, (Child, Children)) or - (isinstance(p, param.ClassSelector) and p.class_ and ( - (isinstance(p.class_, tuple) and - all(issubclass(cls, Viewable) for cls in p.class_)) or - issubclass(p.class_, Viewable) - )) or - (isinstance(p, param.List) and p.item_type and ( - (isinstance(p.item_type, tuple) and - all(issubclass(cls, Viewable) for cls in p.item_type)) or - issubclass(p.item_type, Viewable) - )) - ): + if isinstance(parameter, (Child, Children)): + return True + if isinstance(parameter, param.ClassSelector) and _is_viewable_class_selector(parameter): return True + if isinstance(parameter, param.List) and _is_viewable_list(parameter): + return True + return False diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 02d8180038..b805aadc32 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -57,9 +57,9 @@ RadioButtonGroup, Select, ToggleGroup, ) from .slider import ( # noqa - DateRangeSlider, DateSlider, DatetimeRangeSlider, DiscreteSlider, - EditableFloatSlider, EditableIntSlider, EditableRangeSlider, FloatSlider, - IntRangeSlider, IntSlider, RangeSlider, + DateRangeSlider, DateSlider, DatetimeRangeSlider, DatetimeSlider, + DiscreteSlider, EditableFloatSlider, EditableIntSlider, + EditableRangeSlider, FloatSlider, IntRangeSlider, IntSlider, RangeSlider, ) from .speech_to_text import Grammar, GrammarList, SpeechToText # noqa from .tables import DataFrame, Tabulator # noqa @@ -87,6 +87,7 @@ "DateRangeSlider", "DatetimeRangeSlider", "DateSlider", + "DatetimeSlider", "DatetimeInput", "DatetimePicker", "DatetimeRangeInput", diff --git a/panel/widgets/button.py b/panel/widgets/button.py index 83b08835d7..344b3841f7 100644 --- a/panel/widgets/button.py +++ b/panel/widgets/button.py @@ -33,12 +33,12 @@ class _ButtonBase(Widget): - button_type = param.ObjectSelector(default='default', objects=BUTTON_TYPES, doc=""" + button_type = param.Selector(default='default', objects=BUTTON_TYPES, doc=""" A button theme; should be one of 'default' (white), 'primary' (blue), 'success' (green), 'info' (yellow), 'light' (light), or 'danger' (red).""") - button_style = param.ObjectSelector(default='solid', objects=BUTTON_STYLES, doc=""" + button_style = param.Selector(default='solid', objects=BUTTON_STYLES, doc=""" A button style to switch between 'solid', 'outline'.""") _rename: ClassVar[Mapping[str, str | None]] = {'name': 'label', 'button_style': None} diff --git a/panel/widgets/codeeditor.py b/panel/widgets/codeeditor.py index 8e766afd85..7549ad2ce7 100644 --- a/panel/widgets/codeeditor.py +++ b/panel/widgets/codeeditor.py @@ -49,7 +49,7 @@ class CodeEditor(Widget): readonly = param.Boolean(default=False, doc=""" Define if editor content can be modified. Alias for disabled.""") - theme = param.ObjectSelector(default="chrome", objects=list(ace_themes), + theme = param.Selector(default="chrome", objects=list(ace_themes), doc="Theme of the editor") value = param.String(default="", doc=""" diff --git a/panel/widgets/indicators.py b/panel/widgets/indicators.py index 56f1c87061..65c434565a 100644 --- a/panel/widgets/indicators.py +++ b/panel/widgets/indicators.py @@ -66,7 +66,7 @@ class Indicator(Widget): Indicator is a baseclass for widgets which indicate some state. """ - sizing_mode = param.ObjectSelector(default='fixed', objects=[ + sizing_mode = param.Selector(default='fixed', objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None]) @@ -158,7 +158,7 @@ class BooleanStatus(BooleanIndicator): >>> BooleanStatus(value=True, color='primary', width=100, height=100) """ - color = param.ObjectSelector(default='dark', objects=[ + color = param.Selector(default='dark', objects=[ 'primary', 'secondary', 'success', 'info', 'danger', 'warning', 'light', 'dark'], doc=""" The color of the circle, one of 'primary', 'secondary', 'success', 'info', 'danger', 'warning', 'light', 'dark'""") @@ -203,9 +203,9 @@ class LoadingSpinner(BooleanIndicator): >>> LoadingSpinner(value=True, color='primary', bgcolor='light', width=100, height=100) """ - bgcolor = param.ObjectSelector(default='light', objects=['dark', 'light']) + bgcolor = param.Selector(default='light', objects=['dark', 'light']) - color = param.ObjectSelector(default='dark', objects=[ + color = param.Selector(default='dark', objects=[ 'primary', 'secondary', 'success', 'info', 'danger', 'warning', 'light', 'dark']) @@ -284,13 +284,13 @@ class Progress(ValueIndicator): If no value is set the active property toggles animation of the progress bar on and off.""") - bar_color = param.ObjectSelector(default='success', objects=[ + bar_color = param.Selector(default='success', objects=[ 'primary', 'secondary', 'success', 'info', 'danger', 'warning', 'light', 'dark']) max = param.Integer(default=100, doc="The maximum value of the progress bar.") - sizing_mode = param.ObjectSelector(default=None, objects=[ + sizing_mode = param.Selector(default=None, objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None]) @@ -1120,7 +1120,7 @@ class Trend(SyncableData, Indicator): data = param.Parameter(doc=""" The plot data declared as a dictionary of arrays or a DataFrame.""") - layout = param.ObjectSelector(default="column", objects=["column", "row"]) + layout = param.Selector(default="column", objects=["column", "row"]) plot_x = param.String(default="x", doc=""" The name of the key in the plot_data to use on the x-axis.""") @@ -1131,7 +1131,7 @@ class Trend(SyncableData, Indicator): plot_color = param.String(default=BLUE, doc=""" The color to use in the plot.""") - plot_type = param.ObjectSelector(default="bar", objects=["line", "step", "area", "bar"], doc=""" + plot_type = param.Selector(default="bar", objects=["line", "step", "area", "bar"], doc=""" The plot type to render the plot data as.""") pos_color = param.String(GREEN, doc=""" @@ -1140,7 +1140,7 @@ class Trend(SyncableData, Indicator): neg_color = param.String(RED, doc=""" The color used to indicate a negative change.""") - sizing_mode = param.ObjectSelector(default=None, objects=[ + sizing_mode = param.Selector(default=None, objects=[ 'fixed', 'stretch_width', 'stretch_height', 'stretch_both', 'scale_width', 'scale_height', 'scale_both', None]) diff --git a/panel/widgets/input.py b/panel/widgets/input.py index bcf884579a..b5337e977f 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -171,7 +171,7 @@ class TextAreaInput(_TextInputBase): rows = param.Integer(default=2, doc=""" Number of rows in the text input field.""") - resizable = param.ObjectSelector( + resizable = param.Selector( objects=["both", "width", "height", False], doc=""" Whether the layout is interactively resizable, and if so in which dimensions: `width`, `height`, or `both`. @@ -1141,7 +1141,7 @@ class LiteralInput(Widget): placeholder = param.String(default='', doc=""" Placeholder for empty input field.""") - serializer = param.ObjectSelector(default='ast', objects=['ast', 'json'], doc=""" + serializer = param.Selector(default='ast', objects=['ast', 'json'], doc=""" The serialization (and deserialization) method to use. 'ast' uses ast.literal_eval and 'json' uses json.loads and json.dumps. """) diff --git a/panel/widgets/misc.py b/panel/widgets/misc.py index 027ac2340e..aea99669a9 100644 --- a/panel/widgets/misc.py +++ b/panel/widgets/misc.py @@ -39,7 +39,7 @@ class VideoStream(Widget): >>> VideoStream(name='Video Stream', timeout=100) """ - format = param.ObjectSelector(default='png', objects=['png', 'jpeg'], + format = param.Selector(default='png', objects=['png', 'jpeg'], doc=""" The file format as which the video is returned.""") @@ -86,12 +86,12 @@ class FileDownload(IconMixin): Whether to download on the initial click or allow for right-click save as.""") - button_type = param.ObjectSelector(default='default', objects=BUTTON_TYPES, doc=""" + button_type = param.Selector(default='default', objects=BUTTON_TYPES, doc=""" A button theme; should be one of 'default' (white), 'primary' (blue), 'success' (green), 'info' (yellow), 'light' (light), or 'danger' (red).""") - button_style = param.ObjectSelector(default='solid', objects=BUTTON_STYLES, doc=""" + button_style = param.Selector(default='solid', objects=BUTTON_STYLES, doc=""" A button style to switch between 'solid', 'outline'.""") callback = param.Callable(default=None, allow_refs=False, doc=""" diff --git a/panel/widgets/player.py b/panel/widgets/player.py index 93d94b656a..7f320125c6 100644 --- a/panel/widgets/player.py +++ b/panel/widgets/player.py @@ -30,7 +30,7 @@ class PlayerBase(Widget): Interval between updates, in milliseconds. Default is 500, i.e. two updates per second.""") - loop_policy = param.ObjectSelector( + loop_policy = param.Selector( default='once', objects=['once', 'loop', 'reflect'], doc=""" Policy used when player hits last frame""") @@ -49,7 +49,7 @@ class PlayerBase(Widget): height = param.Integer(default=80) - value_align = param.ObjectSelector( + value_align = param.Selector( objects=["start", "center", "end"], doc=""" Location to display the value of the slider ("start", "center", "end")""") diff --git a/panel/widgets/slider.py b/panel/widgets/slider.py index fc65e60024..305ad5f0fa 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -29,6 +29,7 @@ from ..io import state from ..io.resources import CDN_DIST from ..layout import Column, Panel, Row +from ..models.datetime_slider import DatetimeSlider as _BkDatetimeSlider from ..util import ( datetime_as_utctimestamp, edit_readonly, param_reprs, value_as_date, value_as_datetime, @@ -48,7 +49,7 @@ class _SliderBase(Widget): bar_color = param.Color(default="#e6e6e6", doc="""""") - direction = param.ObjectSelector(default='ltr', objects=['ltr', 'rtl'], doc=""" + direction = param.Selector(default='ltr', objects=['ltr', 'rtl'], doc=""" Whether the slider should go from left-to-right ('ltr') or right-to-left ('rtl').""") @@ -56,7 +57,7 @@ class _SliderBase(Widget): The name of the widget. Also used as the label of the widget. If not set, the widget has no label.""") - orientation = param.ObjectSelector(default='horizontal', objects=['horizontal', 'vertical'], + orientation = param.Selector(default='horizontal', objects=['horizontal', 'vertical'], doc=""" Whether the slider should be oriented horizontally or vertically.""") @@ -319,6 +320,36 @@ def _process_property_change(self, msg): return msg +class DatetimeSlider(DateSlider): + """ + The DatetimeSlider widget allows selecting a value within a set of + bounds using a slider. Supports datetime.date, datetime.datetime + and np.datetime64 values. The step size is fixed at 1 minute. + + Reference: https://panel.holoviz.org/reference/widgets/DatetimeSlider.html + + :Example: + + >>> import datetime as dt + >>> DatetimeSlider( + ... value=dt.datetime(2025, 1, 1), + ... start=dt.datetime(2025, 1, 1), + ... end=dt.datetime(2025, 1, 7), + ... name="A datetime value" + ... ) + """ + + as_datetime = param.Boolean(default=True, readonly=True, doc=""" + Whether to store the date as a datetime.""") + + step = param.Number(default=60, bounds=(1, None), doc=""" + The step size in seconds. Default is 1 minute, i.e 60 seconds.""") + + _property_conversion = staticmethod(value_as_datetime) + + _widget_type: ClassVar[type[Model]] = _BkDatetimeSlider + + class DiscreteSlider(CompositeWidget, _SliderBase): """ The DiscreteSlider widget allows selecting a value from a discrete diff --git a/panel/widgets/speech_to_text.py b/panel/widgets/speech_to_text.py index 93274af00f..0bede0a8d3 100644 --- a/panel/widgets/speech_to_text.py +++ b/panel/widgets/speech_to_text.py @@ -318,7 +318,7 @@ class SpeechToText(Widget): incoming audio, and attempts to return a RecognitionResult using the audio captured so far.""") - lang = param.ObjectSelector(default="", objects=[""] + LANGUAGE_CODES, + lang = param.Selector(default="", objects=[""] + LANGUAGE_CODES, allow_None=True, label="Language", doc=""" The language of the current SpeechRecognition in BCP 47 format. For example 'en-US'. If not specified, this defaults @@ -353,7 +353,7 @@ class SpeechToText(Widget): button_hide = param.Boolean(default=False, label="Hide the Button", doc=""" If True no button is shown. If False a toggle Start/ Stop button is shown.""") - button_type = param.ObjectSelector(default="light", objects=BUTTON_TYPES, doc=""" + button_type = param.Selector(default="light", objects=BUTTON_TYPES, doc=""" The button styling.""") button_not_started = param.String(label="Button Text when not started", doc=""" diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 6feb6d61e5..9663346b11 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -934,7 +934,7 @@ class DataFrame(BaseTable): auto_edit = param.Boolean(default=False, doc=""" Whether clicking on a table cell automatically starts edit mode.""") - autosize_mode = param.ObjectSelector(default='force_fit', objects=[ + autosize_mode = param.Selector(default='force_fit', objects=[ "none", "fit_columns", "fit_viewport", "force_fit"], doc=""" Determines the column autosizing mode, as one of the following options: @@ -1144,14 +1144,14 @@ class Tabulator(BaseTable): hidden_columns = param.List(default=[], nested_refs=True, doc=""" List of columns to hide.""") - layout = param.ObjectSelector(default='fit_data_table', objects=[ + layout = param.Selector(default='fit_data_table', objects=[ 'fit_data', 'fit_data_fill', 'fit_data_stretch', 'fit_data_table', 'fit_columns']) initial_page_size = param.Integer(default=20, bounds=(1, None), doc=""" Initial page size if page_size is None and therefore automatically set.""") - pagination = param.ObjectSelector(default=None, allow_None=True, + pagination = param.Selector(default=None, allow_None=True, objects=['local', 'remote']) page = param.Integer(default=1, doc=""" @@ -1199,7 +1199,7 @@ class Tabulator(BaseTable): Can either be specified as a simple boolean toggling the behavior on and off or as a dictionary specifying the option per column.""") - theme = param.ObjectSelector( + theme = param.Selector( default="simple", objects=[ 'default', 'site', 'simple', 'midnight', 'modern', 'bootstrap', 'bootstrap4', 'materialize', 'bulma', 'semantic-ui', 'fast', diff --git a/panel/widgets/text_to_speech.py b/panel/widgets/text_to_speech.py index 236b2bed11..de84d43df3 100644 --- a/panel/widgets/text_to_speech.py +++ b/panel/widgets/text_to_speech.py @@ -90,7 +90,7 @@ class Utterance(param.Parameterized): spoken. The text may be provided as plain text, or a well-formed SSML document.""") - lang = param.ObjectSelector(default="", doc=""" + lang = param.Selector(default="", doc=""" The language of the utterance.""") pitch = param.Number(default=1.0, bounds=(0.0, 2.0), doc=""" @@ -101,7 +101,7 @@ class Utterance(param.Parameterized): The speed at which the utterance will be spoken at expressed as a number between 0.1 and 10.""" ) - voice = param.ObjectSelector(doc=""" + voice = param.Selector(doc=""" The voice that will be used to speak the utterance.""") volume = param.Number(default=1.0, bounds=(0.0, 1.0), doc=""" The diff --git a/pixi.toml b/pixi.toml index dec24c81d6..9cf82be3f5 100644 --- a/pixi.toml +++ b/pixi.toml @@ -25,6 +25,7 @@ lite = ["py311", "lite"] [dependencies] nodejs = ">=18" +esbuild = "*" nomkl = "*" pip = "*" # Required @@ -141,6 +142,7 @@ ipympl = "*" ipyvuetify = "*" ipywidgets_bokeh = "*" numba = "*" +polars = "*" reacton = "*" scipy = "*" textual = "*" diff --git a/pyproject.toml b/pyproject.toml index 3138f65395..b7b34d79c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,13 +71,13 @@ HoloViz = "https://holoviz.org/" [project.optional-dependencies] recommended = [ 'jupyterlab', - 'holoviews >=1.16.0', + 'holoviews >=1.18.0', 'matplotlib', 'pillow', 'plotly', ] fastapi = [ - 'bokeh-fastapi >= 0.1.0', + 'bokeh-fastapi >= 0.1.1', 'fastapi[standard]', ] dev = [ @@ -111,6 +111,7 @@ raw-options = { version_scheme = "no-guess-dev" } [tool.hatch.build.targets.wheel] include = ["panel"] +exclude = ["panel/node_modules"] [tool.hatch.build.targets.wheel.force-include] "panel/dist" = "panel/dist" @@ -121,7 +122,7 @@ include = ["panel"] [tool.hatch.build.targets.sdist] include = ["panel", "scripts", "examples"] -exclude = ["scripts/jupyterlite"] +exclude = ["scripts/jupyterlite", "panel/node_modules"] [tool.hatch.build.targets.sdist.force-include] "panel/dist" = "panel/dist" @@ -224,6 +225,9 @@ filterwarnings = [ "ignore:distutils Version classes are deprecated:DeprecationWarning:ipywidgets_bokeh.kernel", # OK "ignore:unclosed file <_io.TextIOWrapper name='(/dev/null|nul)' mode='w':ResourceWarning", # OK "ignore:Deprecated in traitlets 4.1, use the instance .metadata dictionary directly", # OK (ipywidgets internal) + # 2024-11 + "ignore:websockets.legacy is deprecated:DeprecationWarning", # https://github.com/encode/uvicorn/issues/1908 + "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", # https://github.com/encode/uvicorn/issues/1908 ] [tool.mypy] diff --git a/scripts/verify_build_size.py b/scripts/verify_build_size.py new file mode 100644 index 0000000000..b71384d1c9 --- /dev/null +++ b/scripts/verify_build_size.py @@ -0,0 +1,32 @@ +import sys + +from pathlib import Path + +EXPECTED_SIZES_MB = { + "conda": 25, + "npm": 25, + "sdist": 30, + "whl": 30, +} + +GLOB_PATH = { + "conda": "dist/*.tar.bz2", + "npm": "panel/*.tgz", + "sdist": "dist/*.tar.gz", + "whl": "dist/*.whl", +} + +PATH = Path(__file__).parents[1] + + +def main(build): + files = list(PATH.rglob(GLOB_PATH[build])) + assert len(files) == 1, f"Expected one {build} file, got {len(files)}" + + size = files[0].stat().st_size / 1024**2 + assert size < EXPECTED_SIZES_MB[build], f"{build} file is too large: {size:.2f} MB" + print(f"{build} file size: {size:.2f} MB") + + +if __name__ == "__main__": + main(sys.argv[1])