Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,13 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-${{ matrix.os }}-${{ matrix.python-version }}
path: '**/junit.xml'
path: junit.xml
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'

- name: Publish Unit Test Results
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
files: '**/junit.xml'
files: junit.xml
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'

- name: Upload coverage
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ test-py: ## run python tests
tests-py: test-py

coverage-py: ## run python tests and collect test coverage
python -m pytest -v csp_gateway/tests --cov=csp_gateway --cov-report term-missing --cov-report xml
python -m pytest -v csp_gateway/tests --junitxml=junit.xml --cov=csp_gateway --cov-report term-missing --cov-report xml

.PHONY: test-js tests-js coverage-js
test-js: ## run js tests
Expand Down
18 changes: 15 additions & 3 deletions csp_gateway/server/demo/omnibus.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,14 @@ class ExampleGatewayChannels(GatewayChannels):
example_list: ts[List[ExampleData]] = None
never_ticks: ts[ExampleData] = None

s_example: ts[State[ExampleData]] = None
# State fields can be added via annotation or the `set_state` API in the module's `connect` method
example_with_state: Annotated[ts[ExampleData], State(("id", "x"))] = None
example_with_state_multiple: Annotated[
ts[ExampleData],
State(("id", "x")),
State(("id", "y"), alias="example_with_state_alternative"),
] = None
# example: Set in connect with a different schema, to show flexibility of state definition

basket: Dict[ExampleEnum, ts[ExampleData]] = None
str_basket: Dict[str, ts[ExampleData]] = None
Expand Down Expand Up @@ -179,6 +186,8 @@ def connect(self, channels: ExampleGatewayChannels):
# Channels set via `set_channel`
channels.set_channel(ExampleGatewayChannels.example, data)
channels.set_channel(ExampleGatewayChannels.example_list, data_list)
channels.set_channel(ExampleGatewayChannels.example_with_state, data)
channels.set_channel(ExampleGatewayChannels.example_with_state_multiple, data)

# Generic channel for sending data from non-csp sources
channels.add_send_channel(ExampleGatewayChannels.example)
Expand All @@ -187,8 +196,11 @@ def connect(self, channels: ExampleGatewayChannels):
channels.add_send_channel(ExampleGatewayChannels.basket, ExampleEnum.C)
channels.add_send_channel(ExampleGatewayChannels.basket)

# Rudimentary state accumulation via `set_state`
channels.set_state(ExampleGatewayChannels.example, "id")
# State accumulation via `set_state`
channels.set_state(
ExampleGatewayChannels.example,
("id",),
)

# Create some data streams for dict baskets
data_a = self.subscribe(
Expand Down
289 changes: 229 additions & 60 deletions csp_gateway/server/gateway/csp/channels.py

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion csp_gateway/server/gateway/csp/factory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Generic, List, Optional, Type
from typing import Generic, List, Optional, Type, get_args, get_origin

from ccflow import BaseModel
from csp.impl.enum import Enum
Expand Down Expand Up @@ -57,6 +57,24 @@ def build(self, channels: ChannelsType) -> ChannelsType:
if self.block_set_channels_until is not None:
channels._block_set_channels_until = self.block_set_channels_until

# Pre-declare dynamically-created state channels so that modules calling get_state
# during connect() are independent of the order in which set_state is called by the
# owning module. The owning module includes the name in both dynamic_channels()
# (which provides the element type) and dynamic_state_channels().
for node in enabled_modules:
declared = node.dynamic_state_channels() if hasattr(node, "dynamic_state_channels") else None
if not declared:
continue
owned_channels = node.dynamic_channels() or {}
for name in declared:
if name not in owned_channels:
raise ValueError(
f"Module {type(node).__name__} declared '{name}' in dynamic_state_channels() but did not include it in dynamic_channels()"
)
t = owned_channels[name]
element_type = get_args(t)[0] if get_origin(t) is list else t
channels._declare_dynamic_state(name, element_type)

# Wire in each edge Provider.
# The implementation of set_channel will handle multiplexing streams
for node in enabled_modules:
Expand Down
9 changes: 7 additions & 2 deletions csp_gateway/server/gateway/csp/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,13 @@ def dynamic_channels(self) -> Optional[Dict[str, Union[Type[GatewayStruct], Type
...

def dynamic_state_channels(self) -> Optional[Set[str]]:
"""
The set of dynamic channels that have state.
"""The subset of :meth:`dynamic_channels` for which this module will also call
:meth:`GatewayChannels.set_state` from within :meth:`connect`.

Declaring a name here lets *other* modules call :meth:`GatewayChannels.get_state`
on the channel from their own ``connect`` regardless of the order in which modules
are connected; the returned state edge is bound to the real state node once the
owning module's ``connect`` calls ``set_state``.
"""
...

Expand Down
66 changes: 49 additions & 17 deletions csp_gateway/server/gateway/csp/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from enum import Enum as PyEnum
from functools import lru_cache
from pprint import pformat
from typing import Any, Deque, Dict, List, Tuple, Union
from typing import Any, Deque, Dict, List, Optional, Tuple, Union

import csp
import duckdb
Expand Down Expand Up @@ -75,6 +75,16 @@ def disable_duckdb_state() -> None:
_USE_DUCKDB_STATE = False


def _resolve_keyby_attr(record: Any, path: str) -> Any:
"""Resolve a (possibly dotted) attribute path on ``record``, returning ``None`` if any segment is missing."""
obj = record
for segment in path.split("."):
if obj is None:
return None
obj = getattr(obj, segment, None)
return obj


class StateType(CoreEnum):
UNKNOWN = 0
DEFAULT = 1
Expand Down Expand Up @@ -149,7 +159,7 @@ def insert(self, record: Any) -> None:

for subkey in self._keyby:
# extract the key from the record
subkey_to_use = getattr(record, subkey, None)
subkey_to_use = _resolve_keyby_attr(record, subkey)

if subkey == self._keyby[-1]:
# Put the element there if last
Expand Down Expand Up @@ -408,7 +418,7 @@ def insert(self, record: Struct) -> None:
obj_id = None
for subkey in self._keyby:
# extract the key from the record
subkey_to_use = getattr(record, subkey, None)
subkey_to_use = _resolve_keyby_attr(record, subkey)

if subkey == self._keyby[-1]:
if subkey_to_use not in place.keys():
Expand Down Expand Up @@ -527,23 +537,45 @@ def get_duckdb_schema_struct(cls: Struct) -> Tuple[Dict, bool]:
return (new_type_info, use_duckdb)


# NOTE: NEVER access State object directly, always access through the __class_getitem__ API
# NOTE: For runtime state instances, always use the State[<typ>] API.
# State() called directly (with no parameterized typ) is the annotation
# marker form used in `Annotated[ts[X], State(keyby=..., indexer=..., alias=...)]`.
class State(BaseState):
def __init__(self, keyby: Union[Tuple[str, ...], str] = ("id",)) -> None:
"""Switch case between different state specializations based on the type of the records"""
# Annotation metadata. Set on every instance; consumed by ChannelsMetaclass
# when this State is found in a field's Annotated metadata.
_meta_keyby: Union[Tuple[str, ...], str] = ("id",)
_meta_indexer: Optional[Union[str, int]] = None
_meta_alias: Optional[str] = None

def __init__(
self,
keyby: Union[Tuple[str, ...], str] = ("id",),
indexer: Optional[Union[str, int]] = None,
alias: Optional[str] = None,
) -> None:
"""Initialize a State.

When ``self._typ`` is set (via ``State[T](...)``), this constructs a runtime
state collection and dispatches to the appropriate backend. Otherwise the
instance is treated as an annotation marker and only retains its metadata.
"""
# Always retain annotation metadata.
self._meta_keyby = keyby
self._meta_indexer = indexer
self._meta_alias = alias

typ = getattr(self, "_typ", None)
if typ is None:
# Annotation-marker form; no runtime backend needed.
return

global _USE_DUCKDB_STATE
try:
typ = self._typ
if _USE_DUCKDB_STATE and isinstance(typ, type) and issubclass(typ, Struct):
schema, use_duckdb = get_duckdb_schema_struct(typ)
if use_duckdb:
self._state_impl = DuckDBState(typ, keyby, schema)
self._state_type = StateType.DUCKDB
return
except AttributeError:
log.warning("Do not create object directly from State use the State[<typ>] API instead for performance reasons")
log.warning("Using DefaultStateClass")
if _USE_DUCKDB_STATE and isinstance(typ, type) and issubclass(typ, Struct):
schema, use_duckdb = get_duckdb_schema_struct(typ)
if use_duckdb:
self._state_impl = DuckDBState(typ, keyby, schema)
self._state_type = StateType.DUCKDB
return
self._state_impl = DefaultState(keyby)
self._state_type = StateType.DEFAULT

Expand Down
9 changes: 1 addition & 8 deletions csp_gateway/server/gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@
from datetime import datetime, timedelta
from socket import gethostname
from time import sleep
from typing import Any, Callable, List, Optional, Type, Union, get_args, get_origin
from typing import Any, Callable, List, Optional, Type, Union

import csp
from csp import ts
from pydantic import Field, PrivateAttr, create_model, model_validator

from csp_gateway.server.gateway import State
from csp_gateway.server.settings import Settings

from .csp import Channels, ChannelsFactory, ChannelsType, Module
Expand Down Expand Up @@ -113,19 +112,13 @@ def _instantiate_dynamic_channel(self, modules: List[Module[GatewayChannels]], c
for m in modules:
module_dynamic_channels = m.dynamic_channels() if hasattr(m, "dynamic_channels") else None
if module_dynamic_channels:
channels_with_state = m.dynamic_state_channels() if hasattr(m, "dynamic_state_channels") else None
Comment thread
timkpaine marked this conversation as resolved.
for n, t in module_dynamic_channels.items():
existing_type = dynamic_channels.get(n, None)
if existing_type is not None:
if t is not existing_type:
raise ValueError(f"Conflicting types for dynamic channel {n}.")

dynamic_channels[n] = t
if channels_with_state and n in channels_with_state:
if get_origin(t) is list:
t = get_args(t)[0]

dynamic_channels[f"s_{n}"] = State[t]

if dynamic_channels:
dynamic_channel_kwargs = {n: (ts[t], None) for n, t in dynamic_channels.items()}
Expand Down
7 changes: 6 additions & 1 deletion csp_gateway/server/modules/logging/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ def _convert_log_level(cls, v: Union[str, int]) -> int:
def connect(self, channels: ChannelsType):
logger_to_use = logging.getLogger(self.log_name)

for field in self.selection.select_from(channels, state_channels=self.log_states):
for field in self.selection.select_from(channels):
data = channels.get_channel(field)
# list baskets not supported yet
if isinstance(data, dict):
Expand All @@ -393,6 +393,11 @@ def connect(self, channels: ChannelsType):
edge = channels.get_channel(field)
csp.log(self.log_level, field, edge, logger=logger_to_use)

if self.log_states:
for alias in self.selection.select_from(channels, state_channels=True):
edge = channels.get_state(alias)
csp.log(self.log_level, f"s_{alias}", edge, logger=logger_to_use)


# Backwards compatibility alias
StdlibLogging = Logging
5 changes: 3 additions & 2 deletions csp_gateway/server/modules/web/mount.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ def _mount_send(self, app: GatewayWebApp) -> None:

def _mount_state(self, app: GatewayWebApp) -> None:
selection = ChannelSelection() if self.force_mount_all else self.mount_state
channels_set = set(selection.select_from(app.gateway.channels_model, state_channels=True))
# Use the instance so dynamically-registered set_state aliases are included.
channels_set = set(selection.select_from(app.gateway.channels, state_channels=True))
seen_channels = set()

# Bind every wire
Expand All @@ -110,7 +111,7 @@ def _mount_state(self, app: GatewayWebApp) -> None:

missing_channels = channels_set - seen_channels
if missing_channels:
log.info(f"Requested channels missing state routes: {list(channel[2:] for channel in missing_channels)}")
log.info(f"Requested channels missing state routes: {list(missing_channels)}")

app.add_state_available_channels(seen_channels)

Expand Down
26 changes: 13 additions & 13 deletions csp_gateway/server/shared/channel_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,24 @@ def select_from(
fields = channels.fields() if self.include is None else self.include
return list(dict.fromkeys([field for field in fields if field not in self.exclude]))

# State aliases live in their own namespace alongside channel fields.
if state_channels:
if isinstance(channels, type):
# called with the Channels class (declared aliases only)
aliases = list(channels._declared_states.keys())
else:
aliases = channels.all_state_aliases()
if self.include is not None:
ordered = [a for a in self.include if a in aliases and a not in self.exclude]
else:
ordered = [a for a in aliases if a not in self.exclude]
return list(dict.fromkeys(ordered))

for idx, field in enumerate(channels.fields()):
# avoid duplicates
if field in names:
continue

# TODO not this `s_` business...
# Return state channels or regular channels
if state_field := field.startswith("s_"):
if not state_channels:
continue
field = field[2:]
else:
if state_channels:
continue

# Check whether static
outer_type = channels.get_outer_type(field)
if is_dict_basket(outer_type) or isTsType(outer_type):
Expand All @@ -103,9 +106,6 @@ def select_from(
if field in self.exclude:
continue

if state_field:
field = f"s_{field}"

names[field] = idx

return [k for k, _ in sorted(names.items(), key=lambda x: x[1])]
46 changes: 35 additions & 11 deletions csp_gateway/server/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,21 +382,45 @@ def add_send_available_channels(self, fields: Optional[Set[str]] = None) -> None
add_send_available_channels(api_router=api_router, fields=fields)

def add_state_api(self, field: str) -> None:
api_router = self.get_router("state")

# Prune s_ from start
name_without_state = field[2:]
"""Mount REST routes for the given state ``field``.

dict_basket = self._is_dict_basket_field(field=name_without_state)
``field`` must be a known state name on the gateway's channels — either
declared via ``Annotated[..., State(...)]`` or registered dynamically
via ``set_state`` during a module's ``connect``.
"""
api_router = self.get_router("state")

if dict_basket:
dict_basket_key_type, model = dict_basket
subroute_key = dict_basket_key_type
spec = self.gateway.channels._states.get(field) or self.gateway.channels_model._declared_states.get(field)
if spec is None:
raise ValueError("Unknown state '{}' on {}".format(field, self.gateway.channels_model.__name__))

if spec.source_field is not None:
dict_basket = self._is_dict_basket_field(field=spec.source_field)
if dict_basket:
dict_basket_key_type, model = dict_basket
# If the annotation pinned an indexer, expose as a non-keyed route on that one key.
subroute_key = None if spec.indexer is not None else dict_basket_key_type
else:
model = self._get_field_pydantic_type(spec.source_field)
subroute_key = None
else:
model = self._get_field_pydantic_type(name_without_state)
# set_state-registered: source is a raw edge with a known type.
state_edge = self.gateway.channels._state_edges.get((field, spec.indexer))
model = None
subroute_key = None

add_state_routes(api_router=api_router, field=field, model=model, subroute_key=subroute_key)
if state_edge is not None:
inner = state_edge.tstype.typ
# Unwrap State[T] -> T for the response model
model = getattr(inner, "_typ", inner)

add_state_routes(
api_router=api_router,
field=field,
model=model,
subroute_key=subroute_key,
keyby=tuple(spec.keyby) if spec.keyby else (),
indexer=spec.indexer,
)

def add_state_available_channels(self, fields: Optional[Set[str]] = None) -> None:
api_router = self.get_router("state")
Expand Down
Loading
Loading