holoviz/panel · #8828
feat: Deprecate REST providers and pn.state.publish in favor of plugins
doc/how_to/server/commandline.md6 + / 4 −
@@ -121,10 +121,12 @@ options: --basic-login-template BASIC_LOGIN_TEMPLATE Template to serve for Basic Authentication login page. --rest-provider REST_PROVIDER- The interface to use to serve REST API+ Deprecated, will be removed in 2.0. The interface to use to serve REST API.+ Declare custom endpoints with --plugins instead. --rest-endpoint REST_ENDPOINT- Endpoint to store REST API on.- --rest-session-info Whether to serve session info on the REST API+ Deprecated, will be removed in 2.0. Endpoint to store REST API on, defaults+ to 'rest'.+ --rest-session-info Whether to serve session info on the /session_info endpoint. --session-history SESSION_HISTORY The length of the session history to record. --warm Whether to execute scripts on startup to warm up the server.@@ -172,7 +174,7 @@ pip install uvicorn fastapi The ASGI implementations support the same options as the Tornado server, with the exception of a few options that are implemented as Tornado request handlers or rely on Tornado specific functionality, and will error out if you supply them: -- `--rest-provider` and `--rest-session-info`+- `--rest-provider`, which is deprecated, see [Add custom endpoints](endpoints) for the replacement - `--enable-xsrf-cookies` - `--num-procs` (run multiple uvicorn processes behind a load balancer instead) - `--plugins` on `--server asgi`. On `--server fastapi` the plugin module must declare a FastAPI `APIRouter` instead of Tornado request handlers, see [Add custom endpoints](endpoints).doc/how_to/server/endpoints.md92 + / 0 −
@@ -90,6 +90,98 @@ panel serve --server fastapi --plugins plugin Everything an `APIRouter` supports is available here, including websocket routes, dependencies, background tasks and Pydantic request and response models. Panel keeps ownership of the paths it serves, i.e. the application routes, `/static/*` and the authentication endpoints, so a plugin cannot shadow those. The index page and `/favicon.ico` on the other hand are served by Panel only as a convenience, so declaring them yourself takes precedence. +## Sharing state with the applications++The plugin is imported once into the same process that runs the applications, so an endpoint can read and modify state the applications display. Put the state in a module that both import; Python imports a module only once, so they share the same object:++```python+# shared.py+import param++class Settings(param.Parameterized):+ threshold = param.Number(default=0.5, bounds=(0, 1))++settings = Settings()+```++The application binds a widget to it, so every session shows and edits the same value:++```python+# app.py+import panel as pn++from shared import settings++pn.Column(+ pn.widgets.FloatSlider.from_param(settings.param.threshold),+ pn.bind(lambda t: f'Threshold: {t}', settings.param.threshold),+).servable()+```++The plugin exposes the same value over HTTP. Here it is declared for both server implementations:++```python+# plugin.py+import json++from fastapi import APIRouter, HTTPException+from tornado.web import HTTPError, RequestHandler++from shared import settings++def update(threshold):+ try:+ settings.threshold = threshold+ except ValueError as e:+ raise HTTPError(400, str(e)) from e++class SettingsHandler(RequestHandler):++ def get(self):+ self.write({'threshold': settings.threshold})++ def put(self):+ update(json.loads(self.request.body)['threshold'])+ self.write({'threshold': settings.threshold})++ROUTES = [('/api/settings', SettingsHandler, {})]++ROUTER = APIRouter(prefix='/api')++@ROUTER.get('/settings')+def get_settings():+ return {'threshold': settings.threshold}++@ROUTER.put('/settings')+def put_settings(threshold: float):+ try:+ settings.threshold = threshold+ except ValueError as e:+ raise HTTPException(400, str(e)) from e+ return {'threshold': settings.threshold}+```++```bash+panel serve app.py --plugins plugin+curl -X PUT -d '{"threshold": 0.8}' http://localhost:5006/api/settings+```++Every open session updates to the new threshold. With `--server fastapi` the FastAPI route takes `threshold` as a query parameter, i.e. `curl -X PUT "http://localhost:5006/api/settings?threshold=0.8"`.++This replaces `pn.state.publish` and `--rest-provider`, which are deprecated and will be removed in Panel 2.0. Unlike those, the endpoint decides which parameters are exposed, which HTTP methods modify them and how the response looks.++Custom endpoints are not covered by Panel's authentication. On Tornado, subclass `bokeh.server.views.auth_request_handler.AuthRequestHandler` instead of `RequestHandler` and decorate the methods with `tornado.web.authenticated`, which rejects requests that Panel's configured authentication does not resolve to a user. On FastAPI, protect the routes with a [dependency](https://fastapi.tiangolo.com/tutorial/security/).++## Serving session information++Panel can also serve information about the sessions it has created, i.e. the contents of `pn.state.session_info`, as JSON on `/session_info`. Enable it with `--rest-session-info` and set `--session-history` to the number of sessions to keep:++```bash+panel serve app.py --rest-session-info --session-history 100+```++The Tornado server also serves this on `/rest/session_info`, nested under a `session_info` key. That route is deprecated and will be removed in Panel 2.0.+ ## Using `pn.serve` When serving programmatically you can provide handlers explicitly using the `extra_patterns` argument, e.g. you can provide the `SumHandler` by running:doc/tutorials/intermediate/serve.md5 + / 3 −
@@ -240,10 +240,12 @@ options: --basic-login-template BASIC_LOGIN_TEMPLATE Template to serve for Basic Authentication login page. --rest-provider REST_PROVIDER- The interface to use to serve REST API+ Deprecated, will be removed in 2.0. The interface to use to serve REST API.+ Declare custom endpoints with --plugins instead. --rest-endpoint REST_ENDPOINT- Endpoint to store REST API on.- --rest-session-info Whether to serve session info on the REST API+ Deprecated, will be removed in 2.0. Endpoint to store REST API on, defaults+ to 'rest'.+ --rest-session-info Whether to serve session info on the /session_info endpoint. --session-history SESSION_HISTORY The length of the session history to record. --warm Whether to execute scripts on startup to warm up the server.doc/upgrade.md35 + / 1 −
@@ -101,10 +101,44 @@ The `routing.py` module, the `bokeh_apps` urlpatterns and `STATICFILES_DIRS = [b panel serve app.py --server fastapi ``` -The choices are `tornado` (the default, unchanged), `fastapi` and `asgi`, the latter running Panel's ASGI application under uvicorn without importing FastAPI. The ASGI implementations support everything the Tornado server does apart from `--rest-provider`, `--rest-session-info`, `--enable-xsrf-cookies` and `--num-procs`, each of which fails with an error naming the option. Authentication, including all the OAuth providers, works on either implementation and the cookies are interoperable, so you can move between them without invalidating existing sessions.+The choices are `tornado` (the default, unchanged), `fastapi` and `asgi`, the latter running Panel's ASGI application under uvicorn without importing FastAPI. The ASGI implementations support everything the Tornado server does apart from the deprecated `--rest-provider`, `--enable-xsrf-cookies` and `--num-procs`, each of which fails with an error naming the option. Authentication, including all the OAuth providers, works on either implementation and the cookies are interoperable, so you can move between them without invalidating existing sessions. `--plugins` is supported on `--server fastapi`, but since the endpoints are no longer Tornado request handlers the plugin module has to declare a FastAPI `APIRouter` in a `ROUTER` variable rather than a list of `ROUTES`. A module may declare both and then be served on either implementation. See [Add custom endpoints](how_to/server/endpoints) for the details. +### Deprecations++#### REST providers and `pn.state.publish`++`--rest-provider`, `--rest-endpoint`, `pn.state.publish` and the `panel.io.rest` module, including the `panel.io.rest` entry point for third-party providers, are deprecated and will be removed in Panel 2.0. Both built-in providers had problems: Tranquilizer is no longer maintained and requires a Werkzeug version older than 2.1, so it cannot be installed alongside current Flask releases, and the `param` provider failed with a server error whenever a request tried to set a parameter. Neither worked on the ASGI servers.++Declare the endpoints in a plugin module instead, as described in [Add custom endpoints](how_to/server/endpoints). A function decorated with `@tranquilize` usually maps directly onto a FastAPI route, which validates the arguments from the type annotations in the same way:++```python+# Before, served with: panel serve app.py --rest-provider tranquilizer+from tranquilizer import tranquilize++@tranquilize()+def order(cost: float, count: int):+ return {'total': cost * count}++# Now, in plugin.py, served with: panel serve app.py --server fastapi --plugins plugin+from fastapi import APIRouter++ROUTER = APIRouter(prefix='/rest')++@ROUTER.get('/order')+def order(cost: float, count: int):+ return {'total': cost * count}+```++To replace `pn.state.publish`, move the published `Parameterized` into a module that both the application and the plugin import, and expose the parameters from the plugin. [Sharing state with the applications](how_to/server/endpoints.md#sharing-state-with-the-applications) has a complete example.++#### `/rest/session_info`++`--rest-session-info` and `pn.serve(session_history=...)` now serve the session information on `/session_info` on every server implementation, returning the contents of `pn.state.session_info` directly. The Tornado server also keeps serving it on `/rest/session_info`, nested under a `session_info` key as before, but that route is deprecated and will be removed in Panel 2.0.++Neither route requires authentication, even when `--basic-auth` or `--oauth-provider` is configured, and the same applies to `/liveness`. The session information includes timestamps and user agents for each session, so if the server is publicly reachable, restrict access to these routes at the reverse proxy or leave `--rest-session-info` disabled.+ ## Version 1.0 The 1.0 release brings a wealth of improvements compared to the 0.x series, including significant changes to the layout engine and an improved approach to handling CSS for individual components. These improvements are thanks to the new Bokeh 3.x releases, which received a bottom-up rewrite of layouts and CSS handling. These updates not only boost the performance but also elevate the customizability of your Panel apps.panel/command/serve.py27 + / 14 −
@@ -46,10 +46,12 @@ from ..io.resources import DIST_DIR from ..io.rest import REST_PROVIDERS from ..io.server import (- INDEX_HTML, admin_auth_patterns, get_static_routes, set_curdoc,+ INDEX_HTML, admin_auth_patterns, get_session_info_routes,+ get_static_routes, set_curdoc, ) from ..io.state import state from ..util import edit_readonly, fullpath+from ..util.warnings import deprecated if t.TYPE_CHECKING: import argparse@@ -276,17 +278,18 @@ class Serve(_BkServe): ('--rest-provider', Argument( action = 'store', type = str,- help = "The interface to use to serve REST API"+ help = ("Deprecated, will be removed in 2.0. The interface to use "+ "to serve REST API. Declare custom endpoints with --plugins instead.") )), ('--rest-endpoint', Argument( action = 'store', type = str,- help = "Endpoint to store REST API on.",- default = 'rest'+ help = ("Deprecated, will be removed in 2.0. Endpoint to store REST "+ "API on, defaults to 'rest'.") )), ('--rest-session-info', Argument( action = 'store_true',- help = "Whether to serve session info on the REST API"+ help = "Whether to serve session info on the /session_info endpoint." )), ('--session-history', Argument( action = 'store',@@ -399,8 +402,6 @@ class Serve(_BkServe): # Arguments which are implemented with Tornado request handlers and # therefore cannot be served by the ASGI implementations. _tornado_only_args: t.ClassVar[dict[str, str]] = {- 'rest_provider': '--rest-provider',- 'rest_session_info': '--rest-session-info', 'enable_xsrf_cookies': '--enable-xsrf-cookies', } @@ -416,6 +417,11 @@ class Serve(_BkServe): 'unused_session_lifetime_milliseconds', ) + _rest_deprecation: t.ClassVar[str] = (+ "Declare custom endpoints in a --plugins module instead, see "+ "https://panel.holoviz.org/how_to/server/endpoints.html"+ )+ # Supported file extensions _extensions = ['.py', '.ipynb', '.md'] @@ -858,11 +864,15 @@ def _tornado_routes(self, args, kwargs): kwargs['extra_patterns'] = patterns = kwargs.get('extra_patterns', []) patterns += get_static_routes(self._static_dirs) - # Handle tranquilized functions in the supplied functions- if args.rest_provider in REST_PROVIDERS:- patterns.extend(REST_PROVIDERS[args.rest_provider](self._files, args.rest_endpoint))- elif args.rest_provider is not None:- raise ValueError(f"rest-provider {args.rest_provider!r} not recognized.")+ if args.rest_endpoint is not None:+ deprecated('2.0', '--rest-endpoint', extra=self._rest_deprecation)+ if args.rest_provider is not None:+ deprecated('2.0', '--rest-provider', extra=self._rest_deprecation)+ if args.rest_provider not in REST_PROVIDERS:+ raise ValueError(f"rest-provider {args.rest_provider!r} not recognized.")+ patterns.extend(REST_PROVIDERS[args.rest_provider](+ self._files, args.rest_endpoint or 'rest'+ )) if args.liveness: argvs = {f: args.args for f in self._files}@@ -874,8 +884,7 @@ def _tornado_routes(self, args, kwargs): patterns.extend(admin_auth_patterns(self._admin_path)) if args.rest_session_info:- patterns.extend(REST_PROVIDERS['param'](self._files, 'rest'))- state.publish('session_info', state, ['session_info'])+ patterns.extend(get_session_info_routes()) for name, module in self._load_plugins(args): patterns.extend(self._plugin_routes(name, module))@@ -1046,6 +1055,9 @@ def _invoke_asgi(self, args: argparse.Namespace) -> None: log.info(f"Using override config file: {args.use_config}") settings.load_config(args.use_config) + if args.rest_provider is not None:+ die(f"--rest-provider is deprecated and not supported with --server "+ f"{args.server}. {self._rest_deprecation}") for arg, flag in self._tornado_only_args.items(): if getattr(args, arg, None): die(f"{flag} is implemented with Tornado request handlers and "@@ -1085,6 +1097,7 @@ def _invoke_asgi(self, args: argparse.Namespace) -> None: redirect_root=not args.disable_index_redirect, static_dirs=self._static_dirs, liveness=args.liveness_endpoint if args.liveness else False,+ session_info=args.rest_session_info, **asgi_kwargs ) if admin_context is not None:panel/io/asgi.py8 + / 3 −
@@ -140,8 +140,10 @@ class PanelASGI(BokehASGI): mem_log_frequency_milliseconds: int How often to log memory usage, 0 to disable. Requires psutil. session_history: int | None- If set, enables session history tracking and serves it on- ``/session_info``.+ If set, enables session history tracking.+ session_info: bool | None+ Whether to serve ``state.session_info`` on ``/session_info``.+ Defaults to serving it whenever session history is enabled. extra_routes: Sequence[tuple[str, RouteHandler]] | None Additional routes as ``(pattern, handler)`` pairs. Patterns are regular expressions matched against the prefix-relative path.@@ -168,6 +170,7 @@ def __init__( liveness: bool | str = False, mem_log_frequency_milliseconds: int = 0, session_history: int | None = None,+ session_info: bool | None = None, extra_routes: Sequence[tuple[str, RouteHandler]] | None = None, auth_provider: AuthProvider | None = None, auth_policy: AuthPolicy | None = None,@@ -193,7 +196,9 @@ def __init__( self._mem_log_task: asyncio.Task | None = None if session_history is not None: config.session_history = session_history- if config.session_history != 0:+ if session_info is None:+ session_info = config.session_history != 0+ if session_info: self._add_route(r'/session_info/?$', self._session_info) if liveness: endpoint = liveness if isinstance(liveness, str) else '/liveness'panel/io/rest.py8 + / 0 −
@@ -1,3 +1,11 @@+"""+REST providers served by ``panel serve --rest-provider``.++.. deprecated:: 1.10+ This module, ``--rest-provider`` and ``pn.state.publish`` will be+ removed in Panel 2.0. Declare custom endpoints in a ``--plugins``+ module instead.+""" import json import os import tempfilepanel/io/server.py34 + / 7 −
@@ -7,6 +7,7 @@ import datetime as dt import importlib import inspect+import json import logging import os import pathlib@@ -1407,6 +1408,36 @@ def prepare(self): self.on_finish() +class SessionInfoHandler(RequestHandler):+ """+ Serves ``state.session_info`` as JSON, matching ``PanelASGI``'s+ ``/session_info`` endpoint.+ """++ def initialize(self, legacy: bool = False):+ # The deprecated /rest/session_info route was served by the param+ # REST provider, which nested the value under its parameter name.+ self.legacy = legacy++ def get(self):+ info = {'session_info': state.session_info} if self.legacy else state.session_info+ self.set_header('Content-Type', 'application/json')+ self.write(json.dumps(info))++ def head(self):+ self.set_header('Content-Type', 'application/json')+++def get_session_info_routes():+ """+ Returns the Tornado routes serving ``state.session_info``.+ """+ return [+ (r"/session_info/?", SessionInfoHandler, {}),+ (r"/rest/session_info/?", SessionInfoHandler, {'legacy': True}),+ ]++ def get_static_routes(static_dirs): """ Returns a list of tornado routes of StaticFileHandlers given a@@ -1543,9 +1574,8 @@ def get_server( authentication is enabled. session_history: int (optional, default=None) The amount of session history to accumulate. If set to non-zero- and non-None value will launch a REST endpoint at- /rest/session_info, which returns information about the session- history.+ and non-None value will launch an endpoint at /session_info,+ which returns information about the session history. 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@@ -1562,7 +1592,6 @@ def get_server( Bokeh Server instance running this panel """ from ..config import config- from .rest import REST_PROVIDERS silence(EMPTY_LAYOUT, True) server_id = kwargs.pop('server_id', uuid.uuid4().hex)@@ -1618,9 +1647,7 @@ def flask_handler(slug, app): if session_history is not None: config.session_history = session_history if config.session_history != 0:- pattern = REST_PROVIDERS['param']([], 'rest')- extra_patterns.extend(pattern)- state.publish('session_info', state, ['session_info'])+ extra_patterns += get_session_info_routes() if liveness: liveness_endpoint = 'liveness' if isinstance(liveness, bool) else livenesspanel/io/state.py12 + / 0 −
@@ -32,6 +32,7 @@ from pyviz_comms import CommManager as _CommManager from ..util import decode_token, edit_readonly, parse_timedelta+from ..util.warnings import deprecated from .logging import LOG_SESSION_RENDERED, LOG_USER_MSG _state_logger = logging.getLogger('panel.state')@@ -1020,6 +1021,10 @@ def publish( """ Publish parameters on a Parameterized object as a REST API. + .. deprecated:: 1.10+ Will be removed in Panel 2.0 along with ``--rest-provider``.+ Declare custom endpoints in a ``--plugins`` module instead.+ Parameters ---------- endpoint: str@@ -1029,6 +1034,13 @@ def publish( parameters: list(str) or None A subset of parameters on the Parameterized to publish. """+ deprecated(+ '2.0', 'pn.state.publish',+ extra=(+ "Declare custom endpoints in a --plugins module instead, see "+ "https://panel.holoviz.org/how_to/server/endpoints.html"+ )+ ) if parameters is None: parameters = list(parameterized.param) if endpoint.startswith('/'):panel/tests/command/test_serve.py26 + / 1 −
@@ -314,6 +314,32 @@ def test_serve_liveness_endpoint(server, tmp_path): assert r.status_code == 400 +@linux_only+def test_serve_session_info_endpoint(server, tmp_path):+ app = "import panel as pn; pn.Row('# Example').servable(title='A')"+ py = tmp_path / "app.py"+ py.write_text(app)++ args = ["--port", "0", "--server", server, py, "--rest-session-info", "--session-history", "5"]+ with run_panel_serve(args, cwd=tmp_path) as p:+ port = wait_for_port(p.stdout)+ assert requests.get(f"http://localhost:{port}/app").status_code == 200+ r = requests.get(f"http://localhost:{port}/session_info")+ assert r.status_code == 200+ assert r.json()['total'] == 1+++@linux_only+def test_serve_rest_provider_deprecated(tmp_path):+ app = "import panel as pn; pn.Row('# Example').servable(title='A')"+ py = tmp_path / "app.py"+ py.write_text(app)++ regex = re.compile(r"('--rest-provider' is deprecated)")+ with run_panel_serve(["--port", "0", py, "--rest-provider", "param"], cwd=tmp_path) as p:+ wait_for_regex(p.stdout, regex=regex)++ @linux_only def test_serve_prefix(server, tmp_path): app = "import panel as pn; pn.Row('# Example').servable(title='A')"@@ -400,7 +426,6 @@ def test_serve_page_config(server, tmp_path): @pytest.mark.parametrize('args', [ ['--plugins', 'some.module'], ['--rest-provider', 'param'],- ['--rest-session-info'], ['--enable-xsrf-cookies'], ['--num-procs', '2'], ])panel/tests/command/test_serve_asgi.py26 + / 8 −
@@ -15,6 +15,7 @@ import uvicorn from bokeh.server.tornado import DEFAULT_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES+from starlette.testclient import TestClient from panel.command import serve as serve_module from panel.command.serve import Serve@@ -103,19 +104,36 @@ def write(name, source): return write -@pytest.mark.parametrize('args', [- ['--rest-provider', 'param'],- ['--rest-session-info'],- ['--enable-xsrf-cookies'],-])-def test_serve_asgi_rejects_tornado_only_args(invoke_asgi, args, capsys):+def test_serve_asgi_rejects_tornado_only_args(invoke_asgi, capsys): with pytest.raises(SystemExit):- invoke_asgi(*args)+ invoke_asgi('--enable-xsrf-cookies') err = capsys.readouterr().err- assert args[0] in err+ assert '--enable-xsrf-cookies' in err assert '--server tornado' in err +def test_serve_asgi_rejects_rest_provider(invoke_asgi, capsys):+ with pytest.raises(SystemExit):+ invoke_asgi('--rest-provider', 'param')+ err = capsys.readouterr().err+ assert '--rest-provider is deprecated' in err+ assert '--plugins' in err+++def test_serve_asgi_session_info(invoke_asgi):+ asgi = _panel_asgi(invoke_asgi('--rest-session-info', '--session-history', '5'))+ with TestClient(asgi) as client:+ r = client.get('/session_info')+ assert r.status_code == 200+ assert r.json() == state.session_info+++def test_serve_asgi_session_history_does_not_serve_session_info(invoke_asgi):+ asgi = _panel_asgi(invoke_asgi('--session-history', '5'))+ with TestClient(asgi) as client:+ assert client.get('/session_info').status_code == 404++ def test_serve_asgi_rejects_num_procs(invoke_asgi, capsys): with pytest.raises(SystemExit): invoke_asgi('--num-procs', '2')panel/tests/io/test_rest.pyadded18 + / 0 −
@@ -0,0 +1,18 @@+import param+import pytest++from panel.io.state import state+from panel.util.warnings import PanelDeprecationWarning+++class Published(param.Parameterized):++ value = param.Integer(default=1)+++def test_state_publish_is_deprecated():+ try:+ with pytest.warns(PanelDeprecationWarning, match="'pn.state.publish' is deprecated"):+ state.publish('deprecated', Published())+ finally:+ state._rest_endpoints.pop('deprecated', None)panel/tests/test_server.py27 + / 0 −
@@ -478,6 +478,33 @@ def test_server_session_info(): assert state.session_info['live'] == 0 +def test_server_session_info_endpoint(server_implementation):+ try:+ port = serve_and_wait(Markdown('# Title'), session_history=5)+ r = requests.get(f"http://127.0.0.1:{port}/session_info", timeout=10)+ finally:+ config.session_history = 0+ assert r.status_code == 200+ assert r.headers['Content-Type'].startswith('application/json')+ assert r.json() == state.session_info+++def test_server_session_info_legacy_endpoint():+ try:+ port = serve_and_wait(Markdown('# Title'), session_history=5)+ r = requests.get(f"http://127.0.0.1:{port}/rest/session_info", timeout=10)+ finally:+ config.session_history = 0+ assert r.status_code == 200+ assert r.json() == {'session_info': state.session_info}+++def test_server_no_session_info_endpoint_by_default(server_implementation):+ port = serve_and_wait(Markdown('# Title'))+ r = requests.get(f"http://127.0.0.1:{port}/session_info", timeout=10)+ assert r.status_code == 404++ def test_server_periodic_async_callback(server_implementation, threads): counts = []