Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to define custom tornado handler plugins #2752

Merged
merged 9 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions doc/how_to/server/endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Add custom endpoints to the Panel Server

The Panel server is built on top of Tornado, which is a general framework for building performant web applications. This means it is very straightforward to add custom endpoints to serve as API endpoints for the application or to perform anything else we might want to do.

## Declaring a new endpoint

To add a new endpoint to our server we have to implement a so called [Tornado `RequestHandler`](https://www.tornadoweb.org/en/stable/web.html). A `RequestHandler` implements has to implement one or more methods corresponding to a so called HTTP verb method. The most common of these are:

- `.get`: Handles HTTP GET requests
- `.post`: Handles HTTP POST requests
- `.head`: Handles HTTP HEAD requests

As a very simple example we might implement a GET request that sums up numbers:

```python
from tornado.web import RequestHandler, HTTPError

class SumHandler(RequestHandler):

def get(self):
values = [self.get_argument(arg) for arg in self.request.arguments]
if not all(arg.isdigit() for arg in values):
raise HTTPError(400, 'Arguments were not all numbers.')
self.set_header('Content-Type', 'text/plain')
self.write(str(sum([int(v) for v in values])))

ROUTES = [('/sum', SumHandler, {})]
```

This `RequestHandler` does a few things:

1. Get the values of all request arguments
2. Validate the input by check if they are all numeric digits
3. Set the `Content-Type` header to declare we are returning text
4. Sum the values and return the `write` the result as a string

Lastly, a valid Panel server plugin must also declares the `ROUTES` to add to the server. In this case we will declare that our handler should be served on the route `/sum`.

Now let's try this handler, write it to a local file called `plugin.py` and then run:

```bash
panel serve --plugins plugin
```

A Panel server will start serving our new endpoint, which means we can visit `http://localhost:5006/sum` which should display zero.

If we add some request arguments we can actually see it summing our data:

```bash
>>> curl http://localhost:5006/sum?a=1&b=3&c=39
42
```

## Using `pn.serve`

When serving from the commandline you can provide handlers explicitly using the `extra_patterns` argument, e.g. you can provide the `SumHandler` by running:

```python
import panel as pn

from plugin import SumHandler

pn.serve({}, extra_patterns=[('/sum', SumHandler)])
```
8 changes: 8 additions & 0 deletions doc/how_to/server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ Discover how to access a Panel deployment running remotely via SSH.
Discover how to serve static files alongside your Panel application(s).
:::

:::{grid-item-card} {octicon}`plus-circle;2.5em;sd-mr-1` Add custom endpoints
:link: endpoints
:link-type: doc

Discover how to add custom endpoints to your Panel server.
:::

::::

```{toctree}
Expand All @@ -70,4 +77,5 @@ multiple
ssh
proxy
static_files
endpoints
```
38 changes: 38 additions & 0 deletions panel/command/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@
Subclasses the bokeh serve commandline handler to extend it in various
ways.
"""
from __future__ import annotations

import argparse
import ast
import base64
import contextlib
import importlib
import logging
import os
import pathlib
import sys

from collections.abc import Iterator
from glob import glob
from types import ModuleType

Expand Down Expand Up @@ -40,6 +44,16 @@

log = logging.getLogger(__name__)

@contextlib.contextmanager
def add_sys_path(path: str | os.PathLike) -> Iterator[None]:
"""Temporarily add the given path to `sys.path`."""
path = os.fspath(path)
try:
sys.path.insert(0, path)
yield
finally:
sys.path.remove(path)

def parse_var(s):
"""
Parse a key, value pair, separated by '='
Expand Down Expand Up @@ -264,6 +278,10 @@ class Serve(_BkServe):
help = "The endpoint for the liveness API.",
default = "liveness"
)),
('--plugins', dict(
action = 'append',
type = str
)),
('--reuse-sessions', Argument(
action = 'store_true',
help = "Whether to reuse sessions when serving the initial request.",
Expand Down Expand Up @@ -547,6 +565,26 @@ def setup_file():
"Supply OAuth provider either using environment variable "
"or via explicit argument, not both."
)

for plugin in (args.plugins or []):
try:
with add_sys_path('./'):
plugin_module = importlib.import_module(plugin)
except ModuleNotFoundError as e:
raise Exception(
f'Specified plugin module {plugin!r} could not be found. '
'Ensure the module exists and is in the right path. '
) from e
try:
routes = plugin_module.ROUTES
except AttributeError as e:
raise Exception(
f'The plugin module {plugin!r} does not declare '
'a ROUTES variable. Ensure that the module provides '
'a list of ROUTES to serve.'
) from e
patterns.extend(routes)

if args.oauth_provider:
config.oauth_provider = args.oauth_provider
if config.oauth_provider:
Expand Down
Loading