diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f362e6edb95..16135a21bf2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,6 +8,19 @@ on: branches: - '*' workflow_dispatch: + inputs: + target: + description: "How much of the test suite to run" + type: choice + default: default + options: + - default + - full + - downstream + cache: + description: "Use cache" + type: boolean + default: true schedule: - cron: '0 19 * * SUN' @@ -21,16 +34,82 @@ jobs: runs-on: 'ubuntu-latest' steps: - uses: holoviz-dev/holoviz_tasks/pre-commit@v0.1a19 + setup: + name: Setup workflow + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + code_change: ${{ steps.filter.outputs.code }} + doc_change: ${{ steps.filter.outputs.doc }} + matrix: ${{ env.MATRIX }} + steps: + - uses: actions/checkout@v3 + if: github.event_name != 'pull_request' + - name: Check for code changes + uses: dorny/paths-filter@v2.11.1 + id: filter + with: + filters: | + code: + - 'panel/**' + - 'examples/**' + - 'setup.py' + - 'pyproject.toml' + - 'tox.ini' + - '.github/workflows/test.yaml' + doc: + - 'doc/getting_started/**' + - 'doc/how_to/**' + - 'scripts/**' + - name: Set matrix option + run: | + if [[ '${{ github.event_name }}' == 'workflow_dispatch' ]]; then + OPTION=${{ github.event.inputs.target }} + elif [[ '${{ github.event_name }}' == 'schedule' ]]; then + OPTION="full" + elif [[ '${{ github.event_name }}' == 'push' && '${{ github.ref_type }}' == 'tag' ]]; then + OPTION="full" + else + OPTION="default" + fi + echo "MATRIX_OPTION=$OPTION" >> $GITHUB_ENV + - name: Set test matrix with 'default' option + if: env.MATRIX_OPTION == 'default' + run: | + MATRIX=$(jq -nsc '{ + "os": ["ubuntu-latest", "macos-latest", "windows-latest"], + "python-version": ["3.9", "3.11"], + "include": [ + {"os": "ubuntu-latest", "python-version": "3.10"} + ] + }') + echo "MATRIX=$MATRIX" >> $GITHUB_ENV + - name: Set test matrix with 'full' option + if: env.MATRIX_OPTION == 'full' + run: | + MATRIX=$(jq -nsc '{ + "os": ["ubuntu-latest", "macos-latest", "windows-latest"], + "python-version": ["3.9", "3.10", "3.11"] + }') + echo "MATRIX=$MATRIX" >> $GITHUB_ENV + - name: Set test matrix with 'downstream' option + if: env.MATRIX_OPTION == 'downstream' + run: | + MATRIX=$(jq -nsc '{ + "os": ["ubuntu-latest"], + "python-version": ["3.11"] + }') + echo "MATRIX=$MATRIX" >> $GITHUB_ENV + unit_test_suite: name: Unit tests on ${{ matrix.os }} with Python ${{ matrix.python-version }} - needs: [pre_commit] + needs: [pre_commit, setup] runs-on: ${{ matrix.os }} + if: needs.setup.outputs.code_change == 'true' strategy: fail-fast: false - matrix: - os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] - # Run on the full set on schedule, workflow_dispatch and push&tags events, otherwise on a subset. - python-version: ${{ ( github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || ( github.event_name == 'push' && github.ref_type == 'tag' ) ) && fromJSON('["3.9", "3.10", "3.11"]') || fromJSON('["3.9", "3.11"]') }} + matrix: ${{ fromJson(needs.setup.outputs.matrix) }} timeout-minutes: 90 defaults: run: @@ -56,7 +135,7 @@ jobs: conda-update: true nodejs: true envs: -o examples -o recommended -o tests -o build - cache: true + cache: ${{ github.event.inputs.cache || github.event.inputs.cache == '' }} opengl: true id: install - name: doit develop_install @@ -98,8 +177,9 @@ jobs: fail_ci_if_error: false # optional (default = false) ui_test_suite: name: UI tests on ${{ matrix.os }} with Python 3.9 - needs: [pre_commit] + needs: [pre_commit, setup] runs-on: ${{ matrix.os }} + if: needs.setup.outputs.code_change == 'true' || needs.setup.outputs.doc_change == 'true' strategy: fail-fast: false matrix: @@ -144,7 +224,7 @@ jobs: python-version: 3.9 channels: pyviz/label/dev,bokeh,conda-forge,nodefaults envs: "-o recommended -o tests -o build" - cache: true + cache: ${{ github.event.inputs.cache || github.event.inputs.cache == '' }} nodejs: true playwright: true id: install @@ -199,8 +279,9 @@ jobs: fail_ci_if_error: false # optional (default = false) core_test_suite: name: Core tests on Python ${{ matrix.python-version }}, ${{ matrix.os }} - needs: [pre_commit] + needs: [pre_commit, setup] runs-on: ${{ matrix.os }} + if: needs.setup.outputs.code_change == 'true' strategy: fail-fast: false matrix: @@ -223,7 +304,7 @@ jobs: # # channel-priority: strict # channels: pyviz/label/dev,conda-forge,nodefaults # envs: "-o tests_core -o tests_ci" - # cache: true + # cache: ${{ github.event.inputs.cache || github.event.inputs.cache == '' }} # conda-update: true # id: install - uses: actions/checkout@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5707fe41993..3a6e21dd319 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,17 +17,17 @@ repos: exclude: \.min\.js$ - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.9 hooks: - id: ruff files: panel/ - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort name: isort (python) - repo: https://github.com/hoxbro/clean_notebook - rev: v0.1.13 + rev: v0.1.14 hooks: - id: clean-notebook args: [-i, tags] @@ -39,7 +39,7 @@ repos: additional_dependencies: - tomli - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.3 + rev: v4.0.0-alpha.8 hooks: - id: prettier types_or: [css] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3fc151095..865b7710308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Releases +## Version 1.3.7 + +Date: 2024-01-19 + +This patch release focuses on a number of fixes and minor enhancements for the chat components and various other smaller improvements and fixes including docs improvements. In particular we want to highlight the new Ploomber deployment guide contributed by @neelash23. Next we want to welcome @jz314, @fayssalelmofatiche and @neelasha23 as new contributors and welcome back @SultanOrazbayev as a returning contributor. Lastly we want to thank the core contributor team, including @MarcSkovMadsen, @ahuang11, @maximlt, @Hoxbro and @philippjfr for their continued efforts maintaining Panel. + +### Enhancements + +- Add `filter_by` to `ChatMessage.serialize` ([#6090](https://github.com/holoviz/panel/pull/6090)) +- Support using an SVG for `ToggleIcon` ([#6127](https://github.com/holoviz/panel/pull/6127)) +- Add resizable param to `TextAreaInput` ([#6126](https://github.com/holoviz/panel/pull/6126)) +- Improve date and datetime picker functionality ([#6152](https://github.com/holoviz/panel/pull/6152)) +- Add activity indicator to `ChatMessage` ([#6153](https://github.com/holoviz/panel/pull/6153)) +- Lazily import bleach HTML sanitizer ([#6179](https://github.com/holoviz/panel/pull/6179)) + +### Bug fixes + +- Fix alignment issues in chat components ([#6104](https://github.com/holoviz/panel/pull/6104), [#6135](https://github.com/holoviz/panel/pull/6135)) +- Fix generator placeholder and optimize updates in Chat components ([#6105](https://github.com/holoviz/panel/pull/6105)) +- Fix issue with callback future handling on Chat components ([#6120](https://github.com/holoviz/panel/pull/6120)) +- Fix bug in Chat interfaces related to `pn.state.browser_info` ([#6122](https://github.com/holoviz/panel/pull/6122)) +- Allow instantiating empty `Matplotlib` pane ([#6128](https://github.com/holoviz/panel/pull/6128)) +- Ensure icon displays inline with text on `FileDownload` ([#6133](https://github.com/holoviz/panel/pull/6133)) +- Fix styling of links in `Tabulator` fast theme ([#6146](https://github.com/holoviz/panel/pull/6146)) +- Fix passing of `card_params` on `ChatFeed` ([#6154](https://github.com/holoviz/panel/pull/6154)) +- Handle `Tabulator.title_formatter` if is type `dict` ([#6166](https://github.com/holoviz/panel/pull/6166)) +- Fix `per_session` caching ([#6169](https://github.com/holoviz/panel/pull/6169)) +- Correctly reshape nd-arrays in `Plotly` pane ([#6174](https://github.com/holoviz/panel/pull/6174)) +- Handle NaT values on `Perspective` pane ([#6176](https://github.com/holoviz/panel/pull/6176)) +- Do not rerender output if `ReplacementPane` object identity is unchanged ([#6183](https://github.com/holoviz/panel/pull/6183)) +- Tabulator: fix valuesLookup set up for older list-like editors ([#6192](https://github.com/holoviz/panel/pull/6192)) +- Fix pyodide loading message styling issues ([#6194](https://github.com/holoviz/panel/pull/6194)) +- More complete patch for the `TextEditor` to support being rendered in the Shadow DOM ([#6222](https://github.com/holoviz/panel/pull/6222)) +- Add guard to `Tabulator` ensuring that it does not error when it is not rendered ([#6223](https://github.com/holoviz/panel/pull/6223)) +- Fix race conditions when instantiating Comm in Jupyter causing notifications to break ([#6229](https://github.com/holoviz/panel/pull/6229)) + +### Compatibility & Security + +- Upgrade Plotly.js to 2.25.3 to address CVE-2023-46308 ([#6230](https://github.com/holoviz/panel/pull/6230)) + +### Documentation + +- Add `Design` and `Theme` explanation documentation ([#4741](https://github.com/holoviz/panel/pull/4741)) +- Fix pyodide execution in documentation +- Fix wrong and broken link ([#5988](https://github.com/holoviz/panel/pull/5988), [#6132](https://github.com/holoviz/panel/pull/6132)) +- Use GoatCounter for website analytics ([#6117](https://github.com/holoviz/panel/pull/6117)) +- Add Dask How to guide ([#4234](https://github.com/holoviz/panel/pull/4234)) +- Fix `Material` template notebook .show() call ([#6137](https://github.com/holoviz/panel/pull/6137)) +- Add missing item in docstring ([#6167](https://github.com/holoviz/panel/pull/6167)) +- Ploomber Cloud deployment documentation ([#6182](https://github.com/holoviz/panel/pull/6182)) +- Correct duplicate wording ([#6188](https://github.com/holoviz/panel/pull/6188)) +- Update JupyterLite Altair example to latest API ([#6226](https://github.com/holoviz/panel/pull/6226)) + ## Version 1.3.6 Date: 2023-12-20 diff --git a/doc/_static/images/asyncify.png b/doc/_static/images/asyncify.png new file mode 100644 index 00000000000..527976db87f Binary files /dev/null and b/doc/_static/images/asyncify.png differ diff --git a/doc/_static/images/dask-dashboard-empty.png b/doc/_static/images/dask-dashboard-empty.png new file mode 100644 index 00000000000..91473648b6f Binary files /dev/null and b/doc/_static/images/dask-dashboard-empty.png differ diff --git a/doc/_static/images/dask_fibonacci_queue.png b/doc/_static/images/dask_fibonacci_queue.png new file mode 100644 index 00000000000..6ecbca2760d Binary files /dev/null and b/doc/_static/images/dask_fibonacci_queue.png differ diff --git a/doc/_static/images/ploomber_deployment.png b/doc/_static/images/ploomber_deployment.png new file mode 100644 index 00000000000..922c46c7808 Binary files /dev/null and b/doc/_static/images/ploomber_deployment.png differ diff --git a/doc/_static/logos/dask-logo.svg b/doc/_static/logos/dask-logo.svg new file mode 100644 index 00000000000..3ac766541b8 --- /dev/null +++ b/doc/_static/logos/dask-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/doc/_static/logos/ploomber.png b/doc/_static/logos/ploomber.png new file mode 100644 index 00000000000..9a18e0903f4 Binary files /dev/null and b/doc/_static/logos/ploomber.png differ diff --git a/doc/about/releases.md b/doc/about/releases.md index 6d1e9d54bdf..d6af9fc7e02 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -2,6 +2,59 @@ See [the HoloViz blog](https://blog.holoviz.org/#category=panel) for a visual summary of the major features added in each release. +## Version 1.3.7 + +Date: 2024-01-19 + +This patch release focuses on a number of fixes and minor enhancements for the chat components and various other smaller improvements and fixes including docs improvements. In particular we want to highlight the new Ploomber deployment guide contributed by @neelash23. Next we want to welcome @jz314, @fayssalelmofatiche and @neelasha23 as new contributors and welcome back @SultanOrazbayev as a returning contributor. Lastly we want to thank the core contributor team, including @MarcSkovMadsen, @ahuang11, @maximlt, @Hoxbro and @philippjfr for their continued efforts maintaining Panel. + +### Enhancements + +- Add `filter_by` to `ChatMessage.serialize` ([#6090](https://github.com/holoviz/panel/pull/6090)) +- Support using an SVG for `ToggleIcon` ([#6127](https://github.com/holoviz/panel/pull/6127)) +- Add resizable param to `TextAreaInput` ([#6126](https://github.com/holoviz/panel/pull/6126)) +- Improve date and datetime picker functionality ([#6152](https://github.com/holoviz/panel/pull/6152)) +- Add activity indicator to `ChatMessage` ([#6153](https://github.com/holoviz/panel/pull/6153)) +- Lazily import bleach HTML sanitizer ([#6179](https://github.com/holoviz/panel/pull/6179)) + +### Bug fixes + +- Fix alignment issues in chat components ([#6104](https://github.com/holoviz/panel/pull/6104), [#6135](https://github.com/holoviz/panel/pull/6135)) +- Fix generator placeholder and optimize updates in Chat components ([#6105](https://github.com/holoviz/panel/pull/6105)) +- Fix issue with callback future handling on Chat components ([#6120](https://github.com/holoviz/panel/pull/6120)) +- Fix bug in Chat interfaces related to `pn.state.browser_info` ([#6122](https://github.com/holoviz/panel/pull/6122)) +- Allow instantiating empty `Matplotlib` pane ([#6128](https://github.com/holoviz/panel/pull/6128)) +- Ensure icon displays inline with text on `FileDownload` ([#6133](https://github.com/holoviz/panel/pull/6133)) +- Fix styling of links in `Tabulator` fast theme ([#6146](https://github.com/holoviz/panel/pull/6146)) +- Fix passing of `card_params` on `ChatFeed` ([#6154](https://github.com/holoviz/panel/pull/6154)) +- Handle `Tabulator.title_formatter` if is type `dict` ([#6166](https://github.com/holoviz/panel/pull/6166)) +- Fix `per_session` caching ([#6169](https://github.com/holoviz/panel/pull/6169)) +- Correctly reshape nd-arrays in `Plotly` pane ([#6174](https://github.com/holoviz/panel/pull/6174)) +- Handle NaT values on `Perspective` pane ([#6176](https://github.com/holoviz/panel/pull/6176)) +- Do not rerender output if `ReplacementPane` object identity is unchanged ([#6183](https://github.com/holoviz/panel/pull/6183)) +- Tabulator: fix valuesLookup set up for older list-like editors ([#6192](https://github.com/holoviz/panel/pull/6192)) +- Fix pyodide loading message styling issues ([#6194](https://github.com/holoviz/panel/pull/6194)) +- More complete patch for the `TextEditor` to support being rendered in the Shadow DOM ([#6222](https://github.com/holoviz/panel/pull/6222)) +- Add guard to `Tabulator` ensuring that it does not error when it is not rendered ([#6223](https://github.com/holoviz/panel/pull/6223)) +- Fix race conditions when instantiating Comm in Jupyter causing notifications to break ([#6229](https://github.com/holoviz/panel/pull/6229)) + +### Compatibility & Security + +- Upgrade Plotly.js to 2.25.3 to address CVE-2023-46308 ([#6230](https://github.com/holoviz/panel/pull/6230)) + +### Documentation + +- Add `Design` and `Theme` explanation documentation ([#4741](https://github.com/holoviz/panel/pull/4741)) +- Fix pyodide execution in documentation +- Fix wrong and broken link ([#5988](https://github.com/holoviz/panel/pull/5988), [#6132](https://github.com/holoviz/panel/pull/6132)) +- Use GoatCounter for website analytics ([#6117](https://github.com/holoviz/panel/pull/6117)) +- Add Dask How to guide ([#4234](https://github.com/holoviz/panel/pull/4234)) +- Fix `Material` template notebook .show() call ([#6137](https://github.com/holoviz/panel/pull/6137)) +- Add missing item in docstring ([#6167](https://github.com/holoviz/panel/pull/6167)) +- Ploomber Cloud deployment documentation ([#6182](https://github.com/holoviz/panel/pull/6182)) +- Correct duplicate wording ([#6188](https://github.com/holoviz/panel/pull/6188)) +- Update JupyterLite Altair example to latest API ([#6226](https://github.com/holoviz/panel/pull/6226)) + ## Version 1.3.6 Date: 2023-12-20 diff --git a/doc/conf.py b/doc/conf.py index a4e2a5d8f64..943fb2e00b4 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -81,7 +81,8 @@ 'sphinx.ext.napoleon', 'nbsite.gallery', 'sphinx_copybutton', - 'nbsite.pyodide' + 'nbsite.pyodide', + 'nbsite.analytics', ] napoleon_numpy_docstring = True @@ -92,6 +93,10 @@ jlite_url = 'https://pyviz-dev.github.io/panelite-dev' if is_dev else 'https://panelite.holoviz.org' pyodide_url = 'https://pyviz-dev.github.io/panel/pyodide' if is_dev else 'https://panel.holoviz.org/pyodide' +nbsite_analytics = { + 'goatcounter_holoviz': True, +} + nbsite_gallery_conf = { 'github_org': 'holoviz', 'github_project': 'panel', diff --git a/doc/explanation/index.md b/doc/explanation/index.md index da6f70ff750..8c3b98d07b9 100644 --- a/doc/explanation/index.md +++ b/doc/explanation/index.md @@ -58,14 +58,22 @@ Deepen your understanding about how Panel communicates between Python and Javasc ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 +:::{grid-item-card} {octicon}`paintbrush;2.5em;sd-mr-1 sd-animate-grow50` Designs & Themes +:link: styling/design +:link-type: doc + +Understand how the Panel `Design` and `Theme` components work internally. +::: + :::{grid-item-card} {octicon}`repo-template;2.5em;sd-mr-1 sd-animate-grow50` Templates -:link: templates/templates_overview +:link: styling/templates_overview :link-type: doc -Deepen your understanding about Template styling in Panel. +Discover Panel templates and how to use them. ::: :::: + ## Dependencies ::::{grid} 1 2 2 3 diff --git a/doc/explanation/styling.md b/doc/explanation/styling.md deleted file mode 100644 index f89d51aba1e..00000000000 --- a/doc/explanation/styling.md +++ /dev/null @@ -1,14 +0,0 @@ -.. raw:: html - - - - -# Styling - -```{toctree} -:titlesonly: -:hidden: -:maxdepth: 1 - -Templates -``` diff --git a/doc/explanation/styling/design.md b/doc/explanation/styling/design.md new file mode 100644 index 00000000000..af32ffa31be --- /dev/null +++ b/doc/explanation/styling/design.md @@ -0,0 +1,110 @@ +# Designs and Theming + +:::{versionadded} 1.0.0 +Designs and theming allow controlling the look and feel of individual components or your entire application. Before version 1.0 the concept of a design and a template were combined but since it is now possible to apply a `Design` separately from a template. +::: + +## `Design` class + +The `Design` class has the ability to apply styling and theming to components via three mechanisms: + +1. Loading external JS and CSS `resources`. +2. Applying `modifiers` that directly override (or combine with) component parameter settings (including CSS stylesheets). + +Additionally a `Design` class always has an associated `Theme` which determines the color palette of the design. This also has the ability to: + +1. Apply a `base_css` (usually containing CSS variable definitions and inherited from the base class). +2. Apply a `bokeh_theme` to override Bokeh component styling. +3. Apply a `css` with specific CSS overrides for that theme. +4. Apply its own set of `modifiers`. + +### Example: Bootstrap + +Let us work through the definition of the `Bootstrap` design step-by-step. + +#### Resources + +First of all, the `Bootstrap` design defines CSS and JS `resources` which load the bootstrap5 library: + +```python + _resources = { + 'css': { + 'bootstrap': CSS_URLS['bootstrap5'] + }, + 'js': { + 'bootstrap': JS_URLS['bootstrap5'] + } + } +``` + +#### Modifiers + +Next we get to the `modifiers` which is where the meat of the styling definition lives. Modifiers are applied per class and override the default parameter settings of a component. We can apply modifiers to a base class such as `Viewable`, which will affect all components or we can override a specific class, e.g. `Tabulator`: + +```python + modifiers = { + Accordion: { + 'active_header_background': 'var(--bs-surface-bg)' + } + ... + Tabulator: { + 'theme': 'bootstrap4' + }, + Viewable: { + 'stylesheets': [Inherit, f'{CDN_DIST}bundled/theme/bootstrap.css'] + } + } +``` + +Here we define specific parameters to override or combine with, e.g. we set the `active_header_background` of an `Accordion` to inherit from the `--bs-surface-bg` CSS variable. In this way we can control settings that are not controlled by CSS. However, most crucially we also declare that all `Viewable` components should inherit a bundled stylesheet: `bundled/theme/bootstrap.css`. + +The `bootstrap.css` file includes styling overrides for many different components and will be loaded by all `Viewable` components. + +#### CSS stylesheets + +All design stylesheets open with CSS variable definitions which map the global color definitions to specific variables used by a particular design system: + +```css +:host, :root { + --bs-primary-color: var(--jp-ui-font-color0, var(--panel-on-primary-color)); + --bs-primary-bg: var(--jp-brand-color0, var(--panel-primary-color)); +} +``` + +The `:host` and `:root` selectors ensure that these variable definition apply inside the shadow DOM, i.e. that they apply to the shadow `host`, and globally to the document `root` (i.e. usually the `` element). + +Note also how we map the `--bs-primary-color` color to two other variables, first the `--jp-ui-font-color0`, which allows us to automatically inherit styles in Jupyter based environments and secondly the `--panel-on-primary-color` which is the primary definition of Panel color themes and also the entrypoint for users to override the color definitions. + +Once declared these CSS variables are then used to set the style of specific components: + +```css +.bk-menu { + background-color: var(--bs-form-control-bg); + color: var(--bs-form-control-color); +} +``` + +In this way global style definitions and variables can flow down all the way to individual components. + +#### Themes + +Lastly we get to the themes, each `Design` component declares the set of supported themes by name: + +```python + _themes = { + 'dark': BootstrapDarkTheme, + 'default': BootstrapDefaultTheme, + } +``` + +Currently only `default` (light) and `dark` themes are supported. The implementation of these themes subclasses from the `DefaultTheme` and `DarkTheme` base classes and applies specific overrides. + +## Developing an internal theme + +If you want to help contribute improvements to a `Design` or `Theme` simply edit the CSS definitions in `panel/theme/css` and periodically re-bundle the resources with: + +```bash +panel bundle --themes --only-local +``` + +On UNIX you can simply use `watch -n 5 panel bundle --themes --only-local` to re-bundle these resources every few seconds. diff --git a/doc/explanation/styling/index.md b/doc/explanation/styling/index.md new file mode 100644 index 00000000000..dcb886bc077 --- /dev/null +++ b/doc/explanation/styling/index.md @@ -0,0 +1,15 @@ +.. raw:: html + + + + +# Styling + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 1 + +Design & Theme +Templates +``` diff --git a/doc/explanation/templates/templates_overview.md b/doc/explanation/styling/templates_overview.md similarity index 100% rename from doc/explanation/templates/templates_overview.md rename to doc/explanation/styling/templates_overview.md diff --git a/doc/getting_started/core_concepts.md b/doc/getting_started/core_concepts.md index e6f7140d671..dfd1431ebd5 100644 --- a/doc/getting_started/core_concepts.md +++ b/doc/getting_started/core_concepts.md @@ -93,7 +93,7 @@ Row ``` ::: -Sometimes an object has multiple possible representations to pick from. In these cases you can explicitly construct construct the desired `Pane` type, e.g. here are a few representations of a `DataFrame`: +Sometimes an object has multiple possible representations to pick from. In these cases you can explicitly construct the desired `Pane` type, e.g. here are a few representations of a `DataFrame`: ::::{tab-set} diff --git a/doc/how_to/callbacks/async.md b/doc/how_to/callbacks/async.md index 92156ed2667..d43eb3bcfe3 100644 --- a/doc/how_to/callbacks/async.md +++ b/doc/how_to/callbacks/async.md @@ -1,13 +1,22 @@ # Use Asynchronous Callbacks -This guide addresses how to leverage asynchronous callbacks to run I/O bound tasks in parallel. +This guide addresses how to leverage asynchronous callbacks to run I/O bound tasks in parallel. This technique is also beneficial for CPU bound tasks that release the GIL. -```{admonition} Prerequisites -1. Python has natively supported asynchronous functions since version 3.5, for a quick overview of some of the concepts involved see [the Python documentation](https://docs.python.org/3/library/asyncio-task.html). +You can use `async` function with event handlers like `on_click` as well as the reactive apis `.bind`, `.depends` and `.watch`. + +You can also schedule asynchronous periodic callbacks with `pn.state.add_periodic_callback` as well as run `async` functions directly with `pn.state.execute`. + +```{admonition} Asyncio +For a quick overview of the most important `asyncio` concepts see [the Python documentation](https://docs.python.org/3/library/asyncio-task.html). +``` + +```{admonition} Bokeh Models +It is important to note that asynchronous callbacks operate without locking the underlying Bokeh Document, which means Bokeh models cannot be safely modified by default. Usually this is not an issue because modifying Panel components appropriately schedules updates to underlying Bokeh models, however in cases where we want to modify a Bokeh model directly, e.g. when embedding and updating a Bokeh plot in a Panel application we explicitly have to decorate the asynchronous callback with `pn.io.with_lock` (see example below). ``` + --- -## `.param.watch` +## `on_click` One of the major benefits of leveraging async functions is that it is simple to write callbacks which will perform some longer running IO tasks in the background. Below we simulate this by creating a `Button` which will update some text when it starts and finishes running a long-running background task (here simulated using `asyncio.sleep`. If you are running this in the notebook you will note that you can start multiple tasks and it will update the text immediately but continue in the background: @@ -30,36 +39,7 @@ button.on_click(run_async) pn.Row(button, text) ``` -Note that `on_click` is simple one way of registering an asynchronous callback, but the more flexible `.param.watch` is also supported. Scheduling asynchronous periodic callbacks can be done with `pn.state.add_periodic_callback`. - -It is important to note that asynchronous callbacks operate without locking the underlying Bokeh Document, which means Bokeh models cannot be safely modified by default. Usually this is not an issue because modifying Panel components appropriately schedules updates to underlying Bokeh models, however in cases where we want to modify a Bokeh model directly, e.g. when embedding and updating a Bokeh plot in a Panel application we explicitly have to decorate the asynchronous callback with `pn.io.with_lock`. - -```{pyodide} -import numpy as np -from bokeh.plotting import figure -from bokeh.models import ColumnDataSource - -button = pn.widgets.Button(name='Click me!') - -p = figure(width=500, height=300) -cds = ColumnDataSource(data={'x': [0], 'y': [0]}) -p.line(x='x', y='y', source=cds) -pane = pn.pane.Bokeh(p) - -@pn.io.with_lock -async def stream(event): - await asyncio.sleep(1) - x, y = cds.data['x'][-1], cds.data['y'][-1] - cds.stream({'x': list(range(x+1, x+6)), 'y': y+np.random.randn(5).cumsum()}) - pane.param.trigger('object') - -# Equivalent to `.on_click` but shown -button.param.watch(stream, 'clicks') - -pn.Row(button, pane) -``` - -## `pn.bind` +## `.bind` ```{pyodide} widget = pn.widgets.IntSlider(start=0, end=10) @@ -80,7 +60,9 @@ pn.Column(widget, pn.bind(get_img, widget)) In this example Panel will invoke the function and update the output when the function returns while leaving the process unblocked for the duration of the `aiohttp` request. -The equivalent can be written using `.param.watch` as: +## `.watch` + +The app from the section above can be written using `.param.watch` as: ```{pyodide} widget = pn.widgets.IntSlider(start=0, end=10) @@ -107,6 +89,33 @@ pn.Column(widget, image) In this example Param will await the asynchronous function and the image will be updated when the request completes. +## Bokeh models with `pn.io.with_lock` + +```{pyodide} +import numpy as np +from bokeh.plotting import figure +from bokeh.models import ColumnDataSource + +button = pn.widgets.Button(name='Click me!') + +p = figure(width=500, height=300) +cds = ColumnDataSource(data={'x': [0], 'y': [0]}) +p.line(x='x', y='y', source=cds) +pane = pn.pane.Bokeh(p) + +@pn.io.with_lock +async def stream(event): + await asyncio.sleep(1) + x, y = cds.data['x'][-1], cds.data['y'][-1] + cds.stream({'x': list(range(x+1, x+6)), 'y': y+np.random.randn(5).cumsum()}) + pane.param.trigger('object') + +# Equivalent to `.on_click` but shown +button.param.watch(stream, 'clicks') + +pn.Row(button, pane) +``` + ## Related Resources - See the related [How-to > Link Parameters with Callbacks API](../links/index.md) guides, including [How to > Create Low-Level Python Links Using `.watch`](../links/watchers.md). diff --git a/doc/how_to/concurrency/async.md b/doc/how_to/concurrency/async.md deleted file mode 100644 index 7fb7494bab0..00000000000 --- a/doc/how_to/concurrency/async.md +++ /dev/null @@ -1,51 +0,0 @@ -# Use Asynchronous Processing - -When using Python you can use async callbacks wherever you would ordinarily use a regular synchronous function. For instance you can use `pn.bind` on an async function: - -```{pyodide} -import panel as pn - -widget = pn.widgets.IntSlider(start=0, end=10) - -async def get_img(index): - url = f"https://picsum.photos/800/300?image={index}" - if pn.state._is_pyodide: - from pyodide.http import pyfetch - return pn.pane.JPG(await (await pyfetch(url)).bytes()) - - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - return pn.pane.JPG(await resp.read()) - -pn.Column(widget, pn.bind(get_img, widget)) -``` - -In this example Panel will invoke the function and update the output when the function returns while leaving the process unblocked for the duration of the `aiohttp` request. - -Similarly you can attach asynchronous callbacks using `.param.watch`: - -```{pyodide} -widget = pn.widgets.IntSlider(start=0, end=10) - -image = pn.pane.JPG() - -async def update_img(event): - url = f"https://picsum.photos/800/300?image={event.new}" - if pn.state._is_pyodide: - from pyodide.http import pyfetch - image.object = await (await pyfetch(url)).bytes() - return - - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - image.object = await resp.read() - -widget.param.watch(update_img, 'value') -widget.param.trigger('value') - -pn.Column(widget, image) -``` - -In this example Param will await the asynchronous function and the image will be updated when the request completes. diff --git a/doc/how_to/concurrency/dask.md b/doc/how_to/concurrency/dask.md new file mode 100644 index 00000000000..bd015acf009 --- /dev/null +++ b/doc/how_to/concurrency/dask.md @@ -0,0 +1,235 @@ +# Scaling with Dask + +This guide demonstrates how you can *offload tasks* to [Dask](https://www.dask.org/) to **scale your apps to bigger datasets, bigger calculations and more users**. + + + +Panel supports `async` and `await`. This means you can easily **offload large computations to your Dask cluster asynchronously and keep your app responsive while you `await` the results**. Please note that off loading the computations to the Dask cluster can add ~250msec of overhead and thus is not suitable for all kinds of use cases. + +## Installation + +Lets start by installing *Panel*, *hvPlot* and *Dask Distributed*. + +```bash +pip install panel hvplot dask[distributed] +``` + +## Start the Cluster + +For development, testing and many use cases a [`LocalCluster`](https://docs.dask.org/en/stable/deploying-python.html#localcluster) is more than fine and will allow you to leverage all the CPUs on your machine. When you want to scale out to an entire cluster will you can switch to a non-local cluster. To avoid any issues when combining Panel and Dask we recommend starting the `LocalCluster` +separately from the Dask `Client` and your Panel app. + +```python +# cluster.py +from dask.distributed import LocalCluster + +DASK_SCHEDULER_PORT = 64719 +DASK_SCHEDULER_ADDRESS = f"tcp://127.0.0.1:{DASK_SCHEDULER_PORT}" +N_WORKERS = 4 + +if __name__ == '__main__': + cluster = LocalCluster(scheduler_port=DASK_SCHEDULER_PORT, n_workers=N_WORKERS) + print(cluster.scheduler_address) + input() +``` + +and running + +```bash +$ python cluster.py +tcp://127.0.0.1:64719 +``` + +You can now open the [Dask Dashboard](https://docs.dask.org/en/stable/dashboard.html) at [http://localhost:8787/status](http://localhost:8787/status). + +So far there is not a lot to see here: + +![Empty Dask Dashboard](../../_static/images/dask-dashboard-empty.png) + +The Dask `Client` will serialize any *tasks* and send them to the Dask `Cluster` for execution. This means that the `Client` and `Cluster` must able to import the same versions of all *tasks* and python package dependencies. + +## Dask Distributed + +## Fibonacci Task Queue + +In this section we will define a Panel app to *submit* and *monitor* Fibonacci tasks. + +Let's start by defining the *fibonacci* tasks in a `tasks.py` file: + +```python +# tasks.py +from datetime import datetime as dt + +import numpy as np + + +def _fib(n): + if n < 2: + return n + else: + return _fib(n - 1) + _fib(n - 2) + + +def fibonacci(n): + start = dt.now() + print(start, "start", n) + result = _fib(n) + end = dt.now() + print(end, "end", (end-start).seconds, n, result) + return result +``` + +Lets now define the full `app.py` file. + +```python +# app.py +from datetime import datetime as dt + +from dask.distributed import Client + +import panel as pn + +from cluster import DASK_SCHEDULER_ADDRESS +from tasks import fibonacci + +QUEUE = [] + +pn.extension("terminal", design="material", sizing_mode="stretch_width") + +@pn.cache # We use caching to share the client across all users and sessions +async def get_client(): + return await Client( + DASK_SCHEDULER_ADDRESS, asynchronous=True + ) + +n_input = pn.widgets.IntInput(value=0, width=100, sizing_mode="fixed", name="n") +submit_button = pn.widgets.Button(name="SUBMIT", button_type="primary", align="end") +terminal_widget = pn.widgets.Terminal( + height=200, +) + +queue = pn.rx(QUEUE) + +@pn.depends(submit_button, watch=True) +async def _handle_click(_): + n = n_input.value + n_input.value += 1 + + start = dt.now() + QUEUE.append(n) + queue.rx.value = QUEUE + + client = await get_client() + fib_n = await client.submit(fibonacci, n) + + end = dt.now() + + QUEUE.pop(QUEUE.index(n)) + queue.rx.value = QUEUE + + duration = (end - start).seconds + terminal_widget.write(f"fibonacci({n})={fib_n} in {duration}sec\n") + + +pn.Column( + "# Fibonacci Tasks", + pn.Row(n_input, submit_button), + pn.rx("## Task queue: {}").format(queue), + "## Results", + terminal_widget, +).servable() +``` + +You can now run `panel serve app.py` and the app will look like + + + +## Dask Dashboard Components + +It can be very useful to include some of the live [Dask endpoints](https://distributed.dask.org/en/stable/http_services.html) in your app. Its easy to do by embedding the specific urls in an *iframe*. + +In the `dashboard.py` file we define the `DaskViewer` component that can be used to explore the *individual dask plots*. + +```python +# dashboard.py +import os + +import param + +import panel as pn + +DASK_DASHBOARD_ADDRESS = os.getenv("DASK_DASHBOARD", "http://localhost:8787/status") + +VIEWS = { + "aggregate-time-per-action": "individual-aggregate-time-per-action", + "bandwidth-types": "individual-bandwidth-types", + "bandwidth-workers": "individual-bandwidth-workers", + "cluster-memory": "individual-cluster-memory", + "compute-time-per-key": "individual-compute-time-per-key", + "cpu": "individual-cpu", + "exceptions": "individual-exceptions", + "gpu-memory": "individual-gpu-memory", + "gpu-utilization": "individual-gpu-utilization", + "graph": "individual-graph", + "groups": "individual-groups", + "memory-by-key": "individual-memory-by-key", + "nprocessing": "individual-nprocessing", + "occupancy": "individual-occupancy", + "profile-server": "individual-profile-server", + "profile": "individual-profile", + "progress": "individual-progress", + "scheduler-system": "individual-scheduler-system", + "task-stream": "individual-task-stream", + "workers-cpu-timeseries": "individual-workers-cpu-timeseries", + "workers-disk-timeseries": "individual-workers-disk-timeseries", + "workers-disk": "individual-workers-disk", + "workers-memory-timeseries": "individual-workers-memory-timeseries", + "workers-memory": "individual-workers-memory", + "workers-network-timeseries": "individual-workers-network-timeseries", + "workers-network": "individual-workers-network", + "workers": "individual-workers", +} + +VIEWER_PARAMETERS = ["url", "path"] + +def dask_dashboard_view(path="individual-cpu", url=DASK_DASHBOARD_ADDRESS): + url = url.replace("/status", "/") + path + return f"""""" + +class DaskViewer(pn.viewable.Viewer): + url = param.String(DASK_DASHBOARD_ADDRESS, doc="The url of the Dask status dashboard") + path = param.Selector(default="individual-cpu", objects=VIEWS, doc="the endpoint", label="View") + + def __init__(self, size=20, **params): + viewer_params = {k:v for k, v in params.items() if k in VIEWER_PARAMETERS} + layout_params = {k:v for k, v in params.items() if k not in VIEWER_PARAMETERS} + + super().__init__(**viewer_params) + + view = pn.bind(dask_dashboard_view, self.param.path, self.param.url) + self._iframe = pn.pane.HTML(view, sizing_mode="stretch_both") + self._select = pn.widgets.Select.from_param(self.param.path, size=size, width=300, sizing_mode="fixed", margin=(20,5,10,5)) + self._link = pn.panel(f"""Dask Dashboard""", height=50, margin=(0,20)) + self._panel = pn.Column(pn.Row(self._iframe, self._select, sizing_mode="stretch_both"), self._link, **layout_params) + + def __panel__(self): + return self._panel + +if __name__.startswith("bokeh"): + pn.extension(sizing_mode="stretch_width") + + DaskViewer(height=500, size=25).servable() +``` + +Try running `panel serve dashboard.py`. If your Dask cluster is working, you will see something like + +![Dask Viewer](https://assets.holoviz.org/panel/how_to/concurrency/dask-dashboard.gif) + +## Additional Resources + +- [Panel - Use Async Callbacks](../callbacks/async.md) +- [Dask - Async/Await and Non-Blocking Execution Documentation](https://examples.dask.org/applications/async-await.html#Async/Await-and-Non-Blocking-Execution) +- [Dask - Async Web Server](https://examples.dask.org/applications/async-web-server.html) diff --git a/doc/how_to/concurrency/index.md b/doc/how_to/concurrency/index.md index 03be0a31c68..7e965bb0ef6 100644 --- a/doc/how_to/concurrency/index.md +++ b/doc/how_to/concurrency/index.md @@ -54,10 +54,33 @@ Discover how to manually set up a Thread to process an event queue. ::: :::{grid-item-card} {octicon}`arrow-switch;2.5em;sd-mr-1 sd-animate-grow50` Use Asynchronous Processing -:link: async +:link: ../callbacks/async :link-type: doc -Discover how to make use of asynchronous callbacks to handle I/O bound operations concurrently. +Discover how to make use of asynchronous callbacks to handle I/O and cpu bound operations concurrently. +::: + +:::{grid-item-card} {octicon}`paper-airplane;2.5em;sd-mr-1 sd-animate-grow50` Sync to Async +:link: sync_to_async +:link-type: doc + +Discover how to run your sync callbacks asynchronously to handle I/O and cpu bound operations concurrently. +::: + +:::: + +## Scaling via an external compute engine + +You can also scale your application by offloading your compute heavy tasks to an external compute engine like [Dask](https://www.dask.org/). Please note that this may add additional overhead of several 100ms to your tasks. + +::::{grid} 1 2 2 2 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`versions;2.5em;sd-mr-1 sd-animate-grow50` Dask +:link: dask +:link-type: doc + +Discover how-to configure and use Dask to scale your Panel application ::: :::: @@ -70,5 +93,8 @@ Discover how to make use of asynchronous callbacks to handle I/O bound operation load_balancing processes threading -async +manual_threading +../callbacks/async +sync_to_async +dask ``` diff --git a/doc/how_to/concurrency/sync_to_async.md b/doc/how_to/concurrency/sync_to_async.md new file mode 100644 index 00000000000..cd80c3da17e --- /dev/null +++ b/doc/how_to/concurrency/sync_to_async.md @@ -0,0 +1,40 @@ +# Run synchronous functions asynchronously + +Running your bound, synchronous functions asynchronously can be an easy way to make your application responsive and scale to more users. + +## Asyncify + +This example will show how to make your app responsive by running a sync, cpu bound function asynchronously. We will be using [asyncer](https://asyncer.tiangolo.com) by Tiangolo. You can install the package via `pip install asyncer`. + +```python +import numpy as np +import pandas as pd + +from asyncer import asyncify +import panel as pn + +widget = pn.widgets.IntSlider(value=5, start=0, end=10) + +def do_sync_work(it, n): + return sum(pd.DataFrame(np.random.rand(n,n)).sum().sum() for _ in range(it)) + +async def create_result(): + yield pn.indicators.LoadingSpinner(value=True, width=25, height=25) + result = await asyncify(do_sync_work)(it=5, n=10000) + yield f"Wow. That was slow.\n\nThe sum is **{result:.2f}**" + +pn.Column(widget.rx() + 1, create_result).servable() +``` + + + +Without [`asyncify`](https://asyncer.tiangolo.com/tutorial/asyncify/) the app would have been unresponsive for 5-10 seconds while loading. + +[`asyncify`](https://asyncer.tiangolo.com/tutorial/asyncify/) works well for IO bound functions as well as for CPU bound functions that releases the GIL. + +## Dask + +If you run many cpu bound functions you may consider offloading your functions asynchronously to an external compute engine like [Dask](https://www.dask.org/). See our [Dask how-to Guide](../performance/dask.md). diff --git a/doc/how_to/deployment/index.md b/doc/how_to/deployment/index.md index 536345e0ae9..702d71ced1b 100644 --- a/doc/how_to/deployment/index.md +++ b/doc/how_to/deployment/index.md @@ -42,6 +42,13 @@ For guides on running and configuring a Panel server see the [server how-to guid ![Hugging Face Logo](../../_static/logos/huggingface.png) ::: +:::{grid-item-card} Ploomber Cloud +:link: ploomber +:link-type: doc + +![Ploomber Logo](../../_static/logos/ploomber.png) +::: + :::: ## Other Cloud Providers @@ -58,4 +65,5 @@ binder gcp heroku huggingface +ploomber ``` diff --git a/doc/how_to/deployment/ploomber.md b/doc/how_to/deployment/ploomber.md new file mode 100644 index 00000000000..61ec362aa4a --- /dev/null +++ b/doc/how_to/deployment/ploomber.md @@ -0,0 +1,49 @@ +# Ploomber Cloud + +Ploomber Cloud offers a [free deployment](https://platform.ploomber.io) option for Panel apps. Once you create an account and log in, follow these steps for deploying using the web application: + +1. Click on the "NEW" button. You'll find the below page: + + + +2. In the "Framework" section, click on Panel. +3. In the "Source code" section, click on "Upload your files". +4. Upload your `.zip` file with the `app.py` and `requirements.txt` file. +5. Click on "CREATE" +6. Wait until deployment finishes. To see your app, click on the `VIEW APPLICATION` button. + + +Full instructions for deploying Panel apps are available [here](https://docs.cloud.ploomber.io/en/latest/apps/panel.html). + +You can also deploy the panel app using the Ploomber command line interface by following the below steps: + +1. First, install the package: + +```bash +pip install ploomber-cloud +``` + +2. Get an [API Key](https://docs.cloud.ploomber.io/en/latest/quickstart/apikey.html) and set the key: + +```bash +ploomber-cloud key YOURKEY +``` + +3. `cd` into the panel app folder that you want to deploy. +4. Initialize your project: + +```bash +ploomber-cloud init +``` + +5. This will prompt you for the project type. You need to enter `panel`. +6. Once your project is configured it will generate a `ploomber-cloud.json` with the project info. +7. Deploy the project by running: + +```bash +ploomber-cloud deploy +``` + +8. To view your application, login to the web application and click on the `VIEW APPLICATION` button. + +Full instructions for deploying apps using the CLI are available [here](https://docs.cloud.ploomber.io/en/latest/user-guide/cli.html). diff --git a/doc/how_to/links/jslinks.md b/doc/how_to/links/jslinks.md index 15d2035f725..9b1837afe38 100644 --- a/doc/how_to/links/jslinks.md +++ b/doc/how_to/links/jslinks.md @@ -81,4 +81,4 @@ pn.Row(url, button) ## Related Resources - See the [Explanation > APIs](../../explanation/api/index.md) for context on this and other Panel APIs -- The [How to > Link Plot Parameters in Javascript](./links) guide addresses how to link Bokeh and HoloViews plot parameters in Javascript. +- The [How to > Link Plot Parameters in Javascript](./link_plots.md) guide addresses how to link Bokeh and HoloViews plot parameters in Javascript. diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md index a6e14baa959..4190c407aab 100644 --- a/doc/how_to/streamlit_migration/chat.md +++ b/doc/how_to/streamlit_migration/chat.md @@ -4,15 +4,15 @@ Both Streamlit and Panel provides special components to help you build conversat | Streamlit | Panel | Description | | -------------------- | ------------------- | -------------------------------------- | -| [`chat_message`](https://docs.streamlit.io/library/api-reference/chat/st.chat_message) | [`ChatMessage`](../../../examples/reference/chat/ChatMessage.ipynb) | Display a chat message | +| [`chat_message`](https://docs.streamlit.io/library/api-reference/chat/st.chat_message) | [`ChatMessage`](../../reference/chat/ChatMessage.md) | Display a chat message | | [`chat_input`](https://docs.streamlit.io/library/api-reference/chat/st.chat_input) | [`ChatInput` example](https://holoviz-topics.github.io/panel-chat-examples/components/#chat_input) | Input a chat message | | [`status`](https://docs.streamlit.io/library/api-reference/status/st.status) | [`Status` example](https://holoviz-topics.github.io/panel-chat-examples/components/#status) | Display the output of long-running tasks in a container | -| | [`ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) | Display multiple chat messages | -| | [`ChatInterface`](../../../examples/reference/chat/ChatInterface.ipynb) | High-level, easy to use chat interface | -| [`StreamlitCallbackHandler`](https://python.langchain.com/docs/integrations/callbacks/streamlit) | [`PanelCallbackHandler`](../../../examples/reference/chat/PanelCallbackHandler.ipynb) | Display the thoughts and actions of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | +| | [`ChatFeed`](../../reference/chat/ChatFeed.md) | Display multiple chat messages | +| | [`ChatInterface`](../../reference/chat/ChatInterface.md) | High-level, easy to use chat interface | +| [`StreamlitCallbackHandler`](https://python.langchain.com/docs/integrations/callbacks/streamlit) | [`PanelCallbackHandler`](../../reference/chat/PanelCallbackHandler.md) | Display the thoughts and actions of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | | [`StreamlitChatMessageHistory`](https://python.langchain.com/docs/integrations/memory/streamlit_chat_message_history) | | Persist the memory of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | -The starting point for most Panel users is the *high-level* [`ChatInterface`](../../../examples/reference/chat/ChatInterface.ipyn) or [`PanelCallbackHandler`](../../../examples/reference/chat/PanelCallbackHandler.ipynb), not the *low-level* [`ChatMessage`](../../../examples/reference/chat/ChatMessage.ipynb) and [`ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) components. +The starting point for most Panel users is the *high-level* [`ChatInterface`](../../reference/chat/ChatInterface.md) or [`PanelCallbackHandler`](../../reference/chat/PanelCallbackHandler.md), not the *low-level* [`ChatMessage`](../../reference/chat/ChatMessage.md) and [`ChatFeed`](../../reference/chat/ChatFeed.md) components. ## Chat Message diff --git a/doc/how_to/streamlit_migration/panes.md b/doc/how_to/streamlit_migration/panes.md index d6806408699..f83a6c108c1 100644 --- a/doc/how_to/streamlit_migration/panes.md +++ b/doc/how_to/streamlit_migration/panes.md @@ -5,7 +5,7 @@ In Panel the objects that can display your Python objects are called *panes*. Wi - Get notifications about interactions like click events on your plots and tables and react to them. - Use unique data visualization ecosystems like HoloViz, ipywidgets and VTK. -Check out the [Panes Section](../../reference/index#panes) of the [Component Gallery](../../reference/index.md) for the full list of *panes*. +Check out the [Panes Section](../../reference/index.md#panes) of the [Component Gallery](../../reference/index.md) for the full list of *panes*. --- diff --git a/doc/how_to/styling/altair.md b/doc/how_to/styling/altair.md index 3f67c6957fc..54cfc568360 100644 --- a/doc/how_to/styling/altair.md +++ b/doc/how_to/styling/altair.md @@ -1,6 +1,6 @@ # Style Altair Plots -This guide addresses how to style Altair plots displayed using the [Vega pane](../../../examples/reference/panes/Vega). +This guide addresses how to style Altair plots displayed using the [Vega pane](../../reference/panes/Vega.md). You can select the theme of Altair plots using [`altair.themes.enable`](https://altair-viz.github.io/user_guide/customization.html#changing-the-theme) and an accent color using the `configure_mark` method. The list of themes is available via `altair.themes.names()`. diff --git a/examples/apps/django/requirements.txt b/examples/apps/django/requirements.txt index 59fc2ed647b..58724108f00 100644 --- a/examples/apps/django/requirements.txt +++ b/examples/apps/django/requirements.txt @@ -2,4 +2,4 @@ django==2.2.28 channels==2.2.0 panel==0.9.3 bokeh==2.0.2 -jinja2==3.0.1 +jinja2==3.1.3 diff --git a/examples/apps/django_multi_apps/requirements.txt b/examples/apps/django_multi_apps/requirements.txt index dea92220fa2..fd6f5c5a3df 100644 --- a/examples/apps/django_multi_apps/requirements.txt +++ b/examples/apps/django_multi_apps/requirements.txt @@ -16,7 +16,7 @@ hvplot==0.5.2 hyperlink==20.0.1 idna==2.8 incremental==21.3.0 -Jinja2==2.11.3 +Jinja2==3.1.3 Markdown==3.1.1 MarkupSafe==1.1.1 numpy==1.22.0 diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 3c03d464d67..0904ad03d11 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -8,7 +8,6 @@ }, "outputs": [], "source": [ - "import time\n", "import panel as pn\n", "\n", "pn.extension()" @@ -44,7 +43,7 @@ "##### Styling\n", "\n", "* **`card_params`** (Dict): Parameters to pass to Card, such as `header`, `header_background`, `header_color`, etc.\n", - "* **`message_params`** (Dict): Parameters to pass to each ChatMessage, such as `reaction_icons`, `timestamp_format`, `show_avatar`, `show_user`, and `show_timestamp`.\n", + "* **`message_params`** (Dict): Parameters to pass to each ChatMessage, such as `reaction_icons`, `timestamp_format`, `show_avatar`, `show_user`, and `show_timestamp` Params passed that are not ChatFeed params will be forwarded into `message_params`.\n", "\n", "##### Other\n", "\n", @@ -55,6 +54,7 @@ "* **`placeholder_threshold`** (float): Min duration in seconds of buffering before displaying the placeholder. If 0, the placeholder will be disabled. Defaults to 0.2.\n", "* **`auto_scroll_limit`** (int): Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 disables auto-scrolling.\n", "* **`scroll_button_threshold`** (int): Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 disables the scroll button.\n", + "* **`show_activity_dot`** (bool): Whether to show an activity dot on the ChatMessage while streaming the callback response.\n", "* **`view_latest`** (bool): Whether to scroll to the latest object on init. If not enabled the view will be on the first object. Defaults to True.\n", "\n", "#### Methods\n", @@ -604,6 +604,30 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The messages can be filtered by using a custom `filter_by` function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def filter_by_reactions(messages):\n", + " return [message for message in messages if \"favorite\" in message.reactions]\n", + "\n", + "\n", + "chat_feed.send(\n", + " pn.chat.ChatMessage(\"I'm a message with a reaction!\", reactions=[\"favorite\"])\n", + ")\n", + "\n", + "chat_feed.serialize(filter_by=filter_by_reactions)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -716,7 +740,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can pass `ChatEntry` params through `entry_params`." + "You can pass `ChatEntry` params through `message_params`." ] }, { @@ -734,6 +758,25 @@ "chat_feed" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, directly pass those params to the ChatFeed constructor and it'll be forwarded into `message_params` automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_feed = pn.chat.ChatFeed(default_avatars={\"System\": \"S\", \"User\": \"👤\"}, reaction_icons={\"like\": \"thumb-up\"})\n", + "chat_feed.send(user=\"System\", value=\"This is the System speaking.\")\n", + "chat_feed.send(user=\"User\", value=\"This is the User speaking.\")\n", + "chat_feed" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/chat/ChatMessage.ipynb b/examples/reference/chat/ChatMessage.ipynb index 4f5b485e97d..6fb9e99e73f 100644 --- a/examples/reference/chat/ChatMessage.ipynb +++ b/examples/reference/chat/ChatMessage.ipynb @@ -59,6 +59,7 @@ "* **`show_timestamp`** (bool): Whether to display the timestamp of the message.\n", "* **`show_reaction_icons`** (bool): Whether to display the reaction icons.\n", "* **`show_copy_icon`** (bool): Whether to show the copy icon.\n", + "* **`show_activity_dot`** (bool): Whether to show the activity dot.\n", "* **`name`** (str): The title or name of the chat message widget, if any.\n", "\n", "___" diff --git a/examples/reference/templates/Material.ipynb b/examples/reference/templates/Material.ipynb index 148bcbc5cde..81211d64d68 100644 --- a/examples/reference/templates/Material.ipynb +++ b/examples/reference/templates/Material.ipynb @@ -82,7 +82,7 @@ " )\n", ")\n", "\n", - "template.show();" + "template.servable();" ] }, { diff --git a/examples/reference/widgets/TextAreaInput.ipynb b/examples/reference/widgets/TextAreaInput.ipynb index b9a990b5681..7371245083e 100644 --- a/examples/reference/widgets/TextAreaInput.ipynb +++ b/examples/reference/widgets/TextAreaInput.ipynb @@ -39,6 +39,7 @@ "* **`name`** (str): The title of the widget\n", "* **`placeholder`** (str): A placeholder string displayed when no value is entered\n", "* **`rows`** (int, default=2): The number of rows in the text input field. \n", + "* **`resizable`** (boolean | str): Whether the layout is interactively resizable, and if so in which dimensions: `width`, `height`, or `both`.\n", "\n", "___" ] diff --git a/examples/reference/widgets/ToggleIcon.ipynb b/examples/reference/widgets/ToggleIcon.ipynb index 839c5f908ab..b8ee06a06d1 100644 --- a/examples/reference/widgets/ToggleIcon.ipynb +++ b/examples/reference/widgets/ToggleIcon.ipynb @@ -25,7 +25,7 @@ "##### Core\n", "\n", "* **`active_icon`** (str): The name of the icon to display when toggled from [tabler-icons.io](https://tabler-icons.io)/\n", - "* **`icon`** (str): The name of the icon to display from [tabler-icons.io](https://tabler-icons.io)/\n", + "* **`icon`** (str): The name of the icon to display from [tabler-icons.io](https://tabler-icons.io)/ or an SVG.\n", "* **`value`** (boolean): Whether the icon is toggled on or off\n", "\n", "##### Display\n", @@ -113,6 +113,29 @@ "pn.widgets.ToggleIcon(icon=\"thumb-down\", active_icon=\"thumb-up\", size='3em')" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also use SVGs for icons." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "SVG = \"\"\"\n", + "\n", + "\"\"\"\n", + "ACTIVE_SVG = \"\"\"\n", + "\n", + "\"\"\"\n", + "\n", + "pn.widgets.ToggleIcon(icon=SVG, active_icon=ACTIVE_SVG, size='3em')" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/lite/files/Getting_Started.ipynb b/lite/files/Getting_Started.ipynb index 65b644444b8..004e861abd4 100644 --- a/lite/files/Getting_Started.ipynb +++ b/lite/files/Getting_Started.ipynb @@ -87,7 +87,7 @@ ").properties(\n", " width=250,\n", " height=250\n", - ").add_selection(\n", + ").add_params(\n", " brush\n", ")\n", "\n", diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 2f048d01e44..0cfff58d09c 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -163,7 +163,8 @@ class ChatFeed(ListPanel): message_params = param.Dict(default={}, doc=""" Params to pass to each ChatMessage, like `reaction_icons`, `timestamp_format`, - `show_avatar`, `show_user`, and `show_timestamp`.""") + `show_avatar`, `show_user`, and `show_timestamp`. Params passed + that are not ChatFeed params will be forwarded into `message_params`.""") header = param.Parameter(doc=""" The header of the chat feed; commonly used for the title. @@ -197,6 +198,10 @@ class ChatFeed(ListPanel): display the scroll button. Setting to 0 disables the scroll button.""") + show_activity_dot = param.Boolean(default=True, doc=""" + Whether to show an activity dot on the ChatMessage while + streaming the callback response.""") + view_latest = param.Boolean(default=True, doc=""" Whether to scroll to the latest object on init. If not enabled the view will be on the first object.""") @@ -205,9 +210,6 @@ class ChatFeed(ListPanel): The placeholder wrapped in a ChatMessage object; primarily to prevent recursion error in _update_placeholder.""") - _callback_future = param.ClassSelector(class_=asyncio.Future, allow_None=True, doc=""" - The current, cancellable async task being executed.""") - _callback_state = param.ObjectSelector(objects=list(CallbackState), doc=""" The current state of the callback.""") @@ -217,10 +219,20 @@ class ChatFeed(ListPanel): _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_feed.css"] def __init__(self, *objects, **params): + self._callback_future = None + if params.get("renderers") and not isinstance(params["renderers"], list): params["renderers"] = [params["renderers"]] if params.get("width") is None and params.get("sizing_mode") is None: params["sizing_mode"] = "stretch_width" + + # forward message params to ChatMessage for convenience + message_params = params.get("message_params", {}) + for param_key in list(params.keys()): + if param_key not in ChatFeed.param and param_key in ChatMessage.param: + message_params[param_key] = params.pop(param_key) + params["message_params"] = message_params + super().__init__(*objects, **params) # instantiate the card's column) is not None) @@ -241,10 +253,8 @@ def __init__(self, *objects, **params): stylesheets=self._stylesheets, **linked_params ) - self.link(self._chat_log, objects='objects', bidirectional=True) - # we have a card for the title - self._card = Card( - self._chat_log, VSpacer(), + card_params = linked_params.copy() + card_params.update( margin=self.param.margin, align=self.param.align, header=self.header, @@ -258,7 +268,14 @@ def __init__(self, *objects, **params): title_css_classes=["chat-feed-title"], styles={"padding": "0px"}, stylesheets=self._stylesheets + self.param.stylesheets.rx(), - **linked_params + ) + card_params.update(self.card_params) + self.link(self._chat_log, objects='objects', bidirectional=True) + # we have a card for the title + self._card = Card( + self._chat_log, + VSpacer(), + **card_params ) # handle async callbacks using this trick @@ -269,14 +286,25 @@ def _get_model( self, doc: Document, root: Model | None = None, parent: Model | None = None, comm: Comm | None = None ) -> Model: - return self._card._get_model(doc, root, parent, comm) + model = self._card._get_model(doc, root, parent, comm) + ref = (root or model).ref['id'] + self._models[ref] = (model, parent) + return model def _cleanup(self, root: Model | None = None) -> None: self._card._cleanup(root) super()._cleanup(root) + @param.depends("card_params", watch=True) + def _update_card_params(self): + self._card.param.update(**self.card_params) + @param.depends("placeholder_text", watch=True, on_init=True) def _update_placeholder(self): + if self._placeholder is not None: + self._placeholder.param.update(object=self.placeholder_text) + return + loading_avatar = SVG( PLACEHOLDER_SVG, sizing_mode=None, css_classes=["rotating-placeholder"] ) @@ -335,6 +363,7 @@ def _build_message( message_params["avatar"] = avatar if self.width: message_params["width"] = int(self.width - 80) + message = ChatMessage(**message_params) return message @@ -402,18 +431,24 @@ async def _serialize_response(self, response: Any) -> ChatMessage | None: updating the message's value. """ response_message = None - if isasyncgen(response): - self._callback_state = CallbackState.GENERATING - async for token in response: - response_message = self._upsert_message(token, response_message) - elif isgenerator(response): - self._callback_state = CallbackState.GENERATING - for token in response: - response_message = self._upsert_message(token, response_message) - elif isawaitable(response): - response_message = self._upsert_message(await response, response_message) - else: - response_message = self._upsert_message(response, response_message) + try: + if isasyncgen(response): + self._callback_state = CallbackState.GENERATING + async for token in response: + response_message = self._upsert_message(token, response_message) + response_message.show_activity_dot = self.show_activity_dot + elif isgenerator(response): + self._callback_state = CallbackState.GENERATING + for token in response: + response_message = self._upsert_message(token, response_message) + response_message.show_activity_dot = self.show_activity_dot + elif isawaitable(response): + response_message = self._upsert_message(await response, response_message) + else: + response_message = self._upsert_message(response, response_message) + finally: + if response_message: + response_message.show_activity_dot = False return response_message async def _schedule_placeholder( @@ -429,7 +464,7 @@ async def _schedule_placeholder( return start = asyncio.get_event_loop().time() - while not task.done() and num_entries == len(self._chat_log): + while not self._callback_state == CallbackState.IDLE and num_entries == len(self._chat_log): duration = asyncio.get_event_loop().time() - start if duration > self.placeholder_threshold: self.append(self._placeholder) @@ -659,6 +694,7 @@ def clear(self) -> List[Any]: def _serialize_for_transformers( self, + messages: List[ChatMessage], role_names: Dict[str, str | List[str]] | None = None, default_role: str | None = "assistant", custom_serializer: Callable = None @@ -681,8 +717,8 @@ def _serialize_for_transformers( for name in names: names_role[name.lower()] = role - messages = [] - for message in self._chat_log.objects: + serialized_messages = [] + for message in messages: lowercase_name = message.user.lower() if lowercase_name not in names_role and not default_role: @@ -703,11 +739,12 @@ def _serialize_for_transformers( else: content = str(message) - messages.append({"role": role, "content": content}) - return messages + serialized_messages.append({"role": role, "content": content}) + return serialized_messages def serialize( self, + filter_by: Callable | None = None, format: Literal["transformers"] = "transformers", custom_serializer: Callable | None = None, **serialize_kwargs @@ -720,10 +757,13 @@ def serialize( format : str The format to export the chat log as; currently only supports "transformers". + filter_by : callable + A function to filter the chat log by. + The function must accept and return a list of ChatMessage objects. custom_serializer : callable A custom function to format the ChatMessage's object. The function must - accept one positional argument. If not provided, - uses the serialize method on ChatMessage. + accept one positional argument, the ChatMessage object, and return a string. + If not provided, uses the serialize method on ChatMessage. **serialize_kwargs Additional keyword arguments to use for the specified format. @@ -742,9 +782,13 @@ def serialize( ------- The chat log serialized in the specified format. """ + messages = self._chat_log.objects.copy() + if filter_by is not None: + messages = filter_by(messages) + if format == "transformers": return self._serialize_for_transformers( - custom_serializer=custom_serializer, **serialize_kwargs + messages, custom_serializer=custom_serializer, **serialize_kwargs ) raise NotImplementedError(f"Format {format!r} is not supported.") diff --git a/panel/chat/interface.py b/panel/chat/interface.py index 3ef6e68e775..8642dc4e25b 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -23,7 +23,7 @@ from ..widgets.button import Button from ..widgets.input import FileInput, TextInput from .feed import CallbackState, ChatFeed -from .message import _FileInputMessage +from .message import ChatMessage, _FileInputMessage @dataclass @@ -292,8 +292,8 @@ def _init_widgets(self): sizing_mode="stretch_width", max_width=show_expr.rx.where(90, 45), max_height=50, - margin=(5, 5, 5, 0), - align="start", + margin=(0, 5, 0, 0), + align="center", visible=visible ) if action != "stop": @@ -543,6 +543,7 @@ def active(self, index: int) -> None: def _serialize_for_transformers( self, + messages: List[ChatMessage], role_names: Dict[str, str | List[str]] | None = None, default_role: str | None = "assistant", custom_serializer: Callable = None @@ -552,6 +553,8 @@ def _serialize_for_transformers( Arguments --------- + messages : list(ChatMessage) + A list of ChatMessage objects to serialize. role_names : dict(str, str | list(str)) | None A dictionary mapping the role to the ChatMessage's user name. Defaults to `{"user": [self.user], "assistant": [self.callback_user]}` @@ -563,8 +566,8 @@ def _serialize_for_transformers( If this is set to None, raises a ValueError if the user name is not found. custom_serializer : callable A custom function to format the ChatMessage's object. The function must - accept one positional argument and return a string. If not provided, - uses the serialize method on ChatMessage. + accept one positional argument, the ChatMessage object, and return a string. + If not provided, uses the serialize method on ChatMessage. Returns ------- @@ -575,7 +578,7 @@ def _serialize_for_transformers( "user": [self.user], "assistant": [self.callback_user], } - return super()._serialize_for_transformers(role_names, default_role, custom_serializer) + return super()._serialize_for_transformers(messages, role_names, default_role, custom_serializer) @param.depends("_callback_state", watch=True) async def _update_input_disabled(self): diff --git a/panel/chat/message.py b/panel/chat/message.py index 9996c5a5b9f..2b4f7c02ce4 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -197,6 +197,9 @@ class ChatMessage(PaneBase): show_copy_icon = param.Boolean(default=True, doc=""" Whether to display the copy icon.""") + show_activity_dot = param.Boolean(default=False, doc=""" + Whether to show the activity dot.""") + renderers = param.HookList(doc=""" A callable or list of callables that accept the object and return a Panel object to render the object. If a list is provided, will @@ -222,7 +225,7 @@ def __init__(self, object=None, **params): tz = params.get("timestamp_tz") if tz is not None: tz = ZoneInfo(tz) - elif state.browser_info.timezone: + elif state.browser_info and state.browser_info.timezone: tz = ZoneInfo(state.browser_info.timezone) params["timestamp"] = datetime.datetime.now(tz=tz) reaction_icons = params.get("reaction_icons", {"favorite": "heart"}) @@ -240,6 +243,9 @@ def __init__(self, object=None, **params): self._build_layout() def _build_layout(self): + self._activity_dot = HTML( + "●", css_classes=["activity-dot"], visible=self.param.show_activity_dot + ) self._left_col = left_col = Column( self._render_avatar(), max_width=60, @@ -275,8 +281,10 @@ def _build_layout(self): Row( self._user_html, self.chat_copy_icon, + self._activity_dot, stylesheets=self._stylesheets, sizing_mode="stretch_width", + css_classes=["header"] ), self._center_row, self._timestamp_html, diff --git a/panel/command/serve.py b/panel/command/serve.py index bcff57c5ccc..de857a3c874 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -579,7 +579,7 @@ def customize_kwargs(self, args, server_kwargs): raise ValueError("OAuth encryption key was not a valid base64 " "string. Generate an encryption key with " "`panel oauth-secret` and ensure you did not " - "truncate the returned string.") + "truncate the returned string.") from None if len(key) != 32: raise ValueError( "OAuth encryption key must be 32 url-safe " @@ -605,7 +605,7 @@ def customize_kwargs(self, args, server_kwargs): "Using OAuth2 provider with Panel requires the " "cryptography library. Install it with `pip install " "cryptography` or `conda install cryptography`." - ) + ) from None state.encryption = Fernet(config.oauth_encryption_key) kwargs['auth_provider'] = OAuthProvider( diff --git a/panel/compiler.py b/panel/compiler.py index b3cae33e17b..06270470421 100644 --- a/panel/compiler.py +++ b/panel/compiler.py @@ -47,7 +47,7 @@ def write_bundled_files(name, files, explicit_dir=None, ext=None): except Exception as e: raise ConnectionError( f"Failed to fetch {name} dependency: {bundle_file}. Errored with {e}." - ) + ) from e try: map_file = f'{bundle_file}.map' map_response = requests.get(map_file) diff --git a/panel/config.py b/panel/config.py index b4b26cea0d4..a1909cbf3ef 100644 --- a/panel/config.py +++ b/panel/config.py @@ -798,7 +798,7 @@ def __call__(self, *args, **params): backend = hv.Store.current_backend else: backend = 'bokeh' - if hasattr(hv.Store, 'set_current_backend'): + if not loaded or (loaded and backend != hv.Store.current_backend) and hasattr(hv.Store, 'set_current_backend'): hv.Store.set_current_backend(backend) else: hv.Store.current_backend = backend @@ -911,9 +911,9 @@ def _apply_signatures(self): parameters = sig_params[:-1] processed_kws, keyword_groups = set(), [] - for cls in reversed(cls.mro()): + for scls in reversed(cls.mro()): keyword_group = [] - for (k, v) in sorted(cls.__dict__.items()): + for (k, v) in sorted(scls.__dict__.items()): if (isinstance(v, param.Parameter) and k not in processed_kws and not v.readonly): keyword_group.append(k) diff --git a/panel/dist/bundled/datatabulator/tabulator-tables@5.5.0/dist/css/tabulator_fast.min.css b/panel/dist/bundled/datatabulator/tabulator-tables@5.5.0/dist/css/tabulator_fast.min.css index 61663d96117..7287073ae06 100644 --- a/panel/dist/bundled/datatabulator/tabulator-tables@5.5.0/dist/css/tabulator_fast.min.css +++ b/panel/dist/bundled/datatabulator/tabulator-tables@5.5.0/dist/css/tabulator_fast.min.css @@ -566,6 +566,12 @@ background-color: var(--accent-fill-hover); cursor: pointer; } +.tabulator-row.tabulator-selected a { + color: var(--foreground-on-accent-rest); +} +.tabulator-row.tabulator-selectable:hover a { + color: var(--foreground-on-accent-rest); +} .tabulator-row.tabulator-row-moving { border: 1px solid #000; background: #fff; diff --git a/panel/dist/css/button.css b/panel/dist/css/button.css index d2c902a9c50..5ae415e8132 100644 --- a/panel/dist/css/button.css +++ b/panel/dist/css/button.css @@ -139,7 +139,7 @@ .bk-btn a { align-items: center; - display: flex; + display: inline; height: 100%; justify-content: center; padding: 6px; diff --git a/panel/dist/css/chat_message.css b/panel/dist/css/chat_message.css index 692dcc4cfa8..fe39d2ea1cc 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -47,6 +47,10 @@ max-width: calc(100% - 80px); } +.header { + width: fit-content; +} + .name { font-size: 1em; margin-bottom: 0px; @@ -67,15 +71,19 @@ color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px 1px; font-size: 1.25em; - min-width: 50px; min-height: 50px; margin-block: 0px; margin-left: 10px; /* Space for avatar */ margin-right: 5px; /* Space for reaction */ background-color: var(--panel-surface-color, #f1f1f1); + min-width: 0; max-width: calc(100% - 40px); padding: 5px; width: fit-content; + display: flex; + align-items: center; + justify-content: center; + overflow-wrap: anywhere; } .footer { @@ -88,7 +96,7 @@ .timestamp { color: #a9a9a9; display: flex; - margin-top: 0px; + margin-top: 3px; } .markdown { @@ -108,3 +116,23 @@ margin-block: 0px; margin-inline: 2px; } + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.activity-dot { + display: inline-block; + animation: fadeOut 2s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55); + color: #32cd32; + font-size: 1.25em; + margin-block: 0px; +} diff --git a/panel/dist/css/loading.css b/panel/dist/css/loading.css index 80294a3bf74..6dbd45299e8 100644 --- a/panel/dist/css/loading.css +++ b/panel/dist/css/loading.css @@ -21,6 +21,8 @@ :host(.pn-loading) .pn-loading-msg, .pn-loading .pn-loading-msg { + color: var(--panel-on-background-color, black); + font-size: 2em; position: absolute; top: 72%; font-size: 2em; diff --git a/panel/dist/css/widgetbox.css b/panel/dist/css/widgetbox.css index 56ec2f00fe4..a20fcf02e4c 100644 --- a/panel/dist/css/widgetbox.css +++ b/panel/dist/css/widgetbox.css @@ -4,6 +4,4 @@ border-radius: 4px; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); - overflow-x: hidden; - overflow-y: hidden; } diff --git a/panel/io/cache.py b/panel/io/cache.py index 6927c7e0428..5a3016dbeb7 100644 --- a/panel/io/cache.py +++ b/panel/io/cache.py @@ -192,14 +192,14 @@ def _generate_hash_inner(obj): raise ValueError( f'User hash function {hash_func!r} failed for input ' f'{obj!r} with following error: {type(e).__name__}("{e}").' - ) + ) from e return output if hasattr(obj, '__reduce__'): h = hashlib.new("md5") try: reduce_data = obj.__reduce__() except BaseException: - raise ValueError(f'Could not hash object of type {type(obj).__name__}') + raise ValueError(f'Could not hash object of type {type(obj).__name__}') from None for item in reduce_data: h.update(_generate_hash(item)) return h.digest() @@ -319,6 +319,9 @@ def cache( A dictionary mapping from a type to a function which returns a hash for an object of that type. If provided this will override the default hashing function provided by Panel. + max_items: int or None + The maximum items to keep in the cache. Default is None, which does + not limit number of items stored in the cache. policy: str A caching policy when max_items is set, must be one of: - FIFO: First in - First out @@ -347,7 +350,8 @@ def cache( max_items=max_items, ttl=ttl, to_disk=to_disk, - cache_path=cache_path + cache_path=cache_path, + per_session=per_session, ) func_hash = None # noqa @@ -431,7 +435,7 @@ def wrapped_func(*args, **kwargs): func_cache[hash_value] = (ret, time, 0, time) return ret - def clear(session_context=None): + def clear(): global func_hash # clear called before anything is cached. if 'func_hash' not in globals(): @@ -445,10 +449,13 @@ def clear(session_context=None): else: cache = state._memoize_cache.get(func_hash, {}) cache.clear() + wrapped_func.clear = clear if per_session and state.curdoc and state.curdoc.session_context: - state.curdoc.on_session_destroyed(clear) + def server_clear(session_context): + clear() + state.curdoc.on_session_destroyed(server_clear) try: wrapped_func.__dict__.update(func.__dict__) diff --git a/panel/io/convert.py b/panel/io/convert.py index bf82a4f93d2..c3e9fb48154 100644 --- a/panel/io/convert.py +++ b/panel/io/convert.py @@ -248,7 +248,7 @@ def script_to_html( except Exception as e: raise ValueError( f'Requirements parser raised following error: {e}' - ) + ) from e # Environment if panel_version == 'local': diff --git a/panel/io/jupyter_server_extension.py b/panel/io/jupyter_server_extension.py index 9bc5d349b01..8ea0a26383d 100644 --- a/panel/io/jupyter_server_extension.py +++ b/panel/io/jupyter_server_extension.py @@ -169,9 +169,9 @@ async def _get_info(self, msg_id, timeout=KERNEL_TIMEOUT): raise TimeoutError('Timed out while waiting for kernel to open Comm channel to Panel application.') try: msg = await ensure_async(self.kernel.iopub_channel.get_msg(timeout=None)) - except Empty: + except Empty as e: if not await ensure_async(self.kernel.is_alive()): - raise RuntimeError("Kernel died before establishing Comm connection to Panel application.") + raise RuntimeError("Kernel died before establishing Comm connection to Panel application.") from e continue if msg['parent_header'].get('msg_id') != msg_id: continue @@ -381,9 +381,9 @@ async def _check_for_message(self): break try: msg = await ensure_async(self.kernel.iopub_channel.get_msg(timeout=None)) - except Empty: + except Empty as e: if not await ensure_async(self.kernel.is_alive()): - raise RuntimeError("Kernel died before expected shutdown of Panel app.") + raise RuntimeError("Kernel died before expected shutdown of Panel app.") from e continue msg_type = msg['header']['msg_type'] diff --git a/panel/io/liveness.py b/panel/io/liveness.py index 3f65b270a95..e0975db2605 100644 --- a/panel/io/liveness.py +++ b/panel/io/liveness.py @@ -25,4 +25,4 @@ async def get(self): except Exception as e: raise web.HTTPError( 500, f"Endpoint {endpoint!r} could not be served. Application raised error: {e}" - ) + ) from e diff --git a/panel/io/model.py b/panel/io/model.py index 44e6606d35b..5fbb5d1757f 100644 --- a/panel/io/model.py +++ b/panel/io/model.py @@ -160,8 +160,8 @@ def bokeh_repr(obj: Model, depth: int = 0, ignored: Optional[Iterable[str]] = No props_repr = ', '.join(props) if isinstance(obj, FlexBox): r += '{cls}(children=[\n'.format(cls=cls) - for obj in obj.children: # type: ignore - r += textwrap.indent(bokeh_repr(obj, depth=depth+1) + ',\n', ' ') + for child_obj in obj.children: # type: ignore + r += textwrap.indent(bokeh_repr(child_obj, depth=depth+1) + ',\n', ' ') r += '], %s)' % props_repr else: r += '{cls}({props})'.format(cls=cls, props=props_repr) diff --git a/panel/io/notebook.py b/panel/io/notebook.py index c09ec4ce887..61c12766c34 100644 --- a/panel/io/notebook.py +++ b/panel/io/notebook.py @@ -8,9 +8,11 @@ import os import sys import uuid +import warnings from collections import OrderedDict from contextlib import contextmanager +from functools import partial from typing import ( TYPE_CHECKING, Any, Dict, Iterator, List, Literal, Optional, Tuple, ) @@ -63,13 +65,31 @@ def _jupyter_server_extension_paths() -> List[Dict[str, str]]: return [{"module": "panel.io.jupyter_server_extension"}] -def push(doc: 'Document', comm: 'Comm', binary: bool = True) -> None: +def push(doc: Document, comm: Comm, binary: bool = True, msg: any = None) -> None: """ Pushes events stored on the document across the provided comm. """ - msg = diff(doc, binary=binary) + if msg is None: + msg = diff(doc, binary=binary) if msg is None: return + elif not comm._comm: + try: + from tornado.ioloop import IOLoop + IOLoop.current().call_later(0.1, partial(push, doc, comm, binary, msg=msg)) + except Exception: + warnings.warn( + 'Attempted to send message over Jupyter Comm but it was not ' + 'yet open and also could not be rescheduled to a later time. ' + 'The update will not be sent.', UserWarning, stacklevel=0 + ) + else: + send(comm, msg) + +def send(comm: Comm, msg: any): + """ + Sends a bokeh message across a pyviz_comms.Comm. + """ # WARNING: CommManager model assumes that either JSON content OR a buffer # is sent. Therefore we must NEVER(!!!) send both at once. comm.send(msg.header_json) diff --git a/panel/io/rest.py b/panel/io/rest.py index 0a76532ed77..0bd052315c7 100644 --- a/panel/io/rest.py +++ b/panel/io/rest.py @@ -152,7 +152,7 @@ def param_rest_provider(files, endpoint): try: import nbconvert # noqa except ImportError: - raise ImportError("Please install nbconvert to serve Jupyter Notebooks.") + raise ImportError("Please install nbconvert to serve Jupyter Notebooks.") from None from nbconvert import ScriptExporter exporter = ScriptExporter() source, _ = exporter.from_filename(filename) diff --git a/panel/io/server.py b/panel/io/server.py index 7c947f0dcf0..dc9f01b9a52 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -704,11 +704,11 @@ def parse_url_path(self, path: str) -> str: try: module = importlib.import_module(mod) except ModuleNotFoundError: - raise HTTPError(404, 'Module not found') + raise HTTPError(404, 'Module not found') from None try: component = getattr(module, cls) except AttributeError: - raise HTTPError(404, 'Component not found') + raise HTTPError(404, 'Component not found') from None # May only access resources listed in specific attributes if rtype not in self._resource_attrs: @@ -717,7 +717,7 @@ def parse_url_path(self, path: str) -> str: try: resources = getattr(component, rtype) except AttributeError: - raise HTTPError(404, 'Resource type not found') + raise HTTPError(404, 'Resource type not found') from None # Handle template resources if rtype == '_resources': @@ -1174,7 +1174,7 @@ def get_server( raise KeyError( "Keys of the title dictionary and of the apps " f"dictionary must match. No {slug} key found in the " - "title dictionary.") + "title dictionary.") from None else: title_ = title slug = slug if slug.startswith('/') else '/'+slug diff --git a/panel/io/state.py b/panel/io/state.py index 09b60a8e429..6336b107510 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -215,7 +215,7 @@ class _state(param.Parameterized): def __repr__(self) -> str: server_info = [] - for server, panel, docs in self._servers.values(): + for server, panel, _docs in self._servers.values(): server_info.append( "{}:{:d} - {!r}".format(server.address or "localhost", server.port, panel) ) @@ -967,10 +967,11 @@ def curdoc(self) -> Document | None: pyodide_session = self._is_pyodide and 'pyodide_kernel' not in sys.modules if doc and (doc.session_context or pyodide_session): return doc - finally: - curdoc = self._curdoc.get() - if curdoc: - return curdoc + except Exception: + pass + curdoc = self._curdoc.get() + if curdoc: + return curdoc @curdoc.setter def curdoc(self, doc: Document) -> None: diff --git a/panel/layout/grid.py b/panel/layout/grid.py index 728fbaf4202..f8c051cce0b 100644 --- a/panel/layout/grid.py +++ b/panel/layout/grid.py @@ -405,7 +405,7 @@ def _yoffset(self): @property def _object_grid(self): grid = np.full((self.nrows, self.ncols), None, dtype=object) - for i, ((y0, x0, y1, x1), obj) in enumerate(self.objects.items()): + for (y0, x0, y1, x1), obj in self.objects.items(): l = 0 if x0 is None else x0 r = self.ncols if x1 is None else x1 t = 0 if y0 is None else y0 diff --git a/panel/layout/gridstack.py b/panel/layout/gridstack.py index a35d3606a2f..bfb9a62e742 100644 --- a/panel/layout/gridstack.py +++ b/panel/layout/gridstack.py @@ -183,7 +183,7 @@ def _update_sizing(self): else: height = 0 - for i, ((y0, x0, y1, x1), obj) in enumerate(self.objects.items()): + for (y0, x0, y1, x1), obj in self.objects.items(): x0 = 0 if x0 is None else x0 x1 = (self.ncols) if x1 is None else x1 y0 = 0 if y0 is None else y0 diff --git a/panel/links.py b/panel/links.py index fedf3cb49a9..faf359e1aa1 100644 --- a/panel/links.py +++ b/panel/links.py @@ -578,10 +578,10 @@ def _get_triggers( def _get_specs( self, link: 'Link', source: 'Reactive', target: 'JSLinkTarget' ) -> Sequence[Tuple['SourceModelSpec', 'TargetModelSpec', str | None]]: - for src_spec, code in link.code.items(): - src_specs = src_spec.split('.') - if src_spec.startswith('event:'): - src_spec = (None, src_spec) + for spec in link.code: + src_specs = spec.split('.') + if spec.startswith('event:'): + src_spec = (None, spec) elif len(src_specs) > 1: src_spec = ('.'.join(src_specs[:-1]), src_specs[-1]) else: @@ -589,7 +589,7 @@ def _get_specs( if isinstance(source, Reactive): src_prop = source._rename.get(src_prop, src_prop) src_spec = (None, src_prop) - return [(src_spec, (None, None), code)] + return [(src_spec, (None, None), link.code[spec])] diff --git a/panel/models/card.ts b/panel/models/card.ts index 7a546041c7f..8c16f5b7c4c 100644 --- a/panel/models/card.ts +++ b/panel/models/card.ts @@ -12,8 +12,7 @@ export class CardView extends ColumnView { connect_signals(): void { super.connect_signals() - const {active_header_background, children, collapsed, header_background, header_color, hide_header} = this.model.properties - this.on_change(children, () => this.render()) + const {active_header_background, collapsed, header_background, header_color, hide_header} = this.model.properties this.on_change(collapsed, () => this._collapse()) this.on_change([header_color, hide_header], () => this.render()) diff --git a/panel/models/file_download.ts b/panel/models/file_download.ts index 486a19b5291..bdb21f161a5 100644 --- a/panel/models/file_download.ts +++ b/panel/models/file_download.ts @@ -90,8 +90,7 @@ export class FileDownloadView extends InputWidgetView { // 3. auto=True: The widget is a button, i.e right click to "Save as..." won't work this.anchor_el = document.createElement('a') this.button_el = button({ - disabled: this.model.disabled, - type: "bk_btn, bk_btn_type", + disabled: this.model.disabled }) if (this.icon_view != null) { const separator = this.model.label != "" ? nbsp() : text("") diff --git a/panel/models/icon.py b/panel/models/icon.py index 5878a36348e..059af48a0e9 100644 --- a/panel/models/icon.py +++ b/panel/models/icon.py @@ -12,7 +12,7 @@ class ToggleIcon(Widget): The name of the icon to display when toggled.""") icon = String(default="heart", help=""" - The name of the icon to display.""") + The name of the icon or SVG to display.""") size = String(default="1em", help=""" The size of the icon as a valid CSS font-size.""") diff --git a/panel/models/icon.ts b/panel/models/icon.ts index 1b9cf847f2c..911c6c80b2b 100644 --- a/panel/models/icon.ts +++ b/panel/models/icon.ts @@ -1,13 +1,14 @@ import { TablerIcon, TablerIconView } from "@bokehjs/models/ui/icons/tabler_icon"; +import { SVGIcon, SVGIconView } from "@bokehjs/models/ui/icons/svg_icon"; import { Control, ControlView } from '@bokehjs/models/widgets/control'; import type { IterViews } from '@bokehjs/core/build_views'; import * as p from "@bokehjs/core/properties"; import { build_view } from '@bokehjs/core/build_views'; - export class ToggleIconView extends ControlView { model: ToggleIcon; - icon_view: TablerIconView; + icon_view: TablerIconView | SVGIconView; + was_svg_icon: boolean public *controls() { } @@ -19,11 +20,8 @@ export class ToggleIconView extends ControlView { override async lazy_initialize(): Promise { await super.lazy_initialize(); - const size = this.calculate_size(); - const icon_model = new TablerIcon({ icon_name: this.model.icon, size: size }); - this.icon_view = await build_view(icon_model, { parent: this }); - - this.icon_view.el.addEventListener('click', () => this.toggle_value()); + this.was_svg_icon = this.is_svg_icon(this.model.icon) + this.icon_view = await this.build_icon_model(this.model.icon, this.was_svg_icon); } override *children(): IterViews { @@ -31,6 +29,10 @@ export class ToggleIconView extends ControlView { yield this.icon_view; } + is_svg_icon(icon: string): boolean { + return icon.trim().startsWith(' { + const size = this.calculate_size(); + let icon_model; + if (is_svg_icon) { + icon_model = new SVGIcon({ svg: icon, size: size }); + } else { + icon_model = new TablerIcon({ icon_name: icon, size: size }); + } + const icon_view = await build_view(icon_model, { parent: this }); + icon_view.el.addEventListener('click', () => this.toggle_value()); + return icon_view; + } + + async update_icon(): Promise { const icon = this.model.value ? this.get_active_icon() : this.model.icon; - this.icon_view.model.icon_name = icon; + const is_svg_icon = this.is_svg_icon(icon) + + if (this.was_svg_icon !== is_svg_icon) { + // If the icon type has changed, we need to rebuild the icon view + // and invalidate the old one. + const icon_view = await this.build_icon_model(icon, is_svg_icon); + icon_view.render(); + this.icon_view.remove(); + this.icon_view = icon_view; + this.was_svg_icon = is_svg_icon; + this.update_cursor(); + this.shadow_el.appendChild(this.icon_view.el); + } + else if (is_svg_icon) { + (this.icon_view as SVGIconView).model.svg = icon; + } else { + (this.icon_view as TablerIconView).model.icon_name = icon; + } this.icon_view.el.style.lineHeight = '0'; } @@ -106,7 +137,7 @@ export class ToggleIcon extends Control { this.define(({ Boolean, Nullable, String }) => ({ active_icon: [String, ""], icon: [String, "heart"], - size: [Nullable(String), null ], + size: [Nullable(String), null], value: [Boolean, false], })); } diff --git a/panel/models/perspective.ts b/panel/models/perspective.ts index 0ab5f0df1b6..f04fed58afc 100644 --- a/panel/models/perspective.ts +++ b/panel/models/perspective.ts @@ -219,8 +219,13 @@ export class PerspectiveView extends HTMLBoxView { get data(): any { const data: any = {} - for (const column of this.model.source.columns()) - data[column] = this.model.source.get_array(column) + for (const column of this.model.source.columns()) { + let array = this.model.source.get_array(column) + if (this.model.schema[column] == 'datetime' && array.includes(-9223372036854776)) { + array = array.map((v) => v === -9223372036854776 ? null : v) + } + data[column] = array + } return data } diff --git a/panel/models/plotly.py b/panel/models/plotly.py index 7886419d462..9e1318d9ff9 100644 --- a/panel/models/plotly.py +++ b/panel/models/plotly.py @@ -18,7 +18,7 @@ class PlotlyPlot(LayoutDOM): __javascript_raw__ = [ JS_URLS['jQuery'], - 'https://cdn.plot.ly/plotly-2.18.0.min.js' + 'https://cdn.plot.ly/plotly-2.25.2.min.js' ] @classproperty @@ -31,7 +31,7 @@ def __js_skip__(cls): __js_require__ = { 'paths': { - 'plotly': 'https://cdn.plot.ly/plotly-2.18.0.min' + 'plotly': 'https://cdn.plot.ly/plotly-2.25.2.min' }, 'exports': {'plotly': 'Plotly'} } diff --git a/panel/models/plotly.ts b/panel/models/plotly.ts index ec0fd2080fb..0c59aa13773 100644 --- a/panel/models/plotly.ts +++ b/panel/models/plotly.ts @@ -5,7 +5,7 @@ import {is_equal} from "@bokehjs/core/util/eq" import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"; import {debounce} from "debounce" -import {deepCopy, isPlainObject, get, throttle} from "./util" +import {deepCopy, isPlainObject, get, reshape, throttle} from "./util" import {HTMLBox, HTMLBoxView, set_size} from "./layout" @@ -321,12 +321,7 @@ export class PlotlyPlotView extends HTMLBoxView { for (const column of cds.columns()) { let array = cds.get_array(column)[0]; if (array.shape != null && array.shape.length > 1) { - const arrays = []; - const shape = array.shape; - for (let s = 0; s < shape[0]; s++) { - arrays.push(array.slice(s*shape[1], (s+1)*shape[1])); - } - array = arrays; + array = reshape(array, array.shape); } let prop_path = column.split("."); let prop = prop_path[prop_path.length - 1]; diff --git a/panel/models/quill.ts b/panel/models/quill.ts index d56e01f37ee..f4667725322 100644 --- a/panel/models/quill.ts +++ b/panel/models/quill.ts @@ -1,49 +1,7 @@ import * as p from "@bokehjs/core/properties" import { div } from "@bokehjs/core/dom" -import {HTMLBox, HTMLBoxView} from "./layout" - -const normalizeNative = (nativeRange: any) => { - - // document.getSelection model has properties startContainer and endContainer - // shadow.getSelection model has baseNode and focusNode - // Unify formats to always look like document.getSelection - - if (nativeRange) { - - const range = nativeRange; - - // // HACK: To allow pasting - if (range.baseNode?.classList?.value === 'ql-clipboard') { - return null - } - - if (range.baseNode) { - range.startContainer = nativeRange.baseNode; - range.endContainer = nativeRange.focusNode; - range.startOffset = nativeRange.baseOffset; - range.endOffset = nativeRange.focusOffset; - - if (range.endOffset < range.startOffset) { - range.startContainer = nativeRange.focusNode; - range.endContainer = nativeRange.baseNode; - range.startOffset = nativeRange.focusOffset; - range.endOffset = nativeRange.baseOffset; - } - } - - if (range.startContainer) { - - return { - start: { node: range.startContainer, offset: range.startOffset }, - end: { node: range.endContainer, offset: range.endOffset }, - native: range - }; - } - } - - return null -}; +import { HTMLBox, HTMLBoxView } from "./layout" export class QuillInputView extends HTMLBoxView { override model: QuillInput @@ -59,7 +17,7 @@ export class QuillInputView extends HTMLBoxView { this.connect(this.model.properties.disabled.change, () => this.quill.enable(!this.model.disabled)) this.connect(this.model.properties.visible.change, () => { if (this.model.visible) - this.container.style.visibility = 'visible'; + this.container.style.visibility = 'visible'; }) this.connect(this.model.properties.text.change, () => { if (this._editing) @@ -71,7 +29,7 @@ export class QuillInputView extends HTMLBoxView { this.quill.enable(!this.model.disabled) this._editing = false }) - const {mode, toolbar, placeholder} = this.model.properties + const { mode, toolbar, placeholder } = this.model.properties this.on_change([placeholder], () => { this.quill.root.setAttribute('data-placeholder', this.model.placeholder) }) @@ -93,7 +51,7 @@ export class QuillInputView extends HTMLBoxView { render(): void { super.render() - this.container = div({style: "visibility: hidden;"}) + this.container = div({ style: "visibility: hidden;" }) this.shadow_el.appendChild(this.container) const theme = (this.model.mode === 'bubble') ? 'bubble' : 'snow' this.watch_stylesheets() @@ -106,16 +64,82 @@ export class QuillInputView extends HTMLBoxView { theme: theme }); - // Apply only with getSelection() is defined (e.g. undefined on Firefox) - if (typeof this.quill.root.getRootNode().getSelection !== 'undefined') { - // Hack Quill and replace document.getSelection with shadow.getSelection - // see https://stackoverflow.com/questions/67914657/quill-editor-inside-shadow-dom/67944380#67944380 - this.quill.selection.getNativeRange = () => { + // Apply ShadowDOM patch found at: + // https://github.com/quilljs/quill/issues/2961#issuecomment-1775999845 + + const hasShadowRootSelection = !!((document.createElement('div').attachShadow({ mode: 'open' }) as any).getSelection); + // Each browser engine has a different implementation for retrieving the Range + const getNativeRange = (rootNode: any) => { + try { + if (hasShadowRootSelection) { + // In Chromium, the shadow root has a getSelection function which returns the range + return rootNode.getSelection().getRangeAt(0); + } else { + const selection = window.getSelection(); + if ((selection as any).getComposedRanges) { + // Webkit range retrieval is done with getComposedRanges (see: https://bugs.webkit.org/show_bug.cgi?id=163921) + return (selection as any).getComposedRanges(rootNode)[0]; + } else { + // Gecko implements the range API properly in Native Shadow: https://developer.mozilla.org/en-US/docs/Web/API/Selection/getRangeAt + return (selection as any).getRangeAt(0); + } + } + } catch { + return null; + } + } + + /** + * Original implementation uses document.active element which does not work in Native Shadow. + * Replace document.activeElement with shadowRoot.activeElement + **/ + this.quill.selection.hasFocus = () => { + const rootNode = (this.quill.root.getRootNode() as ShadowRoot); + return rootNode.activeElement === this.quill.root; + } - const selection = (this.shadow_el as any).getSelection(); - const range = normalizeNative(selection); - return range; - }; + /** + * Original implementation uses document.getSelection which does not work in Native Shadow. + * Replace document.getSelection with shadow dom equivalent (different for each browser) + **/ + this.quill.selection.getNativeRange = () => { + const rootNode = (this.quill.root.getRootNode() as ShadowRoot); + const nativeRange = getNativeRange(rootNode); + return !!nativeRange ? this.quill.selection.normalizeNative(nativeRange) : null; + }; + + /** + * Original implementation relies on Selection.addRange to programmatically set the range, which does not work + * in Webkit with Native Shadow. Selection.addRange works fine in Chromium and Gecko. + **/ + this.quill.selection.setNativeRange = (startNode: any, startOffset: any) => { + var endNode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode; + var endOffset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset; + var force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + if (startNode != null && (this.quill.selection.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) { + return; + } + var selection = document.getSelection(); + if (selection == null) return; + if (startNode != null) { + if (!this.quill.selection.hasFocus()) this.quill.selection.root.focus(); + var native = (this.quill.selection.getNativeRange() || {}).native; + if (native == null || force || startNode !== native.startContainer || startOffset !== native.startOffset || endNode !== native.endContainer || endOffset !== native.endOffset) { + if (startNode.tagName == "BR") { + startOffset = [].indexOf.call(startNode.parentNode.childNodes, startNode); + startNode = startNode.parentNode; + } + if (endNode.tagName == "BR") { + endOffset = [].indexOf.call(endNode.parentNode.childNodes, endNode); + endNode = endNode.parentNode; + } + selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset); + } + } else { + selection.removeAllRanges(); + this.quill.selection.root.blur(); + document.body.focus(); + } } this._editor = (this.shadow_el.querySelector('.ql-editor') as HTMLDivElement) @@ -167,7 +191,7 @@ export namespace QuillInput { } } -export interface QuillInput extends QuillInput.Attrs {} +export interface QuillInput extends QuillInput.Attrs { } export class QuillInput extends HTMLBox { properties: QuillInput.Props diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index c7318144ddc..83c9e78a2f5 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -367,6 +367,8 @@ export class DataTabulatorView extends HTMLBoxView { this.connect(p.sorters.change, () => this.setSorters()) this.connect(p.theme_classes.change, () => this.setCSSClasses(this.tabulator.element)) this.connect(this.model.source.properties.data.change, () => { + if (this.tabulator === undefined) + return this._selection_updating = true this.setData() this._selection_updating = false diff --git a/panel/models/util.ts b/panel/models/util.ts index 59397cd8a6d..e16bc40275d 100644 --- a/panel/models/util.ts +++ b/panel/models/util.ts @@ -1,3 +1,6 @@ +import {concat} from "@bokehjs/core/util/array" + + export const get = (obj: any, path: string, defaultValue: any = undefined) => { const travel = (regexp: RegExp) => String.prototype.split @@ -51,3 +54,25 @@ export function deepCopy(obj: any): any { export function isPlainObject (obj: any) { return Object.prototype.toString.call(obj) === '[object Object]'; } + +export function reshape(arr: any[], dim: number[]) { + let elemIndex = 0; + + if (!dim || !arr) return []; + + function _nest(dimIndex: number): any[] { + let result = []; + + if (dimIndex === dim.length - 1) { + result = concat(arr.slice(elemIndex, elemIndex + dim[dimIndex])); + elemIndex += dim[dimIndex]; + } else { + for (let i = 0; i < dim[dimIndex]; i++) { + result.push(_nest(dimIndex + 1)); + } + } + + return result; + } + return _nest(0); +} diff --git a/panel/pane/base.py b/panel/pane/base.py index 0cb22bf135f..8794375e687 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -267,47 +267,46 @@ def _update_object( ] if indexes: index = indexes[0] + new_model = (new_model,) + parent.children[index][1:] + parent.children[index] = new_model else: raise ValueError - new_model = (new_model,) + parent.children[index][1:] elif isinstance(parent, _BkReactiveHTML): for node, children in parent.children.items(): if old_model in children: index = children.index(old_model) new_models = list(children) new_models[index] = new_model + parent.children[node] = new_models break elif isinstance(parent, _BkTabs): index = [tab.child for tab in parent.tabs].index(old_model) + old_tab = parent.tabs[index] + props = dict(old_tab.properties_with_values(), child=new_model) + parent.tabs[index] = _BkTabPanel(**props) else: index = parent.children.index(old_model) + parent.children[index] = new_model except ValueError: self.param.warning( f'{type(self).__name__} pane model {old_model!r} could not be ' f'replaced with new model {new_model!r}, ensure that the parent ' 'is not modified at the same time the panel is being updated.' ) - else: - if isinstance(parent, _BkReactiveHTML): - parent.children[node] = new_models - elif isinstance(parent, _BkTabs): - old_tab = parent.tabs[index] - props = dict(old_tab.properties_with_values(), child=new_model) - parent.tabs[index] = _BkTabPanel(**props) - else: - parent.children[index] = new_model - layout_parent = self.layout._models.get(ref, [None])[0] - if parent is layout_parent: - parent.update(**self.layout._compute_sizing_mode( - parent.children, - dict( - sizing_mode=self.layout.sizing_mode, - styles=self.layout.styles, - width=self.layout.width, - min_width=self.layout.min_width, - margin=self.layout.margin - ) - )) + return + + layout_parent = self.layout._models.get(ref, [None])[0] + if parent is layout_parent: + parent.update(**self.layout._compute_sizing_mode( + parent.children, + dict( + sizing_mode=self.layout.sizing_mode, + styles=self.layout.styles, + width=self.layout.width, + min_width=self.layout.min_width, + margin=self.layout.margin + ) + )) from ..io import state ref = root.ref['id'] @@ -678,7 +677,8 @@ def _update_from_object(cls, object: Any, old_object: Any, was_internal: bool, i cls._recursive_update(old, new) elif isinstance(object, Reactive): cls._recursive_update(old_object, object) - else: + elif old_object.object is not object: + # See https://github.com/holoviz/param/pull/901 old_object.object = object else: # Replace pane entirely diff --git a/panel/pane/holoviews.py b/panel/pane/holoviews.py index 4b4830a3285..855de13082e 100644 --- a/panel/pane/holoviews.py +++ b/panel/pane/holoviews.py @@ -499,7 +499,7 @@ def _render(self, doc, comm, root): params = {} if self.theme is not None: params['theme'] = self.theme - elif doc.theme and getattr(doc.theme, '_json') != {'attrs': {}}: + elif doc.theme and doc.theme._json != {'attrs': {}}: params['theme'] = doc.theme elif self._design.theme.bokeh_theme: params['theme'] = self._design.theme.bokeh_theme @@ -897,7 +897,7 @@ def link_axes(root_view, root_model): changed.append('y_range') # Reinitialize callbacks linked to replaced axes - subplots = getattr(p, 'subplots') + subplots = p.subplots if subplots: plots = subplots.values() else: diff --git a/panel/pane/plot.py b/panel/pane/plot.py index cf73a59d4ef..e484764c0d3 100644 --- a/panel/pane/plot.py +++ b/panel/pane/plot.py @@ -349,6 +349,9 @@ def _update(self, ref: str, model: Model) -> None: manager.canvas.draw_idle() def _data(self, obj): + if obj is None: + return + try: obj.set_dpi(self.dpi) except Exception as ex: diff --git a/panel/pane/vtk/vtk.py b/panel/pane/vtk/vtk.py index 6d2f8d54381..b1bfc2603f5 100644 --- a/panel/pane/vtk/vtk.py +++ b/panel/pane/vtk/vtk.py @@ -76,7 +76,7 @@ class AbstractVTK(PaneBase): def _process_param_change(self, msg): msg = super()._process_param_change(msg) if 'axes' in msg and msg['axes'] is not None: - VTKAxes = getattr(sys.modules['panel.models.vtk'], 'VTKAxes') + VTKAxes = sys.modules['panel.models.vtk'].VTKAxes axes = msg['axes'] msg['axes'] = VTKAxes(**axes) return msg @@ -86,7 +86,7 @@ def _update_model( root: Model, model: Model, doc: Document, comm: Optional[Comm] ) -> None: if 'axes' in msg and msg['axes'] is not None: - VTKAxes = getattr(sys.modules['panel.models.vtk'], 'VTKAxes') + VTKAxes = sys.modules['panel.models.vtk'].VTKAxes axes = msg['axes'] if isinstance(axes, dict): msg['axes'] = VTKAxes(**axes) diff --git a/panel/param.py b/panel/param.py index 81463ba9681..b949958121e 100644 --- a/panel/param.py +++ b/panel/param.py @@ -372,11 +372,11 @@ def toggle_pane(change, parameter=pname): pane = Param(parameterized, name=parameterized.name, **kwargs) if isinstance(self._expand_layout, Tabs): - title = self.object.param[pname].label + title = self.object.param[parameter].label pane = (title, pane) self._expand_layout.append(pane) - def update_pane(change, parameter=pname): + def update_pane(change, parameter=pname, toggle=toggle): "Adds or removes subpanel from layout" layout = self._expand_layout existing = [p for p in layout.objects if isinstance(p, Param) @@ -924,10 +924,10 @@ def update_pane(*events): deps.append(p) self._replace_pane() - for _, params in full_groupby(params, lambda x: (x.inst or x.cls, x.what)): - p = params[0] + for _, sub_params in full_groupby(params, lambda x: (x.inst or x.cls, x.what)): + p = sub_params[0] pobj = (p.inst or p.cls) - ps = [_p.name for _p in params] + ps = [_p.name for _p in sub_params] if isinstance(pobj, Reactive) and self.loading_indicator: props = {p: 'loading' for p in ps if p in pobj._linkable_params} if props: diff --git a/panel/pipeline.py b/panel/pipeline.py index 82e6e1a49ac..99eb896389b 100644 --- a/panel/pipeline.py +++ b/panel/pipeline.py @@ -171,7 +171,7 @@ def __init__(self, stages=[], graph={}, **params): try: import holoviews as hv except Exception: - raise ImportError('Pipeline requires holoviews to be installed') + raise ImportError('Pipeline requires holoviews to be installed') from None super().__init__(**params) diff --git a/panel/reactive.py b/panel/reactive.py index 78d67b4d2e3..de41e4fd273 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -142,7 +142,7 @@ def __init__(self, **params): #---------------------------------------------------------------- @classproperty - @lru_cache(maxsize=None) + @lru_cache(maxsize=None) # noqa: B019 (cls is not an instance) def _property_mapping(cls): rename = {} for scls in cls.__mro__[::-1]: @@ -1372,7 +1372,7 @@ def __init__(mcs, name: str, bases: Tuple[Type, ...], dict_: Mapping[str, Any]): mcs._node_callbacks: Dict[str, List[Tuple[str, str]]] = {} mcs._inline_callbacks = [] for node, attrs in mcs._parser.attrs.items(): - for (attr, parameters, template) in attrs: + for (attr, parameters, _template) in attrs: for p in parameters: if p in mcs.param or '.' in p: continue @@ -1608,7 +1608,7 @@ def _loaded(cls) -> bool: ) def _cleanup(self, root: Model | None = None) -> None: - for child, panes in self._panes.items(): + for _child, panes in self._panes.items(): for pane in panes: pane._cleanup(root) super()._cleanup(root) @@ -1739,7 +1739,7 @@ def _get_children( elif children_param in self._panes: # Find existing models old_panes = self._panes[children_param] - for i, pane in enumerate(child_panes): + for pane in child_panes: if pane in old_panes and root.ref['id'] in pane._models: child, _ = pane._models[root.ref['id']] else: @@ -1768,8 +1768,8 @@ def _get_template(self) -> Tuple[str, List[str], Mapping[str, List[Tuple[str, Li # ${objects[{{ loop.index0 }}]} # {% endfor %} template_string = self._template - for var, obj in self._parser.loop_map.items(): - for var in self._parser.loop_var_map[var]: + for parent_var, obj in self._parser.loop_map.items(): + for var in self._parser.loop_var_map[parent_var]: template_string = template_string.replace( '${%s}' % var, '${%s[{{ loop.index0 }}]}' % obj) @@ -1797,7 +1797,7 @@ def _get_template(self) -> Tuple[str, List[str], Mapping[str, List[Tuple[str, Li f"{type(self).__name__} could not render " f"template, errored with:\n\n{type(e).__name__}: {e}.\n" f"Full template:\n\n{template_string}" - ) + ) from e # Parse templated HTML parser = ReactiveHTMLParser(self.__class__, template=False) diff --git a/panel/template/base.py b/panel/template/base.py index dfdf269303c..199dbebdebe 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -301,7 +301,8 @@ def _repr_mimebundle_( client_comm = state._comm_manager.get_client_comm( on_msg=partial(self._on_msg, ref, manager), on_error=partial(self._on_error, ref), - on_stdout=partial(self._on_stdout, ref) + on_stdout=partial(self._on_stdout, ref), + on_open=lambda _: comm.init() ) manager.client_comm_id = client_comm.id doc.add_root(manager) diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 2c44c315d3e..f901308b58d 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -41,6 +41,16 @@ def test_hide_header(self, chat_feed): chat_feed.header = "" assert chat_feed._card.hide_header + def test_card_params(self, chat_feed): + chat_feed.card_params = { + "header_background": "red", + "header": "Test", + "hide_header": False + } + assert chat_feed._card.header_background == "red" + assert chat_feed._card.header == "Test" + assert not chat_feed._card.hide_header + def test_send(self, chat_feed): message = chat_feed.send("Message") wait_until(lambda: len(chat_feed.objects) == 1) @@ -352,7 +362,9 @@ async def callback(contents, user, instance): } instance.respond() elif user == "arm": - user_entry = instance.objects[-2] + for user_entry in instance.objects: + if user_entry.user == "User": + break user_contents = user_entry.object yield { "user": "leg", @@ -379,6 +391,15 @@ def callback(contents, user, instance): wait_until(lambda: len(chat_feed.objects) == 1) assert chat_feed.objects[0].object == "Mutated" + def test_forward_message_params(self, chat_feed): + chat_feed = ChatFeed(reaction_icons={"like": "thumb-up"}, reactions=["like"]) + chat_feed.send("Hey!") + chat_message = chat_feed.objects[0] + assert chat_feed.message_params == {"reaction_icons": {"like": "thumb-up"}, "reactions": ["like"]} + assert chat_message.object == "Hey!" + assert chat_message.reactions == ["like"] + assert chat_message.reaction_icons.options == {"like": "thumb-up"} + @pytest.mark.xdist_group("chat") class TestChatFeedCallback: @@ -440,8 +461,7 @@ async def echo(contents, user, instance): chat_feed.callback = echo chat_feed.send("Message", respond=True) - await asyncio.sleep(0.5) - assert len(chat_feed.objects) == 2 + await async_wait_until(lambda: len(chat_feed.objects) == 2) assert chat_feed.objects[1].object == "Message" @pytest.mark.parametrize("callback_user", [None, "Bob"]) @@ -490,23 +510,25 @@ async def echo(contents, user, instance): chat_feed.callback = echo chat_feed.send("Message", respond=True) - await asyncio.sleep(0.5) + await async_wait_until(lambda: len(chat_feed.objects) == 2) assert len(chat_feed.objects) == 2 assert chat_feed.objects[1].object == "Message" @pytest.mark.asyncio - async def test_generator(self, chat_feed): + def test_generator(self, chat_feed): async def echo(contents, user, instance): message = "" for char in contents: message += char yield message + assert instance.objects[-1].show_activity_dot chat_feed.callback = echo chat_feed.send("Message", respond=True) - await asyncio.sleep(0.5) + wait_until(lambda: len(chat_feed.objects) == 2) assert len(chat_feed.objects) == 2 assert chat_feed.objects[1].object == "Message" + assert not chat_feed.objects[-1].show_activity_dot @pytest.mark.asyncio async def test_async_generator(self, chat_feed): @@ -519,12 +541,13 @@ async def echo(contents, user, instance): async for char in async_gen(contents): message += char yield message + assert instance.objects[-1].show_activity_dot chat_feed.callback = echo chat_feed.send("Message", respond=True) - await asyncio.sleep(0.5) - assert len(chat_feed.objects) == 2 + await async_wait_until(lambda: len(chat_feed.objects) == 2) assert chat_feed.objects[1].object == "Message" + assert not chat_feed.objects[-1].show_activity_dot def test_placeholder_disabled(self, chat_feed): def echo(contents, user, instance): @@ -583,11 +606,13 @@ async def echo(contents, user, instance): def test_placeholder_threshold_exceed_generator(self, chat_feed): async def echo(contents, user, instance): + assert instance._placeholder not in instance._chat_log await asyncio.sleep(0.5) assert instance._placeholder in instance._chat_log yield "hello testing" + assert instance._placeholder not in instance._chat_log - chat_feed.placeholder_threshold = 0.1 + chat_feed.placeholder_threshold = 0.2 chat_feed.callback = echo chat_feed.send("Message", respond=True) assert chat_feed._placeholder not in chat_feed._chat_log @@ -799,6 +824,16 @@ def custom_serializer(obj): with pytest.raises(ValueError, match="must return a string"): chat_feed.serialize(custom_serializer=custom_serializer) + def test_serialize_filter_by(self, chat_feed): + def filter_by_reactions(messages): + return [obj for obj in messages if "favorite" in obj.reactions] + + chat_feed.send(ChatMessage("no")) + chat_feed.send(ChatMessage("yes", reactions=["favorite"])) + filtered = chat_feed.serialize(filter_by=filter_by_reactions) + assert len(filtered) == 1 + assert filtered[0]["content"] == "yes" + @pytest.mark.xdist_group("chat") class TestChatFeedSerializeBase: diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index f0aaf56691e..3cf51198dd8 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -114,7 +114,7 @@ def pytest_configure(config): for marker, info in optional_markers.items(): config.addinivalue_line("markers", "{}: {}".format(marker, info['marker-descr'])) - if getattr(config.option, 'jupyter') and not port_open(JUPYTER_PORT): + if config.option.jupyter and not port_open(JUPYTER_PORT): start_jupyter() diff --git a/panel/tests/io/test_cache.py b/panel/tests/io/test_cache.py index a11ea773b4a..589cd27d714 100644 --- a/panel/tests/io/test_cache.py +++ b/panel/tests/io/test_cache.py @@ -3,10 +3,13 @@ import pathlib import time +from collections import Counter + import numpy as np import pandas as pd import param import pytest +import requests try: import diskcache @@ -15,7 +18,8 @@ diskcache_available = pytest.mark.skipif(diskcache is None, reason="requires diskcache") from panel.io.cache import _find_hash_func, cache -from panel.io.state import set_curdoc +from panel.io.state import set_curdoc, state +from panel.tests.util import serve_and_wait ################ # Test hashing # @@ -219,6 +223,26 @@ def test_per_session_cache(document): assert fn(a=0, b=0) == 0 assert fn(a=0, b=0) == 1 +def test_per_session_cache_server(port): + counts = Counter() + + @cache(per_session=True) + def get_data(): + counts[state.curdoc] += 1 + return "Some data" + + def app(): + get_data() + get_data() + return + + serve_and_wait(app, port=port) + + requests.get(f"http://localhost:{port}/") + requests.get(f"http://localhost:{port}/") + + assert list(counts.values()) == [1, 1] + @pytest.mark.xdist_group("cache") @diskcache_available def test_disk_cache(): diff --git a/panel/tests/io/test_state.py b/panel/tests/io/test_state.py index b57e1c0e14f..392c585c5f5 100644 --- a/panel/tests/io/test_state.py +++ b/panel/tests/io/test_state.py @@ -32,7 +32,7 @@ def test_fn(i=[0]): results = [] with ThreadPoolExecutor(max_workers=4) as executor: - for i in range(4): + for _ in range(4): future = executor.submit(state.as_cached, 'test', test_fn) results.append(future) assert [r.result() for r in results] == [1, 1, 1, 1] diff --git a/panel/tests/pane/test_holoviews.py b/panel/tests/pane/test_holoviews.py index 72fe8666061..67dc74c3e8b 100644 --- a/panel/tests/pane/test_holoviews.py +++ b/panel/tests/pane/test_holoviews.py @@ -743,3 +743,37 @@ def test_holoviews_property_override(document, comm): assert model.styles["background"] == 'red' assert model.children[0].css_classes == ['test_class'] + + +@hv_available +def test_holoviews_date_picker_widget(document, comm): + ds = { + "time": [np.datetime64("2000-01-01"), np.datetime64("2000-01-02")], + "x": [0, 1], + "y": [0, 1], + } + viz = hv.Dataset(ds, ["x", "time"], ["y"]) + layout = pn.panel(viz.to( + hv.Scatter, ["x"], ["y"]), widgets={"time": pn.widgets.DatePicker} + ) + widget_box = layout[0][1] + assert isinstance(layout, pn.Row) + assert isinstance(widget_box, pn.WidgetBox) + assert isinstance(widget_box[0], pn.widgets.DatePicker) + + +@hv_available +def test_holoviews_datetime_picker_widget(document, comm): + ds = { + "time": [np.datetime64("2000-01-01"), np.datetime64("2000-01-02")], + "x": [0, 1], + "y": [0, 1], + } + viz = hv.Dataset(ds, ["x", "time"], ["y"]) + layout = pn.panel(viz.to( + hv.Scatter, ["x"], ["y"]), widgets={"time": pn.widgets.DatetimePicker} + ) + widget_box = layout[0][1] + assert isinstance(layout, pn.Row) + assert isinstance(widget_box, pn.WidgetBox) + assert isinstance(widget_box[0], pn.widgets.DatetimePicker) diff --git a/panel/tests/pane/test_plot.py b/panel/tests/pane/test_plot.py index 526a8940bb6..3e7d230c5df 100644 --- a/panel/tests/pane/test_plot.py +++ b/panel/tests/pane/test_plot.py @@ -40,6 +40,19 @@ def test_get_matplotlib_pane_type(): assert PaneBase.get_pane_type(mpl_figure()) is Matplotlib +@mpl_available +def test_matplotlib_pane_initially_empty(document, comm): + pane = pn.pane.Matplotlib() + assert pane.object is None + + model = pane.get_root(document, comm=comm) + assert model.text == '' + + pane.object = mpl_figure() + assert model.text.startswith('<img src="data:image/png;base64,') + assert pane._models[model.ref['id']][0] is model + + @mpl_available def test_matplotlib_pane(document, comm): pane = pn.pane.Matplotlib(mpl_figure()) diff --git a/panel/tests/test_config.py b/panel/tests/test_config.py index 3e723d04402..6b5299c910a 100644 --- a/panel/tests/test_config.py +++ b/panel/tests/test_config.py @@ -105,14 +105,14 @@ def test_console_output_replace_error(document, comm, get_display_handle): handle = get_display_handle(model) try: - 1/0 + 1/0 # noqa: B018 except Exception as e: pane._on_error(model.ref['id'], e) assert 'text/html' in handle assert 'ZeroDivisionError' in handle['text/html'] try: - 1 + '2' + 1 + '2' # noqa: B018 except Exception as e: pane._on_error(model.ref['id'], e) assert 'text/html' in handle @@ -129,14 +129,14 @@ def test_console_output_accumulate_error(document, comm, get_display_handle): handle = get_display_handle(model) try: - 1/0 + 1/0 # noqa: B018 except Exception as e: pane._on_error(model.ref['id'], e) assert 'text/html' in handle assert 'ZeroDivisionError' in handle['text/html'] try: - 1 + '2' + 1 + '2' # noqa: B018 except Exception as e: pane._on_error(model.ref['id'], e) assert 'text/html' in handle @@ -154,7 +154,7 @@ def test_console_output_disable_error(document, comm, get_display_handle): handle = get_display_handle(model) try: - 1/0 + 1/0 # noqa: B018 except Exception as e: pane._on_error(model.ref['id'], e) assert handle == {} diff --git a/panel/tests/test_interact.py b/panel/tests/test_interact.py index d876b6dfab5..88fa921c3f2 100644 --- a/panel/tests/test_interact.py +++ b/panel/tests/test_interact.py @@ -261,8 +261,4 @@ def test_interact_throttled(): for slider, kwargs in slider_dict.items(): widget = getattr(widgets, slider)(**kwargs) - try: - interactive(func, x=widget, throttled=throttled) - assert True - except Exception as e: - assert False, e + interactive(func, x=widget, throttled=throttled) diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 0966a985769..8a3367c65af 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -162,7 +162,7 @@ async def task(): curdoc = state.curdoc await asyncio.sleep(0.5) docs[curdoc] = [] - for i in range(5): + for _ in range(5): await asyncio.sleep(0.1) docs[curdoc].append(state.curdoc) @@ -186,7 +186,7 @@ async def task(depth=1): if depth > 0: asyncio.ensure_future(task(depth-1)) docs[curdoc] = [] - for i in range(10): + for _ in range(10): await asyncio.sleep(0.1) docs[curdoc].append(state.curdoc) @@ -511,7 +511,6 @@ def app(): state.onload(cb2) # Simulate rendering def loaded(): - state.curdoc state._schedule_on_load(state.curdoc, None) state.execute(loaded, schedule=True) return 'App' @@ -536,7 +535,6 @@ def app(): state.onload(cb2) # Simulate rendering def loaded(): - state.curdoc state._schedule_on_load(state.curdoc, None) state.execute(loaded, schedule=True) return 'App' diff --git a/panel/tests/ui/io/test_location.py b/panel/tests/ui/io/test_location.py index 4f1b5a17159..b71d91e6675 100644 --- a/panel/tests/ui/io/test_location.py +++ b/panel/tests/ui/io/test_location.py @@ -13,8 +13,8 @@ def verify_document_location(expected_location, page): for param in expected_location: - wait_until(lambda: param in page.evaluate('() => document.location'), page) - wait_until(lambda: page.evaluate('() => document.location')[param] == expected_location[param], page) + wait_until(lambda: param in page.evaluate('() => document.location'), page) # noqa: B023 + wait_until(lambda: page.evaluate('() => document.location')[param] == expected_location[param], page) # noqa: B023 def test_set_url_params_update_document(page): diff --git a/panel/tests/ui/pane/test_plotly.py b/panel/tests/ui/pane/test_plotly.py index c99ba369b83..402e66e2b8b 100644 --- a/panel/tests/ui/pane/test_plotly.py +++ b/panel/tests/ui/pane/test_plotly.py @@ -46,6 +46,22 @@ def plotly_3d_plot(): return plot_3d, title +@pytest.fixture +def plotly_img_plot(): + fig_dict = dict( + data={ + "z": np.random.randint(0, 255, size=(6, 30, 3)).astype(np.uint8), + "type": "image", + }, + layout={ + "width": 300, + "height": 60, + "margin": {"l": 0, "r": 0, "b": 0, "t": 0}, + }, + ) + return Plotly(fig_dict, width=300, height=60) + + def test_plotly_no_console_errors(page, plotly_2d_plot): msgs, _ = serve_component(page, plotly_2d_plot) @@ -54,6 +70,7 @@ def test_plotly_no_console_errors(page, plotly_2d_plot): assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == [] + def test_plotly_2d_plot(page, plotly_2d_plot): serve_component(page, plotly_2d_plot) @@ -185,3 +202,20 @@ def test_plotly_select_data(page, plotly_2d_plot): assert 'range' in selected assert 'x' in selected['range'] assert 'y' in selected['range'] + + + +def test_plotly_img_plot(page, plotly_img_plot): + msgs, _ = serve_component(page, plotly_img_plot) + + # main pane + plotly_plot = page.locator('.js-plotly-plot .plot-container.plotly') + expect(plotly_plot).to_have_count(1) + + assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == [] + + # Select and hover on first point + point = plotly_plot.locator('image') + point.hover(force=True) + + wait_until(lambda: plotly_img_plot.hover_data == {'points': [{'curveNumber': 0, 'x': 15, 'y': 3, 'colormodel': 'rgb'}]}, page) diff --git a/panel/tests/ui/widgets/test_icon.py b/panel/tests/ui/widgets/test_icon.py index c3489aa3298..883a165cbb8 100644 --- a/panel/tests/ui/widgets/test_icon.py +++ b/panel/tests/ui/widgets/test_icon.py @@ -8,6 +8,12 @@ pytestmark = pytest.mark.ui +SVG = """ + +""" # noqa: E501 +ACTIVE_SVG = """ + +""" # noqa: E501 def test_toggle_icon_click(page): icon = ToggleIcon() @@ -100,3 +106,70 @@ def cb(event): icon.value = True icon.icon = "heart" assert page.locator('.ti-heart') + + # update active icon_name to svg + icon.active_icon = ACTIVE_SVG + assert page.locator('.icon-tabler-ad-filled') + + +def test_toggle_icon_svg(page): + icon = ToggleIcon(icon=SVG, active_icon=ACTIVE_SVG) + serve_component(page, icon) + + # test defaults + assert icon.icon == SVG + assert not icon.value + assert page.locator('.icon-tabler-ad-off') + + events = [] + def cb(event): + events.append(event) + icon.param.watch(cb, "value") + + # test icon click updates value + page.click('.bk-SVGIcon') + wait_until(lambda: len(events) == 1, page) + assert icon.value + assert page.locator('.icon-tabler-ad-filled') + +def test_toggle_icon_tabler_to_svg(page): + tabler = "ad-off" + + icon = ToggleIcon(icon=tabler, active_icon=ACTIVE_SVG) + serve_component(page, icon) + + # test defaults + assert icon.icon == tabler + assert not icon.value + assert page.locator('.icon-tabler-ad-off') + + events = [] + def cb(event): + events.append(event) + icon.param.watch(cb, "value") + + # test icon click updates value + page.click('.bk-TablerIcon') + wait_until(lambda: len(events) == 1, page) + assert icon.value + assert page.locator('.icon-tabler-ad-filled') + +def test_toggle_icon_svg_to_tabler(page): + icon = ToggleIcon(icon=SVG, active_icon="ad-filled") + serve_component(page, icon) + + # test defaults + assert icon.icon == SVG + assert not icon.value + assert page.locator('.icon-tabler-ad-off') + + events = [] + def cb(event): + events.append(event) + icon.param.watch(cb, "value") + + # test icon click updates value + page.click('.bk-SVGIcon') + wait_until(lambda: len(events) == 1, page) + assert icon.value + assert page.locator('.icon-tabler-ad-filled') diff --git a/panel/tests/ui/widgets/test_input.py b/panel/tests/ui/widgets/test_input.py index 19de96501a0..1436ee71b5a 100644 --- a/panel/tests/ui/widgets/test_input.py +++ b/panel/tests/ui/widgets/test_input.py @@ -1,5 +1,6 @@ import datetime +import numpy as np import pytest pytest.importorskip("playwright") @@ -603,6 +604,34 @@ def test_datetimepicker_remove_value(page, datetime_start_end): wait_until(lambda: datetime_picker_widget.value is None, page) +def test_datetime_picker_start_end_datetime64(page): + datetime_picker_widget = DatetimePicker( + value=datetime.datetime(2021, 3, 2), + start=np.datetime64("2021-03-02"), + end=np.datetime64("2021-03-03") + ) + + serve_component(page, datetime_picker_widget) + + datetime_picker = page.locator('.flatpickr-input') + datetime_picker.dblclick() + + # locate by aria label March 1, 2021 + prev_month_day = page.locator('[aria-label="March 1, 2021"]') + # assert class "flatpickr-day flatpickr-disabled" + assert "flatpickr-disabled" in prev_month_day.get_attribute("class"), "The date should be disabled" + + # locate by aria label March 3, 2021 + next_month_day = page.locator('[aria-label="March 3, 2021"]') + # assert not class "flatpickr-day flatpickr-disabled" + assert "flatpickr-disabled" not in next_month_day.get_attribute("class"), "The date should be enabled" + + # locate by aria label March 4, 2021 + next_next_month_day = page.locator('[aria-label="March 4, 2021"]') + # assert class "flatpickr-day flatpickr-disabled" + assert "flatpickr-disabled" in next_next_month_day.get_attribute("class"), "The date should be disabled" + + def test_text_area_auto_grow_init(page): text_area = TextAreaInput(auto_grow=True, value="1\n2\n3\n4\n") diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 0604b6f0a64..0c4e21e3edb 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -1725,7 +1725,7 @@ def test_tabulator_pagination(page, df_mixed, pagination): counts = count_per_page(len(df_mixed), page_size) i = 0 while True: - wait_until(lambda: widget.page == i + 1, page) + wait_until(lambda: widget.page == i + 1, page) # noqa: B023 rows = page.locator('.tabulator-row') expect(rows).to_have_count(counts[i]) assert page.locator(f'[aria-label="Show Page {i+1}"]').count() == 1 @@ -1828,7 +1828,7 @@ class P(param.Parameterized): p.s = filt_val df_filtered = df_mixed.loc[df_mixed[filt_col] == filt_val, :] - wait_until(lambda: widget.current_view.equals(df_filtered), page) + wait_until(lambda: widget.current_view.equals(df_filtered), page) # noqa: B023 # Check the table has the right number of rows expect(page.locator('.tabulator-row')).to_have_count(len(df_filtered)) @@ -1857,7 +1857,7 @@ def filt_(df, val): w_filter.value = filt_val df_filtered = filt_(df_mixed, filt_val) - wait_until(lambda: widget.current_view.equals(df_filtered), page) + wait_until(lambda: widget.current_view.equals(df_filtered), page) # noqa: B023 # Check the table has the right number of rows expect(page.locator('.tabulator-row')).to_have_count(len(df_filtered)) diff --git a/panel/tests/ui/widgets/test_texteditor.py b/panel/tests/ui/widgets/test_texteditor.py index 86110483bbc..d491b84e23c 100644 --- a/panel/tests/ui/widgets/test_texteditor.py +++ b/panel/tests/ui/widgets/test_texteditor.py @@ -126,4 +126,4 @@ def test_texteditor_regression_click_toolbar_cursor_stays_in_place(page): editor.press('Enter') page.locator('.ql-bold').click() editor.press('B') - wait_until(lambda: widget.value == '

A

B

', page) + wait_until(lambda: widget.value == '

A

B

', page) diff --git a/panel/tests/util.py b/panel/tests/util.py index 86f927e5a8e..c1d0e26b19b 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -379,7 +379,7 @@ def wait_for_port(stdout): nbsr = NBSR(stdout) m = None output = [] - for i in range(20): + for _ in range(20): o = nbsr.readline(0.5) if not o: continue diff --git a/panel/tests/widgets/test_icon.py b/panel/tests/widgets/test_icon.py index 3aee458874e..17373a80cd7 100644 --- a/panel/tests/widgets/test_icon.py +++ b/panel/tests/widgets/test_icon.py @@ -20,3 +20,7 @@ def test_custom_values(self): def test_empty_icon(self): with pytest.raises(ValueError, match="The icon parameter must not "): ToggleIcon(icon="") + + def test_icon_svg_empty_active_icon(self): + with pytest.raises(ValueError, match="The active_icon parameter must not "): + ToggleIcon(icon="") diff --git a/panel/tests/widgets/test_tqdm.py b/panel/tests/widgets/test_tqdm.py index d6efa382634..701c0baa87d 100644 --- a/panel/tests/widgets/test_tqdm.py +++ b/panel/tests/widgets/test_tqdm.py @@ -15,7 +15,7 @@ def test_tqdm(): tqdm = Tqdm(layout="row", sizing_mode="stretch_width") - for index in tqdm(range(3)): + for _ in tqdm(range(3)): pass assert tqdm.value == 3 @@ -44,7 +44,7 @@ def test_process_map(): def test_tqdm_leave_false(): tqdm = Tqdm(layout="row", sizing_mode="stretch_width") - for index in tqdm(range(3), leave=False): + for _ in tqdm(range(3), leave=False): pass assert tqdm.value == 0 @@ -55,7 +55,7 @@ def test_tqdm_leave_false(): def test_tqdm_color(): tqdm = Tqdm() - for index in tqdm(range(3), colour='red'): + for _ in tqdm(range(3), colour='red'): pass assert tqdm.text_pane.styles == {'color': 'red'} @@ -65,7 +65,7 @@ def get_tqdm_app(): tqdm = Tqdm(layout="row", sizing_mode="stretch_width") def run(*events): - for index in tqdm(range(10)): + for _ in tqdm(range(10)): time.sleep(0.2) button = pn.widgets.Button(name="Run Loop", button_type="primary") @@ -98,7 +98,7 @@ def get_tqdm_app_simple(): tqdm = Tqdm(layout="row", sizing_mode="stretch_width") def run(*events): - for index in tqdm(range(10)): + for _ in tqdm(range(10)): time.sleep(0.2) button = pn.widgets.Button(name="Run Loop", button_type="primary") diff --git a/panel/theme/base.py b/panel/theme/base.py index cf3b22df576..c3f99707fc3 100644 --- a/panel/theme/base.py +++ b/panel/theme/base.py @@ -246,6 +246,8 @@ def _apply_params(cls, viewable, mref, modifiers, document=None): # this may end up causing issues. from ..io.resources import CDN_DIST, patch_stylesheet + if mref not in viewable._models: + return model, _ = viewable._models[mref] params = { k: v for k, v in modifiers.items() if k != 'children' and diff --git a/panel/util/__init__.py b/panel/util/__init__.py index a95abecc6f0..ff5c4ab850d 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -23,7 +23,6 @@ from importlib import import_module from typing import Any, AnyStr -import bleach import bokeh import numpy as np import param @@ -51,7 +50,23 @@ PARAM_NAME_PATTERN = re.compile(r'^.*\d{5}$') -HTML_SANITIZER = bleach.sanitizer.Cleaner(strip=True) +class LazyHTMLSanitizer: + """ + Wraps bleach.sanitizer.Cleaner lazily importing it on the first + call to the clean method. + """ + + def __init__(self, **kwargs): + self._cleaner = None + self._kwargs = kwargs + + def clean(self, text): + if self._cleaner is None: + import bleach + self._cleaner = bleach.sanitizer.Cleaner(**self._kwargs) + return self._cleaner.clean(text) + +HTML_SANITIZER = LazyHTMLSanitizer(strip=True) def hashable(x): @@ -87,8 +102,6 @@ def param_name(name: str) -> str: return name[:name.index(match[0])] if match else name - - def abbreviated_repr(value, max_length=25, natural_breaks=(',', ' ')): """ Returns an abbreviated repr for the supplied object. Attempts to @@ -466,3 +479,9 @@ def styler_update(styler, new_df): todo = tuple(ops) todos.append(todo) return todos + + +def try_datetime64_to_datetime(value): + if isinstance(value, np.datetime64): + value = value.astype('datetime64[ms]').astype(datetime) + return value diff --git a/panel/viewable.py b/panel/viewable.py index 082e6709324..1963f65da88 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -511,7 +511,8 @@ def _render_mimebundle(self, model: Model, doc: Document, comm: Comm, location: client_comm = state._comm_manager.get_client_comm( on_msg=functools.partial(self._on_msg, ref, manager), on_error=functools.partial(self._on_error, ref), - on_stdout=functools.partial(self._on_stdout, ref) + on_stdout=functools.partial(self._on_stdout, ref), + on_open=lambda _: comm.init() ) self._comms[ref] = (comm, client_comm) manager.client_comm_id = client_comm.id diff --git a/panel/widgets/icon.py b/panel/widgets/icon.py index df21840e9db..a2d355536d0 100644 --- a/panel/widgets/icon.py +++ b/panel/widgets/icon.py @@ -13,11 +13,11 @@ class ToggleIcon(Widget): active_icon = param.String(default='', doc=""" The name of the icon to display when toggled from - tabler-icons.io](https://tabler-icons.io)/""") + tabler-icons.io](https://tabler-icons.io)/ or an SVG.""") icon = param.String(default='heart', doc=""" The name of the icon to display from - [tabler-icons.io](https://tabler-icons.io)/""") + [tabler-icons.io](https://tabler-icons.io)/ or an SVG.""") size = param.String(default=None, doc=""" An explicit size specified as a CSS font-size, e.g. '1.5em' or '20px'.""") @@ -33,5 +33,12 @@ class ToggleIcon(Widget): def __init__(self, **params): super().__init__(**params) + + @param.depends("icon", "active_icon", watch=True, on_init=True) + def _update_icon(self): if not self.icon: raise ValueError('The icon parameter must not be empty.') + + icon_is_svg = self.icon.startswith(' Type[Model]: try: from bokeh.models import ColorMap except Exception: - raise ImportError('ColorMap widget requires bokeh version >= 3.3.0.') + raise ImportError('ColorMap widget requires bokeh version >= 3.3.0.') from None return ColorMap @param.depends('value_name', watch=True, on_init=True) diff --git a/panel/widgets/slider.py b/panel/widgets/slider.py index 9499211f54c..d7c8ab75ba1 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -780,8 +780,8 @@ class DatetimeRangeSlider(DateRangeSlider): def _widget_type(self): try: from bokeh.models import DatetimeRangeSlider - except Exception: - raise ValueError("DatetimeRangeSlider requires bokeh >= 2.4.3") + except ImportError: + raise ValueError("DatetimeRangeSlider requires bokeh >= 2.4.3") from None return DatetimeRangeSlider diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index e89f7236fa7..8f7eeca56bc 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1814,10 +1814,10 @@ def _config_columns(self, column_objs: List[TableColumn]) -> List[Dict[str, Any] col_dict['formatter'] = formatter.pop('type') col_dict['formatterParams'] = formatter title_formatter = self.title_formatters.get(field) - if title_formatter: + if isinstance(title_formatter, str): col_dict['titleFormatter'] = title_formatter elif isinstance(title_formatter, dict): - formatter = dict(title_formatter) + title_formatter = dict(title_formatter) col_dict['titleFormatter'] = title_formatter.pop('type') col_dict['titleFormatterParams'] = title_formatter col_name = self._renamed_cols[field] @@ -1851,7 +1851,7 @@ def _config_columns(self, column_objs: List[TableColumn]) -> List[Dict[str, Any] col_dict['editor'] = 'list' if col_dict.get('editorParams', {}).get('values', False) is True: del col_dict['editorParams']['values'] - col_dict['editorParams']['valuesLookup'] + col_dict['editorParams']['valuesLookup'] = True if field in self.frozen_columns or i in self.frozen_columns: col_dict['frozen'] = True if isinstance(self.widths, dict) and isinstance(self.widths.get(field), str): diff --git a/pyproject.toml b/pyproject.toml index b7155e83241..918a6cf80a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,6 @@ requires = [ "bokeh >=3.3.0,<3.4.0", "pyviz_comms >=0.7.4", "requests", - "bleach", "packaging", "tqdm >=4.48.0", "markdown", @@ -40,8 +39,10 @@ ignore = [ "E741", "W605", "E701", # Multiple statements on one line + "B006", # Do not use mutable data structures for argument defaults ] select = [ + "B", "E", "F", "W", diff --git a/setup.py b/setup.py index db90e2b05d5..ccde91ae337 100644 --- a/setup.py +++ b/setup.py @@ -213,8 +213,7 @@ def run(self): 'tests': _tests, 'recommended': _recommended, 'doc': _recommended + [ - 'nbsite >=0.8.2', - 'myst-nb >=0.17,<1', + 'nbsite >=0.8.4', 'lxml', 'pandas <2.1.0' # Avoid deprecation warnings ],