feldera 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
feldera/pipeline.py ADDED
@@ -0,0 +1,346 @@
1
+ import time
2
+ import pandas
3
+
4
+ from typing import List, Dict, Callable, Optional
5
+ from queue import Queue
6
+
7
+ from feldera.rest.errors import FelderaAPIError
8
+ from feldera.enums import PipelineStatus
9
+ from feldera.rest.pipeline import Pipeline as InnerPipeline
10
+ from feldera.rest.feldera_client import FelderaClient
11
+ from feldera._callback_runner import _CallbackRunnerInstruction, CallbackRunner
12
+ from feldera.output_handler import OutputHandler
13
+ from feldera._helpers import ensure_dataframe_has_columns, chunk_dataframe
14
+
15
+
16
+ class Pipeline:
17
+ def __init__(self, name: str, client: FelderaClient):
18
+ self.name = name
19
+ self.client: FelderaClient = client
20
+ self._inner: InnerPipeline | None = None
21
+ self.views_tx: List[Dict[str, Queue]] = []
22
+
23
+ def __setup_output_listeners(self):
24
+ """
25
+ Internal function used to set up the output listeners.
26
+
27
+ :meta private:
28
+ """
29
+
30
+ for view_queue in self.views_tx:
31
+ for view_name, queue in view_queue.items():
32
+ # sends a message to the callback runner to start listening
33
+ queue.put(_CallbackRunnerInstruction.PipelineStarted)
34
+ # block until the callback runner is ready
35
+ queue.join()
36
+
37
+ def status(self) -> PipelineStatus:
38
+ """
39
+ Return the current status of the pipeline.
40
+ """
41
+
42
+ try:
43
+ inner = self.client.get_pipeline(self.name)
44
+ self._inner = inner
45
+ return PipelineStatus.from_str(inner.deployment_status)
46
+
47
+ except FelderaAPIError as err:
48
+ if err.status_code == 404:
49
+ return PipelineStatus.NOT_FOUND
50
+ else:
51
+ raise err
52
+
53
+ def input_pandas(self, table_name: str, df: pandas.DataFrame, force: bool = False):
54
+ """
55
+ Push all rows in a pandas DataFrame to the pipeline.
56
+
57
+ :param table_name: The name of the table to insert data into.
58
+ :param df: The pandas DataFrame to be pushed to the pipeline.
59
+ :param force: `True` to push data even if the pipeline is paused. `False` by default.
60
+ """
61
+
62
+ status = self.status()
63
+ if status not in [
64
+ PipelineStatus.RUNNING,
65
+ PipelineStatus.PAUSED,
66
+ ]:
67
+ raise RuntimeError("Pipeline must be running or paused to push data")
68
+
69
+ if not force and status == PipelineStatus.PAUSED:
70
+ raise RuntimeError("Pipeline is paused, set force=True to push data")
71
+
72
+ ensure_dataframe_has_columns(df)
73
+
74
+ pipeline = self.client.get_pipeline(self.name)
75
+ if table_name.lower() != "now" and table_name.lower() not in [tbl.name.lower() for tbl in pipeline.tables]:
76
+ raise ValueError(f"Cannot push to table '{table_name}' as it is not registered yet")
77
+ else:
78
+ # consider validating the schema here
79
+ for datum in chunk_dataframe(df):
80
+ self.client.push_to_pipeline(
81
+ self.name,
82
+ table_name,
83
+ "json",
84
+ datum.to_json(orient='records', date_format='epoch'),
85
+ json_flavor='pandas',
86
+ array=True,
87
+ serialize=False,
88
+ force=force,
89
+ )
90
+ return
91
+
92
+ def input_json(self, table_name: str, data: Dict | list, update_format: str = "raw", force: bool = False):
93
+ """
94
+ Push this JSON data to the specified table of the pipeline.
95
+
96
+ :param table_name: The name of the table to push data into.
97
+ :param data: The JSON encoded data to be pushed to the pipeline. The data should be in the form:
98
+ `{'col1': 'val1', 'col2': 'val2'}` or `[{'col1': 'val1', 'col2': 'val2'}, {'col1': 'val1', 'col2': 'val2'}]`
99
+ :param update_format: The update format of the JSON data to be pushed to the pipeline. Must be one of:
100
+ "raw", "insert_delete". <https://docs.feldera.com/formats/json#the-insertdelete-format>
101
+ :param force: `True` to push data even if the pipeline is paused. `False` by default.
102
+ """
103
+
104
+ if update_format not in ["raw", "insert_delete"]:
105
+ ValueError("update_format must be one of raw or insert_delete")
106
+
107
+ array = True if isinstance(data, list) else False
108
+ self.client.push_to_pipeline(
109
+ self.name,
110
+ table_name,
111
+ "json",
112
+ data,
113
+ update_format=update_format,
114
+ array=array,
115
+ force=force
116
+ )
117
+
118
+ def listen(self, view_name: str) -> OutputHandler:
119
+ """
120
+ Listen to the output of the provided view so that it is available in the notebook / python code.
121
+ When the pipeline is shutdown, these listeners are dropped.
122
+
123
+ :param view_name: The name of the view to listen to.
124
+ """
125
+
126
+ queue: Optional[Queue] = None
127
+
128
+ if self.status() not in [PipelineStatus.PAUSED, PipelineStatus.RUNNING]:
129
+ queue = Queue(maxsize=1)
130
+ self.views_tx.append({view_name: queue})
131
+
132
+ handler = OutputHandler(self.client, self.name, view_name, queue)
133
+ handler.start()
134
+
135
+ return handler
136
+
137
+ def foreach_chunk(self, view_name: str, callback: Callable[[pandas.DataFrame, int], None]):
138
+ """
139
+ Run the given callback on each chunk of the output of the specified view.
140
+
141
+ :param view_name: The name of the view.
142
+ :param callback: The callback to run on each chunk. The callback should take two arguments:
143
+
144
+ - **chunk** -> The chunk as a pandas DataFrame
145
+ - **seq_no** -> The sequence number. The sequence number is a monotonically increasing integer that
146
+ starts from 0. Note that the sequence number is unique for each chunk, but not necessarily contiguous.
147
+
148
+ Please note that the callback is run in a separate thread, so it should be thread-safe.
149
+ Please note that the callback should not block for a long time, as by default, backpressure is enabled and
150
+ will block the pipeline.
151
+
152
+ .. note::
153
+ - The callback must be thread-safe as it will be run in a separate thread.
154
+
155
+ """
156
+
157
+ queue: Optional[Queue] = None
158
+
159
+ if self.status() not in [PipelineStatus.RUNNING, PipelineStatus.PAUSED]:
160
+ queue = Queue(maxsize=1)
161
+ self.views_tx.append({view_name: queue})
162
+
163
+ handler = CallbackRunner(self.client, self.name, view_name, callback, queue)
164
+ handler.start()
165
+
166
+ def wait_for_completion(self, shutdown: bool = False):
167
+ """
168
+ Block until the pipeline has completed processing all input records.
169
+
170
+ This method blocks until (1) all input connectors attached to the pipeline
171
+ have finished reading their input data sources and issued end-of-input
172
+ notifications to the pipeline, and (2) all inputs received from these
173
+ connectors have been fully processed and corresponding outputs have been
174
+ sent out through the output connectors.
175
+
176
+ This method will block indefinitely if at least one of the input
177
+ connectors attached to the pipeline is a streaming connector, such as
178
+ Kafka, that does not issue the end-of-input notification.
179
+
180
+ :param shutdown: If True, the pipeline will be shutdown after completion. False by default.
181
+
182
+ :raises RuntimeError: If the pipeline returns unknown metrics.
183
+ """
184
+
185
+ if self.status() not in [
186
+ PipelineStatus.RUNNING,
187
+ PipelineStatus.INITIALIZING,
188
+ PipelineStatus.PROVISIONING,
189
+ ]:
190
+ raise RuntimeError("Pipeline must be running to wait for completion")
191
+
192
+ while True:
193
+ metrics: dict = self.client.get_pipeline_stats(self.name).get("global_metrics")
194
+ pipeline_complete: bool = metrics.get("pipeline_complete")
195
+
196
+ if pipeline_complete is None:
197
+ raise RuntimeError("received unknown metrics from the pipeline, pipeline_complete is None")
198
+
199
+ if pipeline_complete:
200
+ break
201
+
202
+ time.sleep(1)
203
+
204
+ if shutdown:
205
+ self.shutdown()
206
+
207
+ def start(self):
208
+ """
209
+ .. _start:
210
+
211
+ Starts this pipeline.
212
+
213
+ :raises RuntimeError: If the pipeline returns unknown metrics.
214
+ """
215
+
216
+ status = self.status()
217
+ if status != PipelineStatus.SHUTDOWN:
218
+ raise RuntimeError(f"pipeline {self.name} in state: {str(status.name)} cannot be started")
219
+
220
+ self.pause()
221
+ self.__setup_output_listeners()
222
+ self.resume()
223
+
224
+ def restart(self):
225
+ """
226
+ Restarts the pipeline.
227
+ """
228
+
229
+ self.shutdown()
230
+ self.start()
231
+
232
+ def wait_for_idle(
233
+ self,
234
+ idle_interval_s: float = 5.0,
235
+ timeout_s: float = 600.0,
236
+ poll_interval_s: float = 0.2
237
+ ):
238
+ """
239
+ Wait for the pipeline to become idle and then returns.
240
+
241
+ Idle is defined as a sufficiently long interval in which the number of
242
+ input and processed records reported by the pipeline do not change, and
243
+ they equal each other (thus, all input records present at the pipeline
244
+ have been processed).
245
+
246
+ :param idle_interval_s: Idle interval duration (default is 5.0 seconds).
247
+ :param timeout_s: Timeout waiting for idle (default is 600.0 seconds).
248
+ :param poll_interval_s: Polling interval, should be set substantially
249
+ smaller than the idle interval (default is 0.2 seconds).
250
+ :raises ValueError: If idle interval is larger than timeout, poll interval
251
+ is larger than timeout, or poll interval is larger than idle interval.
252
+ :raises RuntimeError: If the metrics are missing or the timeout was
253
+ reached.
254
+ """
255
+ if idle_interval_s > timeout_s:
256
+ raise ValueError(f"idle interval ({idle_interval_s}s) cannot be larger than timeout ({timeout_s}s)")
257
+ if poll_interval_s > timeout_s:
258
+ raise ValueError(f"poll interval ({poll_interval_s}s) cannot be larger than timeout ({timeout_s}s)")
259
+ if poll_interval_s > idle_interval_s:
260
+ raise ValueError(f"poll interval ({poll_interval_s}s) cannot be larger "
261
+ f"than idle interval ({idle_interval_s}s)")
262
+
263
+ start_time_s = time.monotonic()
264
+ idle_started_s = None
265
+ prev = (0, 0)
266
+ while True:
267
+ now_s = time.monotonic()
268
+
269
+ # Metrics retrieval
270
+ metrics: dict = self.client.get_pipeline_stats(self.name).get("global_metrics")
271
+ total_input_records: int | None = metrics.get("total_input_records")
272
+ total_processed_records: int | None = metrics.get("total_processed_records")
273
+ if total_input_records is None:
274
+ raise RuntimeError("total_input_records is missing from the pipeline metrics")
275
+ if total_processed_records is None:
276
+ raise RuntimeError("total_processed_records is missing from the pipeline metrics")
277
+
278
+ # Idle check
279
+ unchanged = prev[0] == total_input_records and prev[1] == total_processed_records
280
+ equal = total_input_records == total_processed_records
281
+ prev = (total_input_records, total_processed_records)
282
+ if unchanged and equal:
283
+ if idle_started_s is None:
284
+ idle_started_s = now_s
285
+ else:
286
+ idle_started_s = None
287
+ if idle_started_s is not None and now_s - idle_started_s >= idle_interval_s:
288
+ return
289
+
290
+ # Timeout
291
+ if now_s - start_time_s >= timeout_s:
292
+ raise RuntimeError(f"waiting for idle reached timeout ({timeout_s}s)")
293
+ time.sleep(poll_interval_s)
294
+
295
+ def pause(self):
296
+ """
297
+ Pause the pipeline.
298
+ """
299
+
300
+ self.client.pause_pipeline(self.name)
301
+
302
+ def shutdown(self):
303
+ """
304
+ Shut down the pipeline.
305
+ """
306
+
307
+ if len(self.views_tx) > 0:
308
+ for _, queue in self.views_tx.pop().items():
309
+ # sends a message to the callback runner to stop listening
310
+ queue.put(_CallbackRunnerInstruction.RanToCompletion)
311
+ # block until the callback runner has been stopped
312
+ queue.join()
313
+
314
+ self.client.shutdown_pipeline(self.name)
315
+
316
+ def resume(self):
317
+ """
318
+ Resumes the pipeline.
319
+ """
320
+
321
+ self.client.start_pipeline(self.name)
322
+
323
+ def delete(self):
324
+ """
325
+ Deletes the pipeline.
326
+ """
327
+
328
+ self.client.delete_pipeline(self.name)
329
+
330
+ @staticmethod
331
+ def get(name: str, client: FelderaClient) -> 'Pipeline':
332
+ """
333
+ Get the pipeline if it exists.
334
+
335
+ :param name: The name of the pipeline.
336
+ :param client: The FelderaClient instance.
337
+ """
338
+
339
+ try:
340
+ inner = client.get_pipeline(name)
341
+ pipeline = Pipeline(inner.name, client)
342
+ pipeline.__inner = inner
343
+ return pipeline
344
+ except FelderaAPIError as err:
345
+ if err.status_code == 404:
346
+ raise RuntimeError(f"Pipeline with name {name} not found")
@@ -0,0 +1,98 @@
1
+ from feldera.rest.feldera_client import FelderaClient
2
+ from feldera.rest.pipeline import Pipeline as InnerPipeline
3
+ from feldera.pipeline import Pipeline
4
+ from feldera.enums import CompilationProfile
5
+ from feldera.runtime_config import RuntimeConfig, Resources
6
+ from feldera.rest.errors import FelderaAPIError
7
+
8
+
9
+ class PipelineBuilder:
10
+ """
11
+ A builder for creating a Feldera Pipeline.
12
+
13
+ :param client: The `.FelderaClient` instance
14
+ :param name: The name of the pipeline
15
+ :param description: The description of the pipeline
16
+ :param sql: The SQL code of the pipeline
17
+ :param compilation_profile: The compilation profile to use
18
+ :param runtime_config: The runtime config to use
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ client: FelderaClient,
24
+ name: str,
25
+ sql: str,
26
+ description: str = "",
27
+ compilation_profile: CompilationProfile = CompilationProfile.OPTIMIZED,
28
+ runtime_config: RuntimeConfig = RuntimeConfig(resources=Resources()),
29
+
30
+ ):
31
+ self.client: FelderaClient = client
32
+ self.name: str | None = name
33
+ self.description: str = description
34
+ self.sql: str = sql
35
+ self.compilation_profile: CompilationProfile = compilation_profile
36
+ self.runtime_config: RuntimeConfig = runtime_config
37
+
38
+ def create(self) -> Pipeline:
39
+ """
40
+ Create the pipeline if it does not exist.
41
+
42
+ :return: The created pipeline
43
+ """
44
+
45
+ if self.name is None or self.sql is None:
46
+ raise ValueError("Name and SQL are required to create a pipeline")
47
+
48
+ if self.client.get_pipeline(self.name) is not None:
49
+ raise RuntimeError(f"Pipeline with name {self.name} already exists")
50
+
51
+ inner = InnerPipeline(
52
+ self.name,
53
+ description=self.description,
54
+ sql=self.sql,
55
+ program_config={
56
+ 'profile': self.compilation_profile.value,
57
+ },
58
+ runtime_config=self.runtime_config.__dict__,
59
+ )
60
+
61
+ inner = self.client.create_pipeline(inner)
62
+ pipeline = Pipeline(inner.name, self.client)
63
+ pipeline._inner = inner
64
+
65
+ return pipeline
66
+
67
+ def create_or_replace(self) -> Pipeline:
68
+ """
69
+ Creates a pipeline if it does not exist and replaces it if it exists.
70
+
71
+ If the pipeline exists and is running, it will be stopped and replaced.
72
+ """
73
+
74
+ if self.name is None or self.sql is None:
75
+ raise ValueError("Name and SQL are required to create a pipeline")
76
+
77
+ try:
78
+ # shutdown the pipeline if it exists and is running
79
+ self.client.shutdown_pipeline(self.name)
80
+ except FelderaAPIError:
81
+ # pipeline doesn't exist, no worries
82
+ pass
83
+
84
+ inner = InnerPipeline(
85
+ self.name,
86
+ description=self.description,
87
+ sql=self.sql,
88
+ program_config={
89
+ 'profile': self.compilation_profile.value,
90
+ },
91
+ runtime_config=dict((k, v) for k, v in self.runtime_config.__dict__.items() if v is not None),
92
+ )
93
+
94
+ inner = self.client.create_or_update_pipeline(inner)
95
+ pipeline = Pipeline(inner.name, self.client)
96
+ pipeline._inner = inner
97
+
98
+ return pipeline
@@ -0,0 +1,11 @@
1
+ """
2
+ This is the lower level REST client for Feldera.
3
+
4
+ This is a thin wrapper around the Feldera REST API.
5
+
6
+ It is recommended to use the higher level abstractions in the `feldera` package,
7
+ instead of using the REST client directly.
8
+
9
+ """
10
+
11
+ from feldera.rest.feldera_client import FelderaClient
@@ -0,0 +1,165 @@
1
+ import logging
2
+
3
+ from feldera.rest.config import Config
4
+
5
+ from feldera.rest.errors import FelderaAPIError, FelderaTimeoutError, FelderaCommunicationError
6
+
7
+ import json
8
+ import requests
9
+ from typing import Callable, Optional, Any, Union, Mapping, Sequence, List
10
+
11
+
12
+ def json_serialize(body: Any) -> str:
13
+ return json.dumps(body) if body else "" if body == "" else "null"
14
+
15
+
16
+ class HttpRequests:
17
+ def __init__(self, config: Config) -> None:
18
+ self.config = config
19
+ self.headers = {
20
+ "User-Agent": "feldera-python-sdk/v1"
21
+ }
22
+ if self.config.api_key:
23
+ self.headers["Authorization"] = f"Bearer {self.config.api_key}"
24
+
25
+ def send_request(
26
+ self,
27
+ http_method: Callable,
28
+ path: str,
29
+ body: Optional[
30
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
31
+ ] = None,
32
+ content_type: str = "application/json",
33
+ params: Optional[Mapping[str, Any]] = None,
34
+ stream: bool = False,
35
+ serialize: bool = True,
36
+ ) -> Any:
37
+ """
38
+ :param http_method: The HTTP method to use. Takes the equivalent `requests.*` module. (Example: `requests.get`)
39
+ :param path: The path to send the request to.
40
+ :param body: The HTTP request body.
41
+ :param content_type: The value for `Content-Type` HTTP header. "application/json" by default.
42
+ :param params: The query parameters part of this request.
43
+ :param stream: True if the response is expected to be a HTTP stream.
44
+ :param serialize: True if the body needs to be serialized to JSON.
45
+ """
46
+ self.headers["Content-Type"] = content_type
47
+
48
+ try:
49
+ timeout = self.config.timeout
50
+ headers = self.headers
51
+
52
+ request_path = self.config.url + "/" + self.config.version + path
53
+
54
+ logging.debug(
55
+ "sending %s request to: %s with headers: %s, and params: %s",
56
+ http_method.__name__, request_path, str(headers), str(params)
57
+ )
58
+
59
+ if http_method.__name__ == "get":
60
+ request = http_method(
61
+ request_path,
62
+ timeout=timeout,
63
+ headers=headers,
64
+ params=params,
65
+ )
66
+ elif isinstance(body, bytes):
67
+ request = http_method(
68
+ request_path,
69
+ timeout=timeout,
70
+ headers=headers,
71
+ data=body,
72
+ params=params,
73
+ stream=stream,
74
+ )
75
+ else:
76
+ request = http_method(
77
+ request_path,
78
+ timeout=timeout,
79
+ headers=headers,
80
+ data=json_serialize(body) if serialize else body,
81
+ params=params,
82
+ stream=stream,
83
+ )
84
+ if stream:
85
+ return request
86
+ resp = self.__validate(request)
87
+ logging.debug("got response: %s", str(resp))
88
+ return resp
89
+
90
+ except requests.exceptions.Timeout as err:
91
+ raise FelderaTimeoutError(str(err)) from err
92
+ except requests.exceptions.ConnectionError as err:
93
+ raise FelderaCommunicationError(str(err)) from err
94
+
95
+ def get(
96
+ self,
97
+ path: str,
98
+ params: Optional[Mapping[str, Any]] = None
99
+ ) -> Any:
100
+ return self.send_request(requests.get, path, params)
101
+
102
+ def post(
103
+ self,
104
+ path: str,
105
+ body: Optional[
106
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
107
+ ] = None,
108
+ content_type: Optional[str] = "application/json",
109
+ params: Optional[Mapping[str, Any]] = None,
110
+ stream: bool = False,
111
+ serialize: bool = True,
112
+ ) -> Any:
113
+ return self.send_request(
114
+ requests.post,
115
+ path,
116
+ body,
117
+ content_type,
118
+ params, stream=stream,
119
+ serialize=serialize
120
+ )
121
+
122
+ def patch(
123
+ self,
124
+ path: str,
125
+ body: Optional[
126
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
127
+ ] = None,
128
+ content_type: Optional[str] = "application/json",
129
+ params: Optional[Mapping[str, Any]] = None
130
+ ) -> Any:
131
+ return self.send_request(requests.patch, path, body, content_type, params)
132
+
133
+ def put(
134
+ self,
135
+ path: str,
136
+ body: Optional[
137
+ Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
138
+ ] = None,
139
+ content_type: Optional[str] = "application/json",
140
+ params: Optional[Mapping[str, Any]] = None
141
+ ) -> Any:
142
+ return self.send_request(requests.put, path, body, content_type, params)
143
+
144
+ def delete(
145
+ self,
146
+ path: str,
147
+ body: Optional[Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str]]] = None,
148
+ params: Optional[Mapping[str, Any]] = None
149
+ ) -> Any:
150
+ return self.send_request(requests.delete, path, body, params=params)
151
+
152
+ @staticmethod
153
+ def __to_json(request: requests.Response) -> Any:
154
+ if request.content == b"":
155
+ return request
156
+ return request.json()
157
+
158
+ @staticmethod
159
+ def __validate(request: requests.Response) -> Any:
160
+ try:
161
+ request.raise_for_status()
162
+ resp = HttpRequests.__to_json(request)
163
+ return resp
164
+ except requests.exceptions.HTTPError as err:
165
+ raise FelderaAPIError(str(err), request) from err
feldera/rest/config.py ADDED
@@ -0,0 +1,26 @@
1
+ from typing import Optional
2
+
3
+
4
+ class Config:
5
+ """
6
+ :class:`.FelderaClient`'s credentials and configuration parameters
7
+ """
8
+
9
+ def __init__(
10
+ self,
11
+ url: str,
12
+ api_key: Optional[str] = None,
13
+ version: Optional[str] = None,
14
+ timeout: Optional[float] = None,
15
+ ) -> None:
16
+ """
17
+ :param url: The url to the Feldera API (ex: https://try.feldera.com)
18
+ :param api_key: The optional API key to access Feldera
19
+ :param version: The version of the API to use
20
+ :param timeout: The timeout for the HTTP requests
21
+ """
22
+
23
+ self.url: str = url
24
+ self.api_key: Optional[str] = api_key
25
+ self.version: Optional[str] = version or "v0"
26
+ self.timeout: Optional[float] = timeout