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

Panel App in FastAPI Router #7514

Open
Amiga501 opened this issue Nov 23, 2024 · 7 comments
Open

Panel App in FastAPI Router #7514

Amiga501 opened this issue Nov 23, 2024 · 7 comments

Comments

@Amiga501
Copy link

Amiga501 commented Nov 23, 2024

[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! 😄 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
Copy link

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
Copy link
Collaborator

I'm not a regular fastapi user.

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

@Amiga501
Copy link
Author

Amiga501 commented Nov 23, 2024

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
Copy link
Member

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.

@petegordon
Copy link

Just for more related/unrelated context to this issue. ChatGPT 4o hallucinates and provides using ApiRouter as the recommended solution to the prompt “Create an example Python panel application within fastapi to also have api endpoints.”

https://chatgpt.com/share/67444906-5460-8008-8788-5800c329095d

I experienced this several weeks ago, and because I typically have additional complexity such as OAuth in my panel app, I tabled my questions about FastApi for the future. And, was just considering other options such as different port and process for the FastApi endpoints to run independent of panel.

@Amiga501
Copy link
Author

Amiga501 commented Nov 25, 2024

This may not be news to those more familiar with the codebase, but just in case.

Modifying the baseline (main.py)

@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",
                   )

as per:

@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"}))

    app_ = pn.Row(slider_1.rx() * '⭐')
    app_.show()

    return app_

Will return a valid view on a localhost port.
So the issue seems to be restricted to construction of the return - which is still highlighting the missing .js as noted in first post.

@Amiga501
Copy link
Author

Amiga501 commented Nov 25, 2024

... and a bit further.

Within main.py, I've defined the missing .js files using StaticFiles import - note the app.mount.

app.mount("**/test_router/**static", StaticFiles(
    directory=Path(Path(__file__).parent,
                   "static")),
    name="static")

needs to reconcile with:

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

Then had to put a load of .js files into /static/ folder alongside the main.py script - these are detailed in point 2.

Notes:
(1) You need to declare the prefix at
app.include_router()
rather than at
router=APIRouter()
otherwise you will run into forbidden websockets

(2) plenty more javascripts were deemed missing, behaviour did differ depending on where prefix was declared. My tree eventually ended up looking like:

  • /static/
    --- /extensions/
    --- --- /panel/
    --- --- --- /bundled/
    --- --- --- --- /reactiveesm/
    --- --- --- --- --- /es-module-shims@^1.10.0/
    --- --- --- --- --- --- /dist/
    --- --- --- --- --- --- --- es-module-shims.min.js
    --- --- --- --- /theme/
    --- --- --- --- --- default.css
    --- --- --- --- --- native.css
    --- --- --- /css/
    --- --- --- --- listpanel.css
    --- --- --- --- loading.css
    --- --- --- --- markdown.css
    --- --- --- --- widgetbox.css
    --- --- --- panel.min.js
    --- /js/
    --- --- bokeh.min.js
    --- --- bokeh-gl.min.js
    --- --- bokeh-tables.min.js
    --- --- bokeh-widgets.min.js

That has me up and running. At least as far as an APIRouter is concerned.
My actual use case was a fastapi_restful.inferring_router.InferringRouter instance, but thats a separate topic from this.

I suspect the much more elegant solution than my hacking may reside somewhere around what is getting passed to ComponentResourceHandler at lines 141-143 at panel.io.fastapi.py

Final main.py code (still need the changes to BokehFastAPI)

import panel as pn

from fastapi import APIRouter, FastAPI
from fastapi.staticfiles import StaticFiles
from panel.io.fastapi import add_application
from pathlib import Path

app = FastAPI()

app.mount("/test_router/static", StaticFiles(
    directory=Path(Path(__file__).parent,
                   "static")),
    name="static")

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"}))

    app_ = pn.Row(slider_1.rx() * '⭐')

    return app_

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants