diff --git a/panel/io/application.py b/panel/io/application.py index a2034a9272..90efa39693 100644 --- a/panel/io/application.py +++ b/panel/io/application.py @@ -264,4 +264,15 @@ def build_applications( else: handler = FunctionHandler(partial(_eval_panel, app, server_id, title_, location, admin)) apps[slug] = Application(handler, admin=admin) + + if admin: + if '/admin' in apps: + raise ValueError( + 'Cannot enable admin panel because another app is being served ' + 'on the /admin endpoint' + ) + from .admin import admin_panel + admin_handler = FunctionHandler(admin_panel) + apps['/admin'] = Application(admin_handler) + return apps diff --git a/panel/io/fastapi.py b/panel/io/fastapi.py index 957d29005d..a18f443278 100644 --- a/panel/io/fastapi.py +++ b/panel/io/fastapi.py @@ -1,21 +1,25 @@ from __future__ import annotations import asyncio +import socket import uuid from functools import wraps -from typing import TYPE_CHECKING, Mapping, cast +from typing import ( + TYPE_CHECKING, Any, Mapping, cast, +) +from ..config import config from .application import build_applications from .document import _cleanup_doc, extra_socket_handlers from .resources import COMPONENT_PATH -from .server import ComponentResourceHandler +from .server import ComponentResourceHandler, server_html_page_for_session from .state import state from .threads import StoppableThread try: from bokeh_fastapi import BokehFastAPI - from bokeh_fastapi.handler import WSHandler + from bokeh_fastapi.handler import DocHandler, WSHandler from fastapi import ( FastAPI, HTTPException, Query, Request, ) @@ -34,6 +38,9 @@ # Private API #--------------------------------------------------------------------- +DocHandler.render_session = server_html_page_for_session + + def dispatch_fastapi(conn, events=None, msg=None): if msg is None: msg = conn.protocol.create("PATCH-DOC", events) @@ -61,6 +68,11 @@ async def liveness_handler(request: Request, endpoint: str | None = Query(None)) else: return {str(request.url.path): True} +def add_history_handler(app, endpoint): + @app.get(endpoint, response_model=dict[str, int | dict[str, Any]]) + async def history_handler(request: Request): + return state.session_info + #--------------------------------------------------------------------- # Public API #--------------------------------------------------------------------- @@ -71,6 +83,7 @@ def add_applications( title: str | dict[str, str] | None = None, location: bool | Location = True, admin: bool = False, + session_history: int | None = None, liveness: bool | str = False, **kwargs ): @@ -92,6 +105,11 @@ def add_applications( set the URL location. admin: boolean (default=False) Whether to enable the admin panel + 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. 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 @@ -100,7 +118,15 @@ def add_applications( Additional keyword arguments to pass to the BokehFastAPI application """ apps = build_applications(panel, title=title, location=location, admin=admin) + ws_origins = kwargs.pop('websocket_origin', []) + if ws_origins and not isinstance(ws_origins, list): + ws_origins = [ws_origins] + kwargs['websocket_origins'] = ws_origins + application = BokehFastAPI(apps, app=app, **kwargs) + if session_history is not None: + config.session_history = session_history + add_history_handler(application.app, endpoint='/session_info') if liveness: liveness_endpoint = liveness if isinstance(liveness, str) else '/liveness' add_liveness_handler(application.app, endpoint=liveness_endpoint, applications=apps) @@ -165,6 +191,7 @@ def wrapper(*args, **kwargs): def get_server( panel: TViewableFuncOrPath | Mapping[str, TViewableFuncOrPath], port: int | None = 0, + show: bool = True, start: bool = False, title: str | dict[str, str] | None = None, location: bool | Location = True, @@ -181,6 +208,8 @@ def get_server( dictionary mapping from the URL slug to either. port: int (optional, default=0) Allows specifying a specific port. + show : boolean (optional, default=True) + Whether to open the server in a new browser tab on start start : boolean(optional, default=False) Whether to start the Server. title : str or {str: str} (optional, default=None) @@ -195,8 +224,11 @@ def get_server( 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. + 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. **kwargs: Additional keyword arguments to pass to the BokehFastAPI application """ @@ -209,26 +241,49 @@ def get_server( "panel.io.fastapi.add_applications API." ) from e + address = kwargs.pop('address', None) + if not port: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('127.0.0.1', 0)) # Bind to any available port + port = sock.getsockname()[1] # Get the dynamically assigned port + sock.close() + loop = kwargs.pop('loop') + config_kwargs = {} if loop: + config_kwargs['loop'] = 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) + if show: + @application.app.on_event('startup') + def show_callback(): + prefix = kwargs.get('prefix', '') + address_string = 'localhost' + if address is not None and address != '': + address_string = address + url = f"http://{address_string}:{config.port}{prefix}" + from bokeh.util.browser import view + view(url, new='tab') + + config = uvicorn.Config(application.app, port=port, **config_kwargs) 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() + if not start: + return server + + if loop: + try: + loop.run_until_complete(server.serve()) + except asyncio.CancelledError: + pass + else: + server.run() + return server @@ -241,10 +296,10 @@ def serve( show: bool = True, start: bool = True, title: str | None = None, - verbose: bool = True, location: bool = True, threaded: bool = False, admin: bool = False, + session_history: int | None = None, liveness: bool | str = False, **kwargs ) -> StoppableThread | Server: @@ -283,8 +338,6 @@ def serve( 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. @@ -296,6 +349,11 @@ def serve( 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. + 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. kwargs: dict Additional keyword arguments to pass to Server instance """ @@ -303,8 +361,9 @@ def serve( # 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 + loop=loop, show=show, start=start, title=title, + location=location, admin=admin, liveness=liveness, + session_history=session_history )) if threaded: # To ensure that we have correspondence between state._threads and state._servers diff --git a/panel/io/server.py b/panel/io/server.py index bc34cebeb3..4ee02f5d6f 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -28,7 +28,6 @@ import tornado # Bokeh imports -from bokeh.application.handlers.function import FunctionHandler from bokeh.core.json_encoder import serialize_json from bokeh.core.templates import AUTOLOAD_JS, FILE, MACROS from bokeh.core.validation import silence @@ -60,7 +59,7 @@ from ..config import config from ..util import edit_readonly, fullpath from ..util.warnings import warn -from .application import Application, build_applications +from .application import build_applications from .document import ( # noqa _cleanup_doc, init_doc, unlocked, with_lock, ) @@ -1000,7 +999,9 @@ def flask_handler(slug, app): ) if warm or config.autoreload: - for app in apps.values(): + for endpoint, app in apps.items(): + if endpoint == '/admin': + continue if config.autoreload: with record_modules(list(apps.values())): session = generate_session(app) @@ -1010,16 +1011,6 @@ def flask_handler(slug, app): state._on_load(None) _cleanup_doc(session.document, destroy=True) - if admin: - if '/admin' in apps: - raise ValueError( - 'Cannot enable admin panel because another app is being served ' - 'on the /admin endpoint' - ) - from .admin import admin_panel - admin_handler = FunctionHandler(admin_panel) - apps['/admin'] = Application(admin_handler) - extra_patterns += get_static_routes(static_dirs) if session_history is not None: