|
| 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 | +) |
0 commit comments