holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.81k stars 520 forks source link

Panel App in FastAPI Router #7514

Open Amiga501 opened 1 day ago

Amiga501 commented 1 day ago

[Originally posted here and raising as issue on recommendation from @ahuang11 ]

Hi all,

Was trying Running Panel apps in FASTAPI and have the example up and running. But in a more real world use case, I would need to run it within routers - even if just for source code structuring.

Taking Marc's example and extending it as below:

import panel as pn

from fastapi import APIRouter, FastAPI
from panel.io.fastapi import add_application

app = FastAPI()

router = APIRouter()

@app.get("/")
async def read_root():
    return {"Hello": "World"}

@add_application('/panel_1', app=app, title='My Panel App')
def create_panel_app():    
    """!
    Create a dummy panel application over a FastAPI interface

    See here: https://github.com/holoviz/panel/issues/7338
    For making API args accessible within the method

    """
    slider_1 = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3)

    if pn.state.location:
        # To populate slider_value in the API call, use a string like
        # /panel?slider_1_value=6&slider_A_value=3
        # If the GET params are not entered in the call, then the previous 
        # entry is used
        pn.state.location.sync(slider_1, {"value": "slider_1_value"})

    return pn.Row(slider_1.rx() * '⭐')

@add_application('/panel_2', app=router, title='My Panel App')
def create_panel_app_router():
    """!
    Dummy panel within a router

    """
    slider_1 = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3)

    if pn.state.location:
        print(pn.state.location.sync(slider_1, {"value": "slider_1_value"}))

    return pn.Row(slider_1.rx() * '⭐')

app.include_router(router,
                   prefix="/test_router",
                   )

On initial running, there were complaints within the bokeh_fastapi.application.BokehFastAPI class because a Router instance has been supplied rather than a FastAPI app instance.

I then tried to dummy this module by modifying as code snip below (only top to tail of section that changed shown). Basically I mapped the Router onto a dummy representation of a FastAPI app.
This worked... but only if prefix="/test_router" does not exist when specifying the app.include_router() line in main.py While this in itself is useful - it does allow source code structuring - it would be neater if it did align with the router paths, so onward the investigation went.

When specifying the prefix, while the endpoint is visible on the openAPI /docs page, attempting to access the endpoint will return multiple failures to access .js files in paths: /test_router/static/extensions/panel/.. ../es-module-shims.min.js /test_router/static/js/bokeh.min.js /test_router/static/js/bokeh-gl.min.js /test_router/static/extensions/panel/panel.min.js /test_router/static/js/bokeh-widgets.min.js /test_router/static/js/bokeh-tables.min.js

Circled around it a few times now from different directions trying to specify StaticFiles and mount the app - but to be honest not really knowing what I'm doing its akin to pinning the tail on a donkey! :smile: So putting this infront of the experts who do actually understand the library and see what you all think.

bokeh_fastapi\application.py

from fastapi import ( 
    applications as fastapi_applications, 
    FastAPI,
    routing as fastapi_routing,
    )

# -----------------------------------------------------------------------------
class RouterApp:
    """
    A dummy representation of a Router instance in the same structure as a 
    FastAPI instance that enables use of BokehFastAPI without cascading changes

    router (FastAPI Router) :
        FastAPI router that we will serve the application through

    prefix (str, optional) :
        A URL prefix to use for all Bokeh server paths. (default: None)
    """

    # -------------------------------------------------------------------------    
    def __init__(self, *, 
                 router: fastapi_routing.APIRouter,
                 prefix: str,
                 ):
        """
        Create this dummy FastAPI instance

        """
        self.router = router
        self.add_api_route = router.add_api_route
        self.add_websocket_route = router.add_websocket_route
        self.root_path = prefix
        self.get = router.get

# -----------------------------------------------------------------------------
class BokehFastAPI:
    """
    applications (dict[str,Application] or Application) :
        A map from paths to ``Application`` instances.

        If the value is a single Application, then the following mapping
        is generated:

        .. code-block:: python

            applications = {{ '/' : applications }}

        When a connection comes in to a given path, the associate
        Application is used to generate a new document for the session.

    app (FastAPI, optional) :
        FastAPI app to serve the ``applications`` from.

    prefix (str, optional) :
        A URL prefix to use for all Bokeh server paths. (default: None)

    websocket_origins (Sequence[str], optional) :
        A set of websocket origins permitted to connect to this server.

    secret_key (str, optional) :
        A secret key for signing session IDs.

        Defaults to the current value of the environment variable
        ``BOKEH_SECRET_KEY``

    sign_sessions (bool, optional) :
        Whether to cryptographically sign session IDs

        Defaults to the current value of the environment variable
        ``BOKEH_SIGN_SESSIONS``. If ``True``, then ``secret_key`` must
        also be provided (either via environment setting or passed as
        a parameter value)

    keep_alive_milliseconds (int, optional) :
        Number of milliseconds between keep-alive pings
        (default: {DEFAULT_KEEP_ALIVE_MS})

        Pings normally required to keep the websocket open. Set to 0 to
        disable pings.

    check_unused_sessions_milliseconds (int, optional) :
        Number of milliseconds between checking for unused sessions
        (default: {DEFAULT_CHECK_UNUSED_MS})

    unused_session_lifetime_milliseconds (int, optional) :
        Number of milliseconds for unused session lifetime
        (default: {DEFAULT_UNUSED_LIFETIME_MS})

    include_headers (list, optional) :
            List of request headers to include in session context
            (by default all headers are included)

    exclude_headers (list, optional) :
        List of request headers to exclude in session context
        (by default all headers are included)

    include_cookies (list, optional) :
        List of cookies to include in session context
        (by default all cookies are included)

    exclude_cookies (list, optional) :
        List of cookies to exclude in session context
        (by default all cookies are included)
    """

    def __init__(
        self,
        applications: Mapping[str, Application | ModifyDoc] | Application | ModifyDoc,
        app: FastAPI | None = None,
        prefix: str | None = None,
        websocket_origins: Sequence[str] | None = None,
        secret_key: bytes | None = settings.secret_key_bytes(),
        sign_sessions: bool = settings.sign_sessions(),
        keep_alive_milliseconds: int = DEFAULT_KEEP_ALIVE_MS,
        check_unused_sessions_milliseconds: int = DEFAULT_CHECK_UNUSED_MS,
        unused_session_lifetime_milliseconds: int = DEFAULT_UNUSED_LIFETIME_MS,
        include_headers: list[str] | None = None,
        include_cookies: list[str] | None = None,
        exclude_headers: list[str] | None = None,
        exclude_cookies: list[str] | None = None,
    ):
        if callable(applications):
            applications = Application(FunctionHandler(applications))
        if isinstance(applications, Application):
            applications = {"/": applications}
        else:
            applications = dict(applications)

        for url, application in applications.items():
            if callable(application):
                applications[url] = application = Application(
                    FunctionHandler(application)
                )
            if all(
                not isinstance(handler, DocumentLifecycleHandler)
                for handler in application._handlers
            ):
                application.add(DocumentLifecycleHandler())
        applications = cast(dict[str, Application], applications)

        # Wrap applications in ApplicationContext
        self._applications = {}
        for url, application in applications.items():
            self._applications[url] = ApplicationContext(application, url=url)

        if app is None:
            app = FastAPI()

        if isinstance(app, fastapi_applications.FastAPI):            
            self.app = app

        elif isinstance(app, fastapi_routing.APIRouter):
            self.app = RouterApp(
                router=app,
                prefix=prefix,
                )
holovizbot commented 1 day ago

This issue has been mentioned on HoloViz Discourse. There might be relevant details there:

https://discourse.holoviz.org/t/panel-app-in-fastapi-router/8414/3

MarcSkovMadsen commented 1 day ago

I'm not a regular fastapi user.

Can you explain why you need it to be within the router. What is the purpose?

Amiga501 commented 17 hours ago

I'm not a regular fastapi user.

Can you explain why you need it to be within the router. What is the purpose?

If you have multiple, related items but want to keep separation of code then use of routers is a convenient way to go.

Say you have (for a particular subject matter) prototype/research endpoints, development endpoints and production endpoints. Each comes with different levels of review, V&V and (the dreaded!) paperwork - but all may very much focus around a particular topic. In that case, the different routers neatly separate along the lines of degree of overhead required for each router codebase to be changed.

Maybe you'd place them all on separate actual FastAPI services, probably even on separate machines/VMs. In which case switch the above out for several subject matters hosted on the (for example) development method service API.

That means any particular subject matter (for latter example) or a stage (proto/dev/production for former example) can be adjusted with complete independence from all others.

It also avoids too much code residing in the main.py (or wherever module the "app = FastAPI()" line would go into)

If your a flask user (?), then consider it akin to blueprints. Pretty sensible to use that, or an equivalent, when doing anything of substantial size.

That help?

[As said in original post, being able to structure the source code in separate routers, even if the actual url isn't "routered" is a good step in allowing good code structuring. Just it'd be even better if it were able to do the full thing and the fix was something simple that I wasn't grasping.]

philippjfr commented 1 hour ago

I'm currently travelling so I haven't worked through all the details but broadly I agree we should enable this use case. The initial work will likely have to happen in bokeh-fastapi and then we have to if there's anything we need to change in Panel.