Skip to content

Commit 914ef02

Browse files
authored
Merge branch 'main' into v3
2 parents 64084e8 + c7df3e4 commit 914ef02

12 files changed

Lines changed: 481 additions & 830 deletions

File tree

csp_gateway/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.7.0"
1+
__version__ = "2.7.1"
22

33
try:
44
from .client import *

csp_gateway/server/config/gateway/omnibus.yaml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,19 @@ modules:
2222
perspective_field: "perspective"
2323
layouts:
2424
Server Defined Layout: '{"sizes":[1],"detail":{"main":{"type":"split-area","orientation":"vertical","children":[{"type":"split-area","orientation":"horizontal","children":[{"type":"tab-area","widgets":["EXAMPLE_LIST_GENERATED_4"],"currentIndex":0},{"type":"tab-area","widgets":["PERSPECTIVE_GENERATED_ID_1"],"currentIndex":0}],"sizes":[0.3,0.7]},{"type":"split-area","orientation":"horizontal","children":[{"type":"tab-area","widgets":["EXAMPLE_GENERATED_3"],"currentIndex":0},{"type":"tab-area","widgets":["PERSPECTIVE_GENERATED_ID_0"],"currentIndex":0}],"sizes":[0.3,0.7]}],"sizes":[0.5,0.5]}},"viewers":{"EXAMPLE_LIST_GENERATED_4":{"version":"3.3.4","plugin":"Datagrid","plugin_config":{"columns":{},"edit_mode":"READ_ONLY","scroll_lock":false},"columns_config":{},"title":"example_list","group_by":[],"split_by":[],"columns":["timestamp","x","y","data","mapping","dt","d","internal_csp_struct.z"],"filter":[],"sort":[["timestamp","desc"]],"expressions":{},"aggregates":{},"table":"example_list","settings":false},"PERSPECTIVE_GENERATED_ID_1":{"version":"3.3.4","plugin":"X Bar","plugin_config":{},"columns_config":{},"title":"example_list (*)","group_by":["x"],"split_by":[],"columns":["y"],"filter":[],"sort":[["x","asc"]],"expressions":{},"aggregates":{"y":"median"},"table":"example_list","settings":false},"EXAMPLE_GENERATED_3":{"version":"3.3.4","plugin":"Datagrid","plugin_config":{"columns":{},"edit_mode":"READ_ONLY","scroll_lock":false},"columns_config":{},"title":"example","group_by":[],"split_by":[],"columns":["timestamp","x","y","data","mapping","dt","d","internal_csp_struct.z"],"filter":[],"sort":[["timestamp","desc"]],"expressions":{},"aggregates":{},"table":"example","settings":false},"PERSPECTIVE_GENERATED_ID_0":{"version":"3.3.4","plugin":"Treemap","plugin_config":{},"columns_config":{},"title":"example (*)","group_by":["x"],"split_by":[],"columns":["y","x",null],"filter":[],"sort":[["timestamp","desc"]],"expressions":{},"aggregates":{},"table":"example","settings":false}}}'
25-
limits:
26-
str_basket: 20
25+
tables:
26+
# Primary table with multi-index
27+
example:
28+
index: ["x", "y"]
29+
# Additional table: same channel, no index (full append-only history)
30+
example_full_history:
31+
channel: example
32+
limit: 100
33+
# Primary table with limit
34+
str_basket:
35+
limit: 20
2736
architectures:
2837
basket: server
29-
indexes:
30-
example: ["x", "y"]
3138
server_views:
3239
server_view:
3340
table: example

csp_gateway/server/modules/web/perspective.py

Lines changed: 184 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from fastapi import APIRouter, WebSocket
2323
from perspective import Client, Server, Table
2424
from perspective.handlers.starlette import PerspectiveStarletteHandler
25-
from pydantic import Field, PrivateAttr, field_validator
25+
from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator
2626
from starlette.websockets import WebSocketDisconnect
2727
from typing_extensions import TypeAliasType
2828

@@ -33,6 +33,7 @@
3333
__all__ = (
3434
"psp_schema_to_arrow_schema",
3535
"create_pyarrow_table",
36+
"TableConfig",
3637
"MountPerspectiveTables",
3738
)
3839

@@ -141,23 +142,74 @@ def pull_data_thread(
141142
]
142143

143144

145+
class TableConfig(BaseModel):
146+
"""Configuration for a perspective table. When channel is omitted, defaults to using the table name as the channel."""
147+
148+
channel: Optional[str] = Field(None, description="The source channel name. Defaults to the table name if omitted.")
149+
limit: Optional[int] = Field(None, description="Row limit for this table.")
150+
index: Optional[Union[str, List[str]]] = Field(None, description="Index field(s) for this table.")
151+
architecture: Literal["server", "client-server"] = Field("client-server", description="Perspective data architecture for this table.")
152+
excluded_columns: Optional[ExcludedColumns] = Field(None, description="Columns to exclude from the schema.")
153+
154+
155+
# Backwards compat alias
156+
AdditionalTableConfig = TableConfig
157+
158+
159+
def _is_channel_selection_input(v) -> bool:
160+
"""Detect whether a raw input value looks like a ChannelSelection (old-style tables field)."""
161+
if v is None or isinstance(v, list):
162+
return True
163+
if isinstance(v, ChannelSelection):
164+
return True
165+
if isinstance(v, dict):
166+
keys = set(v.keys())
167+
# Empty dict is ambiguous — treat as empty TableConfig dict (new-style)
168+
if len(keys) == 0:
169+
return False
170+
if keys <= {"include", "exclude"}:
171+
# Verify values aren't TableConfig-like dicts (handles edge case of channel named "include"/"exclude")
172+
for val in v.values():
173+
if isinstance(val, (dict, TableConfig)):
174+
return False
175+
return True
176+
return False
177+
178+
144179
class MountPerspectiveTables(GatewayModule):
145180
requires: Optional[ChannelSelection] = []
146181

147-
tables: ChannelSelection = Field(default_factory=ChannelSelection)
182+
tables: Dict[str, TableConfig] = Field(
183+
default={},
184+
description=(
185+
"Dictionary mapping table name to a TableConfig. Each entry configures a perspective table. "
186+
"If 'channel' is omitted in the config, the table name is used as the channel name. "
187+
"If 'channel' is set to a different name, an additional table is created mirroring that channel's data. "
188+
"Tables entries whose channel matches their name override per-table settings from legacy fields "
189+
"(limits, indexes, etc.). For backwards compatibility, this field also accepts a ChannelSelection "
190+
"(list or dict with include/exclude keys) which will be moved to 'channel_selection'."
191+
),
192+
)
193+
channel_selection: ChannelSelection = Field(
194+
default_factory=ChannelSelection,
195+
description="Controls which channels are auto-discovered as perspective tables. "
196+
"Channels not explicitly listed in 'tables' will use defaults. "
197+
"This is the successor to the old 'tables' field when it was a ChannelSelection.",
198+
)
148199
_unused_tables: Optional[List[str]] = PrivateAttr(default_factory=list)
149200

150201
server_views: Optional[Dict[str, ViewConfig]] = Field(
151202
default_factory=dict, description="Optional dict mapping new table name to a dict with table and view information"
152203
)
153204

205+
# Legacy per-table config fields (still functional; tables entries take precedence)
154206
limits: Dict[str, int] = Field(
155207
description="Dict mapping table name to [perspective limit](https://perspective-dev.github.io/guide/explanation/table/options.html)",
156208
default={},
157209
)
158210
default_limit: Optional[int] = Field(None, description="Default limit for all tables, i.e. 1000")
159211
indexes: Dict[str, Optional[Union[str, List[str]]]] = Field(
160-
description="Dict mapping table name to [perspective index](https://perspective-dev.github.io/guide/explanation/table/options.html). . If a multi-index is provided, will create a new computed index field.",
212+
description="Dict mapping table name to [perspective index](https://perspective-dev.github.io/guide/explanation/table/options.html). If a multi-index is provided, will create a new computed index field.",
161213
default={},
162214
)
163215
default_index: Optional[Union[str, List[str]]] = Field(
@@ -171,6 +223,15 @@ class MountPerspectiveTables(GatewayModule):
171223
"client-server",
172224
description="Default architecture for all tables, i.e. 'client-server'",
173225
)
226+
excluded_table_columns: Dict[str, ExcludedColumns] = Field(
227+
default={},
228+
description=(
229+
"Dictionary from table name to columns (which are attributes on a GatewayStruct) to exclude from perspective. "
230+
"The columns to exclude can be specified as either be a set of column names or as a dictionary. If specified "
231+
"as a dictionary, the dictionary is a mapping from attribute name to sub-attributes to exclude. This is defined "
232+
"recursively so it can be used to exclude fields that are structs of structs."
233+
),
234+
)
174235

175236
layouts: Dict[str, str] = Field(default={})
176237
default_layout: Optional[str] = Field(
@@ -189,15 +250,28 @@ class MountPerspectiveTables(GatewayModule):
189250
description="Optional field on the channels which has a dictionary of layouts to use, "
190251
"such that it can also be used by other GatewayModules with custom table preparation logic.",
191252
)
192-
excluded_table_columns: Dict[str, ExcludedColumns] = Field(
193-
default={},
194-
description=(
195-
"Dictionary from table name to columns (which are attributes on a GatewayStruct) to exclude from perspective. "
196-
"The columns to exclude can be specified as either be a set of column names or as a dictionary. If specified "
197-
"as a dictionary, the dictionary is a mapping from attribute name to sub-attributes to exclude. This is defined "
198-
"recursively so it can be used to exclude fields that are structs of structs."
199-
),
200-
)
253+
254+
@model_validator(mode="before")
255+
@classmethod
256+
def _normalize_tables_input(cls, data):
257+
if not isinstance(data, dict):
258+
return data
259+
260+
tables_input = data.get("tables")
261+
if _is_channel_selection_input(tables_input):
262+
# Old style: `tables` was a ChannelSelection — move to channel_selection
263+
# Only do this if channel_selection is not already explicitly provided
264+
if tables_input is not None and "channel_selection" not in data:
265+
data["channel_selection"] = tables_input
266+
data.pop("tables", None)
267+
268+
# Merge deprecated additional_tables into tables
269+
additional = data.pop("additional_tables", None)
270+
if additional:
271+
tables = data.setdefault("tables", {})
272+
tables.update(additional)
273+
274+
return data
201275

202276
_route: str = "/perspective"
203277
_server: Server = PrivateAttr(default=None)
@@ -224,11 +298,37 @@ def _validate_server_views(cls, v: Dict[str, ViewConfig]) -> Dict[str, ViewConfi
224298
raise ValueError(f"View config for {new_table_name} must specify at least one view operation in addition to the base 'table'.")
225299
return v
226300

301+
def _get_effective_config(self, table_name: str) -> TableConfig:
302+
"""Get effective config for a table, merging tables dict entry with legacy fields and defaults."""
303+
config = self.tables.get(table_name)
304+
if config:
305+
return TableConfig(
306+
channel=config.channel,
307+
limit=config.limit if config.limit is not None else self.limits.get(table_name, self.default_limit),
308+
index=config.index if config.index is not None else self.indexes.get(table_name, self.default_index),
309+
architecture=config.architecture or self.architectures.get(table_name, self.default_architecture),
310+
excluded_columns=config.excluded_columns if config.excluded_columns is not None else self.excluded_table_columns.get(table_name),
311+
)
312+
return TableConfig(
313+
limit=self.limits.get(table_name, self.default_limit),
314+
index=self.indexes.get(table_name, self.default_index),
315+
architecture=self.architectures.get(table_name, self.default_architecture),
316+
excluded_columns=self.excluded_table_columns.get(table_name),
317+
)
318+
227319
def _connect_all_tables(self, channels: GatewayChannels) -> None:
228-
selected_tables = set(self.tables.select_from(channels)) | set(_["table"] for _ in self.server_views.values())
229-
for field in selected_tables:
320+
# Determine primary channels from channel_selection + server_views + tables entries without explicit channel
321+
selected_channels = set(self.channel_selection.select_from(channels)) | set(_["table"] for _ in self.server_views.values())
322+
for table_name, config in self.tables.items():
323+
channel = config.channel or table_name
324+
if channel == table_name:
325+
selected_channels.add(table_name)
326+
327+
# Register primary tables (table_name == channel_name)
328+
for field in selected_channels:
329+
config = self._get_effective_config(field)
230330
edge = channels.get_channel(field)
231-
excluded_columns = self.excluded_table_columns.get(field, None)
331+
excluded_columns = config.excluded_columns
232332
if isinstance(edge, dict):
233333
if not edge:
234334
raise ValueError(f"No keys defined for dict basket channel {field}.")
@@ -244,8 +344,8 @@ def _connect_all_tables(self, channels: GatewayChannels) -> None:
244344
self.add_table(
245345
field,
246346
schema,
247-
limit=self.limits.get(field, self.default_limit),
248-
index=self.indexes.get(field, self.default_index),
347+
limit=config.limit,
348+
index=config.index,
249349
)
250350

251351
if hasattr(subfield, "name"):
@@ -265,8 +365,8 @@ def _connect_all_tables(self, channels: GatewayChannels) -> None:
265365
self.add_table(
266366
field,
267367
schema,
268-
limit=self.limits.get(field, self.default_limit),
269-
index=self.indexes.get(field, self.default_index),
368+
limit=config.limit,
369+
index=config.index,
270370
)
271371
self.push_to_perspective(
272372
edge,
@@ -286,6 +386,64 @@ def _connect_all_tables(self, channels: GatewayChannels) -> None:
286386
view = base_table.view(**view_config)
287387
self._table_insts[new_table_name] = self._client.table(view, name=new_table_name)
288388

389+
# Register additional tables (tables entries where channel differs from table_name)
390+
for table_name, table_config in self.tables.items():
391+
channel = table_config.channel or table_name
392+
if channel == table_name:
393+
continue # Already handled as a primary table above
394+
395+
if channel not in self._schema_insts:
396+
raise ValueError(
397+
f"Table '{table_name}' references channel '{channel}' which is not a registered table. "
398+
f"Ensure '{channel}' is included in the channel selection or as a primary table."
399+
)
400+
config = self._get_effective_config(table_name)
401+
excluded_columns = config.excluded_columns
402+
if excluded_columns is not None:
403+
# Recompute schema with different exclusions
404+
edge = channels.get_channel(channel)
405+
if isinstance(edge, dict):
406+
a_subfield = list(edge.keys())[0]
407+
ts_type = channels.get_channel(channel, a_subfield).tstype.typ
408+
else:
409+
ts_type = edge.tstype.typ
410+
if get_origin(ts_type) is list:
411+
ts_type = get_args(ts_type)[0]
412+
schema = ts_type.psp_schema(excluded_columns)
413+
else:
414+
schema = self._schema_insts[channel].copy()
415+
416+
self.add_table(
417+
table_name,
418+
schema,
419+
limit=config.limit,
420+
index=config.index,
421+
)
422+
self._schema_insts[table_name] = schema
423+
self._arrow_schema_insts[table_name] = psp_schema_to_arrow_schema(schema)
424+
self._arrow_schema_date_conversions[table_name] = set()
425+
for k, v in schema.items():
426+
if v is date:
427+
self._arrow_schema_date_conversions[table_name].add(k)
428+
429+
# Wire up data flow: push same channel data to the additional table
430+
edge = channels.get_channel(channel)
431+
if isinstance(edge, dict):
432+
to_flatten = []
433+
for subfield in edge.keys():
434+
to_flatten.append(channels.get_channel(channel, subfield))
435+
if hasattr(subfield, "name"):
436+
self.push_to_perspective(csp.flatten(to_flatten), table_name, subfield.name)
437+
else:
438+
self.push_to_perspective(csp.flatten(to_flatten), table_name, subfield)
439+
else:
440+
ts_type = channels.get_channel(channel).tstype.typ
441+
if get_origin(ts_type) is list:
442+
edge = csp.unroll(channels.get_channel(channel))
443+
else:
444+
edge = channels.get_channel(channel)
445+
self.push_to_perspective(edge, table_name)
446+
289447
def get_schema_from_field(self, channels: GatewayChannels, field: str):
290448
edge = channels.get_channel(field)
291449

@@ -451,11 +609,15 @@ async def get_perspective_meta():
451609
indexes, and architecture.
452610
"""
453611
return {
454-
"limits": self.limits,
612+
"limits": {**self.limits, **{k: v.limit for k, v in self.tables.items() if v.limit is not None}},
455613
"default_limit": self.default_limit,
456-
"indexes": {**self.indexes, **{k: v[0] for k, v in self._computed_indexes.items()}},
614+
"indexes": {
615+
**self.indexes,
616+
**{k: v.index for k, v in self.tables.items() if v.index is not None},
617+
**{k: v[0] for k, v in self._computed_indexes.items()},
618+
},
457619
"default_index": self.default_index,
458-
"architectures": self.architectures,
620+
"architectures": {**self.architectures, **{k: v.architecture for k, v in self.tables.items()}},
459621
"default_architecture": self.default_architecture,
460622
"layouts": self.layouts,
461623
"default_layout": self.default_layout,

0 commit comments

Comments
 (0)