Skip to content

Commit 6bf61c0

Browse files
authored
Merge pull request #102 from Point72/ac/filedrop
Add filedrop gateway module
2 parents 1c6c737 + fd27e60 commit 6bf61c0

7 files changed

Lines changed: 1033 additions & 0 deletions

File tree

csp_gateway/server/modules/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .controls import *
2+
from .filedrop import *
23
from .initializer import *
34
from .io import *
45
from .kafka import *
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import sys
2+
3+
if sys.platform.startswith("linux"):
4+
try:
5+
from .adapter import *
6+
from .filedrop import *
7+
except ImportError:
8+
pass
9+
else:
10+
pass
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import csv
2+
import logging
3+
from dataclasses import dataclass
4+
from datetime import datetime
5+
from enum import Enum, auto
6+
from typing import Any, Dict, List, Optional, TypeVar, get_args, get_origin
7+
8+
import orjson
9+
import pyarrow.parquet as pq
10+
from csp import ts
11+
from csp.impl.pushadapter import PushInputAdapter
12+
from csp.impl.types.container_type_normalizer import ContainerTypeNormalizer
13+
from csp.impl.wiring import py_push_adapter_def
14+
from pydantic import TypeAdapter
15+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
16+
from watchdog.observers import Observer
17+
18+
__all__ = (
19+
"FileDropType",
20+
"FileDropAdapterConfiguration",
21+
"filedrop_adapter_def",
22+
)
23+
24+
25+
log = logging.getLogger(__name__)
26+
27+
28+
class FileDropType(Enum):
29+
CSV = auto()
30+
JSON = auto()
31+
PARQUET = auto()
32+
33+
34+
@dataclass
35+
class FileDropAdapterConfiguration:
36+
"""Configuration for the filedrop push adapter"""
37+
38+
# Path to the directory to monitor
39+
dir_path: str
40+
# Format of files to expect to load properly i.e parquet, json, csv
41+
filedrop_type: FileDropType
42+
# Map the data fields from the file to the fields of the structs
43+
field_map: Dict[str, str]
44+
# List of extensions to filter, empty list means all extensions are allowed
45+
extensions: List[str]
46+
# Extra args to the type adapter deserializer
47+
type_adapter_args: Dict[str, Any]
48+
49+
50+
class FileReaderBase:
51+
"""The base file reader that reads data from files and generates structs"""
52+
53+
def __init__(self, config: FileDropAdapterConfiguration, ts_typ: object, deserializer: Optional[object] = None):
54+
self.field_map = config.field_map
55+
self.extensions = config.extensions
56+
if hasattr(config, "type_adapter_args"):
57+
self.context = config.type_adapter_args
58+
else:
59+
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
82+
83+
def read(self, src_path: str) -> object:
84+
"""Generator to return stucts from a filepath"""
85+
86+
should_read = True
87+
if self.extensions:
88+
if not any([src_path.endswith(suffix) for suffix in self.extensions]):
89+
should_read = False
90+
if should_read:
91+
dicts = self.read_impl(src_path)
92+
structs = [self.deserializer(d) for d in dicts]
93+
for s in structs:
94+
yield s
95+
96+
def read_impl(self, src_path: str) -> List[dict]:
97+
"""File type specific implementation"""
98+
99+
raise Exception(f"read not implemented for {self}")
100+
101+
def apply_field_map(self, data: dict) -> dict:
102+
"""Convert the keys in the data to the field names of the struct"""
103+
104+
if self.field_map:
105+
new_data = {}
106+
for k, v in data.items():
107+
new_k = self.field_map.get(k, k)
108+
new_data[new_k] = v
109+
return new_data
110+
else:
111+
return data
112+
113+
114+
class FileReaderCsv(FileReaderBase):
115+
"""File reader for json file type"""
116+
117+
def read_impl(self, src_path: str) -> List[dict]:
118+
data = []
119+
with open(src_path, "r") as f:
120+
reader = csv.DictReader(f)
121+
for row in reader:
122+
data.append(row)
123+
return data
124+
125+
126+
class FileReaderJson(FileReaderBase):
127+
"""File reader for json file type"""
128+
129+
def read_impl(self, src_path: str) -> List[dict]:
130+
with open(src_path, "rb") as f:
131+
data = orjson.loads(f.read())
132+
if isinstance(data, list):
133+
res = data
134+
else:
135+
res = [data]
136+
return res
137+
138+
139+
class FileReaderParquet(FileReaderBase):
140+
"""File reader for parquet file type"""
141+
142+
def read_impl(self, src_path: str) -> List[dict]:
143+
table = pq.read_table(src_path)
144+
return table.to_pylist()
145+
146+
147+
class EventHandlerCustom(FileSystemEventHandler):
148+
def __init__(self, adapter: PushInputAdapter, file_reader: FileReaderBase):
149+
self.file_reader = file_reader
150+
self.adapter = adapter
151+
self._created = set()
152+
self._opened = set()
153+
self._modified = set()
154+
self._closed = set()
155+
156+
def on_created(self, event: FileSystemEvent):
157+
self._created.add(event.src_path)
158+
159+
def on_opened(self, event: FileSystemEvent):
160+
if event.src_path in self._created:
161+
self._created.remove(event.src_path)
162+
self._opened.add(event.src_path)
163+
164+
def on_modified(self, event: FileSystemEvent):
165+
if event.src_path in self._opened:
166+
self._opened.remove(event.src_path)
167+
self._modified.add(event.src_path)
168+
169+
def on_closed(self, event: FileSystemEvent):
170+
if event.src_path in self._modified:
171+
self._modified.remove(event.src_path)
172+
file_path = event.src_path
173+
try:
174+
for data in self.file_reader.read(file_path):
175+
self.adapter.push_tick(data)
176+
except Exception as e:
177+
log.error(f"Failed to read data from {file_path} with exception: {e}, skipping")
178+
179+
180+
T = TypeVar("T")
181+
182+
183+
class _FileDropImpl(PushInputAdapter):
184+
FILEREADER_MAP = {
185+
FileDropType.CSV: FileReaderCsv,
186+
FileDropType.JSON: FileReaderJson,
187+
FileDropType.PARQUET: FileReaderParquet,
188+
}
189+
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
192+
self.dir_path = config.dir_path
193+
self.observer = Observer()
194+
reader = self.FILEREADER_MAP[config.filedrop_type]
195+
file_reader = reader(config, ts_typ[0], deserializer)
196+
self.event_handler = EventHandlerCustom(self, file_reader)
197+
self.observer.schedule(self.event_handler, self.dir_path, recursive=False)
198+
199+
def start(self, starttime: datetime, endtime: datetime):
200+
self.observer.start()
201+
self.observer_started = True
202+
203+
def stop(self):
204+
if self.observer_started:
205+
self.observer.stop()
206+
self.observer.join()
207+
208+
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
216+
filedrop_adapter_def = py_push_adapter_def(
217+
"filedrop_adapter_def",
218+
_FileDropImpl,
219+
ts[object],
220+
config=FileDropAdapterConfiguration,
221+
ts_typ=List[T],
222+
deserializer=Optional[object],
223+
)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import logging
2+
import os
3+
from typing import Dict, List, TypeVar, get_args, get_origin
4+
5+
import csp
6+
from ccflow import BaseModel
7+
from csp.impl.types.container_type_normalizer import ContainerTypeNormalizer
8+
from csp.impl.types.tstype import isTsType
9+
from pydantic import Field
10+
11+
from csp_gateway.server import (
12+
GatewayChannels,
13+
GatewayModule,
14+
)
15+
16+
from .adapter import FileDropAdapterConfiguration, FileDropType, filedrop_adapter_def
17+
18+
__all__ = (
19+
"ReadFileDropConfiguration",
20+
"ReadFileDrop",
21+
)
22+
23+
log = logging.getLogger(__name__)
24+
25+
26+
class ReadFileDropConfiguration(BaseModel):
27+
"""The configuration of a filedrop adapter for a directory and filetype"""
28+
29+
channel_name: str = Field(description="Name of the channel to send the structs to")
30+
filedrop_type: FileDropType = Field(description="The type of files to expect and accordingly read i.e. parquet, json, etc")
31+
field_map: Dict[str, str] = Field(
32+
default={}, description="A map to convert the keys in the data to the field names in the struct type of the channel"
33+
)
34+
extensions: List[str] = Field(default=[], description="List of extensions to decide which files to read, empty list means all extensions")
35+
subscribe_with_struct_id: bool = Field(
36+
default=False,
37+
description=("If False, replaces the id field on GatewayStructs from files with one autogenerated by the current Gateway."),
38+
)
39+
subscribe_with_struct_timestamp: bool = Field(
40+
default=False,
41+
description=("If False, replaces the timestamp field on the GatewayStruct with a timestamp autogenerated by the current Gateway."),
42+
)
43+
44+
45+
T = TypeVar("T")
46+
K = TypeVar("K")
47+
48+
49+
class ReadFileDrop(GatewayModule):
50+
"""The module to read data from files dropped in specific directories and send them as structs to specific channels"""
51+
52+
directory_configs: Dict[str, List[ReadFileDropConfiguration]] = Field(
53+
description="Mapping of directories to a list of ReadFileDropConfiguration, for that directory in the filedrop module"
54+
)
55+
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+
66+
def connect(self, channels: GatewayChannels):
67+
channel_data = {}
68+
channel_basket_types = {}
69+
channel_base_types = {}
70+
for dir, configs in self.directory_configs.items():
71+
# ensure that directories exist
72+
os.makedirs(dir, exist_ok=True)
73+
for config in configs:
74+
channel_data[config.channel_name] = []
75+
for dir, configs in self.directory_configs.items():
76+
for config in configs:
77+
context = {
78+
"force_new_id": not config.subscribe_with_struct_id,
79+
"force_new_timestamp": not config.subscribe_with_struct_timestamp,
80+
}
81+
adapter_config = FileDropAdapterConfiguration(
82+
dir_path=dir,
83+
filedrop_type=config.filedrop_type,
84+
field_map=config.field_map,
85+
extensions=config.extensions,
86+
type_adapter_args=context,
87+
)
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)
113+
channel_data[config.channel_name].append(data)
114+
for channel_name, data_list in channel_data.items():
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)