feldera 0.131.0__py3-none-any.whl → 0.192.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.

Potentially problematic release.


This version of feldera might be problematic. Click here for more details.

@@ -1,17 +1,20 @@
1
- import pathlib
2
- from typing import Any, Dict, Optional
1
+ import json
3
2
  import logging
3
+ import pathlib
4
4
  import time
5
- import json
6
5
  from decimal import Decimal
7
- from typing import Generator, Mapping
6
+ from typing import Any, Dict, Generator, Mapping, Optional
7
+ from urllib.parse import quote
8
+
9
+ import requests
8
10
 
11
+ from feldera.enums import BootstrapPolicy, PipelineFieldSelector, PipelineStatus
12
+ from feldera.rest._helpers import determine_client_version
13
+ from feldera.rest._httprequests import HttpRequests
9
14
  from feldera.rest.config import Config
15
+ from feldera.rest.errors import FelderaAPIError, FelderaTimeoutError
10
16
  from feldera.rest.feldera_config import FelderaConfig
11
- from feldera.rest.errors import FelderaTimeoutError, FelderaAPIError
12
17
  from feldera.rest.pipeline import Pipeline
13
- from feldera.rest._httprequests import HttpRequests
14
- from feldera.rest._helpers import client_version
15
18
 
16
19
 
17
20
  def _validate_no_none_keys_in_map(data):
@@ -35,34 +38,51 @@ def _prepare_boolean_input(value: bool) -> str:
35
38
 
36
39
  class FelderaClient:
37
40
  """
38
- A client for the Feldera HTTP API
41
+ A client for the Feldera HTTP API.
39
42
 
40
- A client instance is needed for every Feldera API method to know the
41
- location of Feldera and its permissions.
43
+ The client is initialized with the configuration needed for interacting with the
44
+ Feldera HTTP API, which it uses in its calls. Its methods are implemented
45
+ by issuing one or more HTTP requests to the API, and as such can provide higher
46
+ level operations (e.g., support waiting for the success of asynchronous HTTP API
47
+ functionality).
42
48
  """
43
49
 
44
50
  def __init__(
45
51
  self,
46
- url: str,
52
+ url: Optional[str] = None,
47
53
  api_key: Optional[str] = None,
48
54
  timeout: Optional[float] = None,
49
55
  connection_timeout: Optional[float] = None,
50
- requests_verify: bool = True,
56
+ requests_verify: Optional[bool | str] = None,
51
57
  ) -> None:
52
58
  """
53
- :param url: The url to Feldera API (ex: https://try.feldera.com)
54
- :param api_key: The optional API key for Feldera
55
- :param timeout: (optional) The amount of time in seconds that the
56
- client will wait for a response before timing out.
57
- :param connection_timeout: (optional) The amount of time in seconds that
58
- the client will wait to establish connection before timing out.
59
- :param requests_verify: The `verify` parameter passed to the requests
60
- library. `True` by default.
59
+ Constructs a Feldera client.
60
+
61
+ :param url: (Optional) URL to the Feldera API.
62
+ The default is read from the `FELDERA_HOST` environment variable;
63
+ if the variable is not set, the default is `"http://localhost:8080"`.
64
+ :param api_key: (Optional) API key to access Feldera (format: `"apikey:..."`).
65
+ The default is read from the `FELDERA_API_KEY` environment variable;
66
+ if the variable is not set, the default is `None` (no API key is provided).
67
+ :param timeout: (Optional) Duration in seconds that the client will wait to receive
68
+ a response of an HTTP request, after which it times out.
69
+ The default is `None` (wait indefinitely; no timeout is enforced).
70
+ :param connection_timeout: (Optional) Duration in seconds that the client will wait
71
+ to establish the connection of an HTTP request, after which it times out.
72
+ The default is `None` (wait indefinitely; no timeout is enforced).
73
+ :param requests_verify: (Optional) The `verify` parameter passed to the `requests` library,
74
+ which is used internally to perform HTTP requests.
75
+ See also: https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification .
76
+ The default is based on the `FELDERA_HTTPS_TLS_CERT` or the `FELDERA_TLS_INSECURE` environment variable.
77
+ By setting `FELDERA_HTTPS_TLS_CERT` to a path, the default becomes the CA bundle it points to.
78
+ By setting `FELDERA_TLS_INSECURE` to `"1"`, `"true"` or `"yes"` (all case-insensitive), the default becomes
79
+ `False` which means to disable TLS verification by default. If both variables are set, the former takes
80
+ priority over the latter. If neither variable is set, the default is `True`.
61
81
  """
62
82
 
63
83
  self.config = Config(
64
- url,
65
- api_key,
84
+ url=url,
85
+ api_key=api_key,
66
86
  timeout=timeout,
67
87
  connection_timeout=connection_timeout,
68
88
  requests_verify=requests_verify,
@@ -70,12 +90,12 @@ class FelderaClient:
70
90
  self.http = HttpRequests(self.config)
71
91
 
72
92
  try:
73
- config = self.get_config()
74
- version = client_version()
75
- if config.version != version:
93
+ client_version = determine_client_version()
94
+ server_config = self.get_config()
95
+ if client_version != server_config.version:
76
96
  logging.warning(
77
- f"Client is on version {version} while server is at "
78
- f"{config.version}. There could be incompatibilities."
97
+ f"Feldera client is on version {client_version} while server is at "
98
+ f"{server_config.version}. There could be incompatibilities."
79
99
  )
80
100
  except Exception as e:
81
101
  logging.error(f"Failed to connect to Feldera API: {e}")
@@ -89,35 +109,46 @@ class FelderaClient:
89
109
 
90
110
  return FelderaClient(f"http://127.0.0.1:{port}")
91
111
 
92
- def get_pipeline(self, pipeline_name) -> Pipeline:
112
+ def get_pipeline(
113
+ self, pipeline_name: str, field_selector: PipelineFieldSelector
114
+ ) -> Pipeline:
93
115
  """
94
116
  Get a pipeline by name
95
117
 
96
118
  :param pipeline_name: The name of the pipeline
119
+ :param field_selector: Choose what pipeline information to refresh; see PipelineFieldSelector enum definition.
97
120
  """
98
121
 
99
- resp = self.http.get(f"/pipelines/{pipeline_name}")
122
+ resp = self.http.get(
123
+ f"/pipelines/{pipeline_name}?selector={field_selector.value}"
124
+ )
100
125
 
101
126
  return Pipeline.from_dict(resp)
102
127
 
103
- def get_runtime_config(self, pipeline_name) -> dict:
128
+ def get_runtime_config(self, pipeline_name) -> Mapping[str, Any]:
104
129
  """
105
130
  Get the runtime config of a pipeline by name
106
131
 
107
132
  :param pipeline_name: The name of the pipeline
108
133
  """
109
134
 
110
- resp: dict = self.http.get(f"/pipelines/{pipeline_name}")
135
+ resp: Mapping[str, Any] = self.http.get(f"/pipelines/{pipeline_name}")
111
136
 
112
- return resp.get("runtime_config")
137
+ runtime_config: Mapping[str, Any] | None = resp.get("runtime_config")
138
+ if runtime_config is None:
139
+ raise ValueError(f"Pipeline {pipeline_name} has no runtime config")
113
140
 
114
- def pipelines(self) -> list[Pipeline]:
141
+ return runtime_config
142
+
143
+ def pipelines(
144
+ self, selector: PipelineFieldSelector = PipelineFieldSelector.STATUS
145
+ ) -> list[Pipeline]:
115
146
  """
116
147
  Get all pipelines
117
148
  """
118
149
 
119
150
  resp = self.http.get(
120
- path="/pipelines",
151
+ path=f"/pipelines?selector={selector.value}",
121
152
  )
122
153
 
123
154
  return [Pipeline.from_dict(pipeline) for pipeline in resp]
@@ -126,12 +157,14 @@ class FelderaClient:
126
157
  wait = ["Pending", "CompilingSql", "SqlCompiled", "CompilingRust"]
127
158
 
128
159
  while True:
129
- p = self.get_pipeline(name)
160
+ p = self.get_pipeline(name, PipelineFieldSelector.STATUS)
130
161
  status = p.program_status
131
162
 
132
163
  if status == "Success":
133
- return p
164
+ return self.get_pipeline(name, PipelineFieldSelector.ALL)
134
165
  elif status not in wait:
166
+ p = self.get_pipeline(name, PipelineFieldSelector.ALL)
167
+
135
168
  # error handling for SQL compilation errors
136
169
  if status == "SqlError":
137
170
  sql_errors = p.program_error["sql_compilation"]["messages"]
@@ -159,12 +192,92 @@ class FelderaClient:
159
192
  logging.debug("still compiling %s, waiting for 100 more milliseconds", name)
160
193
  time.sleep(0.1)
161
194
 
162
- def create_pipeline(self, pipeline: Pipeline) -> Pipeline:
195
+ def __wait_for_pipeline_state(
196
+ self,
197
+ pipeline_name: str,
198
+ state: str,
199
+ timeout_s: Optional[float] = None,
200
+ start: bool = True,
201
+ ):
202
+ start_time = time.monotonic()
203
+
204
+ while True:
205
+ if timeout_s is not None:
206
+ elapsed = time.monotonic() - start_time
207
+ if elapsed > timeout_s:
208
+ raise TimeoutError(
209
+ f"Timed out waiting for pipeline {pipeline_name} to "
210
+ f"transition to '{state}' state"
211
+ )
212
+
213
+ resp = self.get_pipeline(pipeline_name, PipelineFieldSelector.STATUS)
214
+ status = resp.deployment_status
215
+
216
+ if status.lower() == state.lower():
217
+ break
218
+ elif (
219
+ status == "Stopped"
220
+ and len(resp.deployment_error or {}) > 0
221
+ and resp.deployment_desired_status == "Stopped"
222
+ ):
223
+ err_msg = "Unable to START the pipeline:\n" if start else ""
224
+ raise RuntimeError(
225
+ f"""{err_msg}Unable to transition the pipeline to '{state}'.
226
+ Reason: The pipeline is in a STOPPED state due to the following error:
227
+ {resp.deployment_error.get("message", "")}"""
228
+ )
229
+
230
+ logging.debug(
231
+ "still starting %s, waiting for 100 more milliseconds", pipeline_name
232
+ )
233
+ time.sleep(0.1)
234
+
235
+ def __wait_for_pipeline_state_one_of(
236
+ self,
237
+ pipeline_name: str,
238
+ states: list[str],
239
+ timeout_s: float | None = None,
240
+ start: bool = True,
241
+ ) -> PipelineStatus:
242
+ start_time = time.monotonic()
243
+ states = [state.lower() for state in states]
244
+
245
+ while True:
246
+ if timeout_s is not None:
247
+ elapsed = time.monotonic() - start_time
248
+ if elapsed > timeout_s:
249
+ raise TimeoutError(
250
+ f"Timed out waiting for pipeline {pipeline_name} to"
251
+ f"transition to one of the states: {states}"
252
+ )
253
+
254
+ resp = self.get_pipeline(pipeline_name, PipelineFieldSelector.STATUS)
255
+ status = resp.deployment_status
256
+
257
+ if status.lower() in states:
258
+ return PipelineStatus.from_str(status)
259
+ elif (
260
+ status == "Stopped"
261
+ and len(resp.deployment_error or {}) > 0
262
+ and resp.deployment_desired_status == "Stopped"
263
+ ):
264
+ err_msg = "Unable to START the pipeline:\n" if start else ""
265
+ raise RuntimeError(
266
+ f"""{err_msg}Unable to transition the pipeline to one of the states: {states}.
267
+ Reason: The pipeline is in a STOPPED state due to the following error:
268
+ {resp.deployment_error.get("message", "")}"""
269
+ )
270
+ logging.debug(
271
+ "still starting %s, waiting for 100 more milliseconds", pipeline_name
272
+ )
273
+ time.sleep(0.1)
274
+
275
+ def create_pipeline(self, pipeline: Pipeline, wait: bool = True) -> Pipeline:
163
276
  """
164
277
  Create a pipeline if it doesn't exist and wait for it to compile
165
278
 
166
-
167
- :name: The name of the pipeline
279
+ :param pipeline: The pipeline to create
280
+ :param wait: Whether to wait for the pipeline to compile. True by default
168
281
  """
169
282
 
170
283
  body = {
@@ -182,12 +295,21 @@ class FelderaClient:
182
295
  body=body,
183
296
  )
184
297
 
298
+ if not wait:
299
+ return pipeline
300
+
185
301
  return self.__wait_for_compilation(pipeline.name)
186
302
 
187
- def create_or_update_pipeline(self, pipeline: Pipeline) -> Pipeline:
303
+ def create_or_update_pipeline(
304
+ self, pipeline: Pipeline, wait: bool = True
305
+ ) -> Pipeline:
188
306
  """
189
307
  Create a pipeline if it doesn't exist or update a pipeline and wait for
190
308
  it to compile
309
+
310
+ :param pipeline: The pipeline to create or update
311
+ :param wait: Whether to wait for the pipeline to compile. True by default
312
+ :return: The created or updated pipeline
191
313
  """
192
314
 
193
315
  body = {
@@ -205,6 +327,9 @@ class FelderaClient:
205
327
  body=body,
206
328
  )
207
329
 
330
+ if not wait:
331
+ return pipeline
332
+
208
333
  return self.__wait_for_compilation(pipeline.name)
209
334
 
210
335
  def patch_pipeline(
@@ -235,6 +360,21 @@ class FelderaClient:
235
360
  },
236
361
  )
237
362
 
363
+ def testing_force_update_platform_version(self, name: str, platform_version: str):
364
+ self.http.post(
365
+ path=f"/pipelines/{name}/testing",
366
+ params={"set_platform_version": platform_version},
367
+ )
368
+
369
+ def update_pipeline_runtime(self, name: str):
370
+ """
371
+ Recompile a pipeline with the Feldera runtime version included in the currently installed Feldera platform.
372
+
373
+ :param name: The name of the pipeline
374
+ """
375
+
376
+ self.http.post(path=f"/pipelines/{name}/update_runtime")
377
+
238
378
  def delete_pipeline(self, name: str):
239
379
  """
240
380
  Deletes a pipeline by name
@@ -275,130 +415,161 @@ class FelderaClient:
275
415
  yield chunk.decode("utf-8")
276
416
 
277
417
  def activate_pipeline(
278
- self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = 300
279
- ):
418
+ self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = None
419
+ ) -> Optional[PipelineStatus]:
280
420
  """
281
421
 
282
422
  :param pipeline_name: The name of the pipeline to activate
283
- :param wait: Set True to wait for the pipeline to activate. True by default
284
- :param timeout_s: The amount of time in seconds to wait for the pipeline
285
- to activate. 300 seconds by default.
423
+ :param wait: Set True to wait for the pipeline to activate. True by
424
+ default
425
+ :param timeout_s: The amount of time in seconds to wait for the
426
+ pipeline to activate.
286
427
  """
287
428
 
288
- if timeout_s is None:
289
- timeout_s = 300
290
-
291
429
  self.http.post(
292
430
  path=f"/pipelines/{pipeline_name}/activate",
293
431
  )
294
432
 
295
433
  if not wait:
296
- return
434
+ return None
297
435
 
298
- start_time = time.monotonic()
436
+ return self.__wait_for_pipeline_state_one_of(
437
+ pipeline_name, ["running", "AwaitingApproval"], timeout_s
438
+ )
299
439
 
300
- while True:
301
- if timeout_s is not None:
302
- elapsed = time.monotonic() - start_time
303
- if elapsed > timeout_s:
304
- raise TimeoutError(
305
- f"Timed out waiting for pipeline {pipeline_name} to activate"
306
- )
440
+ def _inner_start_pipeline(
441
+ self,
442
+ pipeline_name: str,
443
+ initial: str = "running",
444
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
445
+ wait: bool = True,
446
+ timeout_s: Optional[float] = None,
447
+ ) -> Optional[PipelineStatus]:
448
+ """
307
449
 
308
- resp = self.get_pipeline(pipeline_name)
309
- status = resp.deployment_status
450
+ :param pipeline_name: The name of the pipeline to start
451
+ :param initial: The initial state to start the pipeline in. "running"
452
+ by default.
453
+ :param wait: Set True to wait for the pipeline to start. True by default
454
+ :param timeout_s: The amount of time in seconds to wait for the
455
+ pipeline to start.
456
+ """
310
457
 
311
- if status == "Running":
312
- break
313
- elif (
314
- status == "Stopped"
315
- and len(resp.deployment_error or {}) > 0
316
- and resp.deployment_desired_status == "Stopped"
317
- ):
318
- raise RuntimeError(
319
- f"""Unable to ACTIVATE the pipeline.
320
- Reason: The pipeline is in a STOPPED state due to the following error:
321
- {resp.deployment_error.get("message", "")}"""
322
- )
458
+ params = {"initial": initial}
459
+ if bootstrap_policy is not None:
460
+ params["bootstrap_policy"] = bootstrap_policy.value
323
461
 
324
- logging.debug(
325
- "still starting %s, waiting for 100 more milliseconds", pipeline_name
326
- )
327
- time.sleep(0.1)
462
+ self.http.post(
463
+ path=f"/pipelines/{pipeline_name}/start",
464
+ params=params,
465
+ )
466
+
467
+ if not wait:
468
+ return None
469
+
470
+ return self.__wait_for_pipeline_state_one_of(
471
+ pipeline_name, [initial, "AwaitingApproval"], timeout_s
472
+ )
328
473
 
329
474
  def start_pipeline(
330
- self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = 300
331
- ):
475
+ self,
476
+ pipeline_name: str,
477
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
478
+ wait: bool = True,
479
+ timeout_s: Optional[float] = None,
480
+ ) -> Optional[PipelineStatus]:
332
481
  """
333
482
 
334
483
  :param pipeline_name: The name of the pipeline to start
335
- :param wait: Set True to wait for the pipeline to start. True by default
336
- :param timeout_s: The amount of time in seconds to wait for the pipeline
337
- to start. 300 seconds by default.
484
+ :param wait: Set True to wait for the pipeline to start.
485
+ True by default
486
+ :param timeout_s: The amount of time in seconds to wait for the
487
+ pipeline to start.
338
488
  """
339
489
 
340
- if timeout_s is None:
341
- timeout_s = 300
490
+ return self._inner_start_pipeline(
491
+ pipeline_name, "running", bootstrap_policy, wait, timeout_s
492
+ )
342
493
 
343
- self.http.post(
344
- path=f"/pipelines/{pipeline_name}/start",
494
+ def start_pipeline_as_paused(
495
+ self,
496
+ pipeline_name: str,
497
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
498
+ wait: bool = True,
499
+ timeout_s: float | None = None,
500
+ ) -> Optional[PipelineStatus]:
501
+ """
502
+ :param pipeline_name: The name of the pipeline to start as paused.
503
+ :param wait: Set True to wait for the pipeline to start as pause.
504
+ True by default
505
+ :param timeout_s: The amount of time in seconds to wait for the
506
+ pipeline to start.
507
+ """
508
+
509
+ return self._inner_start_pipeline(
510
+ pipeline_name, "paused", bootstrap_policy, wait, timeout_s
345
511
  )
346
512
 
347
- if not wait:
348
- return
513
+ def start_pipeline_as_standby(
514
+ self,
515
+ pipeline_name: str,
516
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
517
+ wait: bool = True,
518
+ timeout_s: Optional[float] = None,
519
+ ):
520
+ """
521
+ :param pipeline_name: The name of the pipeline to start as standby.
522
+ :param wait: Set True to wait for the pipeline to start as standby.
523
+ True by default
524
+ :param timeout_s: The amount of time in seconds to wait for the
525
+ pipeline to start.
526
+ """
349
527
 
350
- start_time = time.monotonic()
528
+ self._inner_start_pipeline(
529
+ pipeline_name, "standby", bootstrap_policy, wait, timeout_s
530
+ )
351
531
 
352
- while True:
353
- if timeout_s is not None:
354
- elapsed = time.monotonic() - start_time
355
- if elapsed > timeout_s:
356
- raise TimeoutError(
357
- f"Timed out waiting for pipeline {pipeline_name} to start"
358
- )
532
+ def resume_pipeline(
533
+ self,
534
+ pipeline_name: str,
535
+ wait: bool = True,
536
+ timeout_s: Optional[float] = None,
537
+ ):
538
+ """
539
+ Resume a pipeline
359
540
 
360
- resp = self.get_pipeline(pipeline_name)
361
- status = resp.deployment_status
541
+ :param pipeline_name: The name of the pipeline to stop
542
+ :param wait: Set True to wait for the pipeline to pause. True by default
543
+ :param timeout_s: The amount of time in seconds to wait for the pipeline
544
+ to pause.
545
+ """
362
546
 
363
- if status == "Running":
364
- break
365
- elif (
366
- status == "Stopped"
367
- and len(resp.deployment_error or {}) > 0
368
- and resp.deployment_desired_status == "Stopped"
369
- ):
370
- raise RuntimeError(
371
- f"""Unable to START the pipeline.
372
- Reason: The pipeline is in a STOPPED state due to the following error:
373
- {resp.deployment_error.get("message", "")}"""
374
- )
547
+ self.http.post(
548
+ path=f"/pipelines/{pipeline_name}/resume",
549
+ )
375
550
 
376
- logging.debug(
377
- "still starting %s, waiting for 100 more milliseconds", pipeline_name
378
- )
379
- time.sleep(0.1)
551
+ if not wait:
552
+ return
553
+
554
+ self.__wait_for_pipeline_state(pipeline_name, "running", timeout_s)
380
555
 
381
556
  def pause_pipeline(
382
557
  self,
383
558
  pipeline_name: str,
384
- error_message: str = None,
385
559
  wait: bool = True,
386
- timeout_s: Optional[float] = 300,
560
+ timeout_s: Optional[float] = None,
387
561
  ):
388
562
  """
389
- Stop a pipeline
563
+ Pause a pipeline
390
564
 
391
565
  :param pipeline_name: The name of the pipeline to stop
392
566
  :param error_message: The error message to show if the pipeline is in
393
567
  STOPPED state due to a failure.
394
568
  :param wait: Set True to wait for the pipeline to pause. True by default
395
569
  :param timeout_s: The amount of time in seconds to wait for the pipeline
396
- to pause. 300 seconds by default.
570
+ to pause.
397
571
  """
398
572
 
399
- if timeout_s is None:
400
- timeout_s = 300
401
-
402
573
  self.http.post(
403
574
  path=f"/pipelines/{pipeline_name}/pause",
404
575
  )
@@ -406,46 +577,22 @@ Reason: The pipeline is in a STOPPED state due to the following error:
406
577
  if not wait:
407
578
  return
408
579
 
409
- if error_message is None:
410
- error_message = "Unable to PAUSE the pipeline.\n"
411
-
412
- start_time = time.monotonic()
413
-
414
- while True:
415
- if timeout_s is not None:
416
- elapsed = time.monotonic() - start_time
417
- if elapsed > timeout_s:
418
- raise TimeoutError(
419
- f"Timed out waiting for pipeline {pipeline_name} to pause"
420
- )
421
-
422
- resp = self.get_pipeline(pipeline_name)
423
- status = resp.deployment_status
424
-
425
- if status == "Paused":
426
- break
427
- elif (
428
- status == "Stopped"
429
- and len(resp.deployment_error or {}) > 0
430
- and resp.deployment_desired_status == "Stopped"
431
- ):
432
- raise RuntimeError(
433
- error_message
434
- + f"""Reason: The pipeline is in a STOPPED state due to the following error:
435
- {resp.deployment_error.get("message", "")}"""
436
- )
580
+ self.__wait_for_pipeline_state(pipeline_name, "paused", timeout_s)
437
581
 
438
- logging.debug(
439
- "still pausing %s, waiting for 100 more milliseconds", pipeline_name
440
- )
441
- time.sleep(0.1)
582
+ def approve_pipeline(
583
+ self,
584
+ pipeline_name: str,
585
+ ):
586
+ self.http.post(
587
+ path=f"/pipelines/{pipeline_name}/approve",
588
+ )
442
589
 
443
590
  def stop_pipeline(
444
591
  self,
445
592
  pipeline_name: str,
446
593
  force: bool,
447
594
  wait: bool = True,
448
- timeout_s: Optional[float] = 300,
595
+ timeout_s: Optional[float] = None,
449
596
  ):
450
597
  """
451
598
  Stop a pipeline
@@ -455,12 +602,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
455
602
  Set False to automatically checkpoint before stopping.
456
603
  :param wait: Set True to wait for the pipeline to stop. True by default
457
604
  :param timeout_s: The amount of time in seconds to wait for the pipeline
458
- to stop. Default is 300 seconds.
605
+ to stop.
459
606
  """
460
607
 
461
- if timeout_s is None:
462
- timeout_s = 300
463
-
464
608
  params = {"force": str(force).lower()}
465
609
 
466
610
  self.http.post(
@@ -473,8 +617,15 @@ Reason: The pipeline is in a STOPPED state due to the following error:
473
617
 
474
618
  start = time.monotonic()
475
619
 
476
- while time.monotonic() - start < timeout_s:
477
- status = self.get_pipeline(pipeline_name).deployment_status
620
+ while True:
621
+ if timeout_s is not None and time.monotonic() - start > timeout_s:
622
+ raise FelderaTimeoutError(
623
+ f"timeout error: pipeline '{pipeline_name}' did not stop in {timeout_s} seconds"
624
+ )
625
+
626
+ status = self.get_pipeline(
627
+ pipeline_name, PipelineFieldSelector.STATUS
628
+ ).deployment_status
478
629
 
479
630
  if status == "Stopped":
480
631
  return
@@ -485,30 +636,29 @@ Reason: The pipeline is in a STOPPED state due to the following error:
485
636
  )
486
637
  time.sleep(0.1)
487
638
 
488
- raise FelderaTimeoutError(
489
- f"timeout error: pipeline '{pipeline_name}' did not stop in {timeout_s} seconds"
490
- )
491
-
492
- def clear_storage(self, pipeline_name: str, timeout_s: Optional[float] = 300):
639
+ def clear_storage(self, pipeline_name: str, timeout_s: Optional[float] = None):
493
640
  """
494
641
  Clears the storage from the pipeline.
495
642
  This operation cannot be canceled.
496
643
 
497
644
  :param pipeline_name: The name of the pipeline
498
645
  :param timeout_s: The amount of time in seconds to wait for the storage
499
- to clear. Default is 300 seconds.
646
+ to clear.
500
647
  """
501
- if timeout_s is None:
502
- timeout_s = 300
503
-
504
648
  self.http.post(
505
649
  path=f"/pipelines/{pipeline_name}/clear",
506
650
  )
507
651
 
508
652
  start = time.monotonic()
509
653
 
510
- while time.monotonic() - start < timeout_s:
511
- status = self.get_pipeline(pipeline_name).storage_status
654
+ while True:
655
+ if timeout_s is not None and time.monotonic() - start > timeout_s:
656
+ raise FelderaTimeoutError(
657
+ f"timeout error: pipeline '{pipeline_name}' did not clear storage in {timeout_s} seconds"
658
+ )
659
+ status = self.get_pipeline(
660
+ pipeline_name, PipelineFieldSelector.STATUS
661
+ ).storage_status
512
662
 
513
663
  if status == "Cleared":
514
664
  return
@@ -519,10 +669,6 @@ Reason: The pipeline is in a STOPPED state due to the following error:
519
669
  )
520
670
  time.sleep(0.1)
521
671
 
522
- raise FelderaTimeoutError(
523
- f"timeout error: pipeline '{pipeline_name}' did not clear storage in {timeout_s} seconds"
524
- )
525
-
526
672
  def start_transaction(self, pipeline_name: str) -> int:
527
673
  """
528
674
  Start a new transaction.
@@ -562,7 +708,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
562
708
  :raises RuntimeError: If there is currently no transaction in progress.
563
709
  :raises ValueError: If the provided `transaction_id` does not match the current transaction.
564
710
  :raises TimeoutError: If the transaction does not commit within the specified timeout (when `wait` is True).
565
- :raises FelderaAPIError: If the pipeline fails to start a transaction.
711
+ :raises FelderaAPIError: If the pipeline fails to commit a transaction.
566
712
  """
567
713
 
568
714
  # TODO: implement this without using /stats when we have a better pipeline status reporting API.
@@ -718,15 +864,15 @@ Reason: The pipeline is in a STOPPED state due to the following error:
718
864
  _validate_no_none_keys_in_map(datum.get("insert", {}))
719
865
  _validate_no_none_keys_in_map(datum.get("delete", {}))
720
866
  else:
721
- data: dict = data
867
+ data: Mapping[str, Any] = data
722
868
  _validate_no_none_keys_in_map(data.get("insert", {}))
723
869
  _validate_no_none_keys_in_map(data.get("delete", {}))
724
870
  else:
725
871
  _validate_no_none_keys_in_map(data)
726
872
 
727
873
  # python sends `True` which isn't accepted by the backend
728
- array = _prepare_boolean_input(array)
729
- force = _prepare_boolean_input(force)
874
+ array: str = _prepare_boolean_input(array)
875
+ force: str = _prepare_boolean_input(force)
730
876
 
731
877
  params = {
732
878
  "force": force,
@@ -747,7 +893,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
747
893
  data = bytes(str(data), "utf-8")
748
894
 
749
895
  resp = self.http.post(
750
- path=f"/pipelines/{pipeline_name}/ingress/{table_name}",
896
+ path=f"/pipelines/{quote(pipeline_name, safe='')}/ingress/{quote(table_name, safe='')}",
751
897
  params=params,
752
898
  content_type=content_type,
753
899
  body=data,
@@ -765,8 +911,37 @@ Reason: The pipeline is in a STOPPED state due to the following error:
765
911
 
766
912
  return token
767
913
 
914
+ def completion_token_processed(self, pipeline_name: str, token: str) -> bool:
915
+ """
916
+ Check whether the pipeline has finished processing all inputs received from the connector before
917
+ the token was generated.
918
+
919
+ :param pipeline_name: The name of the pipeline
920
+ :param token: The token to check for completion
921
+ :return: True if the pipeline has finished processing all inputs represented by the token, False otherwise
922
+ """
923
+
924
+ params = {
925
+ "token": token,
926
+ }
927
+
928
+ resp = self.http.get(
929
+ path=f"/pipelines/{quote(pipeline_name, safe='')}/completion_status",
930
+ params=params,
931
+ )
932
+
933
+ status: Optional[str] = resp.get("status")
934
+
935
+ if status is None:
936
+ raise FelderaAPIError(
937
+ f"got empty status when checking for completion status for token: {token}",
938
+ resp,
939
+ )
940
+
941
+ return status.lower() == "complete"
942
+
768
943
  def wait_for_token(
769
- self, pipeline_name: str, token: str, timeout_s: Optional[float] = 600
944
+ self, pipeline_name: str, token: str, timeout_s: Optional[float] = None
770
945
  ):
771
946
  """
772
947
  Blocks until all records represented by this completion token have
@@ -775,13 +950,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
775
950
  :param pipeline_name: The name of the pipeline
776
951
  :param token: The token to check for completion
777
952
  :param timeout_s: The amount of time in seconds to wait for the pipeline
778
- to process these records. Default 600s
953
+ to process these records.
779
954
  """
780
955
 
781
- params = {
782
- "token": token,
783
- }
784
-
785
956
  start = time.monotonic()
786
957
  end = start + timeout_s if timeout_s else None
787
958
  initial_backoff = 0.1
@@ -794,22 +965,11 @@ Reason: The pipeline is in a STOPPED state due to the following error:
794
965
  if time.monotonic() > end:
795
966
  raise FelderaTimeoutError(
796
967
  f"timeout error: pipeline '{pipeline_name}' did not"
797
- f" process records represented by token {token} within"
798
- f" {timeout_s}"
968
+ + f" process records represented by token {token} within"
969
+ + f" {timeout_s}"
799
970
  )
800
971
 
801
- resp = self.http.get(
802
- path=f"/pipelines/{pipeline_name}/completion_status", params=params
803
- )
804
-
805
- status: Optional[str] = resp.get("status")
806
- if status is None:
807
- raise FelderaAPIError(
808
- f"got empty status when checking for completion status for token: {token}",
809
- resp,
810
- )
811
-
812
- if status.lower() == "complete":
972
+ if self.completion_token_processed(pipeline_name, token):
813
973
  break
814
974
 
815
975
  elapsed = time.monotonic() - start
@@ -830,6 +990,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
830
990
  backpressure: bool = True,
831
991
  array: bool = False,
832
992
  timeout: Optional[float] = None,
993
+ case_sensitive: bool = False,
833
994
  ):
834
995
  """
835
996
  Listen for updates to views for pipeline, yields the chunks of data
@@ -845,6 +1006,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
845
1006
  "json" format, the default value is False
846
1007
 
847
1008
  :param timeout: The amount of time in seconds to listen to the stream for
1009
+ :param case_sensitive: True if the table name is case sensitive or a reserved keyword, False by default
848
1010
  """
849
1011
 
850
1012
  params = {
@@ -855,8 +1017,10 @@ Reason: The pipeline is in a STOPPED state due to the following error:
855
1017
  if format == "json":
856
1018
  params["array"] = _prepare_boolean_input(array)
857
1019
 
858
- resp = self.http.post(
859
- path=f"/pipelines/{pipeline_name}/egress/{table_name}",
1020
+ table_name = f'"{table_name}"' if case_sensitive else table_name
1021
+
1022
+ resp: requests.Response = self.http.post(
1023
+ path=f"/pipelines/{quote(pipeline_name, safe='')}/egress/{quote(table_name, safe='')}",
860
1024
  params=params,
861
1025
  stream=True,
862
1026
  )
@@ -960,7 +1124,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
960
1124
 
961
1125
  def query_as_json(
962
1126
  self, pipeline_name: str, query: str
963
- ) -> Generator[dict, None, None]:
1127
+ ) -> Generator[Mapping[str, Any], None, None]:
964
1128
  """
965
1129
  Executes an ad-hoc query on the specified pipeline and returns the result as a generator that yields
966
1130
  rows of the query as Python dictionaries.
@@ -982,7 +1146,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
982
1146
  stream=True,
983
1147
  )
984
1148
 
985
- for chunk in resp.iter_lines(chunk_size=50000000):
1149
+ for chunk in resp.iter_lines(chunk_size=1024):
986
1150
  if chunk:
987
1151
  yield json.loads(chunk, parse_float=Decimal)
988
1152
 
@@ -1034,7 +1198,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
1034
1198
 
1035
1199
  def get_config(self) -> FelderaConfig:
1036
1200
  """
1037
- Get general feldera configuration.
1201
+ Retrieves the general Feldera server configuration.
1038
1202
  """
1039
1203
 
1040
1204
  resp = self.http.get(path="/config")
@@ -1069,3 +1233,31 @@ Reason: The pipeline is in a STOPPED state due to the following error:
1069
1233
  buffer += chunk
1070
1234
 
1071
1235
  return buffer
1236
+
1237
+ def generate_completion_token(
1238
+ self, pipeline_name: str, table_name: str, connector_name: str
1239
+ ) -> str:
1240
+ """
1241
+ Generate a completion token that can be passed to :meth:`.FelderaClient.completion_token_processed` to
1242
+ check whether the pipeline has finished processing all inputs received from the connector before
1243
+ the token was generated.
1244
+
1245
+ :param pipeline_name: The name of the pipeline
1246
+ :param table_name: The name of the table associated with this connector.
1247
+ :param connector_name: The name of the connector.
1248
+
1249
+ :raises FelderaAPIError: If the connector cannot be found, or if the pipeline is not running.
1250
+ """
1251
+
1252
+ resp = self.http.get(
1253
+ path=f"/pipelines/{pipeline_name}/tables/{table_name}/connectors/{connector_name}/completion_token",
1254
+ )
1255
+
1256
+ token: str | None = resp.get("token")
1257
+
1258
+ if token is None:
1259
+ raise ValueError(
1260
+ "got invalid response from feldera when generating completion token"
1261
+ )
1262
+
1263
+ return token