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
| Method | Default | Purpose |
|---|---|---|
get_features() | columns only | Which UI controls to enable — see Features declaration |
view_schema(view_name, config) | table_schema | Schema of a temporary table, when it differs from its source |
view_size(view_name) | table_size | Row count of a temporary table, when it differs from its source |
view_get_min_max(view_name, column_name, config) | unsupported | Column 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.