From 3a07bb1d059c1fb7883de7decaa3ba541cf4becc Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 3 Jan 2023 15:09:55 +0100 Subject: [PATCH 01/15] Migrate server configuration and deployment guides into how_to sections --- doc/how_to/deployment/ae5.md | 3 + doc/how_to/deployment/azure.md | 31 ++ doc/how_to/deployment/binder.md | 21 ++ doc/how_to/deployment/gcp.md | 50 ++++ doc/how_to/deployment/heroku.md | 35 +++ doc/how_to/deployment/huggingface.md | 52 ++++ doc/how_to/deployment/index.md | 39 +++ doc/how_to/index.md | 39 +++ .../server/commandline.md} | 118 +------- doc/how_to/server/index.md | 48 ++++ doc/how_to/server/multiple.md | 37 +++ doc/how_to/server/programmatic.md | 61 ++++ doc/how_to/server/proxy.md | 3 + doc/how_to/server/ssh.md | 32 +++ doc/how_to/server/static_files.md | 11 + doc/user_guide/Server_Deployment.md | 267 ------------------ 16 files changed, 463 insertions(+), 384 deletions(-) create mode 100644 doc/how_to/deployment/ae5.md create mode 100644 doc/how_to/deployment/azure.md create mode 100644 doc/how_to/deployment/binder.md create mode 100644 doc/how_to/deployment/gcp.md create mode 100644 doc/how_to/deployment/heroku.md create mode 100644 doc/how_to/deployment/huggingface.md create mode 100644 doc/how_to/deployment/index.md create mode 100644 doc/how_to/index.md rename doc/{user_guide/Server_Configuration.md => how_to/server/commandline.md} (61%) create mode 100644 doc/how_to/server/index.md create mode 100644 doc/how_to/server/multiple.md create mode 100644 doc/how_to/server/programmatic.md create mode 100644 doc/how_to/server/proxy.md create mode 100644 doc/how_to/server/ssh.md create mode 100644 doc/how_to/server/static_files.md delete mode 100644 doc/user_guide/Server_Deployment.md diff --git a/doc/how_to/deployment/ae5.md b/doc/how_to/deployment/ae5.md new file mode 100644 index 0000000000..fb89580db9 --- /dev/null +++ b/doc/how_to/deployment/ae5.md @@ -0,0 +1,3 @@ +# Anaconda Enterprise 5 (AE5) + +All live examples in the Panel documentation are served on AE5, to see further examples deployed there see [examples.pyviz.org](https://examples.pyviz.org) and for detailed instructions follow the [developer guide](https://examples.pyviz.org/make_project.html). diff --git a/doc/how_to/deployment/azure.md b/doc/how_to/deployment/azure.md new file mode 100644 index 0000000000..51f2ec6ff0 --- /dev/null +++ b/doc/how_to/deployment/azure.md @@ -0,0 +1,31 @@ +# Microsoft Azure + +Azure is popular choice for enterprises often in combination with an automated CI/CD pipeline via Azure DevOps. To get started you can use the [Azure Portal](portal.azure.com) to deploy your app as a Linux Web App via the web based user interface. + +There are a few things you need to be aware of in order to be able to start your app. + +Python Web Apps assumes your web app + +- is using `gunicorn` (like Flask or Django) or alternative is started by a `python` command. Thus + - You **cannot use** `panel serve app.py ...` as a *Startup Command*. + - You **can use** `python -m panel serve app.py ...` or `python app.py ...` as a *Startup command*. +- is served on address 0.0.0.0 and port 8000 + +Thus you can use + +```bash +python -m panel serve app.py --address 0.0.0.0 --port 8000 --allow-websocket-origin=app-name.azurewebsites.net +``` + +as a *Startup command*. + +You might be able to use `python app.py` as a *Startup command* with `.show()` or `panel.serve` inside your `app.py` file, if you can configure the `address`, `port` and `allow-websocket-origin` in the app.py file or via environment variables. + +You also need to configure your app service **general settings** to + +- allow `Web sockets` and +- be `Always on` + + + +If you would like to setup **automated CI/ CD** via Azure DevOps, Azure Pipelines and Docker to a Web App for Containers, you can find a good starting point in the [devops Folder](https://github.com/MarcSkovMadsen/awesome-panel/tree/master/devops) of [awesome-panel.org](https://awesome-panel.org). diff --git a/doc/how_to/deployment/binder.md b/doc/how_to/deployment/binder.md new file mode 100644 index 0000000000..ff34361c92 --- /dev/null +++ b/doc/how_to/deployment/binder.md @@ -0,0 +1,21 @@ +# MyBinder + +Binder allows you to create custom computing environments that can be shared and used by many remote users. MyBinder is a public, free hosting option, with limited compute and memory resources, which will allow you to deploy your simple app quickly and easily. + +Here we will take you through the configuration to quickly set up a GitHub repository with notebooks containing Panel apps for deployment on MyBinder.org. As an example refer to the [Clifford demo repository](https://github.com/pyviz-demos/clifford). + +1. Create a GitHub repository and add the notebook or script you want to serve (in the example repository this is the clifford.ipynb file) + +2. Add an ``environment.yml`` which declares a conda environment with the dependencies required to run the app (refer to the [conda documentation](https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#creating-an-environment-file-manually) to see how to declare your dependencies). Add `jupyter_panel_proxy` as a dependency by adding either `conda-forge` or `pyviz` to the channel list: + +``` +channels: +- pyviz + +packages: +- jupyter-panel-proxy +``` + +3. Go to mybinder.org, enter the URL of your GitHub repository and hit ``Launch`` + +4. mybinder.org will give you a link to the deployment, e.g. for the example app it is https://mybinder.org/v2/gh/panel-demos/clifford-interact/master. To visit the app simply append ``?urlpath=/panel/clifford`` where you should replace clifford with the name of the notebook or script you are serving. diff --git a/doc/how_to/deployment/gcp.md b/doc/how_to/deployment/gcp.md new file mode 100644 index 0000000000..169a29be74 --- /dev/null +++ b/doc/how_to/deployment/gcp.md @@ -0,0 +1,50 @@ +# Google Cloud Platform (GCP) + +First, you need to set up your Google cloud account following the [Cloud Run documentation](https://cloud.google.com/run/docs/quickstarts/build-and-deploy/python) or the [App Engine documentation](https://cloud.google.com/appengine/docs/standard/python3/quickstart) depending on whether you would like to deploy your Panel app to Cloud Run or App Engine. + +Next, you will need three files: +1. app.py: This is the Python file that creates the Panel App. + +2. requirements.txt: This file lists all the package dependencies of our Panel app. Here is an example for requirements.txt: + +``` +panel +bokeh +hvplot +``` +3. app.yml (for App Engine) or Dockerfile (for Cloud Run) + +Here is an example for app.yml (if you would like to deploy to App Engine): +``` +runtime: python +env: flex +entrypoint: panel serve app.py --address 0.0.0.0 --port 8080 --allow-websocket-origin="*" + +runtime_config: + python_version: 3 +``` + +Here is an example for Dockerfile (if you would like to deploy to Cloud Run): +``` +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.10-slim + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install --no-cache-dir -r requirements.txt + +# Run the web service on container startup. +CMD panel serve app.py --address 0.0.0.0 --port 8080 --allow-websocket-origin="*" +``` + +Finally, to deploy a Panel app to App Engine run `gcloud app create` and `gcloud app deploy`. To deploy a Panel app to Cloud Run, run `gcloud run deploy`. + +For detailed information and steps, check out this [example](https://towardsdatascience.com/deploy-a-python-visualization-panel-app-to-google-cloud-cafe558fe787?sk=98a75bd79e98cba241cc6711e6fc5be5) on how to deploy a Panel app to App Engine and this [example](https://towardsdatascience.com/deploy-a-python-visualization-panel-app-to-google-cloud-ii-416e487b44eb?sk=aac35055957ba95641a6947bbb436410) on how to deploy a Panel app to Cloud Run. diff --git a/doc/how_to/deployment/heroku.md b/doc/how_to/deployment/heroku.md new file mode 100644 index 0000000000..5638ea0bdf --- /dev/null +++ b/doc/how_to/deployment/heroku.md @@ -0,0 +1,35 @@ +# Heroku + +Heroku makes deployment of arbitrary apps including Panel apps and dashboards very easy and provides a free tier to get you started. This makes it a great starting point for users not too familiar with web development and deployment. + +To get started working with Heroku [signup](https://signup.heroku.com) for a free account and [download and install the CLI](https://devcenter.heroku.com/articles/getting-started-with-python#set-up). Once you are set up follow the instructions to log into the CLI. + +1. Create a new Git repo (or to follow along clone the [minimal-heroku-demo](https://github.com/pyviz-demos/minimal-heroku-demo) GitHub repo) + +2. Add a Jupyter notebook or Python script which declares a Panel app or dashboard to the repository. + +3. Define a requirements.txt containing all the requirements for your app (including Panel itself). For the sample app the requirements are as minimal as: + +``` +panel +hvplot +scikit-learn +``` + +3. Define a `Procfile` which declares the command Heroku should run to serve the app. In the sample app the following command serves the `iris_kmeans.ipynb` example. The websocket origin should match the name of the app on Heroku ``app-name.herokuapp.com`` which you will declare in the next step: + +``` +web: panel serve --address="0.0.0.0" --port=$PORT iris_kmeans.ipynb --allow-websocket-origin=app-name.herokuapp.com +``` + +4. Create a Heroku app using the CLI ensuring that the name matches the URL we declared in the previous step: + +``` +heroku create app-name +``` + +5. Push the app to heroku and wait until it is deployed. + +6. Visit the app at app-name.herokuapp.com + +Once you have deployed the app you might find that if your app is visited by more than one user at a time it will become unresponsive. In this case you can use the Heroku CLI [to scale your deployment](https://devcenter.heroku.com/articles/getting-started-with-python#scale-the-app). diff --git a/doc/how_to/deployment/huggingface.md b/doc/how_to/deployment/huggingface.md new file mode 100644 index 0000000000..f9555f0153 --- /dev/null +++ b/doc/how_to/deployment/huggingface.md @@ -0,0 +1,52 @@ +# Hugging Face + +The guides below assumes you have already signed up and logged into your account at [huggingface.co](https://huggingface.co/). + +## Duplicate an existing space + +The easiest way to get started is to [search](https://huggingface.co/spaces), find and duplicate an existing space. A simple space to duplicate is +[MarcSkovMadsen/awesome-panel](https://huggingface.co/spaces/MarcSkovMadsen/awesome-panel). + +- Open the space [MarcSkovMadsen/awesome-panel](https://huggingface.co/spaces/MarcSkovMadsen/awesome-panel). +- Click the 3 dots and select *Duplicate this Space*. + + + +- Follow the instructions to finish the duplication. + +Once you have finalized the duplication you will need to take a look at the `app.py` file in the new space to figure out what to replace. + + + +## Creating a new space from scratch + +You can deploy Panel to Hugging Face Spaces as a [*Custom Python Space*](https://huggingface.co/docs/hub/spaces-sdks-python). For a general introduction to Hugging Face Spaces see the [Spaces Overview](https://huggingface.co/docs/hub/spaces-overview). + +Go to [Spaces](https://huggingface.co/spaces) and click the "Create New Space" button. + + + +Fill out the form. Make sure to select the *Gradio Space SDK*. + + + +A Gradio space will serve your app via the commmand `python app.py`. I.e. you cannot run `panel serve app.py ...`. + +To work around this your `app.py` will need to either + +- Use `subprocess` to run `panel serve ...` or +- Use `pn.serve` to serve one or more functions. + +The app also needs to run on a port given by the `PORT` environment variable. + +Check out the example repository [MarcSkovMadsen/awesome-panel](https://huggingface.co/spaces/MarcSkovMadsen/awesome-panel/tree/main) for inspiration. + +## Git clone + +Optionally you can git clone your repository using + +```bash +git clone https://huggingface.co/spaces/NAME-OF-USER/NAME-OF-SPACE +``` + + diff --git a/doc/how_to/deployment/index.md b/doc/how_to/deployment/index.md new file mode 100644 index 0000000000..f6e7aa2a1b --- /dev/null +++ b/doc/how_to/deployment/index.md @@ -0,0 +1,39 @@ +# Deploying Panel Applications + +Panel is built on top of Bokeh, which provides a powerful [Tornado](https://www.tornadoweb.org/en/stable/) based web-server to communicate between Python and the browser. The bokeh server makes it possible to share the app or dashboard you have built locally, your own web server or using any of the numerous cloud providers. In the deployment guides we will go through the details of deploying an app on a local system or cloud provider step-by-step. + +For guides on running and configuring a Panel server see the [server how-to guides](../server/index). + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} Azure +:link: azure +:link-type: doc +::: + +:::{grid-item-card} Binder +:link: binder +:link-type: doc +::: + +:::{grid-item-card} Google Cloud +:link: gcp +:link-type: doc +::: + +:::{grid-item-card} Heroku +:link: heroku +:link-type: doc +::: + +:::{grid-item-card} Hugging Face +:link: huggingface +:link-type: doc +::: + +:::: + +#### Other Cloud Providers + +Panel can be used with just about any cloud provider that can launch a Python process, including Amazon Web Services (AWS) and DigitalOcean. The Panel developers will add documentation for these services as they encounter them in their own work, but we would greatly appreciate step-by-step instructions from users working on each of these systems. diff --git a/doc/how_to/index.md b/doc/how_to/index.md new file mode 100644 index 0000000000..9916ce9ffb --- /dev/null +++ b/doc/how_to/index.md @@ -0,0 +1,39 @@ +# How-to Guide + +The Panel's How to Guides provide step by step recipes for solving essential problems and tasks. They are more advanced than the Getting Started material and assume some knowledge of how Panel works. + +## Running and Configuring a Panel Server + +## Deployment + +The deployment guides provide step-by-step instructions on deploying Panel applications to various cloud providers. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} Azure +:link: how_to/azure +:link-type: doc +::: + +:::{grid-item-card} Binder +:link: how_to/binder +:link-type: doc +::: + +:::{grid-item-card} Google Cloud +:link: how_to/gcp +:link-type: doc +::: + +:::{grid-item-card} Heroku +:link: how_to/heroku +:link-type: doc +::: + +:::{grid-item-card} Hugging Face +:link: how_to/huggingface +:link-type: doc +::: + +:::: diff --git a/doc/user_guide/Server_Configuration.md b/doc/how_to/server/commandline.md similarity index 61% rename from doc/user_guide/Server_Configuration.md rename to doc/how_to/server/commandline.md index 0545871f5d..c166eb34da 100644 --- a/doc/user_guide/Server_Configuration.md +++ b/doc/how_to/server/commandline.md @@ -1,8 +1,4 @@ -# Server Configuration - -The Panel server can be launched either from the commandline (using `panel serve`) or programmatically (using `panel.serve()`). In this guide we will discover how to run and configure server instances using these two options. - -## Launching a server on the commandline +# Launching a server on the commandline Once the app is ready for deployment it can be served using the Bokeh server. For a detailed breakdown of the design and functionality of Bokeh server, see the [Bokeh documentation](https://bokeh.pydata.org/en/latest/docs/user_guide/server.html). The most important thing to know is that Panel (and Bokeh) provide a CLI command to serve a Python script, app directory, or Jupyter notebook containing a Bokeh or Panel app. To launch a server using the CLI, simply run: @@ -143,115 +139,3 @@ The ``panel serve`` command has the following options: To turn a notebook into a deployable app simply append ``.servable()`` to one or more Panel objects, which will add the app to Bokeh's ``curdoc``, ensuring it can be discovered by Bokeh server on deployment. In this way it is trivial to build dashboards that can be used interactively in a notebook and then seamlessly deployed on Bokeh server. When called on a notebook, `panel serve` first converts it to a python script using [`nbconvert.PythonExporter()`](https://nbconvert.readthedocs.io/en/stable/api/exporters.html), albeit with [IPython magics](https://ipython.readthedocs.io/en/stable/interactive/magics.html) stripped out. This means that non-code cells, such as raw cells, are entirely handled by `nbconvert` and [may modify the served app](https://nbsphinx.readthedocs.io/en/latest/raw-cells.html). - -## Launching a server dynamically - -The CLI `panel serve` command described below is usually the best approach for deploying applications. However when working on the REPL or embedding a Panel/Bokeh server in another application it is sometimes useful to dynamically launch a server, either using the `.show` method or using the `pn.serve` function. - -### Previewing an application - -Working from the command line will not automatically display rich representations inline as in a notebook, but you can still interact with your Panel components if you start a Bokeh server instance and open a separate browser window using the ``show`` method. The method has the following arguments: - - port: int (optional) - Allows specifying a specific port (default=0 chooses an arbitrary open port) - websocket_origin: str or list(str) (optional) - A list of hosts that can connect to the websocket. - This is typically required when embedding a server app in - an external-facing web site. - If None, "localhost" is used. - threaded: boolean (optional, default=False) - Whether to launch the Server on a separate thread, allowing - interactive use. - title : str - A string title to give the Document (if served as an app) - **kwargs : dict - Additional keyword arguments passed to the bokeh.server.server.Server instance. - -To work with an app completely interactively you can set ``threaded=True`` which will launch the server on a separate thread and let you interactively play with the app. - - - -The ``.show`` call will return either a Bokeh server instance (if ``threaded=False``) or a ``StoppableThread`` instance (if ``threaded=True``) which both provide a ``stop`` method to stop the server instance. - -### Serving multiple apps - -If you want to serve more than one app on a single server you can use the ``pn.serve`` function. By supplying a dictionary where the keys represent the URL slugs and the values must be either Panel objects or functions returning Panel objects you can easily launch a server with a number of apps, e.g.: - -```python -import panel as pn -pn.serve({ - 'markdown': '# This is a Panel app', - 'json': pn.pane.JSON({'abc': 123}) -}) -``` - -Note that when you serve an object directly all sessions will share the same state, i.e. the parameters of all components will be synced across sessions such that the change in a widget by one user will affect all other users. Therefore you will usually want to wrap your app in a function, ensuring that each user gets a new instance of the application: - -```python - -def markdown_app(): - return '# This is a Panel app' - -def json_app(): - return pn.pane.JSON({'abc': 123}) - -pn.serve({ - 'markdown': markdown_app, - 'json': json_app -}) -``` - -You can customize the HTML title of each application by supplying a dictionary where the keys represent the URL slugs and the values represent the titles, e.g.: - -```python -pn.serve({ - 'markdown': '# This is a Panel app', - 'json': pn.pane.JSON({'abc': 123}) -}, title={'markdown': 'A Markdown App', 'json': 'A JSON App'} -) -``` - -The ``pn.serve`` accepts a number of arguments: - - panel: Viewable, function or {str: Viewable or function} - A Panel object, a function returning a Panel object or a - dictionary mapping from the URL slug to either. - port: int (optional, default=0) - Allows specifying a specific port - address: str - The address the server should listen on for HTTP requests. - websocket_origin: str or list(str) (optional) - A list of hosts that can connect to the websocket. - - This is typically required when embedding a server app in - an external web site. - - If None, "localhost" is used. - loop: tornado.ioloop.IOLoop (optional, default=IOLoop.current()) - The tornado IOLoop to run the Server on - show: boolean (optional, default=False) - Whether to open the server in a new browser tab on start - start: boolean(optional, default=False) - Whether to start the Server - title: str or {str: str} (optional, default=None) - An HTML title for the application or a dictionary mapping - from the URL slug to a customized title - verbose: boolean (optional, default=True) - Whether to print the address and port - location: boolean or panel.io.location.Location - Whether to create a Location component to observe and - set the URL location. - kwargs: dict - Additional keyword arguments to pass to Server instance - -## Static file hosting - -Whether you're launching your application using `panel serve` from the commandline or using `pn.serve` in a script you can also serve static files. When using `panel serve` you can use the `--static-dirs` argument to specify a list of static directories to serve along with their routes, e.g.: - - panel serve some_script.py --static-dirs assets=./assets - -This will serve the `./assets` directory on the servers `/assets` route. Note however that the `/static` route is reserved internally by Panel. - -Similarly when using `pn.serve` or `panel_obj.show` the static routes may be defined as a dictionary, e.g. the equivalent to the example would be: - - pn.serve(panel_obj, static_dirs={'assets': './assets'}) diff --git a/doc/how_to/server/index.md b/doc/how_to/server/index.md new file mode 100644 index 0000000000..269af84ec7 --- /dev/null +++ b/doc/how_to/server/index.md @@ -0,0 +1,48 @@ +# Running and configuring a Panel server + +The Panel server can be launched either from the commandline (using `panel serve`) or programmatically (using `panel.serve()`). In this guide we will discover how to run and configure server instances using these two options. + +## The server + +The Bokeh server is built on Tornado, which handles all of the communication between the browser and the backend. Whenever a user accesses the app or dashboard in a browser a new session is created which executes the app code and creates a new ``Document`` containing the models served to the browser where they are rendered by BokehJS. + +
+ +
+ +If you do not want to maintain your own web server and/or set up complex reverse proxies various cloud providers make it relatively simple to quickly deploy arbitrary apps on their system. See the [deployment how-to guides](../deployment/index) for more details. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} Launch Panel server from the commandline +:link: commandline +:link-type: doc +::: + +:::{grid-item-card} Launch Panel server programmatically +:link: programmatic +:link-type: doc +::: + +:::{grid-item-card} Serving multiple applications +:link: multiple +:link-type: doc +::: + +:::{grid-item-card} Accessing a deployment over SSH +:link: ssh +:link-type: doc +::: + +:::{grid-item-card} Setting up a (reverse) proxy +:link: proxy +:link-type: doc +::: + +:::{grid-item-card} Serving static files +:link: static_files +:link-type: doc +::: + +:::: diff --git a/doc/how_to/server/multiple.md b/doc/how_to/server/multiple.md new file mode 100644 index 0000000000..6e87ba109c --- /dev/null +++ b/doc/how_to/server/multiple.md @@ -0,0 +1,37 @@ +# Serving multiple applications + +If you want to serve more than one app on a single server you can use the ``pn.serve`` function. By supplying a dictionary where the keys represent the URL slugs and the values must be either Panel objects or functions returning Panel objects you can easily launch a server with a number of apps, e.g.: + +```python +import panel as pn +pn.serve({ + 'markdown': '# This is a Panel app', + 'json': pn.pane.JSON({'abc': 123}) +}) +``` + +Note that when you serve an object directly all sessions will share the same state, i.e. the parameters of all components will be synced across sessions such that the change in a widget by one user will affect all other users. Therefore you will usually want to wrap your app in a function, ensuring that each user gets a new instance of the application: + +```python + +def markdown_app(): + return '# This is a Panel app' + +def json_app(): + return pn.pane.JSON({'abc': 123}) + +pn.serve({ + 'markdown': markdown_app, + 'json': json_app +}) +``` + +You can customize the HTML title of each application by supplying a dictionary where the keys represent the URL slugs and the values represent the titles, e.g.: + +```python +pn.serve({ + 'markdown': '# This is a Panel app', + 'json': pn.pane.JSON({'abc': 123}) +}, title={'markdown': 'A Markdown App', 'json': 'A JSON App'} +) +``` diff --git a/doc/how_to/server/programmatic.md b/doc/how_to/server/programmatic.md new file mode 100644 index 0000000000..2b9a32e22e --- /dev/null +++ b/doc/how_to/server/programmatic.md @@ -0,0 +1,61 @@ +# Launching a server dynamically + +The CLI `panel serve` command described below is usually the best approach for deploying applications. However when working on the REPL or embedding a Panel/Bokeh server in another application it is sometimes useful to dynamically launch a server, either using the `.show` method or using the `pn.serve` function. + +## Previewing an application + +Working from the command line will not automatically display rich representations inline as in a notebook, but you can still interact with your Panel components if you start a Bokeh server instance and open a separate browser window using the ``show`` method. The method has the following arguments: + + port: int (optional) + Allows specifying a specific port (default=0 chooses an arbitrary open port) + websocket_origin: str or list(str) (optional) + A list of hosts that can connect to the websocket. + This is typically required when embedding a server app in + an external-facing web site. + If None, "localhost" is used. + threaded: boolean (optional, default=False) + Whether to launch the Server on a separate thread, allowing + interactive use. + title : str + A string title to give the Document (if served as an app) + **kwargs : dict + Additional keyword arguments passed to the bokeh.server.server.Server instance. + +To work with an app completely interactively you can set ``threaded=True`` which will launch the server on a separate thread and let you interactively play with the app. + + + +The ``.show`` call will return either a Bokeh server instance (if ``threaded=False``) or a ``StoppableThread`` instance (if ``threaded=True``) which both provide a ``stop`` method to stop the server instance. + +The ``pn.serve`` accepts a number of arguments: + + panel: Viewable, function or {str: Viewable or function} + A Panel object, a function returning a Panel object or a + dictionary mapping from the URL slug to either. + port: int (optional, default=0) + Allows specifying a specific port + address: str + The address the server should listen on for HTTP requests. + websocket_origin: str or list(str) (optional) + A list of hosts that can connect to the websocket. + + This is typically required when embedding a server app in + an external web site. + + If None, "localhost" is used. + loop: tornado.ioloop.IOLoop (optional, default=IOLoop.current()) + The tornado IOLoop to run the Server on + show: boolean (optional, default=False) + Whether to open the server in a new browser tab on start + start: boolean(optional, default=False) + Whether to start the Server + title: str or {str: str} (optional, default=None) + An HTML title for the application or a dictionary mapping + from the URL slug to a customized title + verbose: boolean (optional, default=True) + Whether to print the address and port + location: boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. + kwargs: dict + Additional keyword arguments to pass to Server instance diff --git a/doc/how_to/server/proxy.md b/doc/how_to/server/proxy.md new file mode 100644 index 0000000000..6e265ec3d5 --- /dev/null +++ b/doc/how_to/server/proxy.md @@ -0,0 +1,3 @@ +# Configurign a reverse proxy + +If the goal is to serve an web application to the general Internet, it is often desirable to host the application on an internal network, and proxy connections to it through some dedicated HTTP server. For some basic configurations to set up a Bokeh server behind some common reverse proxies, including Nginx and Apache, refer to the [Bokeh documentation](https://bokeh.pydata.org/en/latest/docs/user_guide/server.html#basic-reverse-proxy-setup). diff --git a/doc/how_to/server/ssh.md b/doc/how_to/server/ssh.md new file mode 100644 index 0000000000..ff8997490f --- /dev/null +++ b/doc/how_to/server/ssh.md @@ -0,0 +1,32 @@ +# Connect to a remote server via SSH + +In some scenarios a standalone bokeh server may be running on remote host. In such cases, SSH can be used to “tunnel” to the server. In the simplest scenario, the Bokeh server will run on one host and will be accessed from another location, e.g., a laptop, with no intermediary machines. + +Run the server as usual on the remote host: + +Next, issue the following command on the local machine to establish an SSH tunnel to the remote host: + +``` +ssh -NfL localhost:5006:localhost:5006 user@remote.host +``` + +Replace user with your username on the remote host and remote.host with the hostname/IP address of the system hosting the Bokeh server. You may be prompted for login credentials for the remote system. After the connection is set up you will be able to navigate to localhost:5006 as though the Bokeh server were running on the local machine. + +The second, slightly more complicated case occurs when there is a gateway between the server and the local machine. In that situation a reverse tunnel must be established from the server to the gateway. Additionally the tunnel from the local machine will also point to the gateway. + +Issue the following commands on the remote host where the Bokeh server will run: + +``` +nohup bokeh server & +ssh -NfR 5006:localhost:5006 user@gateway.host +``` + +Replace user with your username on the gateway and gateway.host with the hostname/IP address of the gateway. You may be prompted for login credentials for the gateway. + +Now set up the other half of the tunnel, from the local machine to the gateway. On the local machine: + +``` +ssh -NfL localhost:5006:localhost:5006 user@gateway.host +``` + +Again, replace user with your username on the gateway and gateway.host with the hostname/IP address of the gateway. You should now be able to access the Bokeh server from the local machine by navigating to localhost:5006 on the local machine, as if the Bokeh server were running on the local machine. You can even set up client connections from a Jupyter notebook running on the local machine. diff --git a/doc/how_to/server/static_files.md b/doc/how_to/server/static_files.md new file mode 100644 index 0000000000..c3d4f081f3 --- /dev/null +++ b/doc/how_to/server/static_files.md @@ -0,0 +1,11 @@ +# Serving static files + +Whether you're launching your application using `panel serve` from the commandline or using `pn.serve` in a script you can also serve static files. When using `panel serve` you can use the `--static-dirs` argument to specify a list of static directories to serve along with their routes, e.g.: + + panel serve some_script.py --static-dirs assets=./assets + +This will serve the `./assets` directory on the servers `/assets` route. Note however that the `/static` route is reserved internally by Panel. + +Similarly when using `pn.serve` or `panel_obj.show` the static routes may be defined as a dictionary, e.g. the equivalent to the example would be: + + pn.serve(panel_obj, static_dirs={'assets': './assets'}) diff --git a/doc/user_guide/Server_Deployment.md b/doc/user_guide/Server_Deployment.md deleted file mode 100644 index 39b544312e..0000000000 --- a/doc/user_guide/Server_Deployment.md +++ /dev/null @@ -1,267 +0,0 @@ -# Server Deployment - -Panel is built on top of Bokeh, which provides a powerful [Tornado](https://www.tornadoweb.org/en/stable/) based web-server to communicate between Python and the browser. The bokeh server makes it possible to share the app or dashboard you have built locally, your own web server or using any of the numerous cloud providers. In this guide we will go through the details of deploying an app on a local system or cloud provider step by step. - -## The server - -The Bokeh server is built on Tornado, which handles all of the communication between the browser and the backend. Whenever a user accesses the app or dashboard in a browser a new session is created which executes the app code and creates a new ``Document`` containing the models served to the browser where they are rendered by BokehJS. - -
- -
- -### Accessing request arguments - -When a user accesses the Panel application via the browser they can optionally provide additional arguments in the URL. For example the query string ``?N=10`` will result in the following argument will be available on ``pn.state.session_args``: ``{'N': [b'10']}``. Such arguments may be used to customize the application. - -## Deployment - -As was covered in [Server Configuration](Server_Configuration.md) guide a Panel app, either in a notebook or a Python script, can be annotated with `.servable()` and then launched from the commandline using ``panel serve``. This launches a Tornado server on a specific port (defaulting to 5006) which you can access locally at ``https://localhost:{PORT}``. This is a good option for simple deployments on a local network. - -However many deployment scenarios have additional requirements around authentication, scaling, and uptime. - -### SSH - -In some scenarios a standalone bokeh server may be running on remote host. In such cases, SSH can be used to “tunnel” to the server. In the simplest scenario, the Bokeh server will run on one host and will be accessed from another location, e.g., a laptop, with no intermediary machines. - -Run the server as usual on the remote host: - -Next, issue the following command on the local machine to establish an SSH tunnel to the remote host: - -``` -ssh -NfL localhost:5006:localhost:5006 user@remote.host -``` - -Replace user with your username on the remote host and remote.host with the hostname/IP address of the system hosting the Bokeh server. You may be prompted for login credentials for the remote system. After the connection is set up you will be able to navigate to localhost:5006 as though the Bokeh server were running on the local machine. - -The second, slightly more complicated case occurs when there is a gateway between the server and the local machine. In that situation a reverse tunnel must be established from the server to the gateway. Additionally the tunnel from the local machine will also point to the gateway. - -Issue the following commands on the remote host where the Bokeh server will run: - -``` -nohup bokeh server & -ssh -NfR 5006:localhost:5006 user@gateway.host -``` - -Replace user with your username on the gateway and gateway.host with the hostname/IP address of the gateway. You may be prompted for login credentials for the gateway. - -Now set up the other half of the tunnel, from the local machine to the gateway. On the local machine: - -``` -ssh -NfL localhost:5006:localhost:5006 user@gateway.host -``` - -Again, replace user with your username on the gateway and gateway.host with the hostname/IP address of the gateway. You should now be able to access the Bokeh server from the local machine by navigating to localhost:5006 on the local machine, as if the Bokeh server were running on the local machine. You can even set up client connections from a Jupyter notebook running on the local machine. - -### Reverse proxy - -If the goal is to serve an web application to the general Internet, it is often desirable to host the application on an internal network, and proxy connections to it through some dedicated HTTP server. For some basic configurations to set up a Bokeh server behind some common reverse proxies, including Nginx and Apache, refer to the [Bokeh documentation](https://bokeh.pydata.org/en/latest/docs/user_guide/server.html#basic-reverse-proxy-setup). - -### Cloud Deployments - -If you do not want to maintain your own web server and/or set up complex reverse proxies various cloud providers make it relatively simple to quickly deploy arbitrary apps on their system. In this section we will go through step-by-step to set up deployments on some of these providers. - -#### MyBinder - -Binder allows you to create custom computing environments that can be shared and used by many remote users. MyBinder is a public, free hosting option, with limited compute and memory resources, which will allow you to deploy your simple app quickly and easily. - -Here we will take you through the configuration to quickly set up a GitHub repository with notebooks containing Panel apps for deployment on MyBinder.org. As an example refer to the [Clifford demo repository](https://github.com/pyviz-demos/clifford). - -1. Create a GitHub repository and add the notebook or script you want to serve (in the example repository this is the clifford.ipynb file) - -2. Add an ``environment.yml`` which declares a conda environment with the dependencies required to run the app (refer to the [conda documentation](https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#creating-an-environment-file-manually) to see how to declare your dependencies). Add `jupyter_panel_proxy` as a dependency by adding either `conda-forge` or `pyviz` to the channel list: - - -``` -channels: -- pyviz - -packages: -- jupyter-panel-proxy -``` - -3. Go to mybinder.org, enter the URL of your GitHub repository and hit ``Launch`` - -4. mybinder.org will give you a link to the deployment, e.g. for the example app it is https://mybinder.org/v2/gh/panel-demos/clifford-interact/main. To visit the app simply append ``?urlpath=/panel/clifford`` where you should replace clifford with the name of the notebook or script you are serving. - - -#### Heroku - -Heroku makes deployment of arbitrary apps including Panel apps and dashboards very easy and provides a free tier to get you started. This makes it a great starting point for users not too familiar with web development and deployment. - -To get started working with Heroku [signup](https://signup.heroku.com) for a free account and [download and install the CLI](https://devcenter.heroku.com/articles/getting-started-with-python#set-up). Once you are set up follow the instructions to log into the CLI. - -1. Create a new Git repo (or to follow along clone the [minimal-heroku-demo](https://github.com/pyviz-demos/minimal-heroku-demo) GitHub repo) - -2. Add a Jupyter notebook or Python script which declares a Panel app or dashboard to the repository. - -3. Define a requirements.txt containing all the requirements for your app (including Panel itself). For the sample app the requirements are as minimal as: - -``` -panel -hvplot -scikit-learn -``` - -3. Define a `Procfile` which declares the command Heroku should run to serve the app. In the sample app the following command serves the `iris_kmeans.ipynb` example. The websocket origin should match the name of the app on Heroku ``app-name.herokuapp.com`` which you will declare in the next step: - -``` -web: panel serve --address="0.0.0.0" --port=$PORT iris_kmeans.ipynb --allow-websocket-origin=app-name.herokuapp.com -``` - -4. Create a Heroku app using the CLI ensuring that the name matches the URL we declared in the previous step: - -``` -heroku create app-name -``` - -5. Push the app to heroku and wait until it is deployed. - -6. Visit the app at app-name.herokuapp.com - -Once you have deployed the app you might find that if your app is visited by more than one user at a time it will become unresponsive. In this case you can use the Heroku CLI [to scale your deployment](https://devcenter.heroku.com/articles/getting-started-with-python#scale-the-app). - -#### Anaconda Enterprise 5 (AE5) - -All live examples in the Panel documentation are served on AE5, to see further examples deployed there see [examples.pyviz.org](https://examples.pyviz.org) and for detailed instructions follow the [developer guide](https://examples.pyviz.org/make_project.html). - -#### Microsoft Azure - -Azure is popular choice for enterprises often in combination with an automated CI/CD pipeline via Azure DevOps. To get started you can use the [Azure Portal](portal.azure.com) to deploy your app as a Linux Web App via the web based user interface. - -There are a few things you need to be aware of in order to be able to start your app. - -Python Web Apps assumes your web app - -- is using `gunicorn` (like Flask or Django) or alternative is started by a `python` command. Thus - - You **cannot use** `panel serve app.py ...` as a *Startup Command*. - - You **can use** `python -m panel serve app.py ...` or `python app.py ...` as a *Startup command*. -- is served on address 0.0.0.0 and port 8000 - -Thus you can use - -```bash -python -m panel serve app.py --address 0.0.0.0 --port 8000 --allow-websocket-origin=app-name.azurewebsites.net -``` - -as a *Startup command*. - -You might be able to use `python app.py` as a *Startup command* with `.show()` or `panel.serve` inside your `app.py` file, if you can configure the `address`, `port` and `allow-websocket-origin` in the app.py file or via environment variables. - -You also need to configure your app service **general settings** to - -- allow `Web sockets` and -- be `Always on` - - - -If you would like to setup **automated CI/ CD** via Azure DevOps, Azure Pipelines and Docker to a Web App for Containers, you can find a good starting point in the [devops Folder](https://github.com/MarcSkovMadsen/awesome-panel/tree/master/devops) of [awesome-panel.org](https://awesome-panel.org). - - -#### Google Cloud Platform (GCP) - -First, you need to set up your Google cloud account following the [Cloud Run documentation](https://cloud.google.com/run/docs/quickstarts/build-and-deploy/python) or the [App Engine documentation](https://cloud.google.com/appengine/docs/standard/python3/quickstart) depending on whether you would like to deploy your Panel app to Cloud Run or App Engine. - -Next, you will need three files: -1. app.py: This is the Python file that creates the Panel App. - -2. requirements.txt: This file lists all the package dependencies of our Panel app. Here is an example for requirements.txt: - -``` -panel -bokeh -hvplot -``` -3. app.yml (for App Engine) or Dockerfile (for Cloud Run) - -Here is an example for app.yml (if you would like to deploy to App Engine): -``` -runtime: python -env: flex -entrypoint: panel serve app.py --address 0.0.0.0 --port 8080 --allow-websocket-origin="*" - -runtime_config: - python_version: 3 -``` - -Here is an example for Dockerfile (if you would like to deploy to Cloud Run): -``` -# Use the official lightweight Python image. -# https://hub.docker.com/_/python -FROM python:3.10-slim - -# Allow statements and log messages to immediately appear in the Knative logs -ENV PYTHONUNBUFFERED True - -# Copy local code to the container image. -ENV APP_HOME /app -WORKDIR $APP_HOME -COPY . ./ - -# Install production dependencies. -RUN pip install --no-cache-dir -r requirements.txt - -# Run the web service on container startup. -CMD panel serve app.py --address 0.0.0.0 --port 8080 --allow-websocket-origin="*" -``` - -Finally, to deploy a Panel app to App Engine run `gcloud app create` and `gcloud app deploy`. To deploy a Panel app to Cloud Run, run `gcloud run deploy`. - -For detailed information and steps, check out this [example](https://towardsdatascience.com/deploy-a-python-visualization-panel-app-to-google-cloud-cafe558fe787?sk=98a75bd79e98cba241cc6711e6fc5be5) on how to deploy a Panel app to App Engine and this [example](https://towardsdatascience.com/deploy-a-python-visualization-panel-app-to-google-cloud-ii-416e487b44eb?sk=aac35055957ba95641a6947bbb436410) on how to deploy a Panel app to Cloud Run. - -#### Hugging Face - -The guides below assumes you have already signed up and logged into your account at [huggingface.co](https://huggingface.co/). - -##### Duplicate an existing space - -The easiest way to get started is to [search](https://huggingface.co/spaces), find and duplicate an existing space. A simple space to duplicate is -[MarcSkovMadsen/awesome-panel](https://huggingface.co/spaces/MarcSkovMadsen/awesome-panel). - -- Open the space [MarcSkovMadsen/awesome-panel](https://huggingface.co/spaces/MarcSkovMadsen/awesome-panel). -- Click the 3 dots and select *Duplicate this Space*. - - - -- Follow the instructions to finish the duplication. - -Once you have finalized the duplication you will need to take a look at the `app.py` file in the new space to figure out what to replace. - - - -##### Creating a new space from scratch - -You can deploy Panel to Hugging Face Spaces as a [*Custom Python Space*](https://huggingface.co/docs/hub/spaces-sdks-python). For a general introduction to Hugging Face Spaces see the [Spaces Overview](https://huggingface.co/docs/hub/spaces-overview). - -Go to [Spaces](https://huggingface.co/spaces) and click the "Create New Space" button. - - - -Fill out the form. Make sure to select the *Gradio Space SDK*. - - - -A Gradio space will serve your app via the commmand `python app.py`. I.e. you cannot run `panel serve app.py ...`. - -To work around this your `app.py` will need to either - -- Use `subprocess` to run `panel serve ...` or -- Use `pn.serve` to serve one or more functions. - -The app also needs to run on a port given by the `PORT` environment variable. - -Check out the example repository [MarcSkovMadsen/awesome-panel](https://huggingface.co/spaces/MarcSkovMadsen/awesome-panel/tree/main) for inspiration. - -##### Git clone - -Optionally you can git clone your repository using - -```bash -git clone https://huggingface.co/spaces/NAME-OF-USER/NAME-OF-SPACE -``` - - - -#### Other Cloud Providers - -Panel can be used with just about any cloud provider that can launch a Python process, including Amazon Web Services (AWS) and DigitalOcean. The Panel developers will add documentation for these services as they encounter them in their own work, but we would greatly appreciate step-by-step instructions from users working on each of these systems. From 32bf2ba71b81b38bc71abc1d536e1890fb9929b7 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 3 Jan 2023 16:25:32 +0100 Subject: [PATCH 02/15] Add integrations section --- doc/how_to/deployment/azure.md | 2 +- doc/how_to/deployment/index.md | 14 +++++- doc/how_to/index.md | 25 ++++++++--- .../integrations}/Django.md | 2 +- .../integrations}/FastAPI.md | 4 +- doc/how_to/integrations/flask.md | 3 ++ doc/how_to/integrations/index.md | 33 ++++++++++++++ doc/how_to/server/index.md | 43 +++++++++++++++---- doc/index.md | 1 + 9 files changed, 106 insertions(+), 21 deletions(-) rename doc/{user_guide => how_to/integrations}/Django.md (99%) rename doc/{user_guide => how_to/integrations}/FastAPI.md (99%) create mode 100644 doc/how_to/integrations/flask.md create mode 100644 doc/how_to/integrations/index.md diff --git a/doc/how_to/deployment/azure.md b/doc/how_to/deployment/azure.md index 51f2ec6ff0..9aa6285dc9 100644 --- a/doc/how_to/deployment/azure.md +++ b/doc/how_to/deployment/azure.md @@ -26,6 +26,6 @@ You also need to configure your app service **general settings** to - allow `Web sockets` and - be `Always on` - + If you would like to setup **automated CI/ CD** via Azure DevOps, Azure Pipelines and Docker to a Web App for Containers, you can find a good starting point in the [devops Folder](https://github.com/MarcSkovMadsen/awesome-panel/tree/master/devops) of [awesome-panel.org](https://awesome-panel.org). diff --git a/doc/how_to/deployment/index.md b/doc/how_to/deployment/index.md index f6e7aa2a1b..605fcf4f6a 100644 --- a/doc/how_to/deployment/index.md +++ b/doc/how_to/deployment/index.md @@ -4,7 +4,7 @@ Panel is built on top of Bokeh, which provides a powerful [Tornado](https://www. For guides on running and configuring a Panel server see the [server how-to guides](../server/index). -::::{grid} 1 2 2 3 +::::{grid} 2 3 3 5 :gutter: 1 1 1 2 :::{grid-item-card} Azure @@ -37,3 +37,15 @@ For guides on running and configuring a Panel server see the [server how-to guid #### Other Cloud Providers Panel can be used with just about any cloud provider that can launch a Python process, including Amazon Web Services (AWS) and DigitalOcean. The Panel developers will add documentation for these services as they encounter them in their own work, but we would greatly appreciate step-by-step instructions from users working on each of these systems. + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +azure +binder +gcp +heroku +huggingface +``` diff --git a/doc/how_to/index.md b/doc/how_to/index.md index 9916ce9ffb..f6f637b5d5 100644 --- a/doc/how_to/index.md +++ b/doc/how_to/index.md @@ -2,38 +2,49 @@ The Panel's How to Guides provide step by step recipes for solving essential problems and tasks. They are more advanced than the Getting Started material and assume some knowledge of how Panel works. -## Running and Configuring a Panel Server +## Configuring a Panel Server ## Deployment The deployment guides provide step-by-step instructions on deploying Panel applications to various cloud providers. -::::{grid} 1 2 2 3 +::::{grid} 2 3 3 5 :gutter: 1 1 1 2 :::{grid-item-card} Azure -:link: how_to/azure +:link: deployment/azure :link-type: doc ::: :::{grid-item-card} Binder -:link: how_to/binder +:link: deployment/binder :link-type: doc ::: :::{grid-item-card} Google Cloud -:link: how_to/gcp +:link: deployment/gcp :link-type: doc ::: :::{grid-item-card} Heroku -:link: how_to/heroku +:link: deployment/heroku :link-type: doc ::: :::{grid-item-card} Hugging Face -:link: how_to/huggingface +:link: deployment/huggingface :link-type: doc ::: :::: + + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +server/index +integrations/index +deployment/index +``` \ No newline at end of file diff --git a/doc/user_guide/Django.md b/doc/how_to/integrations/Django.md similarity index 99% rename from doc/user_guide/Django.md rename to doc/how_to/integrations/Django.md index e5d77976e3..32db02d883 100644 --- a/doc/user_guide/Django.md +++ b/doc/how_to/integrations/Django.md @@ -1,4 +1,4 @@ -# Django +# Running Panel apps inside Django Panel generally runs on the Bokeh server which itself runs on Tornado. However, it is also often useful to embed a Panel app in large web application, such as a Django web server. Using Panel with Django requires a bit more work than for notebooks and Bokeh servers. diff --git a/doc/user_guide/FastAPI.md b/doc/how_to/integrations/FastAPI.md similarity index 99% rename from doc/user_guide/FastAPI.md rename to doc/how_to/integrations/FastAPI.md index 89729454f7..956a491004 100644 --- a/doc/user_guide/FastAPI.md +++ b/doc/how_to/integrations/FastAPI.md @@ -1,4 +1,4 @@ -# FastAPI +# Integrating Panel with FastAPI Panel generally runs on the Bokeh server which itself runs on Tornado. However, it is also often useful to embed a Panel app in large web application, such as a FastAPI web server. [FastAPI](https://fastapi.tiangolo.com/) is especially useful compared to others like Flask and Django because of it's lightning fast, lightweight framework. Using Panel with FastAPI requires a bit more work than for notebooks and Bokeh servers. @@ -11,7 +11,7 @@ Before we start adding a bokeh app to our FastApi server we have to set up some You'll need to create a file called `examples/apps/fastApi/main.py`. In `main.py` you'll need to import the following( which should all be already available from the above conda installs): - +i ```python import panel as pn from bokeh.embed import server_document diff --git a/doc/how_to/integrations/flask.md b/doc/how_to/integrations/flask.md new file mode 100644 index 0000000000..3931408008 --- /dev/null +++ b/doc/how_to/integrations/flask.md @@ -0,0 +1,3 @@ +# Integrating Panel with Flask + +WIP diff --git a/doc/how_to/integrations/index.md b/doc/how_to/integrations/index.md new file mode 100644 index 0000000000..c82cf38a27 --- /dev/null +++ b/doc/how_to/integrations/index.md @@ -0,0 +1,33 @@ +# Server Integrations + +These guides will cover how to integrate Panel applications with various external frameworks such as Django, FastAPI and Flask. + +::::{grid} 2 3 3 5 +:gutter: 1 1 1 2 + +:::{grid-item-card} Flask +:link: flask +:link-type: doc +::: + +:::{grid-item-card} FastAPI +:link: FastAPI +:link-type: doc +::: + +:::{grid-item-card} Django +:link: django +:link-type: doc +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +flask +FastAPI +django +``` diff --git a/doc/how_to/server/index.md b/doc/how_to/server/index.md index 269af84ec7..7f9e42298f 100644 --- a/doc/how_to/server/index.md +++ b/doc/how_to/server/index.md @@ -1,4 +1,4 @@ -# Running and configuring a Panel server +# Configuring a Panel server The Panel server can be launched either from the commandline (using `panel serve`) or programmatically (using `panel.serve()`). In this guide we will discover how to run and configure server instances using these two options. @@ -15,34 +15,59 @@ If you do not want to maintain your own web server and/or set up complex reverse ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 -:::{grid-item-card} Launch Panel server from the commandline +:::{grid-item-card} {octicon}`terminal;2.5em;sd-mr-1` Launch from the commandline :link: commandline :link-type: doc + +Launch and configure a Panel application from the commandline. ::: -:::{grid-item-card} Launch Panel server programmatically +:::{grid-item-card} {octicon}`code-square;2.5em;sd-mr-1` Launch programmatically :link: programmatic :link-type: doc + +Launch and configure a Panel application programmatically. ::: -:::{grid-item-card} Serving multiple applications +:::{grid-item-card} {octicon}`stack;2.5em;sd-mr-1` Serving multiple applications :link: multiple :link-type: doc + +Discover how-to launch and configure multiple applications on the same server. ::: -:::{grid-item-card} Accessing a deployment over SSH -:link: ssh +:::{grid-item-card} {octicon}`server;2.5em;sd-mr-1` Setting up a (reverse) proxy +:link: proxy :link-type: doc + +Discover how-to configure a reverse proxy to scale your deployment. ::: -:::{grid-item-card} Setting up a (reverse) proxy -:link: proxy +:::{grid-item-card} {octicon}`chevron-right;2.5em;sd-mr-1` Access via SSH +:link: ssh :link-type: doc + +Discover how to access a Panel deployment running remotely via SSH. ::: -:::{grid-item-card} Serving static files +:::{grid-item-card} {octicon}`file-media;2.5em;sd-mr-1` Serving static files :link: static_files :link-type: doc + +Discover how to serve static files alongside your Panel application(s). ::: :::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +commandline +programmatic +multiple +ssh +proxy +static_files +``` diff --git a/doc/index.md b/doc/index.md index dad4131513..1c73534470 100644 --- a/doc/index.md +++ b/doc/index.md @@ -155,6 +155,7 @@ alt: Blackstone Logo self getting_started/index.md +how_to/index.md user_guide/index.rst gallery/index.rst reference/index.rst From 0e744d946dc8ce6282d5b51b968ebfa992f20f5b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 3 Jan 2023 19:31:18 +0100 Subject: [PATCH 03/15] Move more materials from user guide to how-to guide --- doc/conf.py | 1 - doc/how_to/authentication/configuration.md | 117 +++++ doc/how_to/authentication/index.md | 51 +++ doc/how_to/authentication/providers.md | 45 ++ doc/how_to/authentication/user_info.md | 40 ++ doc/how_to/caching/index.md | 31 ++ doc/how_to/caching/manual.md | 23 + doc/how_to/caching/memoization.md | 45 ++ doc/how_to/callbacks/async.md | 92 ++++ doc/how_to/callbacks/index.md | 61 +++ doc/how_to/callbacks/load.md | 31 ++ doc/how_to/callbacks/periodic.md | 64 +++ doc/how_to/callbacks/schedule.md | 32 ++ doc/how_to/callbacks/server.md | 25 + doc/how_to/callbacks/session.md | 11 + doc/how_to/display/index.md | 0 doc/how_to/export/bokeh.md | 17 + doc/how_to/export/embedding.md | 43 ++ doc/how_to/export/index.md | 13 + doc/how_to/export/saving.md | 18 + doc/how_to/index.md | 76 ++- doc/how_to/integrations/index.md | 12 +- doc/how_to/state/busy.md | 23 + doc/how_to/state/index.md | 39 ++ doc/how_to/state/request.md | 24 + doc/how_to/state/url.md | 55 +++ doc/how_to/wasm/convert.md | 125 +++++ doc/how_to/wasm/index.md | 60 +++ doc/how_to/wasm/jupyterlite.md | 24 + doc/how_to/wasm/sphinx.md | 122 +++++ doc/how_to/wasm/standalone.md | 144 ++++++ doc/user_guide/Authentication.md | 218 --------- doc/user_guide/Running_in_Webassembly.md | 431 ------------------ doc/user_guide/index.rst | 32 -- examples/user_guide/Display_and_Export.ipynb | 143 ------ .../Performance_and_Debugging.ipynb | 142 +----- .../Session_State_and_Callbacks.ipynb | 361 --------------- 37 files changed, 1446 insertions(+), 1345 deletions(-) create mode 100644 doc/how_to/authentication/configuration.md create mode 100644 doc/how_to/authentication/index.md create mode 100644 doc/how_to/authentication/providers.md create mode 100644 doc/how_to/authentication/user_info.md create mode 100644 doc/how_to/caching/index.md create mode 100644 doc/how_to/caching/manual.md create mode 100644 doc/how_to/caching/memoization.md create mode 100644 doc/how_to/callbacks/async.md create mode 100644 doc/how_to/callbacks/index.md create mode 100644 doc/how_to/callbacks/load.md create mode 100644 doc/how_to/callbacks/periodic.md create mode 100644 doc/how_to/callbacks/schedule.md create mode 100644 doc/how_to/callbacks/server.md create mode 100644 doc/how_to/callbacks/session.md create mode 100644 doc/how_to/display/index.md create mode 100644 doc/how_to/export/bokeh.md create mode 100644 doc/how_to/export/embedding.md create mode 100644 doc/how_to/export/index.md create mode 100644 doc/how_to/export/saving.md create mode 100644 doc/how_to/state/busy.md create mode 100644 doc/how_to/state/index.md create mode 100644 doc/how_to/state/request.md create mode 100644 doc/how_to/state/url.md create mode 100644 doc/how_to/wasm/convert.md create mode 100644 doc/how_to/wasm/index.md create mode 100644 doc/how_to/wasm/jupyterlite.md create mode 100644 doc/how_to/wasm/sphinx.md create mode 100644 doc/how_to/wasm/standalone.md delete mode 100644 doc/user_guide/Authentication.md delete mode 100644 doc/user_guide/Running_in_Webassembly.md delete mode 100644 examples/user_guide/Session_State_and_Callbacks.ipynb diff --git a/doc/conf.py b/doc/conf.py index 31eabe22ba..2f7be6038f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -41,7 +41,6 @@ html_logo = "_static/logo_horizontal.png" html_favicon = "_static/icons/favicon.ico" - html_theme_options = { "github_url": "https://github.com/holoviz/panel", "icon_links": [ diff --git a/doc/how_to/authentication/configuration.md b/doc/how_to/authentication/configuration.md new file mode 100644 index 0000000000..4a69fa8463 --- /dev/null +++ b/doc/how_to/authentication/configuration.md @@ -0,0 +1,117 @@ +# Configuring OAuth + +The OAuth component will stop any user from accessing the application before first logging into the selected provider. The configuration to set up OAuth is all handled via the global `pn.config` object, which has a number of OAuth related parameters. When launching the application via the `panel serve` CLI command these config options can be set as CLI arguments or environment variables, when using the `pn.serve` function on the other hand these variables can be passed in as arguments. + +## `oauth_provider` + +The first step in configuring a OAuth is to specify a specific OAuth provider. Panel ships with a number of providers by default: + +* `azure`: Azure Active Directory +* `bitbucket`: Bitbucket +* `github`: GitHub +* `gitlab`: GitLab +* `google`: Google +* `okta`: Okta + +We will go through the process of configuring each of these individually later but for now all we need to know that the `oauth_provider` can be set on the commandline using the `--oauth-provider` CLI argument to `panel serve` or the `PANEL_OAUTH_PROVIDER` environment variable. + +Examples: + +``` +panel serve oauth_example.py --oauth-provider=... + +PANEL_OAUTH_PROVIDER=... panel serve oauth_example.py +``` + +## `oauth_key` and `oauth_secret` + +To authenticate with a OAuth provider we generally require two pieces of information (although some providers will require more customization): + +1. The Client ID is a public identifier for apps. +2. The Client Secret is a secret known only to the application and the authorization server. + +These can be configured in a number of ways the client ID and client secret can be supplied to the `panel serve` command as `--oauth-key` and `--oauth-secret` CLI arguments or `PANEL_OAUTH_KEY` and `PANEL_OAUTH_SECRET` environment variables respectively. + +Examples: + +``` +panel serve oauth_example.py --oauth-key=... --oauth-secret=... + +PANEL_OAUTH_KEY=... PANEL_OAUTH_KEY=... panel serve oauth_example.py ... +``` + +## `oauth_extra_params` + +Some OAuth providers will require some additional configuration options which will become part of the OAuth URLs. The `oauth_extra_params` configuration variable allows providing this additional information and can be set using the `--oauth-extra-params` CLI argument or `PANEL_OAUTH_EXTRA_PARAMS`. + +Examples: + +``` +panel serve oauth_example.py --oauth-extra-params={'tenant_id': ...} + +PANEL_OAUTH_EXTRA_PARAMS={'tenant_id': ...} panel serve oauth_example.py ... +``` + +## `cookie_secret` + +Once authenticated the user information and authorization token will be set as secure cookies. Cookies are not secure and can easily be modified by clients. A secure cookie ensures that the user information cannot be interfered with or forged by the client by signing it with a secret key. Note that secure cookies guarantee integrity but not confidentiality. That is, the cookie cannot be modified but its contents can be seen by the user. To generate a `cookie_secret` use the `panel secret` CLI argument or generate some other random non-guessable string, ideally with at least 256-bits of entropy. + +To set the `cookie_secret` supply `--cookie-secret` as a CLI argument or set the `PANEL_COOKIE_SECRET` environment variable. + +Examples: + +``` +panel serve oauth_example.py --cookie-secret=... + +PANEL_COOKIE_SECRET=... panel serve oauth_example.py ... +``` + +## `oauth_expiry` + +The OAuth expiry configuration value determines for how long an OAuth token will be valid once it has been issued. By default it is valid for 1 day, but may be overwritten by providing the duration in the number of days (decimal values are allowed). + +To set the `oauth_expiry` supply `--oauth-expiry-days` as a CLI argument or set the `PANEL_OAUTH_EXPIRY` environment variable. + +Examples: + +``` +panel serve oauth_example.py --oauth-expiry-days=... + +PANEL_OAUTH_EXPIRY=... panel serve oauth_example.py ... +``` + +## Encryption + +The architecture of the Bokeh/Panel server means that credentials stored as cookies can be leak in a number of ways. On the initial HTTP(S) request the server will respond with the HTML document that renders the application and this will include an unencrypted token containing the OAuth information. To ensure that the user information and access token are properly encrypted we rely on the Fernet encryption in the `cryptography` library. You can install it with `pip install cryptography` or `conda install cryptography`. + +Once installed you will be able to generate a encryption key with `panel oauth-secret`. This will generate a secret you can pass to the `panel serve` CLI command using the ``--oauth-encryption-key`` argument or `PANEL_OAUTH_ENCRYPTION` environment variable. + +Examples: + +``` +panel serve oauth_example.py --oauth-encryption-key=... + +PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ... +``` + +## Redirect URI + +Once the OAuth provider has authenticated a user it has to redirect them back to the application, this is what is known as the redirect URI. For security reasons this has to match the URL registered with the OAuth provider exactly. By default Panel will redirect the user straight back to the original URL of your app, e.g. when you're hosting your app at `https://myapp.myprovider.com` Panel will use that as the redirect URI. However in certain scenarios you may override this to provide a specific redirect URI. This can be achieved with the `--oauth-redirect-uri` CLI argument or the `PANEL_OAUTH_REDIRECT_URI` environment variable. + +Examples: + +``` +panel serve oauth_example.py --oauth-redirect-uri=... + +PANEL_OAUTH_REDIRECT_URI=... panel serve oauth_example.py +``` + +## Summary + +A fully configured OAuth configuration may look like this: + +``` +panel serve oauth_example.py --oauth-provider=github --oauth-key=... --oauth-secret=... --cookie-secret=... --oauth-encryption-key=... + +PANEL_OAUTH_PROVIDER=... PANEL_OAUTH_KEY=... PANEL_OAUTH_SECRET=... PANEL_COOKIE_SECRET=... PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ...` +``` \ No newline at end of file diff --git a/doc/how_to/authentication/index.md b/doc/how_to/authentication/index.md new file mode 100644 index 0000000000..66d088f1e3 --- /dev/null +++ b/doc/how_to/authentication/index.md @@ -0,0 +1,51 @@ +# Configuring Authentication + +Authentication is a difficult topic fraught with potential pitfalls and complicated configuration options. Panel aims to be a "batteries-included" package for building applications and dashboards and therefore ships with a number of inbuilt providers for authentication in an application. + +The primary mechanism by which Panel performs autentication is [OAuth 2.0](https://oauth.net/2/). The official specification for OAuth 2.0 describes the protocol as follows: + + The OAuth 2.0 authorization framework enables a third-party + application to obtain limited access to an HTTP service, either on + behalf of a resource owner by orchestrating an approval interaction + between the resource owner and the HTTP service, or by allowing the + third-party application to obtain access on its own behalf. + +In other words OAuth outsources authentication to a third party provider, e.g. GitHub, Google or Azure AD, to authenticate the user credentials and give limited access to the APIs of that service. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`gear;2.5em;sd-mr-1` Configuring OAuth +:link: configuration +:link-type: doc + +Discover how to configure OAuth from the commandline. +::: + +:::{grid-item-card} {octicon}`shield;2.5em;sd-mr-1` OAuth Providers +:link: providers +:link-type: doc + +A list of OAuth providers and how to configure them. +::: + +:::{grid-item-card} {octicon}`shield-check;2.5em;sd-mr-1` User Information +:link: user_info +:link-type: doc + +Discover how to make use of the user information and access tokens returned by the OAuth provider. +::: + +:::: + +Note that since Panel is built on Bokeh server and Tornado it is also possible to implement your own authentication independent of the OAuth components shipped with Panel, [see the Bokeh documentation](https://docs.bokeh.org/en/latest/docs/user_guide/server.html#authentication) for further information. + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +configuration +providers +user_info +``` diff --git a/doc/how_to/authentication/providers.md b/doc/how_to/authentication/providers.md new file mode 100644 index 0000000000..4922d577ec --- /dev/null +++ b/doc/how_to/authentication/providers.md @@ -0,0 +1,45 @@ +# OAuth Providers + +Panel supports a number of OAuth providers out-of-the-box. Follow the guide for setting up an OAuth application specific to your provider and then refer to the [Configuring OAuth guide](configuration) to add OAuth to your application. + +## **Azure Active Directory** + +To set up OAuth2.0 authentication for Azure Active directory follow [these instructions](https://docs.microsoft.com/en-us/azure/api-management/api-management-howto-protect-backend-with-aad). In addition to the `oauth_key` and `oauth_secret` ensure that you also supply the tenant ID using `oauth_extra_params`, e.g.: + +``` +panel serve oauth_test.py --oauth-extra-params="{'tenant': '...'}" + +PANEL_OAUTH_EXTRA_PARAMS="{'tenant': '...'}" panel serve oauth_example.py ... +``` + +## **Bitbucket** + +Bitbucket provides instructions about setting [setting up an OAuth consumer](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). Follow these and then supply the `oauth_key` and `oauth_secret` to Panel as described above. + +## **GitHub** + +GitHub provides detailed instructions on [creating an OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Follow these and then supply the `oauth_key` and `oauth_secret` to Panel as described above. + +## **GitLab** + +GitLab provides a detailed guide on [configuring an OAuth](https://docs.gitlab.com/ee/api/oauth2.html) application. In addition to the `oauth_key` and `oauth_secret` you will also have to supply a custom url using the `oauth_extra_params` if you have a custom GitLab instance (the default `oauth_extra_params={'url': 'gitlab.com'}`). + +## **Google** + +Google provides a guide about [configuring a OAuth application](https://developers.google.com/identity/protocols/oauth2/native-app). By default nothing except the `oauth_key` and `oauth_secret` are required but to access Google services you may also want to override the default `scope` via the `oauth_extra_params`. + +## **Okta** + +Okta provides a guide about [configuring OAuth2](https://developer.okta.com/docs/concepts/oauth-openid/). You must provide an `oauth_key` and `oauth_secret` but in most other ordinary setups you will also have to provide a `url` via the `oauth_extra_params` and if you have set up a custom authentication server (i.e. not 'default') with Okta you must also provide 'server', the `oauth_extra_params` should then look something like this: `{'server': 'custom', 'url': 'dev-***.okta.com'}` + +## Plugins + +The Panel OAuth providers are pluggable, in other words downstream libraries may define their own Tornado `RequestHandler` to be used with Panel. To register such a component the `setup.py` of the downstream package should register an entry_point that Panel can discover. To read more about entry points see the [Python documentation](https://packaging.python.org/specifications/entry-points/). A custom OAuth request handler in your library may be registered as follows: + +```python +entry_points={ + 'panel.auth': [ + "custom = my_library.auth:MyCustomOAuthRequestHandler" + ] +} +``` diff --git a/doc/how_to/authentication/user_info.md b/doc/how_to/authentication/user_info.md new file mode 100644 index 0000000000..70cc5971f5 --- /dev/null +++ b/doc/how_to/authentication/user_info.md @@ -0,0 +1,40 @@ +# Accessing User information + +## User State + +Once a user is authorized with the chosen OAuth provider certain user information and an `access_token` will be available to be used in the application to customize the user experience. Like all other global state this may be accessed on the `pn.state` object, specifically it makes three attributes available: + +* **`pn.state.user`**: A unique name, email or ID that identifies the user. +* **`pn.state.access_token`**: The access token issued by the OAuth provider to authorize requests to its APIs. +* **`pn.state.refresh_token`**: The refresh token issued by the OAuth provider to authorize requests to its APIs (if available these are usually longer lived than the `access_token`). +* **`pn.state.user_info`**: Additional user information provided by the OAuth provider. This may include names, email, APIs to request further user information, IDs and more. + +## Authorization callbacks + +The OAuth providers integrated with Panel provide an easy way to enable authentication on your applications. This verifies the identity of a user and also provides some level of access control (i.e. authorization). However often times the OAuth configuration is controlled by a corporate IT department or is otherwise difficult to manage so its often easier to grant permissions to use the OAuth provider freely but then restrict access controls in the application itself. To manage access you can provide an `authorization_callback` as part of your applications. + +The `authorization_callback` can be configured on `pn.config` or via the `pn.extension`: + +```python +import panel as pn + +def authorize(user_info): + with open('users.txt') as f: + valid_users = f.readlines() + return user_info['username'] in valid_users + +pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize) +``` + +The `authorize_callback` is given a dictionary containing the data in the OAuth provider's `id_token`. The example above checks whether the current user is in the list of users specified in a `user.txt` file. However you can implement whatever logic you want to either grant a user access or reject it. + +If a user is not authorized they will be presented with a authorization error template which can be configured using the `--auth-template` commandline option or by setting `config.auth_template`. + + + +The auth template must be a valid Jinja2 template and accepts a number of arguments: + +- `{{ title }}`: The page title. +- `{{ error_type }}`: The type of error. +- `{{ error }}`: A short description of the error. +- `{{ error_msg }}`: A full description of the error. diff --git a/doc/how_to/caching/index.md b/doc/how_to/caching/index.md new file mode 100644 index 0000000000..9b48e4664e --- /dev/null +++ b/doc/how_to/caching/index.md @@ -0,0 +1,31 @@ +# Caching + +Caching data and computation is one of the most effective ways to speed up your applications. Some common examples of scenarios that benefit from caching is working with large datasets that you have to load from disk or over a network connection or you have to perform expensive computations that don't depend on any extraneous state. Panel makes it easy for you to add caching to you applications using a few approaches. Panel' architecture is also very well suited towards caching since multiple user sessions can run in the same process and therefore have access to the same global state. This means that we can cache data in Panel's global `state` object, either by directly assigning to the `pn.state.cache` dictionary object, using the `pn.state.as_cached` helper function or the `pn.cache` decorator. Once cached all current and subsequent sessions will be sped up by having access to the cache. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` Manual Caching +:link: manual +:link-type: doc + +How to manually cache data and objects on `pn.state.cache`. +::: + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` Memoization +:link: memoization +:link-type: doc + +How to use the `panel.cache` decorator to memoize (i.e. cache the output of) functions automatically. +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +manual +memoization +``` \ No newline at end of file diff --git a/doc/how_to/caching/manual.md b/doc/how_to/caching/manual.md new file mode 100644 index 0000000000..2205e9fc54 --- /dev/null +++ b/doc/how_to/caching/manual.md @@ -0,0 +1,23 @@ +# Manual caching + +The `panel.state.cache` object is a simple dictionary that is shared between all sessions on a particular Panel server process. This makes it possible to load large datasets (or other objects you want to share) once and subsequently access the cached object. + +To assign to the cache manually, simply put the data load or expensive calculation in an `if`/`else` block which checks whether the custom key is already present: + +```python +if 'data' in pn.state.cache: + data = pn.state.cache['data'] +else: + pn.state.cache['data'] = data = ... # Load some data or perform an expensive computation +``` + +The `as_cached` helper function provides a slightly cleaner way to write the caching logic. Instead of writing a conditional statement you write a function that is executed only when the inputs to the function change. If provided the `args` and `kwargs` will also be hashed making it easy to cache (or memoize) on the arguments to the function: + +```python +def load_data(*args, **kwargs): + return ... # Load some data + +data = pn.state.as_cached('data', load_data, *args, **kwargs) +``` + +The first time the app is loaded the data will be cached and subsequent sessions will simply look up the data in the cache, speeding up the process of rendering. If you want to warm up the cache before the first user visits the application you can also provide the `--warm` argument to the `panel serve` command, which will ensure the application is initialized as soon as it is launched. If you want to populate the cache in a separate script from your main application you may also provide the path to a setup script using the `--setup` argument to `panel serve`. If you want to periodically update the cache look into the ability to [schedule tasks](../callbacks/schedule). diff --git a/doc/how_to/caching/memoization.md b/doc/how_to/caching/memoization.md new file mode 100644 index 0000000000..1ba246ccde --- /dev/null +++ b/doc/how_to/caching/memoization.md @@ -0,0 +1,45 @@ +# Memoization + +The `pn.cache` decorator provides an easy way to cache the outputs of a function depending on its inputs (i.e. `memoize`). If you've ever used the Python `@lru_cache` decorator you will be familiar with this concept. However the `pn.cache` functions supports additional cache `policy`'s apart from LRU (least-recently used), including `LFU` (least-frequently-used) and 'FIFO' (first-in-first-out). This means that if the specified number of `max_items` is reached Panel will automatically evict items from the cache based on this `policy`. Additionally items can be deleted from the cache based on a `ttl` (time-to-live) value given in seconds. + +## Caching in memory + +The `pn.cache` decorator can easily be combined with the different Panel APIs including `pn.bind` and `pn.depends` providing a powerful way to speed up your applications. + +```python +@pn.cache(max_items=10, policy='LRU') +def load_data(path): + return ... # Load some data +``` + +Once you have decorated your function with `pn.cache` any call to `load_data` will be cached in memory until `max_items` value is reached (i.e. you have loaded 10 different `path` values). At that point the `policy` will determine which item is evicted. + +The `pn.cache` decorator can easily be combined with `pn.bind` to speed up rendering of your reactive components: + +```{pyodide} +import pandas as pd +import panel as pn + +pn.extension('tabulator') + +select = pn.widgets.Select(options={ + 'Penguins': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv', + 'Diamonds': 'https://raw.githucbusercontent.com/mwaskom/seaborn-data/master/diamonds.csv', + 'Titanic': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/titanic.csv', + 'MPG': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/mpg.csv' +}) + +@pn.cache +def fetch_data(url): + return pd.read_csv(url) + +pn.Column(select, pn.bind(pn.widgets.Tabulator, pn.bind(fetch_data, select), page_size=10)) +``` + +## Disk caching + +If you have `diskcache` installed you can also cache the results to disk by setting `to_disk=True`. The `diskcache` library will then cache the value to the supplied `cache_path` (defaulting to `./cache`). Making use of disk caching allows you to cache items even if the server is restarted. + +## Clearing the cache + +Once a function has been decorated with `pn.cache` you can easily clear the cache by calling `.clear()` on that function, e.g. in the example above you could call `load_data.clear()`. If you want to clear all caches you may also call `pn.state.clear_caches()`. diff --git a/doc/how_to/callbacks/async.md b/doc/how_to/callbacks/async.md new file mode 100644 index 0000000000..67bd3032aa --- /dev/null +++ b/doc/how_to/callbacks/async.md @@ -0,0 +1,92 @@ +## Asynchronous callbacks + +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). For full asyncio support in Panel you will have to use `python>=3.8`. + +## `.param.watch` + +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: + +```{pyodide} +import panel as pn +import asyncio + +pn.extension() + +button = pn.widgets.Button(name='Click me!') +text = pn.widgets.StaticText() + +async def run_async(event): + text.value = f'Running {event.new}' + await asyncio.sleep(2) + text.value = f'Finished {event.new}' + +button.on_click(run_async) + +pn.Row(button, text) +``` + +Note that `on_click` is simple one way of registering an asynchronous callback, using `.param.watch` is also supported and so is scheduling asynchronous periodic callbacks 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` + +```{pyodide} +import aiohttp + +widget = pn.widgets.IntSlider(start=0, end=10) + +async def get_img(index): + async with aiohttp.ClientSession() as session: + async with session.get(f"https://picsum.photos/800/300?image={index}") 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. + +The equivalent can be written using `.param.watch` as: + +```{pyodide} +widget = pn.widgets.IntSlider(start=0, end=10) + +image = pn.pane.JPG() + +async def update_img(event): + async with aiohttp.ClientSession() as session: + async with session.get(f"https://picsum.photos/800/300?image={event.new}") 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/callbacks/index.md b/doc/how_to/callbacks/index.md new file mode 100644 index 0000000000..a627cf2829 --- /dev/null +++ b/doc/how_to/callbacks/index.md @@ -0,0 +1,61 @@ +# Session callbacks and events + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` Asynchronous callbacks +:link: async +:link-type: doc + +How to leverage asynchronous callbacks to run I/O bound tasks in parallel. +::: + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` Load callbacks +:link: session +:link-type: doc + +How to set up callbacks to defer a task until the application is loaded. +::: + +:::{grid-item-card} {octicon}`sync;2.5em;sd-mr-1` Periodic callbacks +:link: periodic +:link-type: doc + +How to set up per-session callbacks that run periodically. +::: + +:::{grid-item-card} {octicon}`note;2.5em;sd-mr-1` Session callbacks +:link: session +:link-type: doc + +How to set up callbacks when a session is created and destroyed. +::: + +:::{grid-item-card} {octicon}`calendar;2.5em;sd-mr-1` Schedule Tasks +:link: schedule +:link-type: doc + +How to schedule tasks that run independently of any user visiting the application(s). +::: + +:::{grid-item-card} {octicon}`lock;2.5em;sd-mr-1` Bokeh Server callbacks +:link: server +:link-type: doc + +How to safely modify Bokeh models to avoid running into issues with the Bokeh `Document` lock. +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +async +load +session +periodic +schedule +server +``` \ No newline at end of file diff --git a/doc/how_to/callbacks/load.md b/doc/how_to/callbacks/load.md new file mode 100644 index 0000000000..ba4f7ad11c --- /dev/null +++ b/doc/how_to/callbacks/load.md @@ -0,0 +1,31 @@ +# Load callbacks + +Another useful callback to define the onload callback, in a server context this will execute when a session is first initialized. Let us for example define a minimal example inside a function which we will pass to `pn.serve`. This emulates what happens when we call `panel serve` on the commandline. We will create a widget without populating its options, then we will add an `onload` callback, which will set the options once the initial page is loaded. Imagine for example that we have to fetch the options from some database which might take a little while, by deferring the loading of the options to the callback we can get something on the screen as quickly as possible and only run the expensive callback when we have already rendered something for the user to look at. + +```{pyodide} +import time + +import panel as pn + +def app(): + widget = pn.widgets.Select() + + def on_load(): + time.sleep(1) # Emulate some long running process + widget.options = ['A', 'B', 'C'] + + pn.state.onload(on_load) + + return widget + +# pn.serve(app) +``` + +Alternatively we may also use the `defer_load` option to wait to evaluate a function until the page is loaded. This will render a placeholder and display the global `config.loading_spinner`: + +```{pyodide} +def render_on_load(): + return pn.widgets.Select(options=['A', 'B', 'C']) + +pn.Row(pn.panel(render_on_load, defer_load=True)) +``` diff --git a/doc/how_to/callbacks/periodic.md b/doc/how_to/callbacks/periodic.md new file mode 100644 index 0000000000..ccfea09aa4 --- /dev/null +++ b/doc/how_to/callbacks/periodic.md @@ -0,0 +1,64 @@ +# Periodic callbacks + +Periodic callbacks allow periodically updating your application with new data. Below we will create a simple Bokeh plot and display it with Panel: + +```{pyodide} +import panel as pn + +from bokeh.models import ColumnDataSource +from bokeh.plotting import figure + +source = ColumnDataSource({"x": range(10), "y": range(10)}) +p = figure() +p.line(x="x", y="y", source=source) + +bokeh_pane = pn.pane.Bokeh(p) +bokeh_pane.servable() +``` + +Now we will define a callback that updates the data on the `ColumnDataSource` and use the `pn.state.add_periodic_callback` method to schedule updates every 200 ms. We will also set a timeout of 5 seconds after which the callback will automatically stop. + +```{pyodide} +def update(): + data = np.random.randint(0, 2 ** 31, 10) + source.data.update({"y": data}) + bokeh_pane.param.trigger('object') # Only needed in notebook + +cb = pn.state.add_periodic_callback(update, 200) +``` + +In a notebook or bokeh server context we should now see the plot update periodically. The other nice thing about this is that `pn.state.add_periodic_callback` returns `PeriodicCallback` we can call `.stop()` and `.start()` on if we want to stop or pause the periodic execution. Additionally we can also dynamically adjust the period by setting the `timeout` parameter to speed up or slow down the callback. + +Other nice features on a periodic callback are the ability to check the number of executions using the `cb.counter` property and the ability to toggle the callback on and off simply by setting the running parameter. This makes it possible to link a widget to the running state: + +```{pyodide} +toggle = pn.widgets.Toggle(name='Toggle callback', value=True) + +toggle.link(cb, bidirectional=True, value='running') +toggle +``` + +Note that when starting a server dynamically with `pn.serve` you cannot start a periodic callback before the application is actually being served. Therefore you should create the application and start the callback in a wrapping function: + +```python +from functools import partial + +import numpy as np +import panel as pn + +from bokeh.models import ColumnDataSource +from bokeh.plotting import figure + +def update(source): + data = np.random.randint(0, 2 ** 31, 10) + source.data.update({"y": data}) + +def panel_app(): + source = ColumnDataSource({"x": range(10), "y": range(10)}) + p = figure() + p.line(x="x", y="y", source=source) + cb = pn.state.add_periodic_callback(partial(update, source), 200, timeout=5000) + return pn.pane.Bokeh(p) + +pn.serve(panel_app) +``` \ No newline at end of file diff --git a/doc/how_to/callbacks/schedule.md b/doc/how_to/callbacks/schedule.md new file mode 100644 index 0000000000..5e48ae3aa6 --- /dev/null +++ b/doc/how_to/callbacks/schedule.md @@ -0,0 +1,32 @@ +# Scheduling Tasks + +The `pn.state.schedule_task` functionality allows scheduling global tasks at certain times or on a specific schedule. This is distinct from periodic callbacks, which are scheduled per user session. Global tasks are useful for performing periodic actions like updating cached data, performing cleanup actions or other housekeeping tasks, while periodic callbacks should be reserved for making periodic updates to an application. + +The different contexts in which global tasks and periodic callbacks run also has implications on how they should be scheduled. Scheduled task **must not** be declared in the application code itself, i.e. if you are serving `panel serve app.py` the callback you are scheduling must not be declared in the `app.py`. It must be defined in an external module or in a separate script declared as part of the `panel serve` invocation using the `--setup` commandline argument. + +Scheduling using `pn.state.schedule_task` is idempotent, i.e. if a callback has already been scheduled under the same name subsequent calls will have no effect. By default the starting time is immediate but may be overridden with the `at` keyword argument. The period may be declared using the `period` argument or a cron expression (which requires the `croniter` library). Note that the `at` time should be in local time but if a callable is provided it must return a UTC time. If `croniter` is installed a `cron` expression can be provided using the `cron` argument. + +As a simple example of a task scheduled at a fixed interval: + +```python +import datetime as dt +import asyncio + +async def task(): + print(f'Task executed at: {dt.datetime.now()}') + +pn.state.schedule_task('task', task, period='1s') +await asyncio.sleep(3) + +pn.state.cancel_task('task') +``` + +Note that while both `async` and regular callbacks are supported, asynchronous callbacks are preferred if you are performing any I/O operations to avoid interfering with any running applications. + +If you have the `croniter` library installed you may also provide a cron expression, e.g. the following will schedule a task to be repeated at 4:02 am every Monday and Friday: + +```python +pn.state.schedule_task('task', task, cron='2 4 * * mon,fri') +``` + +See [crontab.guru](https://crontab.guru/) and the [`croniter` README](https://github.com/kiorky/croniter#introduction) to learn about cron expressions genrally and special syntax supported by `croniter`. \ No newline at end of file diff --git a/doc/how_to/callbacks/server.md b/doc/how_to/callbacks/server.md new file mode 100644 index 0000000000..59af138cd2 --- /dev/null +++ b/doc/how_to/callbacks/server.md @@ -0,0 +1,25 @@ +# Server Callbacks + +The Bokeh server that Panel builds on is designed to be thread safe which requires a set of locks to avoid multiple threads modifying the Bokeh models simultaneously. Panel being a high-level wrapper around Bokeh handles this locking for you. However, when you update Bokeh components directly you may need to schedule a callback to get around Bokeh's document lock to avoid errors like this: + +``` +RuntimeError: _pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes +``` + +In the example below we will launch an application on a thread using `pn.serve` and make the Bokeh plot (in practice you may provide handles to this object on a class). To schedule schedule a callback which updates the `y_range` by using the `pn.state.execute` method. This pattern will ensure that the update to the Bokeh model is executed on the correct thread: + +```python +import time +import panel as pn + +from bokeh.plotting import figure + +def app(): + p = figure() + p.line([1, 2, 3], [1, 2, 3]) + return p + +pn.serve(app, threaded=True) + +pn.state.execute(lambda: p.y_range.update(start=0, end=4)) +``` \ No newline at end of file diff --git a/doc/how_to/callbacks/session.md b/doc/how_to/callbacks/session.md new file mode 100644 index 0000000000..2531bef7cb --- /dev/null +++ b/doc/how_to/callbacks/session.md @@ -0,0 +1,11 @@ +# Session callbacks + +Whenever a request is made to an endpoint that is serving a Panel application a new session is created. If you have to perform some setup or tear down tasks on session creation (e.g. logging) you can define `on_session_created` and `on_session_destroyed` callbacks. + +## pn.state.on_session_created + +WIP + +## pn.state.on_session_destroyed + +In many cases it is useful to define on_session_destroyed callbacks to perform any custom cleanup that is required, e.g, dispose a database engine, or when a user is logged out. These callbacks can be registered with `pn.state.on_session_destroyed(callback)` \ No newline at end of file diff --git a/doc/how_to/display/index.md b/doc/how_to/display/index.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/doc/how_to/export/bokeh.md b/doc/how_to/export/bokeh.md new file mode 100644 index 0000000000..09d26b05c4 --- /dev/null +++ b/doc/how_to/export/bokeh.md @@ -0,0 +1,17 @@ +# Accessing the Bokeh model + +Since Panel is built on top of Bokeh, all Panel objects can easily be converted to a Bokeh model. The ``get_root`` method returns a model representing the contents of a Panel: + +```python +model = pn.Column('# Some markdown').get_root() +model +``` + +By default this model will be associated with Bokeh's ``curdoc()``, so if you want to associate the model with some other ``Document`` ensure you supply it explictly as the first argument. Once you have access to the underlying bokeh model you can use all the usual bokeh utilities such as ``components``, ``file_html``, or ``show`` + +```python +from bokeh.embed import components, file_html +from bokeh.io import show + +script, html = components(model) +``` \ No newline at end of file diff --git a/doc/how_to/export/embedding.md b/doc/how_to/export/embedding.md new file mode 100644 index 0000000000..276abb5af9 --- /dev/null +++ b/doc/how_to/export/embedding.md @@ -0,0 +1,43 @@ +# Embedding state + +Panel generally relies on either the Jupyter kernel or a Bokeh Server to be running in the background to provide interactive behavior. However for simple apps with a limited amount of state it is also possible to `embed` all the widget state, allowing the app to be used entirely from within Javascript. To demonstrate this we will create a simple app which simply takes a slider value, multiplies it by 5 and then display the result. + +``` +slider = pn.widgets.IntSlider(start=0, end=10) + +@pn.depends(slider.param.value) +def callback(value): + return '%d * 5 = %d' % (value, value*5) + +row = pn.Row(slider, callback) +``` + +If we displayed this the normal way it would call back into Python every time the value changed. However, the `.embed()` method will record the state of the app for the different widget configurations. + +``` +row.embed() +``` + +If you try the widget above you will note that it only has 3 different states, 0, 5 and 10. This is because by default embed will try to limit the number of options of non-discrete or semi-discrete widgets to at most three values. This can be controlled using the `max_opts` argument to the embed method or you can provide an explicit list of `states` to embed for each widget: + +``` +row.embed(states={slider: list(range(0, 12, 2))}) +``` + + The full set of options for the embed method include: + +- **`max_states`**: The maximum number of states to embed + +- **`max_opts`**: The maximum number of states for a single widget + +- **`states`** (default={}): A dictionary specifying the widget values to embed for each widget + +- **`json`** (default=True): Whether to export the data to json files + +- **`save_path`** (default='./'): The path to save json files to + +- **`load_path`** (default=None): The path or URL the json files will be loaded from (same as ``save_path`` if not specified) + +* **`progress`** (default=False): Whether to report progress + +As you might imagine if there are multiple widgets there can quickly be a combinatorial explosion of states so by default the output is limited to about 1000 states. For larger apps the states can also be exported to json files, e.g. if you want to serve the app on a website specify the ``save_path`` to declare where it will be stored and the ``load_path`` to declare where the JS code running on the website will look for the files. \ No newline at end of file diff --git a/doc/how_to/export/index.md b/doc/how_to/export/index.md new file mode 100644 index 0000000000..885c325670 --- /dev/null +++ b/doc/how_to/export/index.md @@ -0,0 +1,13 @@ +# Exporting and Saving Output + +One of the main design goals for Panel was that it should make it possible to seamlessly transition back and forth between interactively prototyping a dashboard in the notebook or on the commandline to deploying it as a standalone server app. This section shows how to display panels interactively, embed static output, save a snapshot, and deploy as a separate web-server app. For more information about deploying Panel apps to various cloud providers see the [Server Deployment](Server_Deployment.ipynb) documentation. + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +embedding +saving +bokeh +``` \ No newline at end of file diff --git a/doc/how_to/export/saving.md b/doc/how_to/export/saving.md new file mode 100644 index 0000000000..27d60966a8 --- /dev/null +++ b/doc/how_to/export/saving.md @@ -0,0 +1,18 @@ +# Saving output + +In case you don't need an actual server or simply want to export a static snapshot of a panel app, you can use the ``save`` method, which allows exporting the app to a standalone HTML or PNG file. + +By default, the HTML file generated will depend on loading JavaScript code for BokehJS from the online ``CDN`` repository, to reduce the file size. If you need to work in an airgapped or no-network environment, you can declare that ``INLINE`` resources should be used instead of ``CDN``: + +```python +from bokeh.resources import INLINE +panel.save('test.html', resources=INLINE) +``` + +Additionally the save method also allows enabling the `embed` option, which, as explained above, will embed the apps state in the app or save the state to json files which you can ship alongside the exported HTML. + +Finally, if a 'png' file extension is specified, the exported plot will be rendered as a PNG, which currently requires Selenium and PhantomJS to be installed: + +```python +pane.save('test.png') +``` diff --git a/doc/how_to/index.md b/doc/how_to/index.md index f6f637b5d5..c91f5dba3b 100644 --- a/doc/how_to/index.md +++ b/doc/how_to/index.md @@ -2,49 +2,93 @@ The Panel's How to Guides provide step by step recipes for solving essential problems and tasks. They are more advanced than the Getting Started material and assume some knowledge of how Panel works. -## Configuring a Panel Server +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 -## Deployment +:::{grid-item-card} {octicon}`note;2.5em;sd-mr-1` Session callbacks and events +:link: callbacks/index +:link-type: doc -The deployment guides provide step-by-step instructions on deploying Panel applications to various cloud providers. +How to set up callbacks on session related events and periodic tasks. +::: -::::{grid} 2 3 3 5 -:gutter: 1 1 1 2 +:::{grid-item-card} {octicon}`note;2.5em;sd-mr-1` Accessing session state +:link: state/index +:link-type: doc + +How to access state related to the user session, HTTP request and URL arguments. +::: -:::{grid-item-card} Azure -:link: deployment/azure +:::{grid-item-card} {octicon}`versions;2.5em;sd-mr-1` Caching +:link: caching/index :link-type: doc + +How to cache data across sessions and memoize the output of functions. ::: -:::{grid-item-card} Binder -:link: deployment/binder +:::{grid-item-card} {octicon}`device-desktop;2.5em;sd-mr-1` Display and Preview output +:link: display/index :link-type: doc + +How to display Panel components and apps in your favorite notebook or editor environment. ::: -:::{grid-item-card} Google Cloud -:link: deployment/gcp +:::{grid-item-card} {octicon}`file;2.5em;sd-mr-1` Exporting and Saving output +:link: export/index :link-type: doc + +How to export and save Panel applications as static files. ::: -:::{grid-item-card} Heroku -:link: deployment/heroku +:::{grid-item-card} {octicon}`browser;2.5em;sd-mr-1` Running in WebAssembly +:link: wasm/index :link-type: doc + +How to run Panel applications entirely in the browser using WebAssembly, Pyodide and PyScript. ::: -:::{grid-item-card} Hugging Face -:link: deployment/huggingface +:::{grid-item-card} {octicon}`server;2.5em;sd-mr-1` Server Configuration +:link: server/index :link-type: doc + +How to configure the Panel server. ::: -:::: +:::{grid-item-card} {octicon}`package-dependencies;2.5em;sd-mr-1` Server Integrations +:link: integrations/index +:link-type: doc + +How to integrate Panel in other application based on Flask, FastAPI or Django. +::: + +:::{grid-item-card} {octicon}`share;2.5em;sd-mr-1` Deploying applications +:link: deployment/index +:link-type: doc + +How to deploy Panel applications to various cloud providers (e.g. Azure, GCP, AWS etc.) +::: + +:::{grid-item-card} {octicon}`shield-check;2.5em;sd-mr-1` Authentication +:link: authentication/index +:link-type: doc +How to configure OAuth to add authentication to a server deployment. +::: + +:::: ```{toctree} :titlesonly: :hidden: :maxdepth: 2 +callbacks/index +state/index +caching/index +export/index +wasm/index server/index integrations/index deployment/index +authentication/index ``` \ No newline at end of file diff --git a/doc/how_to/integrations/index.md b/doc/how_to/integrations/index.md index c82cf38a27..66f6ab21d0 100644 --- a/doc/how_to/integrations/index.md +++ b/doc/how_to/integrations/index.md @@ -2,22 +2,28 @@ These guides will cover how to integrate Panel applications with various external frameworks such as Django, FastAPI and Flask. -::::{grid} 2 3 3 5 +::::{grid} 1 3 3 3 :gutter: 1 1 1 2 :::{grid-item-card} Flask :link: flask :link-type: doc + +Discover to run Panel applications alongside an existing Flask server. ::: :::{grid-item-card} FastAPI :link: FastAPI :link-type: doc + +Discover to run Panel applications alongside an existing FastAPI server. ::: :::{grid-item-card} Django -:link: django +:link: Django :link-type: doc + +Discover to run Panel applications on a Django server (replacing the standard Tornado based server). ::: :::: @@ -29,5 +35,5 @@ These guides will cover how to integrate Panel applications with various externa flask FastAPI -django +Django ``` diff --git a/doc/how_to/state/busy.md b/doc/how_to/state/busy.md new file mode 100644 index 0000000000..0400a71ab6 --- /dev/null +++ b/doc/how_to/state/busy.md @@ -0,0 +1,23 @@ +# Busyness state + +Often an application will have longer running callbacks which are being processed on the server, to give users some indication that the server is busy you may therefore have some way of indicating that busy state. The `pn.state.busy` parameter indicates whether a callback is being actively processed and may be linked to some visual indicator. + +Below we will create a little application to demonstrate this, we will create a button which executes some longer running task on click and then create an indicator function that displays `'I'm busy'` when the `pn.state.busy` parameter is `True` and `'I'm idle'` when it is not: + +```{pyodide} +import time + +def processing(event): + # Some longer running task + time.sleep(1) + +button = pn.widgets.Button(name='Click me!') +button.on_click(processing) + +def indicator(busy): + return "I'm busy" if busy else "I'm idle" + +pn.Row(button, pn.bind(indicator, pn.state.param.busy)) +``` + +This way we can create a global indicator for the busy state instead of modifying all our callbacks. \ No newline at end of file diff --git a/doc/how_to/state/index.md b/doc/how_to/state/index.md new file mode 100644 index 0000000000..9db6b92e90 --- /dev/null +++ b/doc/how_to/state/index.md @@ -0,0 +1,39 @@ +# Accessing session state + +Whenever a Panel application is being served the `panel.state` object will provide a variety of information about the current user session including the HTTP request that initiated the session, information about the browser and the current URL and more. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` HTTP Request +:link: manual +:link-type: doc + +How to access information about the HTTP request associated with a session. +::: + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` url +:link: url +:link-type: doc + +How to access and manipulate the URL. +::: + +:::{grid-item-card} {octicon}`hourglass;2.5em;sd-mr-1` busy +:link: busy +:link-type: doc + +How to access the busy state. +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +request +url +busy +``` \ No newline at end of file diff --git a/doc/how_to/state/request.md b/doc/how_to/state/request.md new file mode 100644 index 0000000000..15df1988f7 --- /dev/null +++ b/doc/how_to/state/request.md @@ -0,0 +1,24 @@ +# Access HTTP request state + +The `panel.state` object holds a wide range of information about the HTTP request that is associated with a running session. Note that if you are running Panel inside a notebook session these attributes will simply return `None`. + +## Request arguments + +The request arguments are made available to be accessed on ``pn.state.session_args``. For example if your application is hosted at ``localhost:8001/app``, appending ``?phase=0.5`` to the URL will allow you to access the phase variable using the following code: + +```python +try: + phase = int(pn.state.session_args.get('phase')[0]) +except Exception: + phase = 1 +``` + +This mechanism may be used to modify the behavior of an app dependending on parameters provided in the URL. + +## Cookies + +The `panel.state.cookies` will allow accessing the cookies stored in the browser and on the bokeh server. + +## Headers + +The `panel.state.headers` will allow accessing the HTTP headers stored in the browser and on the bokeh server. diff --git a/doc/how_to/state/url.md b/doc/how_to/state/url.md new file mode 100644 index 0000000000..c1c5a14ce2 --- /dev/null +++ b/doc/how_to/state/url.md @@ -0,0 +1,55 @@ +# Accessing and manipulating the URL + +## Accessing + +When starting a server session Panel will attach a `Location` component which can be accessed using `pn.state.location`. The `Location` component servers a number of functions: + +- Navigation between pages via ``pathname`` +- Sharing (parts of) the page state in the url as ``search`` parameters for bookmarking and sharing. +- Navigating to subsections of the page via the ``hash_`` parameter. + +### Core + +* **``pathname``** (string): pathname part of the url, e.g. '/user_guide/Interact.html'. +* **``search``** (string): search part of the url e.g. '?color=blue'. +* **``hash_``** (string): hash part of the url e.g. '#interact'. +* **``reload``** (bool): Whether or not to reload the page when the url is updated. + - For independent apps this should be set to True. + - For integrated or single page apps this should be set to False. + +### Readonly + +* **``href``** (string): The full url, e.g. 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'. +* **``protocol``** (string): protocol part of the url, e.g. 'http:' or 'https:' +* **``port``** (string): port number, e.g. '80' + +## Manipulating + +By default the current [query parameters](https://en.wikipedia.org/wiki/Query_string) in the URL (specified as a URL suffix such as `?color=blue`) are made available on `pn.state.location.query_params`. To make working with query parameters straightforward the `Location` object also provides a `sync` method which allows syncing query parameters with the parameters on a `Parameterized` object. + +We will start by defining a `Parameterized` class: + +```{pyodide} +import param +import panel as pn + +class QueryExample(param.Parameterized): + + integer = param.Integer(default=None, bounds=(0, 10)) + + string = param.String(default='A string') +``` + +Now we will use the `pn.state.location` object to sync it with the URL query string (note that in a notebook environment `pn.state.location` is not initialized until the first plot has been displayed). The sync method takes the Parameterized object or instance to sync with as the first argument and a list or dictionary of the parameters as the second argument. If a dictionary is provided it should map from the Parameterized object's parameters to the query parameter name in the URL: + +```{pyodide} +pn.state.location.sync(QueryExample, {'integer': 'int', 'string': 'str'}) +``` + +Now the Parameterized object is bi-directionally linked to the URL query parameter, if we set a query parameter in Python it will update the URL bar and when we specify a URL with a query parameter that will be set on the Parameterized, e.g. let us set the 'integer' parameter and watch the URL in your browser update: + +```{pyodide} +QueryExample.integer = 5 +``` + +Note to unsync the Parameterized object you can simply call `pn.state.location.unsync(QueryExample)`. diff --git a/doc/how_to/wasm/convert.md b/doc/how_to/wasm/convert.md new file mode 100644 index 0000000000..79d5a38839 --- /dev/null +++ b/doc/how_to/wasm/convert.md @@ -0,0 +1,125 @@ +# Converting Panel applications + +Writing an HTML file from scratch with all the Javascript and Python dependencies and other boilerplate can be quite cumbersome, and requires learning a good bit of HTML. To avoid writing all the boilerplate, Panel provides support for converting an entire application (including Panel templates) to an HTML file, using the `panel convert` command-line interface (CLI). As a starting point create one or more Python scripts or notebook files containing your application. The only requirement is that they import only global modules and packages (relative imports of other scripts or modules is not supported) and that the libraries have been [compiled for Pyodide](https://github.com/pyodide/pyodide/tree/main/packages) or are available as pure-Python wheels from PyPI. + +The ``panel convert`` command has the following options: + + positional arguments: + SCRIPTs The scripts or notebooks to convert + + optional arguments: + -h, --help Show this help message and exit + --to The format to convert to, one of 'pyodide', 'pyodide-worker' or 'pyscript' + --out Directory to export files to + --title Custom title for the application(s) + --skip-embed Whether to skip the prerendering while pyodide loads. + --index Whether to create an index if multiple files are served. + --pwa Whether to add files to allow serving the application as a Progressive Web App. + --requirements List of Python requirements to add to the converted file. By default it will automatically try to infer dependencies based on your imports and Panel will automatically be included. + --watch Watches files for changes and rebuilds them when they are updated. + --disable-http-patch Disables patching of http requests using pyodide-http library. + +## Example + +This example will demonstrate how to *convert* and *serve* a basic data app locally. + +- Create a `script.py` file with the following content + +```python +import panel as pn + +from sklearn.datasets import load_iris +from sklearn.metrics import accuracy_score +from xgboost import XGBClassifier + +pn.extension(sizing_mode="stretch_width", template="fast") +pn.state.template.param.update(site="Panel in the Browser", title="XGBoost Example") + +iris_df = load_iris(as_frame=True) + +trees = pn.widgets.IntSlider(start=2, end=30, name="Number of trees") + +def pipeline(trees): + model = XGBClassifier(max_depth=2, n_estimators=trees) + model.fit(iris_df.data, iris_df.target) + accuracy = round(accuracy_score(iris_df.target, model.predict(iris_df.data)) * 100, 1) + return pn.indicators.Number( + name="Test score", + value=accuracy, + format="{value}%", + colors=[(97.5, "red"), (99.0, "orange"), (100, "green")], + ) + +pn.Column( + "Simple example of training an XGBoost classification model on the small Iris dataset.", + iris_df.data.head(), + "Move the slider below to change the number of training rounds for the XGBoost classifier. The training accuracy score will adjust accordingly.", + trees, + pn.bind(pipeline, trees), +).servable() +``` + +- Run `panel convert script.py --to pyodide-worker --out pyodide` +- Run `python3 -m http.server` to start a web server locally +- Open `http://localhost:8000/pyodide/script.html` to try out the app. + +The app should look like this + +![Panel in the browser](../../_static/images/pyodide_xgboost_app.png) + +You can now add the `script.html` (and `script.js` file if you used the `pyodide-worker` target) to your Github pages or similar. **no separate server needed!** + +## Tips & Tricks for development + +- While developing you should run the script locally with *auto reload*: `panel serve script.py --autoreload`. +- You can also watch your script for changes and rebuild it if you make an edit with `panel convert ... --watch` +- If the converted app does not work as expected, you can most often find the errors in the browser +console. [This guide](https://balsamiq.com/support/faqs/browserconsole/) describes how to open the +console. +- You can find answers to the most frequently asked questions about *Python in the browser* in the [Pyodide - FAQ](https://pyodide.org/en/stable/usage/faq.html) or the [PyScript FAQ](https://docs.pyscript.net/latest/reference/faq.html). For example the answer to "How can I load external data?". + +## Formats + +Using the `--to` argument on the CLI you can control the format of the file that is generated by `panel convert`. You have three options, each with distinct advantages and disadvantages: + +- **`pyodide`** (default): Run application using Pyodide running in the main thread. This option is less performant than pyodide-worker but produces completely standalone HTML files that do not have to be hosted on a static file server (e.g. Github Pages). +- **`pyodide-worker`**: Generates an HTML file and a JS file containing a Web Worker that runs in a separate thread. This is the most performant option, but files have to be hosted on a static file server. +- **`pyscript`**: Generates an HTML leveraging PyScript. This produces standalone HTML files containing `` and `` tags containing the dependencies and the application code. This output is the most readable, and should have equivalent performance to the `pyodide` option. + +## Requirements + +The `panel convert` command will try its best to figure out the requirements of your script based on the imports, which means that in most cases you won't have to provide the explicit `--requirements` argument. However, if some library uses an optional import that cannot be inferred from the list of imports in your app you will have to provide an explicit list of dependencies. Note that `panel` and its dependencies including NumPy and Bokeh will be added loaded automatically, e.g. the explicit requirements for the app above would look like this: + +```bash +panel convert script.py --to pyodide-worker --out pyodide --requirements xgboost scikit-learn pandas +``` + +Alternatively you may also provide a `requirements.txt` file: + +```bash +panel convert script.py --to pyodide-worker --out pyodide --requirements requirements.txt +``` + +## Index + +If you convert multiple applications at once you may want to add an index to be able to navigate between the applications easily. To enable the index simply pass `--index` to the convert command. + +## Prerendering + +In order to improve the loading experience Panel will pre-render and embed the initial render of the page and replace it with live components once the page is loaded. This is important because Pyodide has to fetch the entire Python runtime and all required packages from a CDN. This can be **very** slow depending on your internet connection. If you want to disable this behavior and render an initially blank page use the `--skip-embed` option. Otherwise Panel will render application using the current Python process (presumably outside the browser) into the HTML file as a "cached" copy of the application for the user to see while the Python runtime is initialized and the actual browser-generated application is ready for interaction. + +## Progressive Web Apps + +Progressive web applications (PWAs) provide a way for your web apps to behave almost like a native application, both on mobile devices and on the desktop. The `panel convert` CLI has a `--pwa` option that will generate the necessary files to turn your Panel + Pyodide application into a PWA. The web manifest, service worker script and assets such as thumbnails are exported alongside the other HTML and JS files and can then be hosted on your static file host. Note that Progressive web apps must be served via HTTPS to ensure user privacy, security, and content authenticity, including the application itself and all resources it references. Depending on your hosting service, you will have to enable HTTPS yourself. GitHub pages generally make this very simple and provide a great starting point. + +Once generated, you can inspect the `site.webmanifest` file and modify it to your liking, including updating the favicons in the assets directory. + +```{note} +If you decide to enable the `--pwa` ensure that you also provide a unique `--title`. Otherwise the browser caches storing your apps dependencies will end up overwriting each other. +``` + +## Handling HTTP requests + +By default Panel 0.14.1 will install the [pyodide-http](https://github.com/koenvo/pyodide-http) library which patches `urllib3` and `requests` making it possible to use them within the pyodide process. To disable this behavior use the `--disable-http-patch` CLI option. + +Note that making HTTP requests when converting to the `pyodide` or `pyscript` target will block the main browser thread and result in a poor user experience. Therefore we strongly recommend converting to `pyodide-worker` if your app is making synchronous HTTP requests. diff --git a/doc/how_to/wasm/index.md b/doc/how_to/wasm/index.md new file mode 100644 index 0000000000..d754ffc8a4 --- /dev/null +++ b/doc/how_to/wasm/index.md @@ -0,0 +1,60 @@ +# Running Panel in the Browser with WASM + +Panel lets you write dashboards and other applications in Python that are accessed using a web browser. Typically, the Python interpreter runs as a separate Jupyter or Bokeh server process, communicating with JavaScript code running in the client browser. However, **it is now possible to run Python directly in the browser**, with **no separate server needed!** + +The underlying technology involved is called [WebAssembly](https://webassembly.org/) (or WASM). More specifically, [Pyodide](https://pyodide.org/) pioneered the ability to install Python libraries, manipulate the web page's [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction) from Python, and execute regular Python code entirely in the browser. A number of libraries have sprung up around Python in WASM, including [PyScript](https://pyscript.net/). + +Panel can be run directly in Pyodide and has special support for rendering in PyScript. + +This guide will take you through the process of either + +- Automatically converting Panel applications into a Pyodide/PyScript based application +- Manually installing Panel in the browser and using it to render components. +- Embedding Panel in your Sphinx documentation. +- Setting up a Jupyterlite instance with support for Panel + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`duplicate;2.5em;sd-mr-1` Convert to WASM. +:link: convert +:link-type: doc + +Discover how to convert existing Panel applications to WASM. +::: + +:::{grid-item-card} {octicon}`code;2.5em;sd-mr-1` Use from WASM +:link: standalone +:link-type: doc + +Discover how to set up and use Panel from Pyodide and PyScript. +::: + +:::{grid-item-card} {octicon}`book;2.5em;sd-mr-1` Sphinx Integration +:link: user_info +:link-type: doc + +Discover how to integrate live Panel components in your Sphinx based documentation. +::: + +:::{grid-item-card} {octicon}`zap;2.5em;sd-mr-1` JupyterLite +:link: jupyterlite +:link-type: doc + +Discover how to set up a JupyterLite deployment capable of rendering interactive Panel output. +::: + +:::: + +Note that since Panel is built on Bokeh server and Tornado it is also possible to implement your own authentication independent of the OAuth components shipped with Panel, [see the Bokeh documentation](https://docs.bokeh.org/en/latest/docs/user_guide/server.html#authentication) for further information. + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +convert +standalone +sphinx +jupyterlite +``` diff --git a/doc/how_to/wasm/jupyterlite.md b/doc/how_to/wasm/jupyterlite.md new file mode 100644 index 0000000000..5cf8dff23a --- /dev/null +++ b/doc/how_to/wasm/jupyterlite.md @@ -0,0 +1,24 @@ +# Setting up JupyterLite + +[JupyterLite](https://jupyterlite.readthedocs.io/en/latest/) is a JupyterLab distribution built from all the usual components and extensions that come with JupyterLab, but now running entirely in the browser with no external server needed. In order to use Panel in JupyterLite you will have to build your own distribution. As a starting point we recommend [this guide](https://jupyterlite.readthedocs.io/en/latest/howto/configure/simple_extensions.html) in the JupyterLite documentation, which will tell you how to set up an environment to begin building JupyterLite. + +## Create a `` + +Once your environment is set up, create a new directory, which will become the source for your JupyterLite distribution. Once created place the file contents you want to make available in JupyterLite into `/files`. + +## Adding extensions + +In order for Panel to set up communication channels inside JupyterLite we have to add the `pyviz_comms` extension to the environment. Ensure this package is installed in the environment you are building Panel from, e.g. by running `pip install pyviz_comms` OR by including it in the `requirements.txt` you used when setting up your build environment. + +## Optimized wheels (optional) + +To get Panel installed inside a Jupyterlite session we have to install it with `piplite`. The default Bokeh and Panel packages are quite large since they contain contents which are needed in a server environment. Since we will be running inside Jupyter these contents are not needed. To bundle the optimized packages download them from the CDN and place them in the `/pypi` directory. You can download them from the CDN (replacing the latest version numbers): + +``` +https://cdn.holoviz.org/panel/0.14.2/dist/wheels/bokeh-2.4.3-py3-none-any.whl +https://cdn.holoviz.org/panel/0.14.2/dist/wheels/panel-0.14.2-py3-none-any.whl +``` + +## Building Panel lite + +Finally `cd` into your `` and run `jupyter lite build --output-dir ./dist`. This will bundle up the file contents, extensions and wheels into your JupyterLite distribution. You can now easily deploy this to [GitHub pages](https://jupyterlite.readthedocs.io/en/latest/quickstart/deploy.html) or elsewhere. diff --git a/doc/how_to/wasm/sphinx.md b/doc/how_to/wasm/sphinx.md new file mode 100644 index 0000000000..f3a0cea786 --- /dev/null +++ b/doc/how_to/wasm/sphinx.md @@ -0,0 +1,122 @@ +# Embedding in Sphinx documentation + +One more option is to include live Panel examples in your Sphinx documentation using the `nbsite.pyodide` directive. + +## Setup + +In the near future we hope to make this a separate Sphinx extension, until then simply install latest nbsite with `pip` or `conda`: + +::::{tab-set} +:::{tab-item} Conda +:sync: conda + +``` bash +conda install -c pyviz nbsite +``` + +::: +:::{tab-item} Pip +:sync: pip + +``` bash +pip install nbsite +``` +::: +:::: + +add the extension to the Sphinx `conf.py`: + +```python +extensions += [ + ..., + 'nbsite.pyodide' +] +``` + +## Configuration + +In the `conf.py` of your project you can configure the extension in a number of ways by defining an `nbsite_pyodide_conf` dictionary with the following options: + +- `PYODIDE_URL`: The URl to fetch Pyodide from +- `autodetect_deps` (default=`True`): Whether to automatically detect dependencies in the executed code and install them. +- `enable_pwa` (default=`True`): Whether to add a web manifest and service worker to configure the documentation as a progressive web app. +- `requirements` (default=`['panel']`): Default requirements to include (by default this includes just panel. +- `scripts`: Scripts to add to the website when a Pyodide cell is first executed. +- `setup_code` (default=`''`): Python code to run when initializing the Pyodide runtime. + +and then you can use the `pyodide` as an RST directive: + +```rst +.. pyodide:: + + import panel as pn + + slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') + + def callback(new): + return f'Amplitude is: {new}' + + pn.Row(slider, pn.bind(callback, slider)) +``` + +## Examples + +The resulting output looks like this: + +```{pyodide} +import panel as pn +``` + + +```{pyodide} +slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') + +def callback(new): + return f'Amplitude is: {new}' + +pn.Row(slider, pn.bind(callback, slider)) +``` + +In addition to rendering Panel components it also renders regular Pytho +types: + +```{pyodide} +1+1 +``` + + +```{pyodide} +"A string" +``` + +and also handles stdout and stderr streams: + +```{pyodide} +import numpy as np +for i in range(10): + print(f'Repeat {i}') + for i in range(10000): + np.random.rand(1000) +``` + +```{pyodide} +raise ValueError('Encountered an error') +``` + +and supports `_repr__` methods that are commonly used by the IPython and Jupyter ecosystem: + +```{pyodide} +class HTML: + + def __init__(self, html): + self.html = html + + def _repr_html_(self): + return self.html + +HTML('HTML!') +``` + +## Usage + +The code cell will display a button to execute the cell, which will warn about downloading the Python runtime on first-click and ask you to confirm whether you want to proceed. It will then download Pyodide, all required packages and finally display the output. diff --git a/doc/how_to/wasm/standalone.md b/doc/how_to/wasm/standalone.md new file mode 100644 index 0000000000..af64e74d68 --- /dev/null +++ b/doc/how_to/wasm/standalone.md @@ -0,0 +1,144 @@ +# Using Panel in Pyodide & PyScript + +## Installing Panel in the browser + +To install Panel in the browser you merely have to use the installation mechanism provided by each supported runtime: + +### Pyodide + +Currently the best supported mechanism for installing packages in Pyodide is `micropip`. + +To get started with Pyodide simply follow their [Getting started guide](https://pyodide.org/en/stable/usage/quickstart.html). Note that if you want to render Panel output you will also have to load [Bokeh.js](https://docs.bokeh.org/en/2.4.1/docs/first_steps/installation.html#install-bokehjs:~:text=Installing%20standalone%20BokehJS%C2%B6) and Panel.js from CDN. The most basic pyodide application therefore looks like this: + +```html + + + + + + + + + + + + +
+ + + +``` + +The app should look like this + +![Panel Pyodide App](../_static/images/pyodide_app_simple.png) + +:::{admonition} +The default bokeh and panel packages are very large, therefore we recommend you pip install specialized wheels: + +```javascript +const bk_whl = "https://cdn.holoviz.org/panel/0.14.0/wheels/bokeh-2.4.3-py3-none-any.whl" +const pn_whl = "https://cdn.holoviz.org/panel/0.14.0/wheels/panel-0.14.0-py3-none-any.whl" +await micropip.install(bk_whl, pn_whl) +``` +::: + +### PyScript + +PyScript makes it even easier to manage your dependencies, with a `` HTML tag. Simply include `panel` in the list of dependencies and PyScript will install it automatically: + +```html + +packages = [ + "panel", + ... +] + +``` + +Once installed you will be able to `import panel` in your `` tag. Again, make sure you also load Bokeh.js and Panel.js: + +```html + + + + + + + + + + + + + + packages = [ + "panel", + ... + ] + +
+ + import panel as pn + + pn.extension(sizing_mode="stretch_width") + + slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') + + def callback(new): + return f'Amplitude is: {new}' + + pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app'); + + + +``` + +The app should look identical to the one above but show a loading spinner while Pyodide is initializing. + +## Rendering Panel components in Pyodide or Pyscript + +Rendering Panel components into the DOM is quite straightforward. You can simply use the `.servable()` method on any component and provide a target that should match the `id` of a DOM node: + +```python +import panel as pn + +slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') + +def callback(new): + return f'Amplitude is: {new}' + +pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app'); +``` + +This code will render this simple application into the `simple_app` DOM node: + +```html +
+``` + +Alternatively you can also use the `panel.io.pyodide.write` function to write into a particular DOM node: + +```python +await pn.io.pyodide.write('simple_app', component) +``` diff --git a/doc/user_guide/Authentication.md b/doc/user_guide/Authentication.md deleted file mode 100644 index a5906bbe3e..0000000000 --- a/doc/user_guide/Authentication.md +++ /dev/null @@ -1,218 +0,0 @@ -# Authentication - -Authentication is a difficult topic fraught with potential pitfalls and complicated configuration options. Panel aims to be a "batteries-included" package for building applications and dashboards and therefore ships with a number of inbuilt providers for authentication in an application. - -The primary mechanism by which Panel performs autentication is [OAuth 2.0](https://oauth.net/2/). The official specification for OAuth 2.0 describes the protocol as follows: - - The OAuth 2.0 authorization framework enables a third-party - application to obtain limited access to an HTTP service, either on - behalf of a resource owner by orchestrating an approval interaction - between the resource owner and the HTTP service, or by allowing the - third-party application to obtain access on its own behalf. - -In other words OAuth outsources authentication to a third party provider, e.g. GitHub, Google or Azure AD, to authenticate the user credentials and give limited access to the APIs of that service. - -Note that since Panel is built on Bokeh server and Tornado it is also possible to implement your own authentication independent of the OAuth components shipped with Panel, [see the Bokeh documentation](https://docs.bokeh.org/en/latest/docs/user_guide/server.html#authentication) for further information. - -## Configuring OAuth - -The OAuth component will stop any user from accessing the application before first logging into the selected provider. The configuration to set up OAuth is all handled via the global `pn.config` object, which has a number of OAuth related parameters. When launching the application via the `panel serve` CLI command these config options can be set as CLI arguments or environment variables, when using the `pn.serve` function on the other hand these variables can be passed in as arguments. - -### `oauth_provider` - -The first step in configuring a OAuth is to specify a specific OAuth provider. Panel ships with a number of providers by default: - -* `azure`: Azure Active Directory -* `bitbucket`: Bitbucket -* `github`: GitHub -* `gitlab`: GitLab -* `google`: Google -* `okta`: Okta - -We will go through the process of configuring each of these individually later but for now all we need to know that the `oauth_provider` can be set on the commandline using the `--oauth-provider` CLI argument to `panel serve` or the `PANEL_OAUTH_PROVIDER` environment variable. - -Examples: - -``` -panel serve oauth_example.py --oauth-provider=... - -PANEL_OAUTH_PROVIDER=... panel serve oauth_example.py -``` - -### `oauth_key` and `oauth_secret` - -To authenticate with a OAuth provider we generally require two pieces of information (although some providers will require more customization): - -1. The Client ID is a public identifier for apps. -2. The Client Secret is a secret known only to the application and the authorization server. - -These can be configured in a number of ways the client ID and client secret can be supplied to the `panel serve` command as `--oauth-key` and `--oauth-secret` CLI arguments or `PANEL_OAUTH_KEY` and `PANEL_OAUTH_SECRET` environment variables respectively. - -Examples: - -``` -panel serve oauth_example.py --oauth-key=... --oauth-secret=... - -PANEL_OAUTH_KEY=... PANEL_OAUTH_KEY=... panel serve oauth_example.py ... -``` - -### `oauth_extra_params` - -Some OAuth providers will require some additional configuration options which will become part of the OAuth URLs. The `oauth_extra_params` configuration variable allows providing this additional information and can be set using the `--oauth-extra-params` CLI argument or `PANEL_OAUTH_EXTRA_PARAMS`. - -Examples: - -``` -panel serve oauth_example.py --oauth-extra-params={'tenant_id': ...} - -PANEL_OAUTH_EXTRA_PARAMS={'tenant_id': ...} panel serve oauth_example.py ... -``` - -### `cookie_secret` - -Once authenticated the user information and authorization token will be set as secure cookies. Cookies are not secure and can easily be modified by clients. A secure cookie ensures that the user information cannot be interfered with or forged by the client by signing it with a secret key. Note that secure cookies guarantee integrity but not confidentiality. That is, the cookie cannot be modified but its contents can be seen by the user. To generate a `cookie_secret` use the `panel secret` CLI argument or generate some other random non-guessable string, ideally with at least 256-bits of entropy. - -To set the `cookie_secret` supply `--cookie-secret` as a CLI argument or set the `PANEL_COOKIE_SECRET` environment variable. - -Examples: - -``` -panel serve oauth_example.py --cookie-secret=... - -PANEL_COOKIE_SECRET=... panel serve oauth_example.py ... -``` - -### `oauth_expiry` - -The OAuth expiry configuration value determines for how long an OAuth token will be valid once it has been issued. By default it is valid for 1 day, but may be overwritten by providing the duration in the number of days (decimal values are allowed). - -To set the `oauth_expiry` supply `--oauth-expiry-days` as a CLI argument or set the `PANEL_OAUTH_EXPIRY` environment variable. - -Examples: - -``` -panel serve oauth_example.py --oauth-expiry-days=... - -PANEL_OAUTH_EXPIRY=... panel serve oauth_example.py ... -``` - -### Encryption - -The architecture of the Bokeh/Panel server means that credentials stored as cookies can be leak in a number of ways. On the initial HTTP(S) request the server will respond with the HTML document that renders the application and this will include an unencrypted token containing the OAuth information. To ensure that the user information and access token are properly encrypted we rely on the Fernet encryption in the `cryptography` library. You can install it with `pip install cryptography` or `conda install cryptography`. - -Once installed you will be able to generate a encryption key with `panel oauth-secret`. This will generate a secret you can pass to the `panel serve` CLI command using the ``--oauth-encryption-key`` argument or `PANEL_OAUTH_ENCRYPTION` environment variable. - -Examples: - -``` -panel serve oauth_example.py --oauth-encryption-key=... - -PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ... -``` - -### Redirect URI - -Once the OAuth provider has authenticated a user it has to redirect them back to the application, this is what is known as the redirect URI. For security reasons this has to match the URL registered with the OAuth provider exactly. By default Panel will redirect the user straight back to the original URL of your app, e.g. when you're hosting your app at `https://myapp.myprovider.com` Panel will use that as the redirect URI. However in certain scenarios you may override this to provide a specific redirect URI. This can be achieved with the `--oauth-redirect-uri` CLI argument or the `PANEL_OAUTH_REDIRECT_URI` environment variable. - -Examples: - -``` -panel serve oauth_example.py --oauth-redirect-uri=... - -PANEL_OAUTH_REDIRECT_URI=... panel serve oauth_example.py -``` - -### Summary - -A fully configured OAuth configuration may look like this: - -``` -panel serve oauth_example.py --oauth-provider=github --oauth-key=... --oauth-secret=... --cookie-secret=... --oauth-encryption-key=... - -PANEL_OAUTH_PROVIDER=... PANEL_OAUTH_KEY=... PANEL_OAUTH_SECRET=... PANEL_COOKIE_SECRET=... PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ...` -``` - -## Accessing OAuth information - -Once a user is authorized with the chosen OAuth provider certain user information and an `access_token` will be available to be used in the application to customize the user experience. Like all other global state this may be accessed on the `pn.state` object, specifically it makes three attributes available: - -* **`pn.state.user`**: A unique name, email or ID that identifies the user. -* **`pn.state.access_token`**: The access token issued by the OAuth provider to authorize requests to its APIs. -* **`pn.state.refresh_token`**: The refresh token issued by the OAuth provider to authorize requests to its APIs (if available these are usually longer lived than the `access_token`). -* **`pn.state.user_info`**: Additional user information provided by the OAuth provider. This may include names, email, APIs to request further user information, IDs and more. - -## Authorization - -The OAuth providers integrated with Panel provide an easy way to enable authentication on your applications. This verifies the identity of a user and also provides some level of access control (i.e. authorization). However often times the OAuth configuration is controlled by a corporate IT department or is otherwise difficult to manage so its often easier to grant permissions to use the OAuth provider freely but then restrict access controls in the application itself. To manage access you can provide an `authorization_callback` as part of your applications. - -The `authorization_callback` can be configured on `pn.config` or via the `pn.extension`: - -```python -import panel as pn - -def authorize(user_info): - with open('users.txt') as f: - valid_users = f.readlines() - return user_info['username'] in valid_users - -pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize) -``` - -The `authorize_callback` is given a dictionary containing the data in the OAuth provider's `id_token`. The example above checks whether the current user is in the list of users specified in a `user.txt` file. However you can implement whatever logic you want to either grant a user access or reject it. - -If a user is not authorized they will be presented with a authorization error template which can be configured using the `--auth-template` commandline option or by setting `config.auth_template`. - - - -The auth template must be a valid Jinja2 template and accepts a number of arguments: - -- `{{ title }}`: The page title. -- `{{ error_type }}`: The type of error. -- `{{ error }}`: A short description of the error. -- `{{ error_msg }}`: A full description of the error. - -## OAuth Providers - -Panel provides a number of inbuilt OAuth providers, below is the list - -### **Azure Active Directory** - -To set up OAuth2.0 authentication for Azure Active directory follow [these instructions](https://docs.microsoft.com/en-us/azure/api-management/api-management-howto-protect-backend-with-aad). In addition to the `oauth_key` and `oauth_secret` ensure that you also supply the tenant ID using `oauth_extra_params`, e.g.: - -``` -panel serve oauth_test.py --oauth-extra-params="{'tenant': '...'}" - -PANEL_OAUTH_EXTRA_PARAMS="{'tenant': '...'}" panel serve oauth_example.py ... -``` - -### **Bitbucket** - -Bitbucket provides instructions about setting [setting up an OAuth consumer](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). Follow these and then supply the `oauth_key` and `oauth_secret` to Panel as described above. - -### **GitHub** - -GitHub provides detailed instructions on [creating an OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Follow these and then supply the `oauth_key` and `oauth_secret` to Panel as described above. - -### **GitLab** - -GitLab provides a detailed guide on [configuring an OAuth](https://docs.gitlab.com/ee/api/oauth2.html) application. In addition to the `oauth_key` and `oauth_secret` you will also have to supply a custom url using the `oauth_extra_params` if you have a custom GitLab instance (the default `oauth_extra_params={'url': 'gitlab.com'}`). - -### **Google** - -Google provides a guide about [configuring a OAuth application](https://developers.google.com/identity/protocols/oauth2/native-app). By default nothing except the `oauth_key` and `oauth_secret` are required but to access Google services you may also want to override the default `scope` via the `oauth_extra_params`. - -### **Okta** - -Okta provides a guide about [configuring OAuth2](https://developer.okta.com/docs/concepts/oauth-openid/). You must provide an `oauth_key` and `oauth_secret` but in most other ordinary setups you will also have to provide a `url` via the `oauth_extra_params` and if you have set up a custom authentication server (i.e. not 'default') with Okta you must also provide 'server', the `oauth_extra_params` should then look something like this: `{'server': 'custom', 'url': 'dev-***.okta.com'}` - -### Plugins - -The Panel OAuth providers are pluggable, in other words downstream libraries may define their own Tornado `RequestHandler` to be used with Panel. To register such a component the `setup.py` of the downstream package should register an entry_point that Panel can discover. To read more about entry points see the [Python documentation](https://packaging.python.org/specifications/entry-points/). A custom OAuth request handler in your library may be registered as follows: - -```python -entry_points={ - 'panel.auth': [ - "custom = my_library.auth:MyCustomOAuthRequestHandler" - ] -} -``` diff --git a/doc/user_guide/Running_in_Webassembly.md b/doc/user_guide/Running_in_Webassembly.md deleted file mode 100644 index 83d9635e5f..0000000000 --- a/doc/user_guide/Running_in_Webassembly.md +++ /dev/null @@ -1,431 +0,0 @@ -# Running Panel in the Browser with WASM - -Panel lets you write dashboards and other applications in Python that are accessed using a web browser. Typically, the Python interpreter runs as a separate Jupyter or Bokeh server process, communicating with JavaScript code running in the client browser. However, **it is now possible to run Python directly in the browser**, with **no separate server needed!** - -The underlying technology involved is called [WebAssembly](https://webassembly.org/) (or WASM). More specifically, [Pyodide](https://pyodide.org/) pioneered the ability to install Python libraries, manipulate the web page's [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction) from Python, and execute regular Python code entirely in the browser. A number of libraries have sprung up around Python in WASM, including [PyScript](https://pyscript.net/). - -Panel can be run directly in Pyodide and has special support for rendering in PyScript. - -This guide will take you through the process of either - -- Automatically converting Panel applications into a Pyodide/PyScript based application -- Manually installing Panel in the browser and using it to render components. -- Embedding Panel in your Sphinx documentation. -- Setting up a Jupyterlite instance with support for Panel - -## Converting Panel applications - -Writing an HTML file from scratch with all the Javascript and Python dependencies and other boilerplate can be quite cumbersome, and requires learning a good bit of HTML. To avoid writing all the boilerplate, Panel provides support for converting an entire application (including Panel templates) to an HTML file, using the `panel convert` command-line interface (CLI). As a starting point create one or more Python scripts or notebook files containing your application. The only requirement is that they import only global modules and packages (relative imports of other scripts or modules is not supported) and that the libraries have been [compiled for Pyodide](https://github.com/pyodide/pyodide/tree/main/packages) or are available as pure-Python wheels from PyPI. - -The ``panel convert`` command has the following options: - - positional arguments: - SCRIPTs The scripts or notebooks to convert - - optional arguments: - -h, --help Show this help message and exit - --to The format to convert to, one of 'pyodide', 'pyodide-worker' or 'pyscript' - --out Directory to export files to - --title Custom title for the application(s) - --skip-embed Whether to skip the prerendering while pyodide loads. - --index Whether to create an index if multiple files are served. - --pwa Whether to add files to allow serving the application as a Progressive Web App. - --requirements List of Python requirements to add to the converted file. By default it will automatically try to infer dependencies based on your imports and Panel will automatically be included. - --watch Watches files for changes and rebuilds them when they are updated. - --disable-http-patch Disables patching of http requests using pyodide-http library. - -### Example - -This example will demonstrate how to *convert* and *serve* a basic data app locally. - -- Create a `script.py` file with the following content - -```python -import panel as pn - -from sklearn.datasets import load_iris -from sklearn.metrics import accuracy_score -from xgboost import XGBClassifier - -pn.extension(sizing_mode="stretch_width", template="fast") -pn.state.template.param.update(site="Panel in the Browser", title="XGBoost Example") - -iris_df = load_iris(as_frame=True) - -trees = pn.widgets.IntSlider(start=2, end=30, name="Number of trees") - -def pipeline(trees): - model = XGBClassifier(max_depth=2, n_estimators=trees) - model.fit(iris_df.data, iris_df.target) - accuracy = round(accuracy_score(iris_df.target, model.predict(iris_df.data)) * 100, 1) - return pn.indicators.Number( - name="Test score", - value=accuracy, - format="{value}%", - colors=[(97.5, "red"), (99.0, "orange"), (100, "green")], - ) - -pn.Column( - "Simple example of training an XGBoost classification model on the small Iris dataset.", - iris_df.data.head(), - "Move the slider below to change the number of training rounds for the XGBoost classifier. The training accuracy score will adjust accordingly.", - trees, - pn.bind(pipeline, trees), -).servable() -``` - -- Run `panel convert script.py --to pyodide-worker --out pyodide` -- Run `python3 -m http.server` to start a web server locally -- Open `http://localhost:8000/pyodide/script.html` to try out the app. - -The app should look like this - -![Panel in the browser](../_static/images/pyodide_xgboost_app.png) - -You can now add the `script.html` (and `script.js` file if you used the `pyodide-worker` target) to your Github pages or similar. **no separate server needed!** - -#### Tips & Tricks for development - -- While developing you should run the script locally with *auto reload*: `panel serve script.py --autoreload`. -- You can also watch your script for changes and rebuild it if you make an edit with `panel convert ... --watch` -- If the converted app does not work as expected, you can most often find the errors in the browser -console. [This guide](https://balsamiq.com/support/faqs/browserconsole/) describes how to open the -console. -- You can find answers to the most frequently asked questions about *Python in the browser* in the [Pyodide - FAQ](https://pyodide.org/en/stable/usage/faq.html) or the [PyScript FAQ](https://docs.pyscript.net/latest/reference/faq.html). For example the answer to "How can I load external data?". - -### Formats - -Using the `--to` argument on the CLI you can control the format of the file that is generated by `panel convert`. You have three options, each with distinct advantages and disadvantages: - -- **`pyodide`** (default): Run application using Pyodide running in the main thread. This option is less performant than pyodide-worker but produces completely standalone HTML files that do not have to be hosted on a static file server (e.g. Github Pages). -- **`pyodide-worker`**: Generates an HTML file and a JS file containing a Web Worker that runs in a separate thread. This is the most performant option, but files have to be hosted on a static file server. -- **`pyscript`**: Generates an HTML leveraging PyScript. This produces standalone HTML files containing `` and `` tags containing the dependencies and the application code. This output is the most readable, and should have equivalent performance to the `pyodide` option. - -### Requirements - -The `panel convert` command will try its best to figure out the requirements of your script based on the imports, which means that in most cases you won't have to provide the explicit `--requirements` argument. However, if some library uses an optional import that cannot be inferred from the list of imports in your app you will have to provide an explicit list of dependencies. Note that `panel` and its dependencies including NumPy and Bokeh will be added loaded automatically, e.g. the explicit requirements for the app above would look like this: - -```bash -panel convert script.py --to pyodide-worker --out pyodide --requirements xgboost scikit-learn pandas -``` - -Alternatively you may also provide a `requirements.txt` file: - -```bash -panel convert script.py --to pyodide-worker --out pyodide --requirements requirements.txt -``` - -### Index - -If you convert multiple applications at once you may want to add an index to be able to navigate between the applications easily. To enable the index simply pass `--index` to the convert command. - -### Prerendering - -In order to improve the loading experience Panel will pre-render and embed the initial render of the page and replace it with live components once the page is loaded. This is important because Pyodide has to fetch the entire Python runtime and all required packages from a CDN. This can be **very** slow depending on your internet connection. If you want to disable this behavior and render an initially blank page use the `--skip-embed` option. Otherwise Panel will render application using the current Python process (presumably outside the browser) into the HTML file as a "cached" copy of the application for the user to see while the Python runtime is initialized and the actual browser-generated application is ready for interaction. - -### Progressive Web Apps - -Progressive web applications (PWAs) provide a way for your web apps to behave almost like a native application, both on mobile devices and on the desktop. The `panel convert` CLI has a `--pwa` option that will generate the necessary files to turn your Panel + Pyodide application into a PWA. The web manifest, service worker script and assets such as thumbnails are exported alongside the other HTML and JS files and can then be hosted on your static file host. Note that Progressive web apps must be served via HTTPS to ensure user privacy, security, and content authenticity, including the application itself and all resources it references. Depending on your hosting service, you will have to enable HTTPS yourself. GitHub pages generally make this very simple and provide a great starting point. - -Once generated, you can inspect the `site.webmanifest` file and modify it to your liking, including updating the favicons in the assets directory. - -```{note} -If you decide to enable the `--pwa` ensure that you also provide a unique `--title`. Otherwise the browser caches storing your apps dependencies will end up overwriting each other. -``` - -### Handling HTTP requests - -By default Panel 0.14.1 will install the [pyodide-http](https://github.com/koenvo/pyodide-http) library which patches `urllib3` and `requests` making it possible to use them within the pyodide process. To disable this behavior use the `--disable-http-patch` CLI option. - -Note that making HTTP requests when converting to the `pyodide` or `pyscript` target will block the main browser thread and result in a poor user experience. Therefore we strongly recommend converting to `pyodide-worker` if your app is making synchronous HTTP requests. - -## Installing Panel in the browser - -To install Panel in the browser you merely have to use the installation mechanism provided by each supported runtime: - -### Pyodide - -Currently the best supported mechanism for installing packages in Pyodide is `micropip`. - -To get started with Pyodide simply follow their [Getting started guide](https://pyodide.org/en/stable/usage/quickstart.html). Note that if you want to render Panel output you will also have to load [Bokeh.js](https://docs.bokeh.org/en/2.4.1/docs/first_steps/installation.html#install-bokehjs:~:text=Installing%20standalone%20BokehJS%C2%B6) and Panel.js from CDN. The most basic pyodide application therefore looks like this: - -```html - - - - - - - - - - - - -
- - - -``` - -The app should look like this - -![Panel Pyodide App](../_static/images/pyodide_app_simple.png) - -:::{admonition} -The default bokeh and panel packages are very large, therefore we recommend you pip install specialized wheels: - -```javascript -const bk_whl = "https://cdn.holoviz.org/panel/0.14.0/wheels/bokeh-2.4.3-py3-none-any.whl" -const pn_whl = "https://cdn.holoviz.org/panel/0.14.0/wheels/panel-0.14.0-py3-none-any.whl" -await micropip.install(bk_whl, pn_whl) -``` -::: - -### PyScript - -PyScript makes it even easier to manage your dependencies, with a `` HTML tag. Simply include `panel` in the list of dependencies and PyScript will install it automatically: - -```html - -packages = [ - "panel", - ... -] - -``` - -Once installed you will be able to `import panel` in your `` tag. Again, make sure you also load Bokeh.js and Panel.js: - -```html - - - - - - - - - - - - - - packages = [ - "panel", - ... - ] - -
- - import panel as pn - - pn.extension(sizing_mode="stretch_width") - - slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') - - def callback(new): - return f'Amplitude is: {new}' - - pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app'); - - - -``` - -The app should look identical to the one above but show a loading spinner while Pyodide is initializing. - -### Rendering Panel components in Pyodide or Pyscript - -Rendering Panel components into the DOM is quite straightforward. You can simply use the `.servable()` method on any component and provide a target that should match the `id` of a DOM node: - -```python -import panel as pn - -slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') - -def callback(new): - return f'Amplitude is: {new}' - -pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app'); -``` - -This code will render this simple application into the `simple_app` DOM node: - -```html -
-``` - -Alternatively you can also use the `panel.io.pyodide.write` function to write into a particular DOM node: - -```python -await pn.io.pyodide.write('simple_app', component) -``` - -## Embedding in Sphinx documentation - -One more option is to include live Panel examples in your Sphinx documentation using the `nbsite.pyodide` directive. - -### Setup - -In the near future we hope to make this a separate Sphinx extension, until then simply install latest nbsite with `pip` or `conda`: - -::::{tab-set} -:::{tab-item} Conda -:sync: conda - -``` bash -conda install -c pyviz nbsite -``` - -::: -:::{tab-item} Pip -:sync: pip - -``` bash -pip install nbsite -``` -::: -:::: - -add the extension to the Sphinx `conf.py`: - -```python -extensions += [ - ..., - 'nbsite.pyodide' -] -``` - -### Configuration - -In the `conf.py` of your project you can configure the extension in a number of ways by defining an `nbsite_pyodide_conf` dictionary with the following options: - -- `PYODIDE_URL`: The URl to fetch Pyodide from -- `autodetect_deps` (default=`True`): Whether to automatically detect dependencies in the executed code and install them. -- `enable_pwa` (default=`True`): Whether to add a web manifest and service worker to configure the documentation as a progressive web app. -- `requirements` (default=`['panel']`): Default requirements to include (by default this includes just panel. -- `scripts`: Scripts to add to the website when a Pyodide cell is first executed. -- `setup_code` (default=`''`): Python code to run when initializing the Pyodide runtime. - -and then you can use the `pyodide` as an RST directive: - -```rst -.. pyodide:: - - import panel as pn - - slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') - - def callback(new): - return f'Amplitude is: {new}' - - pn.Row(slider, pn.bind(callback, slider)) -``` - -### Examples - -The resulting output looks like this: - -```{pyodide} -import panel as pn -``` - - -```{pyodide} -slider = pn.widgets.FloatSlider(start=0, end=10, name='Amplitude') - -def callback(new): - return f'Amplitude is: {new}' - -pn.Row(slider, pn.bind(callback, slider)) -``` - -In addition to rendering Panel components it also renders regular Pytho -types: - -```{pyodide} -1+1 -``` - - -```{pyodide} -"A string" -``` - -and also handles stdout and stderr streams: - -```{pyodide} -import numpy as np -for i in range(10): - print(f'Repeat {i}') - for i in range(10000): - np.random.rand(1000) -``` - -```{pyodide} -raise ValueError('Encountered an error') -``` - -and supports `_repr__` methods that are commonly used by the IPython and Jupyter ecosystem: - -```{pyodide} -class HTML: - - def __init__(self, html): - self.html = html - - def _repr_html_(self): - return self.html - -HTML('HTML!') -``` - -### Usage - -The code cell will display a button to execute the cell, which will warn about downloading the Python runtime on first-click and ask you to confirm whether you want to proceed. It will then download Pyodide, all required packages and finally display the output. - -## Setting up JupyterLite - -[JupyterLite](https://jupyterlite.readthedocs.io/en/latest/) is a JupyterLab distribution built from all the usual components and extensions that come with JupyterLab, but now running entirely in the browser with no external server needed. In order to use Panel in JupyterLite you will have to build your own distribution. As a starting point we recommend [this guide](https://jupyterlite.readthedocs.io/en/latest/howto/configure/simple_extensions.html) in the JupyterLite documentation, which will tell you how to set up an environment to begin building JupyterLite. - -### Create a `` - -Once your environment is set up, create a new directory, which will become the source for your JupyterLite distribution. Once created place the file contents you want to make available in JupyterLite into `/files`. - -### Adding extensions - -In order for Panel to set up communication channels inside JupyterLite we have to add the `pyviz_comms` extension to the environment. Ensure this package is installed in the environment you are building Panel from, e.g. by running `pip install pyviz_comms` OR by including it in the `requirements.txt` you used when setting up your build environment. - -### Optimized wheels (optional) - -To get Panel installed inside a Jupyterlite session we have to install it with `piplite`. The default Bokeh and Panel packages are quite large since they contain contents which are needed in a server environment. Since we will be running inside Jupyter these contents are not needed. To bundle the optimized packages download them from the CDN and place them in the `/pypi` directory. You can download them from the CDN (replacing the latest version numbers): - -``` -https://cdn.holoviz.org/panel/0.14.2/dist/wheels/bokeh-2.4.3-py3-none-any.whl -https://cdn.holoviz.org/panel/0.14.2/dist/wheels/panel-0.14.2-py3-none-any.whl -``` - -### Building Panel lite - -Finally `cd` into your `` and run `jupyter lite build --output-dir ./dist`. This will bundle up the file contents, extensions and wheels into your JupyterLite distribution. You can now easily deploy this to [GitHub pages](https://jupyterlite.readthedocs.io/en/latest/quickstart/deploy.html) or elsewhere. diff --git a/doc/user_guide/index.rst b/doc/user_guide/index.rst index 4e602ba4b4..949a94745a 100644 --- a/doc/user_guide/index.rst +++ b/doc/user_guide/index.rst @@ -56,9 +56,6 @@ when needed. State, Caching & Callbacks ^^^^^^^^^^^^^^^^^^^^^^^^^^ -`Session State and Callbacks `_ - Learn how to access session state and schedule callbacks. - `Asynchronous and Concurrent Processing `_ Learn how leverage asynchronous and concurrent processing to make your app more responsive. @@ -68,34 +65,12 @@ Export `Display & Export `_ Guide towards configuring and displaying output and exporting Panel apps and components. -`Running Panel in the Browser with WASM `_ - Guide to embedding interactive Panel components in a web page or converting entire Panel applications to run entirely in your browser. - -Server Usage -^^^^^^^^^^^^ - -`Server configuration `_ - A guide detailing how to launch and configure a server from the commandline or programmatically. - -`Server Deployment `_ - Step-by-step guides for deploying Panel apps locally, on a web server or on common cloud providers. - -`Authentication `_ - Learn how to add an authentication component in front of your application. - -`Django Integration `_ - How to embed a Panel/Bokeh app inside a Django web-server deployment. - -`FastAPI Integration `_ - How to embed a Panel/Bokeh app inside a FastAPI web-server deployment. - Extending Panel ^^^^^^^^^^^^^^^ `Building custom components `_ Learn how to extend Panel by building custom components. - .. toctree:: :titlesonly: :hidden: @@ -112,13 +87,6 @@ Extending Panel Pipelines Templates Performance and Debugging - Session state & Callbacks Asynchronous and Concurrent Process Display & Export - Running Panel in the Browser with WASM - Server Configuration - Server Deployment - Authentication - Django Integration - FastAPI Integration Building Custom Components diff --git a/examples/user_guide/Display_and_Export.ipynb b/examples/user_guide/Display_and_Export.ipynb index c5f9837012..11526e819f 100644 --- a/examples/user_guide/Display_and_Export.ipynb +++ b/examples/user_guide/Display_and_Export.ipynb @@ -188,149 +188,6 @@ "conda install -c bokeh jupyter_bokeh\n", "```" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Accessing the Bokeh model\n", - "\n", - "Since Panel is built on top of Bokeh, all Panel objects can easily be converted to a Bokeh model. The ``get_root`` method returns a model representing the contents of a Panel:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model = pn.Column('# Some markdown').get_root()\n", - "model" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "By default this model will be associated with Bokeh's ``curdoc()``, so if you want to associate the model with some other ``Document`` ensure you supply it explictly as the first argument. Once you have access to the underlying bokeh model you can use all the usual bokeh utilities such as ``components``, ``file_html``, or ``show``" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from bokeh.embed import components, file_html\n", - "from bokeh.io import show\n", - "\n", - "script, html = components(model)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Embedding\n", - "\n", - "Panel generally relies on either the Jupyter kernel or a Bokeh Server to be running in the background to provide interactive behavior. However for simple apps with a limited amount of state it is also possible to `embed` all the widget state, allowing the app to be used entirely from within Javascript. To demonstrate this we will create a simple app which simply takes a slider value, multiplies it by 5 and then display the result." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "slider = pn.widgets.IntSlider(start=0, end=10)\n", - "\n", - "@pn.depends(slider.param.value)\n", - "def callback(value):\n", - " return '%d * 5 = %d' % (value, value*5)\n", - "\n", - "row = pn.Row(slider, callback)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If we displayed this the normal way it would call back into Python every time the value changed. However, the `.embed()` method will record the state of the app for the different widget configurations." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "row.embed()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If you try the widget above you will note that it only has 3 different states, 0, 5 and 10. This is because by default embed will try to limit the number of options of non-discrete or semi-discrete widgets to at most three values. This can be controlled using the `max_opts` argument to the embed method or you can provide an explicit list of `states` to embed for each widget:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "row.embed(states={slider: list(range(0, 12, 2))})" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - " The full set of options for the embed method include:\n", - "\n", - "- **`max_states`**: The maximum number of states to embed\n", - "\n", - "- **`max_opts`**: The maximum number of states for a single widget\n", - "\n", - "- **`states`** (default={}): A dictionary specifying the widget values to embed for each widget\n", - "\n", - "- **`json`** (default=True): Whether to export the data to json files\n", - "\n", - "- **`save_path`** (default='./'): The path to save json files to\n", - "\n", - "- **`load_path`** (default=None): The path or URL the json files will be loaded from (same as ``save_path`` if not specified)\n", - "\n", - "* **`progress`** (default=False): Whether to report progress\n", - "\n", - "\n", - "As you might imagine if there are multiple widgets there can quickly be a combinatorial explosion of states so by default the output is limited to about 1000 states. For larger apps the states can also be exported to json files, e.g. if you want to serve the app on a website specify the ``save_path`` to declare where it will be stored and the ``load_path`` to declare where the JS code running on the website will look for the files." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Saving \n", - "\n", - "In case you don't need an actual server or simply want to export a static snapshot of a panel app, you can use the ``save`` method, which allows exporting the app to a standalone HTML or PNG file.\n", - "\n", - "By default, the HTML file generated will depend on loading JavaScript code for BokehJS from the online ``CDN`` repository, to reduce the file size. If you need to work in an airgapped or no-network environment, you can declare that ``INLINE`` resources should be used instead of ``CDN``:\n", - "\n", - "```python\n", - "from bokeh.resources import INLINE\n", - "panel.save('test.html', resources=INLINE)\n", - "```\n", - "\n", - "Additionally the save method also allows enabling the `embed` option, which, as explained above, will embed the apps state in the app or save the state to json files which you can ship alongside the exported HTML.\n", - "\n", - "Finally, if a 'png' file extension is specified, the exported plot will be rendered as a PNG, which currently requires Selenium and PhantomJS to be installed:\n", - "\n", - "```python\n", - "pane.save('test.png')\n", - "\n", - "```" - ] } ], "metadata": { diff --git a/examples/user_guide/Performance_and_Debugging.ipynb b/examples/user_guide/Performance_and_Debugging.ipynb index 12b765c140..9acc6636d3 100644 --- a/examples/user_guide/Performance_and_Debugging.ipynb +++ b/examples/user_guide/Performance_and_Debugging.ipynb @@ -15,86 +15,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "When developing applications that are to be used by multiple users and which may process a lot of data it is important to ensure the application is well optimized. Additionally complex applications may have very complex callbacks which are difficult to trace and debug. In this user guide section we will walk you some of the best practices to debug your applications and profile your application to maximize performance.\n", - "\n", - "## Caching\n", - "\n", - "Caching data and computation is one of the most effective ways to speed up your applications. Some common examples of scenarios that benefit from caching is working with large datasets that you have to load from disk or over a network connection or you have to perform expensive computations that don't depend on any extraneous state. Panel makes it easy for you to add caching to you applications using a few approaches. Panel' architecture is also very well suited towards caching since multiple user sessions can run in the same process and therefore have access to the same global state. This means that we can cache data in Panel's global `state` object, either by directly assigning to the `pn.state.cache` dictionary object, using the `pn.state.as_cached` helper function or the `pn.cache` decorator. Once cached all current and subsequent sessions will be sped up by having access to the cache.\n", - "\n", - "### Manual usage\n", - "\n", - "To assign to the cache manually, simply put the data load or expensive calculation in an `if`/`else` block which checks whether the custom key is already present: \n", - "\n", - "```python\n", - "if 'data' in pn.state.cache:\n", - " data = pn.state.cache['data']\n", - "else:\n", - " pn.state.cache['data'] = data = ... # Load some data or perform an expensive computation\n", - "```\n", - "\n", - "### `pn.cache` decorator\n", - "\n", - "The `pn.cache` decorator provides an easy way to cache the outputs of a function depending on its inputs (i.e. `memoize`). If you've ever used the Python `@lru_cache` decorator you will be familiar with this concept. However the `pn.cache` functions supports additional cache `policy`'s apart from LRU (least-recently used), including `LFU` (least-frequently-used) and 'FIFO' (first-in-first-out). This means that if the specified number of `max_items` is reached Panel will automatically evict items from the cache based on this `policy`. Additionally items can be deleted from the cache based on a `ttl` (time-to-live) value given in seconds.\n", - "\n", - "#### Caching in memory\n", - "\n", - "The `pn.cache` decorator can easily be combined with the different Panel APIs including `pn.bind` and `pn.depends` providing a powerful way to speed up your applications.\n", - "\n", - "```python\n", - "@pn.cache(max_items=10, policy='LRU')\n", - "def load_data(path):\n", - " return ... # Load some data\n", - "```\n", - "\n", - "Once you have decorated your function with `pn.cache` any call to `load_data` will be cached in memory until `max_items` value is reached (i.e. you have loaded 10 different `path` values). At that point the `policy` will determine which item is evicted.\n", - "\n", - "The `pn.cache` decorator can easily be combined with `pn.bind` to speed up rendering of your reactive components:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "select = pn.widgets.Select(options={\n", - " 'Penguins': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv',\n", - " 'Diamonds': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/diamonds.csv',\n", - " 'Titanic': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/titanic.csv',\n", - " 'MPG': 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/mpg.csv'\n", - "})\n", - "\n", - "@pn.cache\n", - "def fetch_data(url):\n", - " return pd.read_csv(url)\n", - "\n", - "pn.Column(select, pn.bind(pn.widgets.Tabulator, pn.bind(fetch_data, select), page_size=10))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Disk caching\n", - "\n", - "If you have `diskcache` installed you can also cache the results to disk by setting `to_disk=True`. The `diskcache` library will then cache the value to the supplied `cache_path` (defaulting to `./cache`). Making use of disk caching allows you to cache items even if the server is restarted.\n", - "\n", - "#### Clearing the cache\n", - "\n", - "Once a function has been decorated with `pn.cache` you can easily clear the cache by calling `.clear()` on that function, e.g. in the example above you could call `load_data.clear()`. If you want to clear all caches you may also call `pn.state.clear_caches()`.\n", - "\n", - "### `pn.state.as_cached`\n", - "\n", - "The `as_cached` helper function on the other hand allows providing a custom key and a function and automatically caching the return value. If provided the `args` and `kwargs` will also be hashed making it easy to cache (or memoize) on the arguments to the function: \n", - "\n", - "```python\n", - "def load_data(*args, **kwargs):\n", - " return ... # Load some data\n", - "\n", - "data = pn.state.as_cached('data', load_data, *args, **kwargs)\n", - "```\n", - "\n", - "The first time the app is loaded the data will be cached and subsequent sessions will simply look up the data in the cache, speeding up the process of rendering. If you want to warm up the cache before the first user visits the application you can also provide the `--warm` argument to the `panel serve` command, which will ensure the application is initialized as soon as it is launched. If you want to populate the cache in a separate script from your main application you may also provide the path to a setup script using the `--setup` argument to `panel serve`. If you want to periodically update the cache look into the ability to [schedule tasks](Deploy_and_Export.ipynb#Scheduling-task-with-pn.state.schedule_task).\n" + "When developing applications that are to be used by multiple users and which may process a lot of data it is important to ensure the application is well optimized. Additionally complex applications may have very complex callbacks which are difficult to trace and debug. In this user guide section we will walk you some of the best practices to debug your applications and profile your application to maximize performance." ] }, { @@ -226,66 +147,7 @@ "... 2 second wait\n", "> Finished processing 1th click.\n", "> Finished processing 2th click.\n", - "```\n", - "\n", - "### AsyncIO\n", - "\n", - "When using Python>=3.8 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:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import aiohttp\n", - "\n", - "widget = pn.widgets.IntSlider(start=0, end=10)\n", - "\n", - "async def get_img(index):\n", - " async with aiohttp.ClientSession() as session:\n", - " async with session.get(f\"https://picsum.photos/800/300?image={index}\") as resp:\n", - " return pn.pane.JPG(await resp.read())\n", - " \n", - "pn.Column(widget, pn.bind(get_img, widget))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "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. \n", - "\n", - "Similarly you can attach asynchronous callbacks using `.param.watch`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "widget = pn.widgets.IntSlider(start=0, end=10)\n", - "\n", - "image = pn.pane.JPG()\n", - "\n", - "async def update_img(event):\n", - " async with aiohttp.ClientSession() as session:\n", - " async with session.get(f\"https://picsum.photos/800/300?image={event.new}\") as resp:\n", - " image.object = await resp.read()\n", - " \n", - "widget.param.watch(update_img, 'value')\n", - "widget.param.trigger('value')\n", - " \n", - "pn.Column(widget, image)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In this example Param will await the asynchronous function and the image will be updated when the request completes." + "```" ] }, { diff --git a/examples/user_guide/Session_State_and_Callbacks.ipynb b/examples/user_guide/Session_State_and_Callbacks.ipynb deleted file mode 100644 index 64f003388c..0000000000 --- a/examples/user_guide/Session_State_and_Callbacks.ipynb +++ /dev/null @@ -1,361 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import panel as pn\n", - "\n", - "pn.extension()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Accessing session state\n", - "\n", - "Whenever a Panel app is being served the ``panel.state`` object exposes some of the internal Bokeh server components to a user.\n", - "\n", - "#### Document\n", - "\n", - "The current Bokeh ``Document`` can be accessed using ``panel.state.curdoc``.\n", - "\n", - "#### Request arguments\n", - "\n", - "When a browser makes a request to a Bokeh server a session is created for the Panel application. The request arguments are made available to be accessed on ``pn.state.session_args``. For example if your application is hosted at ``localhost:8001/app``, appending ``?phase=0.5`` to the URL will allow you to access the phase variable using the following code:\n", - "\n", - "```python\n", - "try:\n", - " phase = int(pn.state.session_args.get('phase')[0])\n", - "except Exception:\n", - " phase = 1\n", - "```\n", - "\n", - "This mechanism may be used to modify the behavior of an app dependending on parameters provided in the URL. \n", - "\n", - "#### Cookies\n", - "\n", - "The `panel.state.cookies` will allow accessing the cookies stored in the browser and on the bokeh server.\n", - "\n", - "#### Headers\n", - "\n", - "The `panel.state.headers` will allow accessing the HTTP headers stored in the browser and on the bokeh server.\n", - "\n", - "#### Location\n", - "\n", - "When starting a server session Panel will attach a `Location` component which can be accessed using `pn.state.location`. The `Location` component servers a number of functions:\n", - "\n", - "- Navigation between pages via ``pathname``\n", - "- Sharing (parts of) the page state in the url as ``search`` parameters for bookmarking and sharing.\n", - "- Navigating to subsections of the page via the ``hash_`` parameter.\n", - "\n", - "##### Core\n", - "\n", - "* **``pathname``** (string): pathname part of the url, e.g. '/user_guide/Interact.html'.\n", - "* **``search``** (string): search part of the url e.g. '?color=blue'.\n", - "* **``hash_``** (string): hash part of the url e.g. '#interact'.\n", - "* **``reload``** (bool): Whether or not to reload the page when the url is updated.\n", - " - For independent apps this should be set to True. \n", - " - For integrated or single page apps this should be set to False.\n", - "\n", - "##### Readonly\n", - "\n", - "* **``href``** (string): The full url, e.g. 'https://panel.holoviz.org/user_guide/Interact.html:80?color=blue#interact'.\n", - "* **``protocol``** (string): protocol part of the url, e.g. 'http:' or 'https:'\n", - "* **``port``** (string): port number, e.g. '80'\n", - "\n", - "#### pn.state.busy\n", - "\n", - "Often an application will have longer running callbacks which are being processed on the server, to give users some indication that the server is busy you may therefore have some way of indicating that busy state. The `pn.state.busy` parameter indicates whether a callback is being actively processed and may be linked to some visual indicator.\n", - "\n", - "Below we will create a little application to demonstrate this, we will create a button which executes some longer running task on click and then create an indicator function that displays `'I'm busy'` when the `pn.state.busy` parameter is `True` and `'I'm idle'` when it is not:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import time\n", - "\n", - "def processing(event):\n", - " # Some longer running task\n", - " time.sleep(1)\n", - " \n", - "button = pn.widgets.Button(name='Click me!')\n", - "button.on_click(processing)\n", - "\n", - "@pn.depends(pn.state.param.busy)\n", - "def indicator(busy):\n", - " return \"I'm busy\" if busy else \"I'm idle\"\n", - "\n", - "pn.Row(button, indicator)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This way we can create a global indicator for the busy state instead of modifying all our callbacks." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Scheduling task with `pn.state.schedule_task`\n", - "\n", - "The `pn.state.schedule_task` functionality allows scheduling global tasks at certain times or on a specific schedule. This is distinct from periodic callbacks, which are scheduled per user session. Global tasks are useful for performing periodic actions like updating cached data, performing cleanup actions or other housekeeping tasks, while periodic callbacks should be reserved for making periodic updates to an application.\n", - "\n", - "The different contexts in which global tasks and periodic callbacks run also has implications on how they should be scheduled. Scheduled task **must not** be declared in the application code itself, i.e. if you are serving `panel serve app.py` the callback you are scheduling must not be declared in the `app.py`. It must be defined in an external module or in a separate script declared as part of the `panel serve` invocation using the `--setup` commandline argument.\n", - "\n", - "Scheduling using `pn.state.schedule_task` is idempotent, i.e. if a callback has already been scheduled under the same name subsequent calls will have no effect. By default the starting time is immediate but may be overridden with the `at` keyword argument. The period may be declared using the `period` argument or a cron expression (which requires the `croniter` library). Note that the `at` time should be in local time but if a callable is provided it must return a UTC time. If `croniter` is installed a `cron` expression can be provided using the `cron` argument.\n", - "\n", - "As a simple example of a task scheduled at a fixed interval:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import datetime as dt\n", - "import asyncio\n", - "\n", - "async def task():\n", - " print(f'Task executed at: {dt.datetime.now()}')\n", - "\n", - "pn.state.schedule_task('task', task, period='1s')\n", - "await asyncio.sleep(3)\n", - "\n", - "pn.state.cancel_task('task')" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that while both `async` and regular callbacks are supported, asynchronous callbacks are preferred if you are performing any I/O operations to avoid interfering with any running applications.\n", - "\n", - "If you have the `croniter` library installed you may also provide a cron expression, e.g. the following will schedule a task to be repeated at 4:02 am every Monday and Friday:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pn.state.schedule_task('task', task, cron='2 4 * * mon,fri')" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "See [crontab.guru](https://crontab.guru/) and the [`croniter` README](https://github.com/kiorky/croniter#introduction) to learn about cron expressions genrally and special syntax supported by `croniter`." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### pn.state.onload\n", - "\n", - "Another useful callback to define the onload callback, in a server context this will execute when a session is first initialized. Let us for example define a minimal example inside a function which we will pass to `pn.serve`. This emulates what happens when we call `panel serve` on the commandline. We will create a widget without populating its options, then we will add an `onload` callback, which will set the options once the initial page is loaded. Imagine for example that we have to fetch the options from some database which might take a little while, by deferring the loading of the options to the callback we can get something on the screen as quickly as possible and only run the expensive callback when we have already rendered something for the user to look at." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import time\n", - "\n", - "def app():\n", - " widget = pn.widgets.Select()\n", - "\n", - " def on_load():\n", - " time.sleep(1) # Emulate some long running process\n", - " widget.options = ['A', 'B', 'C']\n", - "\n", - " pn.state.onload(on_load)\n", - "\n", - " return widget\n", - "\n", - "# pn.serve(app) " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Alternatively we may also use the `defer_load` option to wait to evaluate a function until the page is loaded. This will render a placeholder and display the global `config.loading_spinner`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def render_on_load():\n", - " return pn.widgets.Select(options=['A', 'B', 'C'])\n", - "\n", - "pn.Row(pn.panel(render_on_load, defer_load=True))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### pn.state.on_session_destroyed\n", - "\n", - "In many cases it is useful to define on_session_destroyed callbacks to perform any custom cleanup that is required, e.g, dispose a database engine, or when a user is logged out. These callbacks can be registered with `pn.state.on_session_destroyed(callback)`" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Scheduling callbacks\n", - "\n", - "When you build an app you frequently want to schedule a callback to be run periodically to refresh the data and update visual components. Additionally if you want to update Bokeh components directly you may need to schedule a callback to get around Bokeh's document lock to avoid errors like this:\n", - "\n", - "> RuntimeError: _pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes\n", - "\n", - "In this section we will discover how we can leverage Bokeh's Document and `pn.state.add_periodic_callback` to set this up.\n", - "\n", - "### Server callbacks\n", - "\n", - "The Bokeh server that Panel builds on is designed to be thread safe which requires a set of locks to avoid multiple threads modifying the Bokeh models simultaneously. Therefore if we want to work with Bokeh models directly we should ensure that any changes to a Bokeh model are executed on the correct thread by adding a callback, which the event loop will then execute safely.\n", - "\n", - "In the example below we will launch an application on a thread using `pn.serve` and make the Bokeh plot (in practice you may provide handles to this object on a class). To schedule schedule a callback which updates the `y_range` by using the `pn.state.execute` method. This pattern will ensure that the update to the Bokeh model is executed on the correct thread:\n", - "\n", - "```python\n", - "import time\n", - "import panel as pn\n", - "\n", - "from bokeh.plotting import figure\n", - "\n", - "def app():\n", - " p = figure()\n", - " p.line([1, 2, 3], [1, 2, 3])\n", - " return p\n", - "\n", - "pn.serve(app, threaded=True)\n", - "\n", - "pn.state.execute(lambda: p.y_range.update(start=0, end=4))\n", - "```\n", - "\n", - "### Periodic callbacks\n", - "\n", - "As we discussed above periodic callbacks allow periodically updating your application with new data. Below we will create a simple Bokeh plot and display it with Panel:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from bokeh.models import ColumnDataSource\n", - "from bokeh.plotting import figure\n", - "\n", - "source = ColumnDataSource({\"x\": range(10), \"y\": range(10)})\n", - "p = figure()\n", - "p.line(x=\"x\", y=\"y\", source=source)\n", - "\n", - "bokeh_pane = pn.pane.Bokeh(p)\n", - "bokeh_pane.servable()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we will define a callback that updates the data on the `ColumnDataSource` and use the `pn.state.add_periodic_callback` method to schedule updates every 200 ms. We will also set a timeout of 5 seconds after which the callback will automatically stop." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def update():\n", - " data = np.random.randint(0, 2 ** 31, 10)\n", - " source.data.update({\"y\": data})\n", - " bokeh_pane.param.trigger('object') # Only needed in notebook\n", - "\n", - "cb = pn.state.add_periodic_callback(update, 200)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In a notebook or bokeh server context we should now see the plot update periodically. The other nice thing about this is that `pn.state.add_periodic_callback` returns `PeriodicCallback` we can call `.stop()` and `.start()` on if we want to stop or pause the periodic execution. Additionally we can also dynamically adjust the period by setting the `timeout` parameter to speed up or slow down the callback.\n", - "\n", - "Other nice features on a periodic callback are the ability to check the number of executions using the `cb.counter` property and the ability to toggle the callback on and off simply by setting the running parameter. This makes it possible to link a widget to the running state:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "toggle = pn.widgets.Toggle(name='Toggle callback', value=True)\n", - "\n", - "toggle.link(cb, bidirectional=True, value='running')\n", - "toggle" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that when starting a server dynamically with `pn.serve` you cannot start a periodic callback before the application is actually being served. Therefore you should create the application and start the callback in a wrapping function:\n", - "\n", - "```python\n", - "from functools import partial\n", - "\n", - "import numpy as np\n", - "import panel as pn\n", - "\n", - "from bokeh.models import ColumnDataSource\n", - "from bokeh.plotting import figure\n", - "\n", - "def update(source):\n", - " data = np.random.randint(0, 2 ** 31, 10)\n", - " source.data.update({\"y\": data})\n", - "\n", - "def panel_app():\n", - " source = ColumnDataSource({\"x\": range(10), \"y\": range(10)})\n", - " p = figure()\n", - " p.line(x=\"x\", y=\"y\", source=source)\n", - " cb = pn.state.add_periodic_callback(partial(update, source), 200, timeout=5000)\n", - " return pn.pane.Bokeh(p)\n", - "\n", - "pn.serve(panel_app)\n", - "```" - ] - } - ], - "metadata": { - "language_info": { - "name": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 643a9a1a0601f20b474464c3f8aba3c0a522f877 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:31:53 +0000 Subject: [PATCH 04/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/how_to/authentication/configuration.md | 2 +- doc/how_to/authentication/index.md | 2 +- doc/how_to/caching/index.md | 2 +- doc/how_to/caching/manual.md | 4 ++-- doc/how_to/callbacks/async.md | 10 +++++----- doc/how_to/callbacks/index.md | 2 +- doc/how_to/callbacks/periodic.md | 2 +- doc/how_to/callbacks/schedule.md | 2 +- doc/how_to/callbacks/server.md | 2 +- doc/how_to/callbacks/session.md | 4 ++-- doc/how_to/export/bokeh.md | 2 +- doc/how_to/export/embedding.md | 2 +- doc/how_to/export/index.md | 2 +- doc/how_to/export/saving.md | 2 +- doc/how_to/index.md | 2 +- doc/how_to/integrations/index.md | 2 +- doc/how_to/server/index.md | 4 ++-- doc/how_to/state/busy.md | 4 ++-- doc/how_to/state/index.md | 4 ++-- doc/how_to/state/request.md | 2 +- doc/how_to/state/url.md | 6 +++--- doc/how_to/wasm/index.md | 4 ++-- 22 files changed, 34 insertions(+), 34 deletions(-) diff --git a/doc/how_to/authentication/configuration.md b/doc/how_to/authentication/configuration.md index 4a69fa8463..2b003de47e 100644 --- a/doc/how_to/authentication/configuration.md +++ b/doc/how_to/authentication/configuration.md @@ -114,4 +114,4 @@ A fully configured OAuth configuration may look like this: panel serve oauth_example.py --oauth-provider=github --oauth-key=... --oauth-secret=... --cookie-secret=... --oauth-encryption-key=... PANEL_OAUTH_PROVIDER=... PANEL_OAUTH_KEY=... PANEL_OAUTH_SECRET=... PANEL_COOKIE_SECRET=... PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ...` -``` \ No newline at end of file +``` diff --git a/doc/how_to/authentication/index.md b/doc/how_to/authentication/index.md index 66d088f1e3..266585ac1b 100644 --- a/doc/how_to/authentication/index.md +++ b/doc/how_to/authentication/index.md @@ -33,7 +33,7 @@ A list of OAuth providers and how to configure them. :link: user_info :link-type: doc -Discover how to make use of the user information and access tokens returned by the OAuth provider. +Discover how to make use of the user information and access tokens returned by the OAuth provider. ::: :::: diff --git a/doc/how_to/caching/index.md b/doc/how_to/caching/index.md index 9b48e4664e..0381eae407 100644 --- a/doc/how_to/caching/index.md +++ b/doc/how_to/caching/index.md @@ -28,4 +28,4 @@ How to use the `panel.cache` decorator to memoize (i.e. cache the output of) fun manual memoization -``` \ No newline at end of file +``` diff --git a/doc/how_to/caching/manual.md b/doc/how_to/caching/manual.md index 2205e9fc54..229e1a96b3 100644 --- a/doc/how_to/caching/manual.md +++ b/doc/how_to/caching/manual.md @@ -2,7 +2,7 @@ The `panel.state.cache` object is a simple dictionary that is shared between all sessions on a particular Panel server process. This makes it possible to load large datasets (or other objects you want to share) once and subsequently access the cached object. -To assign to the cache manually, simply put the data load or expensive calculation in an `if`/`else` block which checks whether the custom key is already present: +To assign to the cache manually, simply put the data load or expensive calculation in an `if`/`else` block which checks whether the custom key is already present: ```python if 'data' in pn.state.cache: @@ -11,7 +11,7 @@ else: pn.state.cache['data'] = data = ... # Load some data or perform an expensive computation ``` -The `as_cached` helper function provides a slightly cleaner way to write the caching logic. Instead of writing a conditional statement you write a function that is executed only when the inputs to the function change. If provided the `args` and `kwargs` will also be hashed making it easy to cache (or memoize) on the arguments to the function: +The `as_cached` helper function provides a slightly cleaner way to write the caching logic. Instead of writing a conditional statement you write a function that is executed only when the inputs to the function change. If provided the `args` and `kwargs` will also be hashed making it easy to cache (or memoize) on the arguments to the function: ```python def load_data(*args, **kwargs): diff --git a/doc/how_to/callbacks/async.md b/doc/how_to/callbacks/async.md index 67bd3032aa..fded26f089 100644 --- a/doc/how_to/callbacks/async.md +++ b/doc/how_to/callbacks/async.md @@ -47,7 +47,7 @@ async def stream(event): 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') @@ -65,11 +65,11 @@ async def get_img(index): async with aiohttp.ClientSession() as session: async with session.get(f"https://picsum.photos/800/300?image={index}") 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. +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: @@ -82,10 +82,10 @@ async def update_img(event): async with aiohttp.ClientSession() as session: async with session.get(f"https://picsum.photos/800/300?image={event.new}") as resp: image.object = await resp.read() - + widget.param.watch(update_img, 'value') widget.param.trigger('value') - + pn.Column(widget, image) ``` diff --git a/doc/how_to/callbacks/index.md b/doc/how_to/callbacks/index.md index a627cf2829..dc40340d3e 100644 --- a/doc/how_to/callbacks/index.md +++ b/doc/how_to/callbacks/index.md @@ -58,4 +58,4 @@ session periodic schedule server -``` \ No newline at end of file +``` diff --git a/doc/how_to/callbacks/periodic.md b/doc/how_to/callbacks/periodic.md index ccfea09aa4..81647edf82 100644 --- a/doc/how_to/callbacks/periodic.md +++ b/doc/how_to/callbacks/periodic.md @@ -61,4 +61,4 @@ def panel_app(): return pn.pane.Bokeh(p) pn.serve(panel_app) -``` \ No newline at end of file +``` diff --git a/doc/how_to/callbacks/schedule.md b/doc/how_to/callbacks/schedule.md index 5e48ae3aa6..9a5592bb3c 100644 --- a/doc/how_to/callbacks/schedule.md +++ b/doc/how_to/callbacks/schedule.md @@ -29,4 +29,4 @@ If you have the `croniter` library installed you may also provide a cron express pn.state.schedule_task('task', task, cron='2 4 * * mon,fri') ``` -See [crontab.guru](https://crontab.guru/) and the [`croniter` README](https://github.com/kiorky/croniter#introduction) to learn about cron expressions genrally and special syntax supported by `croniter`. \ No newline at end of file +See [crontab.guru](https://crontab.guru/) and the [`croniter` README](https://github.com/kiorky/croniter#introduction) to learn about cron expressions genrally and special syntax supported by `croniter`. diff --git a/doc/how_to/callbacks/server.md b/doc/how_to/callbacks/server.md index 59af138cd2..0eb14464f8 100644 --- a/doc/how_to/callbacks/server.md +++ b/doc/how_to/callbacks/server.md @@ -22,4 +22,4 @@ def app(): pn.serve(app, threaded=True) pn.state.execute(lambda: p.y_range.update(start=0, end=4)) -``` \ No newline at end of file +``` diff --git a/doc/how_to/callbacks/session.md b/doc/how_to/callbacks/session.md index 2531bef7cb..032ae733e9 100644 --- a/doc/how_to/callbacks/session.md +++ b/doc/how_to/callbacks/session.md @@ -1,6 +1,6 @@ # Session callbacks -Whenever a request is made to an endpoint that is serving a Panel application a new session is created. If you have to perform some setup or tear down tasks on session creation (e.g. logging) you can define `on_session_created` and `on_session_destroyed` callbacks. +Whenever a request is made to an endpoint that is serving a Panel application a new session is created. If you have to perform some setup or tear down tasks on session creation (e.g. logging) you can define `on_session_created` and `on_session_destroyed` callbacks. ## pn.state.on_session_created @@ -8,4 +8,4 @@ WIP ## pn.state.on_session_destroyed -In many cases it is useful to define on_session_destroyed callbacks to perform any custom cleanup that is required, e.g, dispose a database engine, or when a user is logged out. These callbacks can be registered with `pn.state.on_session_destroyed(callback)` \ No newline at end of file +In many cases it is useful to define on_session_destroyed callbacks to perform any custom cleanup that is required, e.g, dispose a database engine, or when a user is logged out. These callbacks can be registered with `pn.state.on_session_destroyed(callback)` diff --git a/doc/how_to/export/bokeh.md b/doc/how_to/export/bokeh.md index 09d26b05c4..8edefaea7e 100644 --- a/doc/how_to/export/bokeh.md +++ b/doc/how_to/export/bokeh.md @@ -14,4 +14,4 @@ from bokeh.embed import components, file_html from bokeh.io import show script, html = components(model) -``` \ No newline at end of file +``` diff --git a/doc/how_to/export/embedding.md b/doc/how_to/export/embedding.md index 276abb5af9..56ea012c55 100644 --- a/doc/how_to/export/embedding.md +++ b/doc/how_to/export/embedding.md @@ -40,4 +40,4 @@ row.embed(states={slider: list(range(0, 12, 2))}) * **`progress`** (default=False): Whether to report progress -As you might imagine if there are multiple widgets there can quickly be a combinatorial explosion of states so by default the output is limited to about 1000 states. For larger apps the states can also be exported to json files, e.g. if you want to serve the app on a website specify the ``save_path`` to declare where it will be stored and the ``load_path`` to declare where the JS code running on the website will look for the files. \ No newline at end of file +As you might imagine if there are multiple widgets there can quickly be a combinatorial explosion of states so by default the output is limited to about 1000 states. For larger apps the states can also be exported to json files, e.g. if you want to serve the app on a website specify the ``save_path`` to declare where it will be stored and the ``load_path`` to declare where the JS code running on the website will look for the files. diff --git a/doc/how_to/export/index.md b/doc/how_to/export/index.md index 885c325670..2b115086f3 100644 --- a/doc/how_to/export/index.md +++ b/doc/how_to/export/index.md @@ -10,4 +10,4 @@ One of the main design goals for Panel was that it should make it possible to se embedding saving bokeh -``` \ No newline at end of file +``` diff --git a/doc/how_to/export/saving.md b/doc/how_to/export/saving.md index 27d60966a8..42a65cbe0f 100644 --- a/doc/how_to/export/saving.md +++ b/doc/how_to/export/saving.md @@ -1,4 +1,4 @@ -# Saving output +# Saving output In case you don't need an actual server or simply want to export a static snapshot of a panel app, you can use the ``save`` method, which allows exporting the app to a standalone HTML or PNG file. diff --git a/doc/how_to/index.md b/doc/how_to/index.md index c91f5dba3b..b0d194dcfb 100644 --- a/doc/how_to/index.md +++ b/doc/how_to/index.md @@ -91,4 +91,4 @@ server/index integrations/index deployment/index authentication/index -``` \ No newline at end of file +``` diff --git a/doc/how_to/integrations/index.md b/doc/how_to/integrations/index.md index 66f6ab21d0..a0f435047c 100644 --- a/doc/how_to/integrations/index.md +++ b/doc/how_to/integrations/index.md @@ -16,7 +16,7 @@ Discover to run Panel applications alongside an existing Flask server. :link: FastAPI :link-type: doc -Discover to run Panel applications alongside an existing FastAPI server. +Discover to run Panel applications alongside an existing FastAPI server. ::: :::{grid-item-card} Django diff --git a/doc/how_to/server/index.md b/doc/how_to/server/index.md index 7f9e42298f..dba34970c8 100644 --- a/doc/how_to/server/index.md +++ b/doc/how_to/server/index.md @@ -33,7 +33,7 @@ Launch and configure a Panel application programmatically. :link: multiple :link-type: doc -Discover how-to launch and configure multiple applications on the same server. +Discover how-to launch and configure multiple applications on the same server. ::: :::{grid-item-card} {octicon}`server;2.5em;sd-mr-1` Setting up a (reverse) proxy @@ -54,7 +54,7 @@ Discover how to access a Panel deployment running remotely via SSH. :link: static_files :link-type: doc -Discover how to serve static files alongside your Panel application(s). +Discover how to serve static files alongside your Panel application(s). ::: :::: diff --git a/doc/how_to/state/busy.md b/doc/how_to/state/busy.md index 0400a71ab6..bd9e472b6e 100644 --- a/doc/how_to/state/busy.md +++ b/doc/how_to/state/busy.md @@ -10,7 +10,7 @@ import time def processing(event): # Some longer running task time.sleep(1) - + button = pn.widgets.Button(name='Click me!') button.on_click(processing) @@ -20,4 +20,4 @@ def indicator(busy): pn.Row(button, pn.bind(indicator, pn.state.param.busy)) ``` -This way we can create a global indicator for the busy state instead of modifying all our callbacks. \ No newline at end of file +This way we can create a global indicator for the busy state instead of modifying all our callbacks. diff --git a/doc/how_to/state/index.md b/doc/how_to/state/index.md index 9db6b92e90..ec0befcbb7 100644 --- a/doc/how_to/state/index.md +++ b/doc/how_to/state/index.md @@ -1,6 +1,6 @@ # Accessing session state -Whenever a Panel application is being served the `panel.state` object will provide a variety of information about the current user session including the HTTP request that initiated the session, information about the browser and the current URL and more. +Whenever a Panel application is being served the `panel.state` object will provide a variety of information about the current user session including the HTTP request that initiated the session, information about the browser and the current URL and more. ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 @@ -36,4 +36,4 @@ How to access the busy state. request url busy -``` \ No newline at end of file +``` diff --git a/doc/how_to/state/request.md b/doc/how_to/state/request.md index 15df1988f7..ab71caeaa7 100644 --- a/doc/how_to/state/request.md +++ b/doc/how_to/state/request.md @@ -13,7 +13,7 @@ except Exception: phase = 1 ``` -This mechanism may be used to modify the behavior of an app dependending on parameters provided in the URL. +This mechanism may be used to modify the behavior of an app dependending on parameters provided in the URL. ## Cookies diff --git a/doc/how_to/state/url.md b/doc/how_to/state/url.md index c1c5a14ce2..e8e7b351fc 100644 --- a/doc/how_to/state/url.md +++ b/doc/how_to/state/url.md @@ -14,7 +14,7 @@ When starting a server session Panel will attach a `Location` component which ca * **``search``** (string): search part of the url e.g. '?color=blue'. * **``hash_``** (string): hash part of the url e.g. '#interact'. * **``reload``** (bool): Whether or not to reload the page when the url is updated. - - For independent apps this should be set to True. + - For independent apps this should be set to True. - For integrated or single page apps this should be set to False. ### Readonly @@ -34,9 +34,9 @@ import param import panel as pn class QueryExample(param.Parameterized): - + integer = param.Integer(default=None, bounds=(0, 10)) - + string = param.String(default='A string') ``` diff --git a/doc/how_to/wasm/index.md b/doc/how_to/wasm/index.md index d754ffc8a4..d381ca9d0d 100644 --- a/doc/how_to/wasm/index.md +++ b/doc/how_to/wasm/index.md @@ -34,14 +34,14 @@ Discover how to set up and use Panel from Pyodide and PyScript. :link: user_info :link-type: doc -Discover how to integrate live Panel components in your Sphinx based documentation. +Discover how to integrate live Panel components in your Sphinx based documentation. ::: :::{grid-item-card} {octicon}`zap;2.5em;sd-mr-1` JupyterLite :link: jupyterlite :link-type: doc -Discover how to set up a JupyterLite deployment capable of rendering interactive Panel output. +Discover how to set up a JupyterLite deployment capable of rendering interactive Panel output. ::: :::: From 9e9d92897b98b708d80ef7f5a55bb1af20aa4189 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 4 Jan 2023 12:13:18 +0100 Subject: [PATCH 05/15] Add logos --- doc/_static/logos/azure.png | Bin 0 -> 19567 bytes doc/_static/logos/binder.png | Bin 0 -> 29485 bytes doc/_static/logos/django.png | Bin 0 -> 15109 bytes doc/_static/logos/fastapi.png | Bin 0 -> 5394 bytes doc/_static/logos/flask.png | Bin 0 -> 31518 bytes doc/_static/logos/gcp.png | Bin 0 -> 14852 bytes doc/_static/logos/heroku.png | Bin 0 -> 9725 bytes doc/_static/logos/huggingface.png | Bin 0 -> 24470 bytes doc/how_to/deployment/index.md | 12 +++++++++++- doc/how_to/display/index.md | 1 + doc/how_to/integrations/index.md | 6 ++++++ doc/how_to/server/index.md | 4 ++-- doc/how_to/state/index.md | 6 +++--- 13 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 doc/_static/logos/azure.png create mode 100644 doc/_static/logos/binder.png create mode 100644 doc/_static/logos/django.png create mode 100644 doc/_static/logos/fastapi.png create mode 100644 doc/_static/logos/flask.png create mode 100644 doc/_static/logos/gcp.png create mode 100644 doc/_static/logos/heroku.png create mode 100644 doc/_static/logos/huggingface.png diff --git a/doc/_static/logos/azure.png b/doc/_static/logos/azure.png new file mode 100644 index 0000000000000000000000000000000000000000..5f2018c3025f86e70d48ad966a9ea49c3b7852ba GIT binary patch literal 19567 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 literal 0 HcmV?d00001 diff --git a/doc/_static/logos/binder.png b/doc/_static/logos/binder.png new file mode 100644 index 0000000000000000000000000000000000000000..3071dde09bd3286ce22f7a9003ffb3ee55ff32e9 GIT binary patch literal 29485 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 literal 0 HcmV?d00001 diff --git a/doc/_static/logos/django.png b/doc/_static/logos/django.png new file mode 100644 index 0000000000000000000000000000000000000000..aa33958926aa37b10eebd09fc872f7891fb1c4e2 GIT binary patch literal 15109 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 P6`&xaDqa84JoNtnEKtuJ literal 0 HcmV?d00001 diff --git a/doc/_static/logos/fastapi.png b/doc/_static/logos/fastapi.png new file mode 100644 index 0000000000000000000000000000000000000000..0649ddc6d1886668fea7393faab576a9e33b4551 GIT binary patch literal 5394 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 z%0D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 literal 0 HcmV?d00001 diff --git a/doc/_static/logos/gcp.png b/doc/_static/logos/gcp.png new file mode 100644 index 0000000000000000000000000000000000000000..d5457535a8fffa941b206309dbb47988da565f0e GIT binary patch literal 14852 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 literal 0 HcmV?d00001 diff --git a/doc/_static/logos/heroku.png b/doc/_static/logos/heroku.png new file mode 100644 index 0000000000000000000000000000000000000000..7ac327b2c030930bc9a847d2483197b67ff0fd93 GIT binary patch literal 9725 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