diff --git a/doc/how_to/integrations/FastAPI.md b/doc/how_to/integrations/FastAPI.md index d8b68ce24a..ff97a96d68 100644 --- a/doc/how_to/integrations/FastAPI.md +++ b/doc/how_to/integrations/FastAPI.md @@ -1,272 +1,161 @@ -# Integrating Panel with FastAPI +# Running Panel apps in 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. +Panel generally runs on the Bokeh server, which itself runs on [Tornado](https://tornadoweb.org/en/stable/). However, it is also often useful to embed a Panel app in an existing web application, such as a [FastAPI](https://fastapi.tiangolo.com/) web server. -Following FastAPI's [Tutorial - User Guide](https://fastapi.tiangolo.com/tutorial/) make sure you first have FastAPI installed using: `conda install -c conda-forge fastapi`. Also make sure Panel is installed `conda install -c conda-forge panel`. +Since Panel 1.5.0 it is possible to run Panel application(s) natively on a FastAPI and uvicorn based server. Therefore this how-to guide will explain how to add Panel application(s) directly to an existing FastAPI application. This functionality is new and experimental so we also provide a [how-to guide to embed a Tornado based Panel server application inside a FastAPI application](./FastAPI_Tornado). -## Configuration +By the end of this guide, you'll be able to run a FastAPI application that serves a simple interactive Panel app. The Panel app will consist of a slider widget that dynamically updates a string of stars (⭐) based on the slider's value. -Before we start adding a bokeh app to our FastAPI server we have to set up some of the basic plumbing. In the `examples/apps/fastApi` folder we will add some basic configurations. +## Setup -You'll need to create a file called `examples/apps/fastApi/main.py`. +Following FastAPI's [Tutorial - User Guide](https://fastapi.tiangolo.com/tutorial/) make sure you first have FastAPI installed using: -In `main.py` you'll need to import the following( which should all be already available from the above conda installs): +::::{tab-set} -```python -import panel as pn -from bokeh.embed import server_document -from fastapi import FastAPI, Request -from fastapi.templating import Jinja2Templates +:::{tab-item} `conda` +```bash +conda install fastapi ``` +::: +:::{tab-item} `pip` +```pip +conda install fastapi +``` +::: -Each of these will be explained as we add them in. - -Next we are going to need to create an instance of FastAPI below your imports in `main.py` and set up the path to your templates like so: +## Create a FastAPI application +Start by creating a FastAPI application. In this application, we will define a root endpoint that returns a simple JSON response. Open your text editor or IDE and create a file named main.py: ```python -app = FastAPI() -templates = Jinja2Templates(directory="examples/apps/fastApi/templates") -``` +from fastapi import FastAPI -We will now need to create our first route via an async function and point it to the path of our server: +# Initialize FastAPI application +app = FastAPI() -```python @app.get("/") -async def bkapp_page(request: Request): - script = server_document('http://127.0.0.1:5000/app') - return templates.TemplateResponse("base.html", {"request": request, "script": script}) -``` - -As you can see in this code we will also need to create an html [Jinja2](https://fastapi.tiangolo.com/advanced/templates/#using-jinja2templates) template. Create a new directory named `examples/apps/fastApi/templates` and create the file `examples/apps/fastApi/templates/base.html` in that directory. - -Now add the following to `base.html`. This is a minimal version but feel free to add whatever else you need to it. - -```html - - - - Panel in FastAPI: sliders - - - {{ script|safe }} - - +async def read_root(): + return {"Hello": "World"} ``` -Return back to your `examples/apps/fastApi/main.py` file. We will use pn.serve() to start the bokeh server (Which Panel is built on). Configure it to whatever port and address you want, for our example we will use port 5000 and address 127.0.0.1. show=False will make it so the bokeh server is spun up but not shown yet. The allow_websocket_origin will list of hosts that can connect to the websocket, for us this is FastAPI so we will use (127.0.0.1:8000). The `createApp` function call in this example is how we call our panel app. This is not set up yet but will be in the next section. - -```python -pn.serve({'/app': createApp}, - port=5000, allow_websocket_origin=["127.0.0.1:8000"], - address="127.0.0.1", show=False) -``` - -You could optionally add BOKEH_ALLOW_WS_ORIGIN=127.0.0.1:8000 as an environment variable instead of setting it here. In conda it is done like this. - -`conda env config vars set BOKEH_ALLOW_WS_ORIGIN=127.0.0.1:8000` - -## Sliders app - -Based on a standard FastAPI app template, this app shows how to integrate Panel and FastAPI. - -The sliders app is in `examples/apps/fastApi/sliders`. We will cover the following additions/modifications to the Django2 app template: - - * `sliders/sinewave.py`: a parameterized object (representing your pre-existing code) - - * `sliders/pn_app.py`: creates an app function from the SineWave class - - To start with, in `sliders/sinewave.py` we create a parameterized object to serve as a placeholder for your own, existing code: - -```python -import numpy as np -import param -from bokeh.models import ColumnDataSource -from bokeh.plotting import figure - - -class SineWave(param.Parameterized): - offset = param.Number(default=0.0, bounds=(-5.0, 5.0)) - amplitude = param.Number(default=1.0, bounds=(-5.0, 5.0)) - phase = param.Number(default=0.0, bounds=(0.0, 2 * np.pi)) - frequency = param.Number(default=1.0, bounds=(0.1, 5.1)) - N = param.Integer(default=200, bounds=(0, None)) - x_range = param.Range(default=(0, 4 * np.pi), bounds=(0, 4 * np.pi)) - y_range = param.Range(default=(-2.5, 2.5), bounds=(-10, 10)) - - def __init__(self, **params): - super(SineWave, self).__init__(**params) - x, y = self.sine() - self.cds = ColumnDataSource(data=dict(x=x, y=y)) - self.plot = figure(plot_height=400, plot_width=400, - tools="crosshair, pan, reset, save, wheel_zoom", - x_range=self.x_range, y_range=self.y_range) - self.plot.line('x', 'y', source=self.cds, line_width=3, line_alpha=0.6) - - @param.depends('N', 'frequency', 'amplitude', 'offset', 'phase', 'x_range', 'y_range', watch=True) - def update_plot(self): - x, y = self.sine() - self.cds.data = dict(x=x, y=y) - self.plot.x_range.start, self.plot.x_range.end = self.x_range - self.plot.y_range.start, self.plot.y_range.end = self.y_range - - def sine(self): - x = np.linspace(0, 4 * np.pi, self.N) - y = self.amplitude * np.sin(self.frequency * x + self.phase) + self.offset - return x, y -``` +## Create a Panel Application -However the app itself is defined we need to configure an entry point, which is a function that adds the application to it. In case of the slider app it looks like this in `sliders/pn_app.py`: +Next we will define a simple Panel application that allows you to control the number of displayed stars with an integer slider and decorate it with the `add_panel_app` decorator: ```python import panel as pn -from .sinewave import SineWave +from panel.io.fastapi import add_panel_app -def createApp(): - sw = SineWave() - return pn.Row(sw.param, sw.plot).servable() +@add_panel_app('/panel', app=app, title='My Panel App') +def create_panel_app(): + slider = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3) + return slider.rx() * '⭐' ``` -We now need to return to our `main.py` and import the createApp function. Add the following import near the other imports: - -```python -from sliders.pn_app import createApp -``` +That's it! This decorator will map a specific URL path to the Panel app, allowing it to be served as part of the FastAPI application. -Your file structure should now be like the following: - -``` -fastApi -│ main.py -│ -└───sliders -│ │ sinewave.py -│ │ pn_app.py -│ -└───templates - │ base.html -``` - -And your finished `main.py` should look like this: +The complete file should now look something like this: ```python import panel as pn -from bokeh.embed import server_document -from fastapi import FastAPI, Request -from fastapi.templating import Jinja2Templates -from sliders.pn_app import createApp +from fastapi import FastAPI +from panel.io.fastapi import add_application app = FastAPI() -templates = Jinja2Templates(directory="templates") @app.get("/") -async def bkapp_page(request: Request): - script = server_document('http://127.0.0.1:5000/app') - return templates.TemplateResponse("base.html", {"request": request, "script": script}) - +async def read_root(): + return {"Hello": "World"} -pn.serve({'/app': createApp}, - port=5000, allow_websocket_origin=["127.0.0.1:8000"], - address="127.0.0.1", show=False) +@add_application('/panel', app=app, title='My Panel App') +def create_panel_app(): + slider = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3) + return slider.rx() * '⭐' ``` -``` -uvicorn main:app --reload +Now run it with: + +```bash +fastapi dev main.py ``` -The output should give you a link to go to to view your app: +You should see the following output: ``` -Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -``` +INFO Using path main.py +INFO Resolved absolute path /home/user/code/awesomeapp/main.py +INFO Searching for package file structure from directories with __init__.py files +INFO Importing from /home/user/code/awesomeapp/fast_api -Go to that address and your app should be there running! + ╭─ Python module file ─╮ + │ │ + │ 🐍 main.py │ + │ │ + ╰──────────────────────╯ -## Multiple apps +INFO Importing module main +/panel +INFO Found importable FastAPI app + ╭─ Importable FastAPI app ─╮ + │ │ + │ from main import app │ + │ │ + ╰──────────────────────────╯ -This is the most basic configuration for a bokeh server. It is of course possible to add multiple apps in the same way and then registering them with FastAPI in the way described in the [configuration](#configuration) section above. To see a multi-app FastAPI server have a look at ``examples/apps/fastApi_multi_apps`` and launch it with `uvicorn main:app --reload` as before. +INFO Using import string main:app -To run multiple apps you will need to do the following: -1. Create a new directory in your and a new file with your panel app (ex. `sinewave.py`). -2. Create another pn_app file in your new directory (ex. `pn_app.py`) That might look something like this: -``` -import panel as pn - -from .sinewave import SineWave + ╭────────── FastAPI CLI - Development mode ───────────╮ + │ │ + │ Serving at: http://127.0.0.1:8000 │ + │ │ + │ API docs: http://127.0.0.1:8000/docs │ + │ │ + │ Running in development mode, for production use: │ + │ │ + │ fastapi run │ + │ │ + ╰─────────────────────────────────────────────────────╯ -def createApp2(): - sw = SineWave() - return pn.Row(sw.param, sw.plot).servable() +INFO: Will watch for changes in these directories: ['/home/user/code/awesomeapp/'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [39089] using WatchFiles +INFO: Started server process [39128] +INFO: Waiting for application startup. +INFO: Application startup complete. ``` -With this as your new file structure: +If you visit `http://127.0.0.1:8000` you will see the Panel application. -``` -fastApi -│ main.py -│ -└───sliders -│ │ sinewave.py -│ │ pn_app.py -│ │ -└───sliders2 -│ │ sinewave.py -│ │ pn_app.py -│ -└───templates - │ base.html -``` +## Adding multiple applications -3. Create a new html template (ex. app2.html) with the same contents as base.html in `examples/apps/fastApi/templates` -4. Import your new app in main.py `from sliders2.pn_app import createApp2` -5. Add your new app to the dictionary in pn.serve() +The `add_application` decorator is useful when server an application defined in a function, if you want to serve multiple applications, whether they are existing Panel objects, functions, or paths to Panel application scripts you can use the `add_applications` function instead, e.g.: ```python -{'/app': createApp, '/app2': createApp2} -``` +import panel as pn -7. Add a new async function to rout your new app (The bottom of `main.py` should look something like this now): +from fastapi import FastAPI +from panel.io.fastapi import add_application + +app = FastAPI() -```python @app.get("/") -async def bkapp_page(request: Request): - script = server_document('http://127.0.0.1:5000/app') - return templates.TemplateResponse("base.html", {"request": request, "script": script}) - -@app.get("/app2") -async def bkapp_page2(request: Request): - script = server_document('http://127.0.0.1:5000/app2') - return templates.TemplateResponse("app2.html", {"request": request, "script": script}) - -pn.serve({'/app': createApp, '/app2': createApp2}, - port=5000, allow_websocket_origin=["127.0.0.1:8000"], - address="127.0.0.1", show=False) -``` +async def read_root(): + return {"Hello": "World"} -With this as your file structure +def create_panel_app(): + slider = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3) + return slider.rx() * '⭐' +add_applications({ + "/panel_app1": create_panel_app, + "/panel_app2": pn.Column('I am a Panel object!'), + "/panel_app3": "my_panel_app.py" +}, app=app) ``` -fastApi -│ main.py -│ -└───sliders -│ │ sinewave.py -│ │ pn_app.py -│ │ -└───sliders2 -│ │ sinewave.py -│ │ pn_app.py -│ -└───templates - │ base.html - │ app2.html -``` - -Sliders 2 will be available at `http://127.0.0.1:8000/app2` ## Conclusion diff --git a/doc/how_to/integrations/FastAPI_Tornado.md b/doc/how_to/integrations/FastAPI_Tornado.md new file mode 100644 index 0000000000..a1c58bf907 --- /dev/null +++ b/doc/how_to/integrations/FastAPI_Tornado.md @@ -0,0 +1,274 @@ +# Embedding a Panel Server in 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. + +Following FastAPI's [Tutorial - User Guide](https://fastapi.tiangolo.com/tutorial/) make sure you first have FastAPI installed using: `conda install -c conda-forge fastapi`. Also make sure Panel is installed `conda install -c conda-forge panel`. + + +## Configuration + +Before we start adding a bokeh app to our FastAPI server we have to set up some of the basic plumbing. In the `examples/apps/fastApi` folder we will add some basic configurations. + +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): + +```python +import panel as pn +from bokeh.embed import server_document +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates +``` + + +Each of these will be explained as we add them in. + +Next we are going to need to create an instance of FastAPI below your imports in `main.py` and set up the path to your templates like so: + + +```python +app = FastAPI() +templates = Jinja2Templates(directory="examples/apps/fastApi/templates") +``` + +We will now need to create our first route via an async function and point it to the path of our server: + +```python +@app.get("/") +async def bkapp_page(request: Request): + script = server_document('http://127.0.0.1:5000/app') + return templates.TemplateResponse("base.html", {"request": request, "script": script}) +``` + +As you can see in this code we will also need to create an html [Jinja2](https://fastapi.tiangolo.com/advanced/templates/#using-jinja2templates) template. Create a new directory named `examples/apps/fastApi/templates` and create the file `examples/apps/fastApi/templates/base.html` in that directory. + +Now add the following to `base.html`. This is a minimal version but feel free to add whatever else you need to it. + +```html + + + + Panel in FastAPI: sliders + + + {{ script|safe }} + + +``` + +Return back to your `examples/apps/fastApi/main.py` file. We will use pn.serve() to start the bokeh server (Which Panel is built on). Configure it to whatever port and address you want, for our example we will use port 5000 and address 127.0.0.1. show=False will make it so the bokeh server is spun up but not shown yet. The allow_websocket_origin will list of hosts that can connect to the websocket, for us this is FastAPI so we will use (127.0.0.1:8000). The `createApp` function call in this example is how we call our panel app. This is not set up yet but will be in the next section. + +```python +pn.serve({'/app': createApp}, + port=5000, allow_websocket_origin=["127.0.0.1:8000"], + address="127.0.0.1", show=False) +``` + +You could optionally add BOKEH_ALLOW_WS_ORIGIN=127.0.0.1:8000 as an environment variable instead of setting it here. In conda it is done like this. + +`conda env config vars set BOKEH_ALLOW_WS_ORIGIN=127.0.0.1:8000` + +## Sliders app + +Based on a standard FastAPI app template, this app shows how to integrate Panel and FastAPI. + +The sliders app is in `examples/apps/fastApi/sliders`. We will cover the following additions/modifications to the Django2 app template: + + * `sliders/sinewave.py`: a parameterized object (representing your pre-existing code) + + * `sliders/pn_app.py`: creates an app function from the SineWave class + + To start with, in `sliders/sinewave.py` we create a parameterized object to serve as a placeholder for your own, existing code: + +```python +import numpy as np +import param +from bokeh.models import ColumnDataSource +from bokeh.plotting import figure + + +class SineWave(param.Parameterized): + offset = param.Number(default=0.0, bounds=(-5.0, 5.0)) + amplitude = param.Number(default=1.0, bounds=(-5.0, 5.0)) + phase = param.Number(default=0.0, bounds=(0.0, 2 * np.pi)) + frequency = param.Number(default=1.0, bounds=(0.1, 5.1)) + N = param.Integer(default=200, bounds=(0, None)) + x_range = param.Range(default=(0, 4 * np.pi), bounds=(0, 4 * np.pi)) + y_range = param.Range(default=(-2.5, 2.5), bounds=(-10, 10)) + + def __init__(self, **params): + super(SineWave, self).__init__(**params) + x, y = self.sine() + self.cds = ColumnDataSource(data=dict(x=x, y=y)) + self.plot = figure(plot_height=400, plot_width=400, + tools="crosshair, pan, reset, save, wheel_zoom", + x_range=self.x_range, y_range=self.y_range) + self.plot.line('x', 'y', source=self.cds, line_width=3, line_alpha=0.6) + + @param.depends('N', 'frequency', 'amplitude', 'offset', 'phase', 'x_range', 'y_range', watch=True) + def update_plot(self): + x, y = self.sine() + self.cds.data = dict(x=x, y=y) + self.plot.x_range.start, self.plot.x_range.end = self.x_range + self.plot.y_range.start, self.plot.y_range.end = self.y_range + + def sine(self): + x = np.linspace(0, 4 * np.pi, self.N) + y = self.amplitude * np.sin(self.frequency * x + self.phase) + self.offset + return x, y +``` + +However the app itself is defined we need to configure an entry point, which is a function that adds the application to it. In case of the slider app it looks like this in `sliders/pn_app.py`: + +```python +import panel as pn + +from .sinewave import SineWave + +def createApp(): + sw = SineWave() + return pn.Row(sw.param, sw.plot).servable() +``` + +We now need to return to our `main.py` and import the createApp function. Add the following import near the other imports: + +```python +from sliders.pn_app import createApp +``` + +Your file structure should now be like the following: + +``` +fastApi +│ main.py +│ +└───sliders +│ │ sinewave.py +│ │ pn_app.py +│ +└───templates + │ base.html +``` + +And your finished `main.py` should look like this: + +```python +import panel as pn +from bokeh.embed import server_document +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates + +from sliders.pn_app import createApp + +app = FastAPI() +templates = Jinja2Templates(directory="templates") + +@app.get("/") +async def bkapp_page(request: Request): + script = server_document('http://127.0.0.1:5000/app') + return templates.TemplateResponse("base.html", {"request": request, "script": script}) + + +pn.serve({'/app': createApp}, + port=5000, allow_websocket_origin=["127.0.0.1:8000"], + address="127.0.0.1", show=False) +``` + +``` +uvicorn main:app --reload +``` + +The output should give you a link to go to to view your app: + +``` +Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +``` + +Go to that address and your app should be there running! + +## Multiple apps + + +This is the most basic configuration for a bokeh server. It is of course possible to add multiple apps in the same way and then registering them with FastAPI in the way described in the [configuration](#configuration) section above. To see a multi-app FastAPI server have a look at ``examples/apps/fastApi_multi_apps`` and launch it with `uvicorn main:app --reload` as before. + +To run multiple apps you will need to do the following: +1. Create a new directory in your and a new file with your panel app (ex. `sinewave.py`). +2. Create another pn_app file in your new directory (ex. `pn_app.py`) That might look something like this: +``` +import panel as pn + +from .sinewave import SineWave + +def createApp2(): + sw = SineWave() + return pn.Row(sw.param, sw.plot).servable() +``` + +With this as your new file structure: + +``` +fastApi +│ main.py +│ +└───sliders +│ │ sinewave.py +│ │ pn_app.py +│ │ +└───sliders2 +│ │ sinewave.py +│ │ pn_app.py +│ +└───templates + │ base.html +``` + +3. Create a new html template (ex. app2.html) with the same contents as base.html in `examples/apps/fastApi/templates` +4. Import your new app in main.py `from sliders2.pn_app import createApp2` +5. Add your new app to the dictionary in pn.serve() + +```python +{'/app': createApp, '/app2': createApp2} +``` + +7. Add a new async function to rout your new app (The bottom of `main.py` should look something like this now): + +```python +@app.get("/") +async def bkapp_page(request: Request): + script = server_document('http://127.0.0.1:5000/app') + return templates.TemplateResponse("base.html", {"request": request, "script": script}) + +@app.get("/app2") +async def bkapp_page2(request: Request): + script = server_document('http://127.0.0.1:5000/app2') + return templates.TemplateResponse("app2.html", {"request": request, "script": script}) + +pn.serve({'/app': createApp, '/app2': createApp2}, + port=5000, allow_websocket_origin=["127.0.0.1:8000"], + address="127.0.0.1", show=False) +``` + +With this as your file structure + +``` +fastApi +│ main.py +│ +└───sliders +│ │ sinewave.py +│ │ pn_app.py +│ │ +└───sliders2 +│ │ sinewave.py +│ │ pn_app.py +│ +└───templates + │ base.html + │ app2.html +``` + +Sliders 2 will be available at `http://127.0.0.1:8000/app2` + +## Conclusion + +That's it! You now have embedded panel in FastAPI! You can now build off of this to create your own web app tailored to your needs. diff --git a/doc/how_to/integrations/index.md b/doc/how_to/integrations/index.md index cfc75c4b7a..7ceb719da6 100644 --- a/doc/how_to/integrations/index.md +++ b/doc/how_to/integrations/index.md @@ -5,20 +5,20 @@ These guides will cover how to integrate Panel applications with various externa ::::{grid} 1 3 3 3 :gutter: 1 1 1 2 -:::{grid-item-card} Flask -:link: flask +:::{grid-item-card} FastAPI +:link: FastAPI :link-type: doc -Discover to run Panel applications alongside an existing Flask server. +Discover to run Panel applications natively on a FastAPI server. -![Flask Logo](../../_static/logos/flask.png) +![FastAPI Logo](../../_static/logos/fastapi.png) ::: -:::{grid-item-card} FastAPI -:link: FastAPI +:::{grid-item-card} FastAPI (Embedded) +:link: FastAPI_Tornado :link-type: doc -Discover to run Panel applications alongside an existing FastAPI server. +Discover to embed a Panel Tornado server application in a FastAPI server. ![FastAPI Logo](../../_static/logos/fastapi.png) ::: @@ -27,11 +27,21 @@ Discover to run Panel applications alongside an existing FastAPI server. :link: Django :link-type: doc -Discover to run Panel applications on a Django server (replacing the standard Tornado based server). +Discover to run Panel applications natively on a Django server. ![Django Logo](../../_static/logos/django.png) ::: +:::{grid-item-card} Flask +:link: flask +:link-type: doc + +Discover to run Panel applications alongside an existing Flask server. + +![Flask Logo](../../_static/logos/flask.png) +::: + + :::: ```{toctree} @@ -39,7 +49,8 @@ Discover to run Panel applications on a Django server (replacing the standard To :hidden: :maxdepth: 2 -flask FastAPI +FastAPI_Tornado +flask Django ``` diff --git a/panel/io/application.py b/panel/io/application.py index ae3062c98c..a2034a9272 100644 --- a/panel/io/application.py +++ b/panel/io/application.py @@ -8,7 +8,10 @@ import os from functools import partial -from typing import TYPE_CHECKING, Any +from types import FunctionType, MethodType +from typing import ( + TYPE_CHECKING, Any, Callable, Mapping, +) import bokeh.command.util @@ -17,19 +20,63 @@ from bokeh.application.handlers.document_lifecycle import ( DocumentLifecycleHandler, ) +from bokeh.application.handlers.function import FunctionHandler +from bokeh.models import CustomJS from ..config import config from .document import _destroy_document from .handlers import MarkdownHandler, NotebookHandler, ScriptHandler +from .loading import LOADING_INDICATOR_CSS_CLASS from .logging import LOG_SESSION_DESTROYED, LOG_SESSION_LAUNCHING from .state import set_curdoc, state if TYPE_CHECKING: from bokeh.application.application import SessionContext from bokeh.application.handlers.handler import Handler + from bokeh.document.document import Document + + from ..template.base import BaseTemplate + from ..viewable import Viewable, Viewer + from .location import Location + + TViewable = Viewable | Viewer | BaseTemplate + TViewableFuncOrPath = TViewable | Callable[[], TViewable] | os.PathLike | str + + +logger = logging.getLogger('panel.io.application') + -log = logging.getLogger('panel.io.application') +def _eval_panel( + panel: TViewableFuncOrPath, server_id: str, title: str, + location: bool | Location, admin: bool, doc: Document +): + from ..pane import panel as as_panel + from ..template import BaseTemplate + if config.global_loading_spinner: + doc.js_on_event( + 'document_ready', CustomJS(code=f""" + const body = document.getElementsByTagName('body')[0] + body.classList.remove({LOADING_INDICATOR_CSS_CLASS!r}, {config.loading_spinner!r}) + """) + ) + + doc.on_event('document_ready', partial(state._schedule_on_load, doc)) + + # Set up instrumentation for logging sessions + logger.info(LOG_SESSION_LAUNCHING, id(doc)) + def _log_session_destroyed(session_context): + logger.info(LOG_SESSION_DESTROYED, id(doc)) + doc.on_session_destroyed(_log_session_destroyed) + + with set_curdoc(doc): + if isinstance(panel, (FunctionType, MethodType)): + panel = panel() + if isinstance(panel, BaseTemplate): + doc = panel._modify_doc(server_id, title, doc, location) + else: + doc = as_panel(panel)._modify_doc(server_id, title, doc, location) + return doc def _on_session_destroyed(session_context: SessionContext) -> None: """ @@ -41,8 +88,10 @@ def _on_session_destroyed(session_context: SessionContext) -> None: try: callback(session_context) except Exception as e: - log.warning("DocumentLifecycleHandler on_session_destroyed " - f"callback {callback} failed with following error: {e}") + logger.warning( + "DocumentLifecycleHandler on_session_destroyed " + f"callback {callback} failed with following error: {e}" + ) class Application(BkApplication): @@ -73,14 +122,14 @@ def add(self, handler: Handler) -> None: super().add(handler) def initialize_document(self, doc): - log.info(LOG_SESSION_LAUNCHING, id(doc)) + logger.info(LOG_SESSION_LAUNCHING, id(doc)) super().initialize_document(doc) if doc in state._templates and doc not in state._templates[doc]._documents: template = state._templates[doc] with set_curdoc(doc): template.server_doc(title=template.title, location=True, doc=doc) def _log_session_destroyed(session_context): - log.info(LOG_SESSION_DESTROYED, id(doc)) + logger.info(LOG_SESSION_DESTROYED, id(doc)) doc.destroy = partial(_destroy_document, doc) # type: ignore doc.on_event('document_ready', partial(state._schedule_on_load, doc)) doc.on_session_destroyed(_log_session_destroyed) @@ -144,3 +193,75 @@ def build_single_handler_application(path, argv=None): return application bokeh.command.util.build_single_handler_application = build_single_handler_application + + +def build_applications( + panel: TViewableFuncOrPath | Mapping[str, TViewableFuncOrPath], + title: str | dict[str, str] | None = None, + location: bool | Location = True, + admin: bool = False, + server_id: str | None = None, + custom_handlers: list | None = None +) -> dict[str, Application]: + """ + Converts a variety of objects into a dictionary of Applications. + + Arguments + --------- + panel: Viewable, function or {str: Viewable} + A Panel object, a function returning a Panel object or a + dictionary mapping from the URL slug to either. + 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. + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. + admin: boolean (default=False) + Whether to enable the admin panel + server_id: str + ID of the server running the application(s) + """ + if not isinstance(panel, dict): + panel = {'/': panel} + + apps = {} + for slug, app in panel.items(): + if slug.endswith('/') and slug != '/': + raise ValueError(f"Invalid URL: trailing slash '/' used for {slug!r} not supported.") + if isinstance(title, dict): + try: + title_ = title[slug] + except KeyError: + raise KeyError( + "Keys of the title dictionary and of the apps " + f"dictionary must match. No {slug} key found in the " + "title dictionary.") from None + else: + title_ = title + slug = slug if slug.startswith('/') else '/'+slug + + # Handle other types of apps using a custom handler + for handler in (custom_handlers or ()): + new_app = handler(slug, app) + if app is not None: + break + else: + new_app = app + if new_app is not None: + app = new_app + if app is True: + continue + + if isinstance(app, os.PathLike): + app = str(app) # enables serving apps from Paths + if (isinstance(app, str) and app.endswith(('.py', '.ipynb', '.md')) + and os.path.isfile(app)): + apps[slug] = app = build_single_handler_application(app) + app._admin = admin + elif isinstance(app, BkApplication): + apps[slug] = app + else: + handler = FunctionHandler(partial(_eval_panel, app, server_id, title_, location, admin)) + apps[slug] = Application(handler, admin=admin) + return apps diff --git a/panel/io/fastapi.py b/panel/io/fastapi.py new file mode 100644 index 0000000000..957d29005d --- /dev/null +++ b/panel/io/fastapi.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import asyncio +import uuid + +from functools import wraps +from typing import TYPE_CHECKING, Mapping, cast + +from .application import build_applications +from .document import _cleanup_doc, extra_socket_handlers +from .resources import COMPONENT_PATH +from .server import ComponentResourceHandler +from .state import state +from .threads import StoppableThread + +try: + from bokeh_fastapi import BokehFastAPI + from bokeh_fastapi.handler import WSHandler + from fastapi import ( + FastAPI, HTTPException, Query, Request, + ) + from fastapi.responses import FileResponse +except ImportError: + msg = "bokeh_fastapi must be installed to use the panel.io.fastapi module." + raise ImportError(msg) from None + +if TYPE_CHECKING: + from uvicorn import Server + + from .application import TViewableFuncOrPath + from .location import Location + +#--------------------------------------------------------------------- +# Private API +#--------------------------------------------------------------------- + +def dispatch_fastapi(conn, events=None, msg=None): + if msg is None: + msg = conn.protocol.create("PATCH-DOC", events) + return [conn._socket.send_message(msg)] + +extra_socket_handlers[WSHandler] = dispatch_fastapi + + +def add_liveness_handler(app, endpoint, applications): + @app.get(endpoint, response_model=dict[str, bool]) + async def liveness_handler(request: Request, endpoint: str | None = Query(None)): + if endpoint is not None: + if endpoint not in applications: + raise HTTPException(status_code=400, detail=f"Endpoint {endpoint!r} does not exist.") + app_instance = applications[endpoint] + try: + doc = app_instance.create_document() + _cleanup_doc(doc) + return {endpoint: True} + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Endpoint {endpoint!r} could not be served. Application raised error: {e}" + ) from e + else: + return {str(request.url.path): True} + +#--------------------------------------------------------------------- +# Public API +#--------------------------------------------------------------------- + +def add_applications( + panel: TViewableFuncOrPath | Mapping[str, TViewableFuncOrPath], + app: FastAPI | None = None, + title: str | dict[str, str] | None = None, + location: bool | Location = True, + admin: bool = False, + liveness: bool | str = False, + **kwargs +): + """ + Adds application(s) to an existing FastAPI application. + + Arguments + --------- + app: FastAPI + FastAPI app to add Panel application(s) to. + panel: Viewable, function or {str: Viewable} + A Panel object, a function returning a Panel object or a + dictionary mapping from the URL slug to either. + 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. + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. + admin: boolean (default=False) + Whether to enable the admin panel + liveness: bool | str (optional, default=False) + Whether to add a liveness endpoint. If a string is provided + then this will be used as the endpoint, otherwise the endpoint + will be hosted at /liveness. + **kwargs: + Additional keyword arguments to pass to the BokehFastAPI application + """ + apps = build_applications(panel, title=title, location=location, admin=admin) + application = BokehFastAPI(apps, app=app, **kwargs) + if liveness: + liveness_endpoint = liveness if isinstance(liveness, str) else '/liveness' + add_liveness_handler(application.app, endpoint=liveness_endpoint, applications=apps) + + @application.app.get( + f"/{COMPONENT_PATH.rstrip('/')}" + "/{path:path}", include_in_schema=False + ) + def get_component_resource(path: str): + # ComponentResourceHandler.parse_url_path only ever accesses + # self._resource_attrs, which fortunately is a class attribute. Thus, we can + # get away with using the method without actually instantiating the class + self_ = cast(ComponentResourceHandler, ComponentResourceHandler) + resolved_path = ComponentResourceHandler.parse_url_path(self_, path) + return FileResponse(resolved_path) + + return application + + +def add_application( + path: str, + app: FastAPI, + title: str = "Panel App", + location: bool | Location = True, + admin: bool = False, + **kwargs +): + """ + Decorator that adds a Panel app to a FastAPI application. + + Arguments + --------- + path: str + The path to serve the application on. + app: FastAPI + FastAPI app to add Panel application(s) to. + title : str + An HTML title for the application. + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. + admin: boolean (default=False) + Whether to enable the admin panel + **kwargs: + Additional keyword arguments to pass to the BokehFastAPI application + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + # Register the Panel application after the function is defined + add_applications( + {path: func}, + app=app, + title=title + ) + return wrapper + + return decorator + + +def get_server( + panel: TViewableFuncOrPath | Mapping[str, TViewableFuncOrPath], + port: int | None = 0, + start: bool = False, + title: str | dict[str, str] | None = None, + location: bool | Location = True, + admin: bool = False, + **kwargs +): + """ + Creates a FastAPI server running the provided Panel application(s). + + Arguments + --------- + panel: Viewable, function or {str: Viewable} + 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. + 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. + location : boolean or panel.io.location.Location + Whether to create a Location component to observe and + set the URL location. + admin: boolean (default=False) + Whether to enable the admin panel + liveness: bool | str (optional, default=False) + Whether to add a liveness endpoint. If a string is provided + then this will be used as the endpoint, otherwise the endpoint + will be hosted at /liveness. + start : boolean(optional, default=False) + Whether to start the Server. + **kwargs: + Additional keyword arguments to pass to the BokehFastAPI application + """ + try: + import uvicorn + except Exception as e: + raise ImportError( + "Running a FastAPI server requires uvicorn to be available. " + "If you want to use a different server implementation use the " + "panel.io.fastapi.add_applications API." + ) from e + + loop = kwargs.pop('loop') + if loop: + asyncio.set_event_loop(loop) + server_id = kwargs.pop('server_id', uuid.uuid4().hex) + application = add_applications( + panel, title=title, location=location, admin=admin, **kwargs + ) + + config = uvicorn.Config(application.app, port=port, loop=loop) + server = uvicorn.Server(config) + + state._servers[server_id] = (server, panel, []) + if start: + if loop: + try: + loop.run_until_complete(server.serve()) + except asyncio.CancelledError: + pass + else: + server.run() + return server + + +def serve( + panels: TViewableFuncOrPath | Mapping[str, TViewableFuncOrPath], + port: int = 0, + address: str | None = None, + websocket_origin: str | list[str] | None = None, + loop: asyncio.AbstractEventLoop | None = None, + show: bool = True, + start: bool = True, + title: str | None = None, + verbose: bool = True, + location: bool = True, + threaded: bool = False, + admin: bool = False, + liveness: bool | str = False, + **kwargs +) -> StoppableThread | Server: + """ + Allows serving one or more panel objects on a single server. + The panels argument should be either a Panel object or a function + returning a Panel object or a dictionary of these two. If a + dictionary is supplied the keys represent the slugs at which + each app is served, e.g. `serve({'app': panel1, 'app2': panel2})` + will serve apps at /app and /app2 on the server. + + Reference: https://panel.holoviz.org/user_guide/Server_Configuration.html#serving-multiple-apps + + 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=True) + Whether to open the server in a new browser tab on start + start : boolean(optional, default=True) + 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. + threaded: boolean (default=False) + Whether to start the server on a new Thread + admin: boolean (default=False) + Whether to enable the admin panel + liveness: bool | str (optional, default=False) + Whether to add a liveness endpoint. If a string is provided + then this will be used as the endpoint, otherwise the endpoint + will be hosted at /liveness. + kwargs: dict + Additional keyword arguments to pass to Server instance + """ + # Empty layout are valid and the Bokeh warning is silenced as usually + # not relevant to Panel users. + kwargs = dict(kwargs, **dict( + port=port, address=address, websocket_origin=websocket_origin, + loop=loop, show=show, start=start, title=title, verbose=verbose, + location=location, admin=admin, liveness=liveness + )) + if threaded: + # To ensure that we have correspondence between state._threads and state._servers + # we must provide a server_id here + kwargs['loop'] = loop = asyncio.new_event_loop() if loop is None else loop + if 'server_id' not in kwargs: + kwargs['server_id'] = uuid.uuid4().hex + server = StoppableThread( + target=get_server, io_loop=loop, args=(panels,), kwargs=kwargs + ) + server_id = kwargs['server_id'] + state._threads[server_id] = server + server.start() + else: + return get_server(panels, **kwargs) + return server diff --git a/panel/io/server.py b/panel/io/server.py index 625afacd33..bc34cebeb3 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -13,15 +13,13 @@ import pathlib import signal import sys -import threading import uuid from contextlib import contextmanager from functools import partial, wraps from html import escape -from types import FunctionType, MethodType from typing import ( - TYPE_CHECKING, Any, Callable, Mapping, Optional, Union, + TYPE_CHECKING, Any, Callable, Mapping, Optional, ) from urllib.parse import urljoin, urlparse @@ -30,7 +28,6 @@ import tornado # Bokeh imports -from bokeh.application import Application as BkApplication from bokeh.application.handlers.function import FunctionHandler from bokeh.core.json_encoder import serialize_json from bokeh.core.templates import AUTOLOAD_JS, FILE, MACROS @@ -40,7 +37,6 @@ from bokeh.embed.elements import script_for_render_items from bokeh.embed.util import RenderItem from bokeh.embed.wrappers import wrap_in_script_tag -from bokeh.models import CustomJS from bokeh.server.server import Server as BokehServer from bokeh.server.urls import per_app_patterns, toplevel_patterns from bokeh.server.views.autoload_js_handler import ( @@ -64,15 +60,13 @@ from ..config import config from ..util import edit_readonly, fullpath from ..util.warnings import warn -from .application import Application, build_single_handler_application +from .application import Application, build_applications from .document import ( # noqa _cleanup_doc, init_doc, unlocked, with_lock, ) from .liveness import LivenessHandler from .loading import LOADING_INDICATOR_CSS_CLASS -from .logging import ( - LOG_SESSION_CREATED, LOG_SESSION_DESTROYED, LOG_SESSION_LAUNCHING, -) +from .logging import LOG_SESSION_CREATED from .reload import record_modules from .resources import ( BASE_TEMPLATE, CDN_DIST, COMPONENT_PATH, ERROR_TEMPLATE, LOCAL_DIST, @@ -80,23 +74,21 @@ ) from .session import generate_session from .state import set_curdoc, state +from .threads import StoppableThread logger = logging.getLogger(__name__) if TYPE_CHECKING: from bokeh.bundle import Bundle from bokeh.core.types import ID - from bokeh.document.document import DocJson, Document + from bokeh.document.document import DocJson from bokeh.server.contexts import BokehSessionContext from bokeh.server.session import ServerSession from jinja2 import Template - from ..template.base import BaseTemplate - from ..viewable import Viewable, Viewer + from .application import TViewableFuncOrPath from .location import Location - TViewable = Union[Viewable, Viewer, BaseTemplate] - TViewableFuncOrPath = Union[TViewable, Callable[[], TViewable], os.PathLike, str] #--------------------------------------------------------------------- # Private API @@ -116,38 +108,6 @@ def _server_url(url: str, port: int) -> str: else: return 'http://%s:%d%s' % (url.split(':')[0], port, "/") -def _eval_panel( - panel: TViewableFuncOrPath, server_id: str, title: str, - location: bool | Location, admin: bool, doc: Document -): - from ..pane import panel as as_panel - from ..template import BaseTemplate - - if config.global_loading_spinner: - doc.js_on_event( - 'document_ready', CustomJS(code=f""" - const body = document.getElementsByTagName('body')[0] - body.classList.remove({LOADING_INDICATOR_CSS_CLASS!r}, {config.loading_spinner!r}) - """) - ) - - doc.on_event('document_ready', partial(state._schedule_on_load, doc)) - - # Set up instrumentation for logging sessions - logger.info(LOG_SESSION_LAUNCHING, id(doc)) - def _log_session_destroyed(session_context): - logger.info(LOG_SESSION_DESTROYED, id(doc)) - doc.on_session_destroyed(_log_session_destroyed) - - with set_curdoc(doc): - if isinstance(panel, (FunctionType, MethodType)): - panel = panel() - if isinstance(panel, BaseTemplate): - doc = panel._modify_doc(server_id, title, doc, location) - else: - doc = as_panel(panel)._modify_doc(server_id, title, doc, location) - return doc - def async_execute(func: Callable[..., None]) -> None: """ Wrap async event loop scheduling to ensure that with_lock flag @@ -817,9 +777,6 @@ def serve( kwargs: dict Additional keyword arguments to pass to Server instance """ - # Empty layout are valid and the Bokeh warning is silenced as usually - # not relevant to Panel users. - silence(EMPTY_LAYOUT, True) kwargs = dict(kwargs, **dict( port=port, address=address, websocket_origin=websocket_origin, loop=loop, show=show, start=start, title=title, verbose=verbose, @@ -891,7 +848,7 @@ def get_server( loop: Optional[IOLoop] = None, show: bool = False, start: bool = False, - title: bool = None, + title: str | dict[str, str] | None = None, verbose: bool = False, location: bool | Location = True, admin: bool = False, @@ -1018,57 +975,29 @@ def get_server( from ..config import config from .rest import REST_PROVIDERS + silence(EMPTY_LAYOUT, True) server_id = kwargs.pop('server_id', uuid.uuid4().hex) - kwargs['extra_patterns'] = extra_patterns = kwargs.get('extra_patterns', []) - if isinstance(panel, dict): - apps = {} - for slug, app in panel.items(): - if slug.endswith('/') and not slug == '/': - raise ValueError(f"Invalid URL: trailing slash '/' used for {slug!r} not supported.") - if isinstance(title, dict): - try: - title_ = title[slug] - except KeyError: - raise KeyError( - "Keys of the title dictionary and of the apps " - f"dictionary must match. No {slug} key found in the " - "title dictionary.") from None - else: - title_ = title - slug = slug if slug.startswith('/') else '/'+slug - if 'flask' in sys.modules: - from flask import Flask - if isinstance(app, Flask): - wsgi = WSGIContainer(app) - if slug == '/': - raise ValueError('Flask apps must be served on a subpath.') - if not slug.endswith('/'): - slug += '/' - extra_patterns.append(('^'+slug+'.*', ProxyFallbackHandler, - dict(fallback=wsgi, proxy=slug))) - continue - if isinstance(app, pathlib.Path): - app = str(app) # enables serving apps from Paths - if (isinstance(app, str) and app.endswith(('.py', '.ipynb', '.md')) - and os.path.isfile(app)): - apps[slug] = app = build_single_handler_application(app) - app._admin = admin - elif isinstance(app, BkApplication): - apps[slug] = app - else: - handler = FunctionHandler(partial(_eval_panel, app, server_id, title_, location, admin)) - apps[slug] = Application(handler, admin=admin) - else: - if isinstance(panel, pathlib.Path): - panel = str(panel) # enables serving apps from Paths - if isinstance(panel, BkApplication): - panel = {'/': app} - elif (isinstance(panel, str) and panel.endswith(('.py', '.ipynb', '.md')) - and os.path.isfile(panel)): - apps = {'/': build_single_handler_application(panel)} - else: - handler = FunctionHandler(partial(_eval_panel, panel, server_id, title, location, admin)) - apps = {'/': Application(handler, admin=admin)} + kwargs['extra_patterns'] = extra_patterns = list(kwargs.get('extra_patterns', [])) + + def flask_handler(slug, app): + if 'flask' not in sys.modules: + return + from flask import Flask + if not isinstance(app, Flask): + return + wsgi = WSGIContainer(app) + if slug == '/': + raise ValueError('Flask apps must be served on a subpath.') + if not slug.endswith('/'): + slug += '/' + extra_patterns.append(( + f'^{slug}.*', ProxyFallbackHandler, dict(fallback=wsgi, proxy=slug) + )) + return True + + apps = build_applications( + panel, title=title, location=location, admin=admin, custom_handlers=(flask_handler,) + ) if warm or config.autoreload: for app in apps.values(): @@ -1177,7 +1106,7 @@ def show_callback(): server.io_loop.add_callback(show_callback) def sig_exit(*args, **kwargs): - server.io_loop.add_callback_from_signal(do_stop) + server.io_loop.asyncio_loop.call_soon_threadsafe(do_stop) def do_stop(*args, **kwargs): server.io_loop.stop() @@ -1199,46 +1128,3 @@ def do_stop(*args, **kwargs): "process invoking the panel.io.server.serve." ) return server - - -class StoppableThread(threading.Thread): - """Thread class with a stop() method.""" - - def __init__(self, io_loop: IOLoop, **kwargs): - super().__init__(**kwargs) - self.io_loop = io_loop - - def run(self) -> None: - if hasattr(self, '_target'): - target, args, kwargs = self._target, self._args, self._kwargs # type: ignore - else: - target, args, kwargs = self._Thread__target, self._Thread__args, self._Thread__kwargs # type: ignore - if not target: - return - bokeh_server = None - try: - bokeh_server = target(*args, **kwargs) - finally: - if isinstance(bokeh_server, Server): - try: - bokeh_server.stop() - except Exception: - pass - if hasattr(self, '_target'): - del self._target, self._args, self._kwargs # type: ignore - else: - del self._Thread__target, self._Thread__args, self._Thread__kwargs # type: ignore - - def stop(self) -> None: - """Signal to stop the event loop gracefully.""" - self.io_loop.add_callback(self._graceful_stop) - - async def _shutdown(self): - tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - self.io_loop.stop() - - def _graceful_stop(self): - self.io_loop.add_callback(self._shutdown) diff --git a/panel/io/threads.py b/panel/io/threads.py new file mode 100644 index 0000000000..610ba94216 --- /dev/null +++ b/panel/io/threads.py @@ -0,0 +1,62 @@ +import asyncio +import threading + +from .state import state + + +class StoppableThread(threading.Thread): + """Thread class with a stop() method.""" + + def __init__(self, io_loop, **kwargs): + super().__init__(**kwargs) + # Backward compatibility to handle Tornado IOLoop + if hasattr(io_loop, 'asyncio_loop'): + io_loop = io_loop.asyncio_loop + self.asyncio_loop = io_loop + self.server_id = kwargs.get('kwargs', {}).get('server_id') + self._shutdown_task = None + + def run(self) -> None: + if hasattr(self, '_target'): + target, args, kwargs = self._target, self._args, self._kwargs # type: ignore + else: + target, args, kwargs = self._Thread__target, self._Thread__args, self._Thread__kwargs # type: ignore + if not target: + return + bokeh_server = None + try: + bokeh_server = target(*args, **kwargs) + finally: + if hasattr(bokeh_server, 'stop'): + # Handle tornado server + try: + bokeh_server.stop() + except Exception: + pass + if hasattr(self, '_target'): + del self._target, self._args, self._kwargs # type: ignore + else: + del self._Thread__target, self._Thread__args, self._Thread__kwargs # type: ignore + + def stop(self) -> None: + if not self.is_alive(): + return + elif self.server_id and self.server_id in state._servers: + server, _, _ = state._servers[self.server_id] + if hasattr(server, 'should_exit'): + server.should_exit = True + while self.is_alive(): + continue + return + if self._shutdown_task: + raise RuntimeError("Thread already stopping") + self._shutdown_task = asyncio.run_coroutine_threadsafe(self._shutdown(), self.asyncio_loop) + + async def _shutdown(self): + cur_task = asyncio.current_task() + tasks = [t for t in asyncio.all_tasks() if t is not cur_task] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + self.asyncio_loop.stop() + self._shutdown_task = None diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index 1d57365aa2..3235a4ae96 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -33,6 +33,7 @@ ) from panel.io.state import set_curdoc, state from panel.pane import HTML, Markdown +from panel.tests.util import get_open_ports from panel.theme import Design CUSTOM_MARKS = ('ui', 'jupyter', 'subprocess', 'docs') @@ -246,11 +247,7 @@ def watch_files(*files): @pytest.fixture def port(): - worker_id = os.environ.get("PYTEST_XDIST_WORKER", "0") - worker_count = int(os.environ.get("PYTEST_XDIST_WORKER_COUNT", "1")) - new_port = PORT[0] + int(re.sub(r"\D", "", worker_id)) - PORT[0] += worker_count - return new_port + return get_open_ports()[0] @pytest.fixture diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 1f0fc0e169..c9e8161aff 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -1,11 +1,14 @@ import asyncio import datetime as dt +import importlib import logging import os import pathlib import time import weakref +from functools import partial + import param import pytest import requests @@ -23,12 +26,28 @@ from panel.param import ParamFunction from panel.reactive import ReactiveHTML from panel.template import BootstrapTemplate -from panel.tests.util import serve_and_request, serve_and_wait, wait_until +from panel.tests.util import ( + get_open_ports, serve_and_request, serve_and_wait, wait_until, +) from panel.widgets import ( Button, Tabulator, Terminal, TextInput, ) +@pytest.fixture(params=["tornado", "fastapi"]) +def server_implementation(request): + try: + importlib.import_module(request.param) + except Exception: + pytest.skip(f'{request.param!r} is not installed') + old = serve_and_wait.server_implementation + serve_and_wait.server_implementation = request.param + try: + yield request.param + finally: + serve_and_wait.server_implementation = old + + @pytest.mark.xdist_group(name="server") def test_get_server(html_server_session): html, server, session, port = html_server_session @@ -80,7 +99,7 @@ def test_server_root_handler(): assert 'href="./app"' in r.content.decode('utf-8') -def test_server_template_static_resources(): +def test_server_template_static_resources(server_implementation): template = BootstrapTemplate() r = serve_and_request({'template': template}, suffix="/static/extensions/panel/bundled/bootstraptemplate/bootstrap.css") @@ -89,6 +108,7 @@ def test_server_template_static_resources(): assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') +#@pytest.mark.parametrize('server_implementation', ["tornado", "fastapi"], indirect=True) def test_server_template_static_resources_with_prefix(): template = BootstrapTemplate() @@ -98,6 +118,7 @@ def test_server_template_static_resources_with_prefix(): assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') +#@pytest.mark.parametrize('server_implementation', ["tornado", "fastapi"], indirect=True) def test_server_template_static_resources_with_prefix_relative_url(): template = BootstrapTemplate() @@ -114,7 +135,7 @@ def test_server_template_static_resources_with_subpath_and_prefix_relative_url() assert f'href="../static/extensions/panel/bundled/bootstraptemplate/bootstrap.css?v={JS_VERSION}"' in r.content.decode('utf-8') -def test_server_extensions_on_root(): +def test_server_extensions_on_root(server_implementation): md = Markdown('# Title') assert serve_and_request(md).ok @@ -129,7 +150,7 @@ def test_autoload_js(port): assert f"http://localhost:{port}/static/extensions/panel/panel.min.js" in r.content.decode('utf-8') -def test_server_async_callbacks(): +def test_server_async_callbacks(server_implementation): button = Button(name='Click') counts = [] @@ -153,7 +174,7 @@ async def cb(event, count=[0]): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_async_local_state(bokeh_curdoc): +def test_server_async_local_state(server_implementation, bokeh_curdoc): docs = {} async def task(): @@ -174,7 +195,7 @@ def app(): wait_until(lambda: all([len(set(docs)) == 1 and docs[0] is doc for doc, docs in docs.items()])) -def test_server_async_local_state_nested_tasks(bokeh_curdoc): +def test_server_async_local_state_nested_tasks(server_implementation, bokeh_curdoc): docs = {} _tasks = set() @@ -200,7 +221,7 @@ def app(): wait_until(lambda: all(len(set(docs)) == 1 and docs[0] is doc for doc, docs in docs.items())) -def test_serve_config_per_session_state(): +def test_serve_config_per_session_state(server_implementation): CSS1 = 'body { background-color: red }' CSS2 = 'body { background-color: green }' def app1(): @@ -208,7 +229,7 @@ def app1(): def app2(): config.raw_css = [CSS2] - port1, port2 = 7001, 7002 + port1, port2 = get_open_ports(n=2) serve_and_wait(app1, port=port1) serve_and_wait(app2, port=port2) @@ -223,7 +244,7 @@ def app2(): assert CSS2 in r2 -def test_server_on_session_created(): +def test_server_on_session_created(server_implementation): session_contexts = [] def append_session(session_context): session_contexts.append(session_context) @@ -236,6 +257,7 @@ def append_session(session_context): assert len(session_contexts) == 3 +#@pytest.mark.parametrize('server_implementation', ["tornado", "fastapi"], indirect=True) def test_server_on_session_destroyed(): session_contexts = [] def append_session(session_context): @@ -281,7 +303,7 @@ def test_server_session_info(): assert state.session_info['live'] == 0 -def test_server_periodic_async_callback(threads): +def test_server_periodic_async_callback(server_implementation, threads): counts = [] async def cb(count=[0]): @@ -301,7 +323,7 @@ def loaded(): wait_until(lambda: len(counts) >= 5 and counts == list(range(len(counts)))) -def test_server_schedule_repeat(): +def test_server_schedule_repeat(server_implementation): state.cache['count'] = 0 def periodic_cb(): state.cache['count'] += 1 @@ -314,7 +336,8 @@ def app(): wait_until(lambda: state.cache['count'] > 0) -def test_server_schedule_threaded(threads): + +def test_server_schedule_threaded(server_implementation, threads): counts = [] def periodic_cb(count=[0]): count[0] += 1 @@ -333,37 +356,40 @@ def app(): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_schedule_at(): +def test_server_schedule_at(server_implementation): def periodic_cb(): state.cache['at'] = dt.datetime.now() - scheduled = dt.datetime.now() + dt.timedelta(seconds=1.57) + scheduled = [] def app(): - state.schedule_task('periodic', periodic_cb, at=scheduled) + scheduled.append(dt.datetime.now() + dt.timedelta(seconds=0.57)) + state.schedule_task('periodic', periodic_cb, at=scheduled[0]) return '# state.schedule test' serve_and_request(app) # Check callback was executed within small margin of error wait_until(lambda: 'at' in state.cache) - assert abs(state.cache['at'] - scheduled) < dt.timedelta(seconds=0.2) + assert abs(state.cache['at'] - scheduled[0]) < dt.timedelta(seconds=0.2) assert len(state._scheduled) == 0 -def test_server_schedule_at_iterator(): +def test_server_schedule_at_iterator(server_implementation): state.cache['at'] = [] def periodic_cb(): state.cache['at'].append(dt.datetime.now()) - scheduled1 = dt.datetime.now() + dt.timedelta(seconds=1.57) - scheduled2 = dt.datetime.now() + dt.timedelta(seconds=1.86) + scheduled = [] def schedule(): - yield scheduled1 - yield scheduled2 + yield from scheduled def app(): + scheduled.extend([ + dt.datetime.now() + dt.timedelta(seconds=0.57), + dt.datetime.now() + dt.timedelta(seconds=0.86) + ]) state.schedule_task('periodic', periodic_cb, at=schedule()) return '# state.schedule test' @@ -371,27 +397,27 @@ def app(): # Check callbacks were executed within small margin of error wait_until(lambda: len(state.cache['at']) == 2) - assert abs(state.cache['at'][0] - scheduled1) < dt.timedelta(seconds=0.2) - assert abs(state.cache['at'][1] - scheduled2) < dt.timedelta(seconds=0.2) + assert abs(state.cache['at'][0] - scheduled[0]) < dt.timedelta(seconds=0.2) + assert abs(state.cache['at'][1] - scheduled[1]) < dt.timedelta(seconds=0.2) assert len(state._scheduled) == 0 -def test_server_schedule_at_callable(): +def test_server_schedule_at_callable(server_implementation): state.cache['at'] = [] def periodic_cb(): state.cache['at'].append(dt.datetime.now()) - scheduled = [ - dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=1.57), - dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=1.86) - ] - siter = iter(scheduled) + scheduled = [] - def schedule(utcnow): + def schedule(siter, utcnow): return next(siter) def app(): - state.schedule_task('periodic', periodic_cb, at=schedule) + scheduled[:] = [ + dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=s) + for s in (0.57, 0.86) + ] + state.schedule_task('periodic', periodic_cb, at=partial(schedule, iter(scheduled))) return '# state.schedule test' serve_and_request(app) @@ -400,12 +426,9 @@ def app(): wait_until(lambda: len(state.cache['at']) == 2) # Convert scheduled times to local time - scheduled = [ - s.replace(tzinfo=dt.timezone.utc).astimezone().replace(tzinfo=None) - for s in scheduled - ] - assert abs(state.cache['at'][0] - scheduled[0]) < dt.timedelta(seconds=0.2) - assert abs(state.cache['at'][1] - scheduled[1]) < dt.timedelta(seconds=0.2) + converted = [s.replace(tzinfo=dt.timezone.utc).astimezone().replace(tzinfo=None) for s in scheduled] + assert abs(state.cache['at'][0] - converted[0]) < dt.timedelta(seconds=0.2) + assert abs(state.cache['at'][1] - converted[1]) < dt.timedelta(seconds=0.2) assert len(state._scheduled) == 0 @@ -487,20 +510,20 @@ def test_multiple_titles(multiple_apps_server_sessions): slugs=('app1', 'app2'), titles={'badkey': 'APP1', 'app2': 'APP2'}) -def test_serve_can_serve_panel_app_from_file(): +def test_serve_can_serve_panel_app_from_file(server_implementation): path = pathlib.Path(__file__).parent / "io"/"panel_app.py" server = get_server({"panel-app": path}) assert "/panel-app" in server._tornado.applications -def test_serve_can_serve_bokeh_app_from_file(): +def test_serve_can_serve_bokeh_app_from_file(server_implementation): path = pathlib.Path(__file__).parent / "io"/"bk_app.py" server = get_server({"bk-app": path}) assert "/bk-app" in server._tornado.applications -def test_server_on_load_after_init_with_threads(threads): +def test_server_on_load_after_init_with_threads(server_implementation, threads): loaded = [] def cb(): @@ -526,7 +549,7 @@ def loaded(): assert loaded == [(doc, False), (doc, True)] -def test_server_on_load_after_init(): +def test_server_on_load_after_init(server_implementation): loaded = [] def cb(): @@ -552,7 +575,7 @@ def loaded(): assert loaded == [(doc, False), (doc, True)] -def test_server_on_load_during_load(threads): +def test_server_on_load_during_load(server_implementation, threads): loaded = [] def cb(): @@ -576,7 +599,7 @@ def loaded(): wait_until(lambda: loaded == [False, False]) -def test_server_thread_pool_on_load(threads): +def test_server_thread_pool_on_load(server_implementation, threads): counts = [] def cb(count=[0]): @@ -602,7 +625,7 @@ def loaded(): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_thread_pool_execute(threads): +def test_server_thread_pool_execute(server_implementation, threads): counts = [] def cb(count=[0]): @@ -622,7 +645,7 @@ def app(): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_thread_pool_defer_load(threads): +def test_server_thread_pool_defer_load(server_implementation, threads): counts = [] def cb(count=[0]): @@ -650,7 +673,7 @@ def loaded(): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_thread_pool_change_event(threads): +def test_server_thread_pool_change_event(server_implementation, threads): button = Button(name='Click') button2 = Button(name='Click') @@ -678,7 +701,7 @@ def cb(event, count=[0]): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_thread_pool_bokeh_event(threads): +def test_server_thread_pool_bokeh_event(server_implementation, threads): import pandas as pd df = pd.DataFrame([[1, 1], [2, 2]], columns=['A', 'B']) @@ -706,7 +729,7 @@ def cb(event, count=[0]): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_thread_pool_periodic(threads): +def test_server_thread_pool_periodic(server_implementation, threads): counts = [] def cb(count=[0]): @@ -755,7 +778,7 @@ def loaded(): wait_until(lambda: len(counts) > 0 and max(counts) > 1) -def test_server_thread_pool_busy(threads): +def test_server_thread_pool_busy(server_implementation, threads): button = Button(name='Click') clicks = [] diff --git a/panel/tests/util.py b/panel/tests/util.py index bb5c14c89c..d7af7ab17a 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -4,6 +4,7 @@ import os import platform import re +import socket import subprocess import sys import time @@ -272,15 +273,36 @@ def get_ctrl_modifier(): raise ValueError(f'No control modifier defined for platform {sys.platform}') +def get_open_ports(n=1): + sockets,ports = [], [] + for _ in range(n): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('127.0.0.1', 0)) + ports.append(s.getsockname()[1]) + sockets.append(s) + for s in sockets: + s.close() + return tuple(ports) + + def serve_and_wait(app, page=None, prefix=None, port=None, **kwargs): server_id = kwargs.pop('server_id', uuid.uuid4().hex) - serve(app, port=port or 0, threaded=True, show=False, liveness=True, server_id=server_id, prefix=prefix or "", **kwargs) + if serve_and_wait.server_implementation == 'fastapi': + from panel.io.fastapi import serve as serve_app + port = port or get_open_ports()[0] + else: + serve_app = serve + serve_app(app, port=port or 0, threaded=True, show=False, liveness=True, server_id=server_id, prefix=prefix or "", **kwargs) wait_until(lambda: server_id in state._servers, page) server = state._servers[server_id][0] - port = server.port + if serve_and_wait.server_implementation == 'fastapi': + port = port + else: + port = server.port wait_for_server(port, prefix=prefix) return port +serve_and_wait.server_implementation = 'tornado' def serve_component(page, app, suffix='', wait=True, **kwargs): msgs = [] diff --git a/pixi.toml b/pixi.toml index e0a72fa368..eb90e7ec52 100644 --- a/pixi.toml +++ b/pixi.toml @@ -112,6 +112,7 @@ pytest-xdist = "*" altair = "*" anywidget = "*" diskcache = "*" +fastapi = "*" folium = "*" ipympl = "*" ipyvuetify = "*" @@ -121,6 +122,9 @@ reacton = "*" scipy = "*" textual = "*" +[feature.test.pypi-dependencies] +bokeh-fastapi = "~=0.1.0a0" + [feature.test-unit-task.tasks] # So it is not showing up in the test-ui environment test-unit = 'pytest panel/tests -n logical --dist loadgroup' test-subprocess = 'pytest panel/tests --subprocess'