Skip to content

Commit fd27e60

Browse files
committed
Add support for list/dict baskets
Signed-off-by: Arham Chopra <arham.chopra@cubistsystematic.com>
1 parent 05fa92e commit fd27e60

4 files changed

Lines changed: 221 additions & 66 deletions

File tree

csp_gateway/server/modules/filedrop/adapter.py

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from dataclasses import dataclass
44
from datetime import datetime
55
from enum import Enum, auto
6-
from typing import Any, Dict, List, get_args, get_origin
6+
from typing import Any, Dict, List, Optional, TypeVar, get_args, get_origin
77

88
import orjson
99
import pyarrow.parquet as pq
1010
from csp import ts
1111
from csp.impl.pushadapter import PushInputAdapter
1212
from csp.impl.types.container_type_normalizer import ContainerTypeNormalizer
1313
from csp.impl.wiring import py_push_adapter_def
14+
from pydantic import TypeAdapter
1415
from watchdog.events import FileSystemEvent, FileSystemEventHandler
1516
from watchdog.observers import Observer
1617

@@ -49,21 +50,35 @@ class FileDropAdapterConfiguration:
4950
class FileReaderBase:
5051
"""The base file reader that reads data from files and generates structs"""
5152

52-
def __init__(self, config: FileDropAdapterConfiguration, ts_typ: object):
53+
def __init__(self, config: FileDropAdapterConfiguration, ts_typ: object, deserializer: Optional[object] = None):
5354
self.field_map = config.field_map
5455
self.extensions = config.extensions
55-
normalized_type = ContainerTypeNormalizer.normalize_type(ts_typ)
56-
self.is_list = get_origin(normalized_type) is list
57-
if self.is_list:
58-
inner_type = get_args(normalized_type)[0]
59-
type_adapter = inner_type.type_adapter()
60-
else:
61-
type_adapter = ts_typ.type_adapter()
62-
self.type_adapter = type_adapter
6356
if hasattr(config, "type_adapter_args"):
6457
self.context = config.type_adapter_args
6558
else:
6659
self.context = {}
60+
if not deserializer:
61+
normalized_type = ContainerTypeNormalizer.normalize_type(ts_typ)
62+
type_adapter = TypeAdapter(normalized_type)
63+
if get_origin(normalized_type) is list:
64+
65+
def deserialize_tick(data, type_adapter=type_adapter, apply_field_map=self.apply_field_map, context=self.context):
66+
data = [apply_field_map(d) for d in data]
67+
return type_adapter.validate_python(data, context=context)
68+
elif get_origin(normalized_type) is dict:
69+
key_type, inner_type = get_args(normalized_type)
70+
71+
def deserialize_tick(data, type_adapter=type_adapter, apply_field_map=self.apply_field_map, context=self.context):
72+
data = {k: apply_field_map(d) for k, d in data.items()}
73+
return type_adapter.validate_python(data, context=context)
74+
else:
75+
76+
def deserialize_tick(data, type_adapter=type_adapter, apply_field_map=self.apply_field_map, context=self.context):
77+
return type_adapter.validate_python(apply_field_map(data), context=context)
78+
79+
self.deserializer = deserialize_tick
80+
else:
81+
self.deserializer = deserializer
6782

6883
def read(self, src_path: str) -> object:
6984
"""Generator to return stucts from a filepath"""
@@ -74,12 +89,9 @@ def read(self, src_path: str) -> object:
7489
should_read = False
7590
if should_read:
7691
dicts = self.read_impl(src_path)
77-
structs = [self.deserialize_dict(self.apply_field_map(v)) for v in dicts]
78-
if self.is_list:
79-
yield structs
80-
else:
81-
for s in structs:
82-
yield s
92+
structs = [self.deserializer(d) for d in dicts]
93+
for s in structs:
94+
yield s
8395

8496
def read_impl(self, src_path: str) -> List[dict]:
8597
"""File type specific implementation"""
@@ -98,11 +110,6 @@ def apply_field_map(self, data: dict) -> dict:
98110
else:
99111
return data
100112

101-
def deserialize_dict(self, dict: dict) -> object:
102-
"""Convert a dict to struct"""
103-
104-
return self.type_adapter.validate_python(dict, context=self.context)
105-
106113

107114
class FileReaderCsv(FileReaderBase):
108115
"""File reader for json file type"""
@@ -170,18 +177,22 @@ def on_closed(self, event: FileSystemEvent):
170177
log.error(f"Failed to read data from {file_path} with exception: {e}, skipping")
171178

172179

180+
T = TypeVar("T")
181+
182+
173183
class _FileDropImpl(PushInputAdapter):
174184
FILEREADER_MAP = {
175185
FileDropType.CSV: FileReaderCsv,
176186
FileDropType.JSON: FileReaderJson,
177187
FileDropType.PARQUET: FileReaderParquet,
178188
}
179189

180-
def __init__(self, config: FileDropAdapterConfiguration, ts_typ: "T"): # noqa
190+
def __init__(self, config: FileDropAdapterConfiguration, ts_typ: T, deserializer: Optional[object] = None):
191+
# NOTE ts_typ is assumed to be a List["Y"] type where "Y" is the actual type
181192
self.dir_path = config.dir_path
182193
self.observer = Observer()
183194
reader = self.FILEREADER_MAP[config.filedrop_type]
184-
file_reader = reader(config, ts_typ)
195+
file_reader = reader(config, ts_typ[0], deserializer)
185196
self.event_handler = EventHandlerCustom(self, file_reader)
186197
self.observer.schedule(self.event_handler, self.dir_path, recursive=False)
187198

@@ -195,10 +206,18 @@ def stop(self):
195206
self.observer.join()
196207

197208

209+
# NOTE: Ref: https://github.com/Point72/csp/issues/569
210+
# Instead of passing the actual type T we want as ts_typ, we have to pass in List[T]
211+
# This is due to a type normalization bug in csp, where it converts Dict[K, T] to dict, i.e removing the type annotation hints
212+
# The type annotations are needed to properly deserialize the raw data using the pydantic type adapters.
213+
# Further we can only take the object type as output due to issues caused by using List[T] instead of T and bypassing
214+
# the type normalization process in csp. The user of the adapter, needs to keep track of the type they pass in ts_type,
215+
# and cast the ts[object] to the required ts[T], they want using csp.appy
198216
filedrop_adapter_def = py_push_adapter_def(
199217
"filedrop_adapter_def",
200218
_FileDropImpl,
201-
ts["T"],
219+
ts[object],
202220
config=FileDropAdapterConfiguration,
203-
ts_typ="T",
221+
ts_typ=List[T],
222+
deserializer=Optional[object],
204223
)

csp_gateway/server/modules/filedrop/filedrop.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
22
import os
3-
from typing import Dict, List
3+
from typing import Dict, List, TypeVar, get_args, get_origin
44

55
import csp
66
from ccflow import BaseModel
7+
from csp.impl.types.container_type_normalizer import ContainerTypeNormalizer
8+
from csp.impl.types.tstype import isTsType
79
from pydantic import Field
810

911
from csp_gateway.server import (
@@ -40,23 +42,39 @@ class ReadFileDropConfiguration(BaseModel):
4042
)
4143

4244

45+
T = TypeVar("T")
46+
K = TypeVar("K")
47+
48+
4349
class ReadFileDrop(GatewayModule):
4450
"""The module to read data from files dropped in specific directories and send them as structs to specific channels"""
4551

4652
directory_configs: Dict[str, List[ReadFileDropConfiguration]] = Field(
4753
description="Mapping of directories to a list of ReadFileDropConfiguration, for that directory in the filedrop module"
4854
)
4955

56+
@csp.node
57+
def handle_list_basket(self, data: csp.ts[List[T]], list_size: int) -> csp.OutputBasket(List[csp.ts[T]], shape="list_size"):
58+
if csp.ticked(data):
59+
return data
60+
61+
@csp.node
62+
def handle_dict_basket(self, data: csp.ts[Dict[K, T]], dict_keys: List[K]) -> csp.OutputBasket(Dict[K, csp.ts[T]], shape="dict_keys"):
63+
if csp.ticked(data):
64+
return data
65+
5066
def connect(self, channels: GatewayChannels):
5167
channel_data = {}
68+
channel_basket_types = {}
69+
channel_base_types = {}
5270
for dir, configs in self.directory_configs.items():
5371
# ensure that directories exist
5472
os.makedirs(dir, exist_ok=True)
5573
for config in configs:
5674
channel_data[config.channel_name] = []
5775
for dir, configs in self.directory_configs.items():
5876
for config in configs:
59-
type_adapter_args = {
77+
context = {
6078
"force_new_id": not config.subscribe_with_struct_id,
6179
"force_new_timestamp": not config.subscribe_with_struct_timestamp,
6280
}
@@ -65,10 +83,40 @@ def connect(self, channels: GatewayChannels):
6583
filedrop_type=config.filedrop_type,
6684
field_map=config.field_map,
6785
extensions=config.extensions,
68-
type_adapter_args=type_adapter_args,
86+
type_adapter_args=context,
6987
)
70-
channel_type = channels.get_outer_type(config.channel_name).typ
71-
data = filedrop_adapter_def(config=adapter_config, ts_typ=channel_type)
88+
channel_type = channels.get_outer_type(config.channel_name)
89+
if isTsType(channel_type):
90+
non_ts_type = channel_type.typ
91+
channel_basket_types[config.channel_name] = ""
92+
else:
93+
normalized_type = ContainerTypeNormalizer.normalize_type(channel_type)
94+
if get_origin(normalized_type) is list:
95+
inner_type = get_args(normalized_type)[0]
96+
if not isTsType(inner_type):
97+
raise ValueError(f"Channel type for {config.channel_name} should be of the form List[TsType], got: {channel_type}")
98+
non_ts_type = List[inner_type.typ]
99+
channel_basket_types[config.channel_name] = "list"
100+
elif get_origin(normalized_type) is dict:
101+
key_type, inner_type = get_args(normalized_type)
102+
if not isTsType(inner_type):
103+
raise ValueError(
104+
f"Channel type for {config.channel_name} should be of the form Dict[KeyType, TsType], got: {channel_type}"
105+
)
106+
non_ts_type = Dict[key_type, inner_type.typ]
107+
channel_basket_types[config.channel_name] = "dict"
108+
else:
109+
raise Exception(f"Channel type cannot be handled: {channel_type}")
110+
channel_base_types[config.channel_name] = non_ts_type
111+
# NOTE: We have to pass in the type as [type] because of the type normalization issue in csp: https://github.com/Point72/csp/issues/569
112+
data = filedrop_adapter_def(config=adapter_config, ts_typ=[non_ts_type], deserializer=None)
72113
channel_data[config.channel_name].append(data)
73114
for channel_name, data_list in channel_data.items():
74-
channels.set_channel(channel_name, csp.flatten(data_list))
115+
data = csp.flatten(data_list)
116+
typed_data = csp.apply(data, lambda x: x, channel_base_types[channel_name])
117+
if channel_basket_types[channel_name] == "list":
118+
channels.set_channel(channel_name, self.handle_list_basket(typed_data, list_size=channels.dynamic_keys()[channel_name]))
119+
elif channel_basket_types[channel_name] == "dict":
120+
channels.set_channel(channel_name, self.handle_dict_basket(typed_data, dict_keys=channels.dynamic_keys()[channel_name]))
121+
else:
122+
channels.set_channel(channel_name, typed_data)

0 commit comments

Comments
 (0)