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/__init__.py +3 -0
- feldera/_callback_runner.py +115 -0
- feldera/_helpers.py +100 -0
- feldera/enums.py +186 -0
- feldera/output_handler.py +60 -0
- feldera/pipeline.py +346 -0
- feldera/pipeline_builder.py +98 -0
- feldera/rest/__init__.py +11 -0
- feldera/rest/_httprequests.py +165 -0
- feldera/rest/config.py +26 -0
- feldera/rest/errors.py +55 -0
- feldera/rest/feldera_client.py +377 -0
- feldera/rest/pipeline.py +69 -0
- feldera/rest/sql_table.py +17 -0
- feldera/rest/sql_view.py +17 -0
- feldera/runtime_config.py +79 -0
- feldera-0.27.0.dist-info/METADATA +94 -0
- feldera-0.27.0.dist-info/RECORD +20 -0
- feldera-0.27.0.dist-info/WHEEL +5 -0
- feldera-0.27.0.dist-info/top_level.txt +1 -0
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
|
feldera/rest/__init__.py
ADDED
|
@@ -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
|