Panel + Perspective on the server

A demo of Panel and Perspective (on server+client).

It’s just a quick hacking together of perspective tornado example and Panel.

Could potentially allow panel apps to efficiently stream lots of data to lots of users without too much of some types of overhead (e.g. server cpu, app coding difficulty level).

Note: I had never used python 3.9, panel 1.3.1, or perspective 1.9.4 (or tornado 6.3, or bokeh 3…) until making this post (I am stuck using many years older versions of everything). So I’m sure some stuff is wrong (not just the missing perspective theme/style) because the world seems to have changed a lot since I last looked :slight_smile:

################################################################################
### some app (a data source, a "per-user" part, and a "server part")

# data source
import asyncio
import random
from datetime import datetime, timezone
async def send_data(f):
    while True:
        await asyncio.sleep(0.1)
        f({"x": [datetime.now(tz=timezone.utc)], "y": [random.random()]})

# panel app
def widgets():
    import panel as pn
    return pn.Row(
        ExamplePerspectiveWidget(width=400, height=500),
        ExamplePerspectiveWidget(width=400, height=500),
    )

# "server side" of this app (e.g. startup script)
def server_table():
    from perspective import Table
    from datetime import datetime, timezone
    # https://perspective.finos.org/docs/python/#table
    return ("testing123", Table({"x": datetime, "y": float}))
    
    
################################################################################
# demo of panel and perspective "server side" integration

import panel.io.server
from perspective import PerspectiveManager
def some_perspective_panel_server_integration(app, perspective_tables):
    server = panel.io.server.get_server(app, start=False, admin=True, port=9876)
 
    # https://perspective.finos.org/docs/python/#perspectivemanager    
    perspective_manager = PerspectiveManager(lock=True)   
    # https://perspective.finos.org/docs/python/#async-mode
    perspective_manager.set_loop_callback(server.io_loop.add_callback)
    
    # https://perspective.finos.org/docs/python/#perspectivetornadohandler
    from perspective import PerspectiveTornadoHandler    
    server._tornado.add_handlers(".*", [
        ("/perspective_websocket",PerspectiveTornadoHandler,{"manager": perspective_manager, "check_origin": True})])

    for name,table in perspective_tables:
        # https://perspective.finos.org/docs/python/#hosting-table-and-view-instances
        perspective_manager.host_table(name, table)

    return server



###########################################################################
# sketch of a widget

from panel import config
from panel.reactive import ReactiveHTML

PSP_VERSION = "1.9.4"
class ExamplePerspectiveWidget(ReactiveHTML):
    
    # copy paste hack of https://github.com/holoviz/panel/blob/main/panel/models/perspective.py
    
    __javascript__ = [
        f"{config.npm_cdn}/@finos/perspective@{PSP_VERSION}/dist/umd/perspective.js",
        f"{config.npm_cdn}/@finos/perspective-viewer@{PSP_VERSION}/dist/umd/perspective-viewer.js",
        f"{config.npm_cdn}/@finos/perspective-viewer-datagrid@{PSP_VERSION}/dist/umd/perspective-viewer-datagrid.js",
        f"{config.npm_cdn}/@finos/perspective-viewer-d3fc@{PSP_VERSION}/dist/umd/perspective-viewer-d3fc.js",
    ]

    __js_skip__ = {"perspective": __javascript__}

    __js_require__ = {
        "paths": {
            "perspective": f"{config.npm_cdn}/@finos/perspective@{PSP_VERSION}/dist/umd/perspective",
            "perspective-viewer": f"{config.npm_cdn}/@finos/perspective-viewer@{PSP_VERSION}/dist/umd/perspective-viewer",
            "perspective-viewer-datagrid": f"{config.npm_cdn}/@finos/perspective-viewer-datagrid@{PSP_VERSION}/dist/umd/perspective-viewer-datagrid",
            "perspective-viewer-d3fc": f"{config.npm_cdn}/@finos/perspective-viewer-d3fc@{PSP_VERSION}/dist/umd/perspective-viewer-d3fc",
        },
        "exports": {
            "perspective": "perspective",
            "perspective-viewer": "PerspectiveViewer",
            "perspective-viewer-datagrid": "PerspectiveViewerDatagrid",
            "perspective-viewer-d3fc": "PerspectiveViewerD3fc",
        },
    }

    __css__ = [
        f"{config.npm_cdn}/@finos/perspective-viewer@{PSP_VERSION}/dist/css/themes.css"
    ]

    _template = """
        <perspective-viewer 
          id="viewer" 
          plugin="datagrid" 
          style="height:100%; width:100%;" 
          editable
        > </perspective-viewer>"""

    _scripts = {
        "after_layout": """
            const websocket = perspective.websocket(`ws://${window.location['host']}/perspective_websocket`);
            const table = websocket.open_table("testing123");
            viewer.load(table);
            viewer.restore({plugin: "Datagrid", columns: ["x","y"], sort: [["x","desc"]]});
            viewer.toggleConfig();
        """
    }


if __name__ == '__main__':
    name, table = server_table()
    server = some_perspective_panel_server_integration(widgets, [(name,table)])

    # data sending
    server.io_loop.add_callback(lambda: send_data(table.update))
    
    server.start()
    try:
        server.io_loop.start()
    except RuntimeError:
        pass

To run this, I set up a fresh environment on linux python 3.9.18 via pip install "panel==1.3.1" "perspective-python==1.9.4".

For the record, pip freeze shows:

asttokens==2.4.1
bleach==6.1.0
bokeh==3.3.1
certifi==2023.11.17
charset-normalizer==3.3.2
comm==0.2.0
contourpy==1.2.0
decorator==5.1.1
exceptiongroup==1.1.3
executing==2.0.1
future==0.18.3
idna==3.4
importlib-metadata==6.8.0
ipython==8.17.2
ipywidgets==8.1.1
jedi==0.19.1
Jinja2==3.1.2
jupyterlab-widgets==3.0.9
linkify-it-py==2.0.2
Markdown==3.5.1
markdown-it-py==3.0.0
MarkupSafe==2.1.3
matplotlib-inline==0.1.6
mdit-py-plugins==0.4.0
mdurl==0.1.2
numpy==1.26.2
packaging==23.2
pandas==1.5.3
panel==1.3.1
param==2.0.1
parso==0.8.3
perspective-python==1.9.4
pexpect==4.8.0
Pillow==10.1.0
prompt-toolkit==3.0.41
ptyprocess==0.7.0
pure-eval==0.2.2
Pygments==2.17.0
python-dateutil==2.8.2
pytz==2023.3.post1
pyviz_comms==3.0.0
PyYAML==6.0.1
requests==2.31.0
six==1.16.0
stack-data==0.6.3
tornado==6.3.3
tqdm==4.66.1
traitlets==5.13.0
typing_extensions==4.8.0
uc-micro-py==1.0.2
urllib3==2.1.0
wcwidth==0.2.10
webencodings==0.5.1
widgetsnbextension==4.0.9
xyzservices==2023.10.1
zipp==3.17.0

Hi @MyCat

Welcome to the community. That is a really interesting example. Thanks so much for sharing.

I’ve never seen an extra web socket being added to the Panel/ Tornado server before. That is quite interesting. I guess this also makes for much more performant streaming with Perspective in Panel?

I updated the example code a bit below to better support my context. I needed the pathname to be added to the websocket because I’m working on a JupyterHub and not my laptop.

# data source
import asyncio
import random
from datetime import datetime, timezone

from panel import config
from panel.reactive import ReactiveHTML
from perspective import Table
from datetime import datetime, timezone
import panel.io.server
from perspective import PerspectiveManager
from perspective import PerspectiveTornadoHandler    
import panel as pn
import param

PSP_VERSION = "1.9.4"
TABLE = "testing123"

class ExamplePerspectiveWidget(ReactiveHTML):
    table = param.String()
    plugin = param.String("Datagrid")

    # copy paste hack of https://github.com/holoviz/panel/blob/main/panel/models/perspective.py
    
    __javascript__ = [
        f"{config.npm_cdn}/@finos/perspective@{PSP_VERSION}/dist/umd/perspective.js",
        f"{config.npm_cdn}/@finos/perspective-viewer@{PSP_VERSION}/dist/umd/perspective-viewer.js",
        f"{config.npm_cdn}/@finos/perspective-viewer-datagrid@{PSP_VERSION}/dist/umd/perspective-viewer-datagrid.js",
        f"{config.npm_cdn}/@finos/perspective-viewer-d3fc@{PSP_VERSION}/dist/umd/perspective-viewer-d3fc.js",
    ]

    __js_skip__ = {"perspective": __javascript__}

    __js_require__ = {
        "paths": {
            "perspective": f"{config.npm_cdn}/@finos/perspective@{PSP_VERSION}/dist/umd/perspective",
            "perspective-viewer": f"{config.npm_cdn}/@finos/perspective-viewer@{PSP_VERSION}/dist/umd/perspective-viewer",
            "perspective-viewer-datagrid": f"{config.npm_cdn}/@finos/perspective-viewer-datagrid@{PSP_VERSION}/dist/umd/perspective-viewer-datagrid",
            "perspective-viewer-d3fc": f"{config.npm_cdn}/@finos/perspective-viewer-d3fc@{PSP_VERSION}/dist/umd/perspective-viewer-d3fc",
        },
        "exports": {
            "perspective": "perspective",
            "perspective-viewer": "PerspectiveViewer",
            "perspective-viewer-datagrid": "PerspectiveViewerDatagrid",
            "perspective-viewer-d3fc": "PerspectiveViewerD3fc",
        },
    }

    __css__ = [
        f"{config.npm_cdn}/@finos/perspective-viewer@{PSP_VERSION}/dist/css/themes.css"
    ]

    _template = """
        <perspective-viewer 
          id="viewer" 
          plugin="datagrid" 
          style="height:100%; width:100%;" 
          editable
        > </perspective-viewer>"""

    _scripts = {
        "after_layout": """
            const websocket = perspective.websocket(`wss://${window.location['host']}${window.location['pathname']}perspective_websocket`);
            const table = websocket.open_table(data.table);
            viewer.load(table);
            viewer.restore({plugin: data.plugin, columns: ["x","y"], sort: [["x","desc"]]});
            viewer.toggleConfig();
        """
    }

# *** "server side" of this app (e.g. startup script)
def add_perspective_tables_to_panel_server(server, perspective_tables):
    # https://perspective.finos.org/docs/python/#perspectivemanager    
    perspective_manager = PerspectiveManager(lock=True)   
    # https://perspective.finos.org/docs/python/#async-mode
    perspective_manager.set_loop_callback(server.io_loop.add_callback)
    
    # https://perspective.finos.org/docs/python/#perspectivetornadohandler
    server._tornado.add_handlers(".*", [
        ("/perspective_websocket",PerspectiveTornadoHandler,{"manager": perspective_manager, "check_origin": True})])

    for name,table in perspective_tables:
        # https://perspective.finos.org/docs/python/#hosting-table-and-view-instances
        perspective_manager.host_table(name, table)

    return server



async def send_data(f):
    while True:
        await asyncio.sleep(0.1)
        f({"x": [datetime.now(tz=timezone.utc)], "y": [random.random()]})

def app(table=TABLE):
    component= pn.Row(
        ExamplePerspectiveWidget(table=TABLE, sizing_mode="stretch_both"),
        ExamplePerspectiveWidget(table=TABLE, plugin="xy_line", sizing_mode="stretch_both"),
    )
    return pn.template.FastListTemplate(
        title="Panel Perspective Streaming", main=[component]
    )


if __name__ == '__main__':
    pn.extension()
    
    server = pn.serve(app, start=False, port=9876, admin=True)
    
    tables=[(TABLE, Table({"x": datetime, "y": float}))]
    server = add_perspective_tables_to_panel_server(server, tables)
    
    for _, table in tables:
        server.io_loop.add_callback(lambda: send_data(table.update))
    
    server.start()
    try:
        server.io_loop.start()
    except RuntimeError:
        pass

Sorry, yes, the demo was just a minimal mashing together of pre-existing public code (i.e. it contains nothing that does not already exist in public), rather than being set up as a reusable pattern.

Some features of Perspective that can be useful for performance in various scenarios:

  • Uses Apache Arrow for efficient serialization of updates.
  • Compiled to native code for use in python on the server (and releases the GIL).
  • Client compiled for webassembly is available, and can also run in parallel using web workers.
  • A table can be only on the server, or on both the server and the client.

Regarding the last two points above: the demo is using server-only mode but could easily be switched to client/server replicated mode.

Perspective and Panel are both just so much fun :smile:

1 Like