Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Implementing a custom Virtual Server

You can connect Perspective to any data source by subclassing VirtualServerHandler, wrapping it in a VirtualServer, and exposing that via a small session factory object which the WebSocket handlers use to give each connected client its own session.

For background on virtual servers, see the Virtual Servers overview.

The handler

VirtualServerHandler is imported from perspective.virtual_servers. Only get_hosted_tables, table_schema, table_size, table_make_view, view_delete and view_get_data are required; the rest have defaults.

from perspective.virtual_servers import VirtualServerHandler

class MyHandler(VirtualServerHandler):
    def __init__(self, db):
        self.db = db

    def get_features(self):
        return {
            "group_by": True,
            "split_by": False,
            "sort": True,
            "filter_ops": {
                "string": ["==", "!=", "contains"],
                "float": ["==", "!=", ">", "<"],
            },
            "aggregates": {
                "float": ["sum", "avg", "count"],
                "string": ["count"],
            },
        }

    def get_hosted_tables(self):
        return ["my_table"]

    def table_schema(self, table_name):
        return {"name": "string", "price": "float"}

    def table_size(self, table_name):
        return 1000

    def table_make_view(self, table_name, view_name, config):
        # Translate `config` (group_by, sort, filter, etc.) into a query
        # against your data source. Store the query keyed by `view_name`
        # for later data retrieval.
        pass

    def table_describe(self, table_name, config):
        # Validate `config` against `table_name` and report the schema the
        # temporary table would have, WITHOUT creating it. Return
        # {"expression_schema", "view_schema"} when valid,
        # {"expression_schema", "expression_errors"} when an expression is
        # invalid, or {"config_error"} otherwise. A model with no cheaper
        # answer may `return describe_via_make_view(self, table_name, config)`,
        # which builds and drops a real temporary table - at that cost.
        pass

    def view_delete(self, view_name):
        # Clean up resources for this view. The UI does this automatically,
        # and can recover if a view dies early.
        pass

    def view_get_data(self, view_name, config, viewport, data):
        # Serialize the rectangular slice `viewport` of the temporary table
        # `view_name` into `data`, a push-only `VirtualDataSlice`. Once a
        # type has been pushed for a column name it must not change. `data`
        # is consumed when this method returns.
        pass

Optional methods

MethodDefaultPurpose
get_features()columns onlyWhich UI controls to enable — see Features declaration
view_schema(view_name, config)table_schemaSchema of a temporary table, when it differs from its source
view_size(view_name)table_sizeRow count of a temporary table, when it differs from its source
view_get_min_max(view_name, column_name, config)unsupportedColumn bounds as a (min, max) tuple — required for gradient and sparkbar column styles

The session factory

The WebSocket handlers call new_session(callback) once per connection, so the object passed as perspective_server must provide it. Wrap your handler in a perspective.VirtualServer — which owns the protocol — and return one session per client. Each session must provide handle_request(msg) and close(), which the handlers call when the connection ends; VirtualServer.close() deletes the views the client left open, via view_delete. close() should also release the session’s callback (which may otherwise form a reference cycle and leak):

import perspective

class MyVirtualSession:
    def __init__(self, callback, db):
        self.session = perspective.VirtualServer(MyHandler(db))
        self.callback = callback

    def handle_request(self, msg):
        self.callback(self.session.handle_request(msg))

    def close(self):
        session, self.session, self.callback = self.session, None, None
        if session is not None:
            session.close()


class MyVirtualServer:
    def __init__(self, db):
        self.db = db

    def new_session(self, callback):
        return MyVirtualSession(callback, self.db)

Serving it

A MyVirtualServer instance can then be passed to a Tornado, Starlette or AIOHTTP handler just like a regular Server:

from perspective.handlers.tornado import PerspectiveTornadoHandler

app = tornado.web.Application([
    (r"/websocket", PerspectiveTornadoHandler, {
        "perspective_server": MyVirtualServer(db),
    }),
])

The built-in DuckDB, ClickHouse, PostgreSQL and Polars implementations all follow exactly this shape and are worth reading as complete references.