feldera 0.162.0__tar.gz → 0.164.0__tar.gz

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.

Files changed (32) hide show
  1. {feldera-0.162.0 → feldera-0.164.0}/PKG-INFO +1 -1
  2. feldera-0.164.0/feldera/_callback_runner.py +69 -0
  3. {feldera-0.162.0 → feldera-0.164.0}/feldera/enums.py +21 -13
  4. {feldera-0.162.0 → feldera-0.164.0}/feldera/output_handler.py +13 -1
  5. {feldera-0.162.0 → feldera-0.164.0}/feldera/pipeline.py +112 -33
  6. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/_helpers.py +5 -5
  7. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/_httprequests.py +4 -4
  8. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/config.py +1 -1
  9. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/feldera_client.py +134 -71
  10. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/pipeline.py +1 -0
  11. {feldera-0.162.0 → feldera-0.164.0}/feldera.egg-info/PKG-INFO +1 -1
  12. {feldera-0.162.0 → feldera-0.164.0}/pyproject.toml +1 -1
  13. feldera-0.162.0/feldera/_callback_runner.py +0 -64
  14. {feldera-0.162.0 → feldera-0.164.0}/README.md +0 -0
  15. {feldera-0.162.0 → feldera-0.164.0}/feldera/__init__.py +0 -0
  16. {feldera-0.162.0 → feldera-0.164.0}/feldera/_helpers.py +0 -0
  17. {feldera-0.162.0 → feldera-0.164.0}/feldera/pipeline_builder.py +0 -0
  18. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/__init__.py +0 -0
  19. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/errors.py +0 -0
  20. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/feldera_config.py +0 -0
  21. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/sql_table.py +0 -0
  22. {feldera-0.162.0 → feldera-0.164.0}/feldera/rest/sql_view.py +0 -0
  23. {feldera-0.162.0 → feldera-0.164.0}/feldera/runtime_config.py +0 -0
  24. {feldera-0.162.0 → feldera-0.164.0}/feldera/stats.py +0 -0
  25. {feldera-0.162.0 → feldera-0.164.0}/feldera/tests/test_datafusionize.py +0 -0
  26. {feldera-0.162.0 → feldera-0.164.0}/feldera/testutils.py +0 -0
  27. {feldera-0.162.0 → feldera-0.164.0}/feldera/testutils_oidc.py +0 -0
  28. {feldera-0.162.0 → feldera-0.164.0}/feldera.egg-info/SOURCES.txt +0 -0
  29. {feldera-0.162.0 → feldera-0.164.0}/feldera.egg-info/dependency_links.txt +0 -0
  30. {feldera-0.162.0 → feldera-0.164.0}/feldera.egg-info/requires.txt +0 -0
  31. {feldera-0.162.0 → feldera-0.164.0}/feldera.egg-info/top_level.txt +0 -0
  32. {feldera-0.162.0 → feldera-0.164.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feldera
3
- Version: 0.162.0
3
+ Version: 0.164.0
4
4
  Summary: The feldera python client
5
5
  Author-email: Feldera Team <dev@feldera.com>
6
6
  License: MIT
@@ -0,0 +1,69 @@
1
+ from threading import Thread
2
+ from typing import Callable, Optional
3
+
4
+ import pandas as pd
5
+ from feldera import FelderaClient
6
+ from feldera._helpers import dataframe_from_response
7
+ from feldera.enums import PipelineFieldSelector
8
+
9
+
10
+ class CallbackRunner(Thread):
11
+ def __init__(
12
+ self,
13
+ client: FelderaClient,
14
+ pipeline_name: str,
15
+ view_name: str,
16
+ callback: Callable[[pd.DataFrame, int], None],
17
+ exception_callback: Callable[[BaseException], None],
18
+ ):
19
+ super().__init__()
20
+ self.daemon = True
21
+ self.client: FelderaClient = client
22
+ self.pipeline_name: str = pipeline_name
23
+ self.view_name: str = view_name
24
+ self.callback: Callable[[pd.DataFrame, int], None] = callback
25
+ self.exception_callback: Callable[[BaseException], None] = exception_callback
26
+ self.schema: Optional[dict] = None
27
+
28
+ def run(self):
29
+ """
30
+ The main loop of the thread. Listens for data and calls the callback function on each chunk of data received.
31
+
32
+ :meta private:
33
+ """
34
+
35
+ try:
36
+ pipeline = self.client.get_pipeline(
37
+ self.pipeline_name, PipelineFieldSelector.ALL
38
+ )
39
+
40
+ schemas = pipeline.tables + pipeline.views
41
+ for schema in schemas:
42
+ if schema.name == self.view_name:
43
+ self.schema = schema
44
+ break
45
+
46
+ if self.schema is None:
47
+ raise ValueError(
48
+ f"Table or View {self.view_name} not found in the pipeline schema."
49
+ )
50
+
51
+ gen_obj = self.client.listen_to_pipeline(
52
+ self.pipeline_name,
53
+ self.view_name,
54
+ format="json",
55
+ case_sensitive=self.schema.case_sensitive,
56
+ )
57
+
58
+ iterator = gen_obj()
59
+
60
+ for chunk in iterator:
61
+ chunk: dict = chunk
62
+ data: Optional[list[dict]] = chunk.get("json_data")
63
+ seq_no: Optional[int] = chunk.get("sequence_number")
64
+ if data is not None and seq_no is not None:
65
+ self.callback(
66
+ dataframe_from_response([data], self.schema.fields), seq_no
67
+ )
68
+ except BaseException as e:
69
+ self.exception_callback(e)
@@ -122,12 +122,13 @@ class DeploymentRuntimeStatus(Enum):
122
122
 
123
123
  UNAVAILABLE = 0
124
124
  STANDBY = 1
125
- INITIALIZING = 2
126
- BOOTSTRAPPING = 3
127
- REPLAYING = 4
128
- PAUSED = 5
129
- RUNNING = 6
130
- SUSPENDED = 7
125
+ AWAITINGAPPROVAL = 2
126
+ INITIALIZING = 3
127
+ BOOTSTRAPPING = 4
128
+ REPLAYING = 5
129
+ PAUSED = 6
130
+ RUNNING = 7
131
+ SUSPENDED = 8
131
132
 
132
133
  @staticmethod
133
134
  def from_str(value):
@@ -149,13 +150,14 @@ class PipelineStatus(Enum):
149
150
  PROVISIONING = 2
150
151
  UNAVAILABLE = 3
151
152
  STANDBY = 4
152
- INITIALIZING = 5
153
- BOOTSTRAPPING = 6
154
- REPLAYING = 7
155
- PAUSED = 8
156
- RUNNING = 9
157
- SUSPENDED = 10
158
- STOPPING = 11
153
+ AWAITINGAPPROVAL = 5
154
+ INITIALIZING = 6
155
+ BOOTSTRAPPING = 7
156
+ REPLAYING = 8
157
+ PAUSED = 9
158
+ RUNNING = 10
159
+ SUSPENDED = 11
160
+ STOPPING = 12
159
161
 
160
162
  @staticmethod
161
163
  def from_str(value):
@@ -344,3 +346,9 @@ class PipelineFieldSelector(Enum):
344
346
 
345
347
  STATUS = "status"
346
348
  """Select only the fields required to know the status of a pipeline."""
349
+
350
+
351
+ class BootstrapPolicy(Enum):
352
+ AWAIT_APPROVAL = "await_approval"
353
+ ALLOW = "allow"
354
+ REJECT = "reject"
@@ -1,5 +1,7 @@
1
1
  import pandas as pd
2
2
 
3
+ from typing import Optional
4
+
3
5
  from feldera import FelderaClient
4
6
  from feldera._callback_runner import CallbackRunner
5
7
 
@@ -20,15 +22,23 @@ class OutputHandler:
20
22
  self.pipeline_name: str = pipeline_name
21
23
  self.view_name: str = view_name
22
24
  self.buffer: list[pd.DataFrame] = []
25
+ self.exception: Optional[BaseException] = None
23
26
 
24
27
  # the callback that is passed to the `CallbackRunner`
25
28
  def callback(df: pd.DataFrame, _: int):
26
29
  if not df.empty:
27
30
  self.buffer.append(df)
28
31
 
32
+ def exception_callback(exception: BaseException):
33
+ self.exception = exception
34
+
29
35
  # sets up the callback runner
30
36
  self.handler = CallbackRunner(
31
- self.client, self.pipeline_name, self.view_name, callback
37
+ self.client,
38
+ self.pipeline_name,
39
+ self.view_name,
40
+ callback,
41
+ exception_callback,
32
42
  )
33
43
 
34
44
  def start(self):
@@ -45,6 +55,8 @@ class OutputHandler:
45
55
  :param clear_buffer: Whether to clear the buffer after getting the output.
46
56
  """
47
57
 
58
+ if self.exception is not None:
59
+ raise self.exception
48
60
  if len(self.buffer) == 0:
49
61
  return pd.DataFrame()
50
62
  res = pd.concat(self.buffer, ignore_index=True)
@@ -11,6 +11,7 @@ from collections import deque
11
11
 
12
12
  from feldera.rest.errors import FelderaAPIError
13
13
  from feldera.enums import (
14
+ BootstrapPolicy,
14
15
  PipelineFieldSelector,
15
16
  PipelineStatus,
16
17
  ProgramStatus,
@@ -71,6 +72,30 @@ class Pipeline:
71
72
  else:
72
73
  raise err
73
74
 
75
+ def wait_for_status(
76
+ self, expected_status: PipelineStatus, timeout: Optional[int] = None
77
+ ) -> None:
78
+ """
79
+ Wait for the pipeline to reach the specified status.
80
+
81
+ :param expected_status: The status to wait for
82
+ :param timeout: Maximum time to wait in seconds. If None, waits forever (default: None)
83
+ :raises TimeoutError: If the expected status is not reached within the timeout
84
+ """
85
+ start_time = time.time()
86
+
87
+ while True:
88
+ current_status = self.status()
89
+ if current_status == expected_status:
90
+ return
91
+
92
+ if timeout is not None and time.time() - start_time >= timeout:
93
+ raise TimeoutError(
94
+ f"Pipeline did not reach {expected_status.name} status within {timeout} seconds"
95
+ )
96
+
97
+ time.sleep(1)
98
+
74
99
  def stats(self) -> PipelineStatistics:
75
100
  """Gets the pipeline metrics and performance counters."""
76
101
 
@@ -269,11 +294,13 @@ class Pipeline:
269
294
  if self.status() not in [PipelineStatus.RUNNING, PipelineStatus.PAUSED]:
270
295
  raise RuntimeError("Pipeline must be running or paused to listen to output")
271
296
 
272
- handler = CallbackRunner(self.client, self.name, view_name, callback)
297
+ handler = CallbackRunner(
298
+ self.client, self.name, view_name, callback, lambda exception: None
299
+ )
273
300
  handler.start()
274
301
 
275
302
  def wait_for_completion(
276
- self, force_stop: bool = False, timeout_s: Optional[float] = None
303
+ self, force_stop: bool = False, timeout_s: float | None = None
277
304
  ):
278
305
  """
279
306
  Block until the pipeline has completed processing all input records.
@@ -301,6 +328,7 @@ class Pipeline:
301
328
  PipelineStatus.RUNNING,
302
329
  PipelineStatus.INITIALIZING,
303
330
  PipelineStatus.PROVISIONING,
331
+ PipelineStatus.BOOTSTRAPPING,
304
332
  ]:
305
333
  raise RuntimeError("Pipeline must be running to wait for completion")
306
334
 
@@ -345,25 +373,10 @@ class Pipeline:
345
373
 
346
374
  return self.stats().global_metrics.pipeline_complete
347
375
 
348
- def restart(self, timeout_s: Optional[float] = None):
349
- """
350
- Restarts the pipeline.
351
-
352
- This method forcibly **STOPS** the pipeline regardless of its current
353
- state and then starts it again. No checkpoints are made when stopping
354
- the pipeline.
355
-
356
- :param timeout_s: The maximum time (in seconds) to wait for the
357
- pipeline to restart.
358
- """
359
-
360
- self.stop(force=True, timeout_s=timeout_s)
361
- self.start(timeout_s=timeout_s)
362
-
363
376
  def wait_for_idle(
364
377
  self,
365
378
  idle_interval_s: float = 5.0,
366
- timeout_s: float = 600.0,
379
+ timeout_s: float | None = None,
367
380
  poll_interval_s: float = 0.2,
368
381
  ):
369
382
  """
@@ -383,12 +396,12 @@ class Pipeline:
383
396
  :raises RuntimeError: If the metrics are missing or the timeout was
384
397
  reached.
385
398
  """
386
- if idle_interval_s > timeout_s:
399
+ if timeout_s is not None and idle_interval_s > timeout_s:
387
400
  raise ValueError(
388
401
  f"idle interval ({idle_interval_s}s) cannot be larger than"
389
402
  f" timeout ({timeout_s}s)"
390
403
  )
391
- if poll_interval_s > timeout_s:
404
+ if timeout_s is not None and poll_interval_s > timeout_s:
392
405
  raise ValueError(
393
406
  f"poll interval ({poll_interval_s}s) cannot be larger than"
394
407
  f" timeout ({timeout_s}s)"
@@ -434,11 +447,13 @@ metrics"""
434
447
  return
435
448
 
436
449
  # Timeout
437
- if now_s - start_time_s >= timeout_s:
450
+ if timeout_s is not None and now_s - start_time_s >= timeout_s:
438
451
  raise RuntimeError(f"waiting for idle reached timeout ({timeout_s}s)")
439
452
  time.sleep(poll_interval_s)
440
453
 
441
- def activate(self, wait: bool = True, timeout_s: Optional[float] = None):
454
+ def activate(
455
+ self, wait: bool = True, timeout_s: Optional[float] = None
456
+ ) -> Optional[PipelineStatus]:
442
457
  """
443
458
  Activates the pipeline when starting from STANDBY mode. Only applicable
444
459
  when the pipeline is starting from a checkpoint in object store.
@@ -449,9 +464,14 @@ metrics"""
449
464
  pipeline to pause.
450
465
  """
451
466
 
452
- self.client.activate_pipeline(self.name, wait=wait, timeout_s=timeout_s)
467
+ return self.client.activate_pipeline(self.name, wait=wait, timeout_s=timeout_s)
453
468
 
454
- def start(self, wait: bool = True, timeout_s: Optional[float] = None):
469
+ def start(
470
+ self,
471
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
472
+ wait: bool = True,
473
+ timeout_s: Optional[float] = None,
474
+ ):
455
475
  """
456
476
  .. _start:
457
477
 
@@ -461,6 +481,7 @@ metrics"""
461
481
  - If the pipeline is in any other state, an error will be raised.
462
482
  - If the pipeline is in PAUSED state, use `.meth:resume` instead.
463
483
 
484
+ :param bootstrap_policy: The bootstrap policy to use.
464
485
  :param timeout_s: The maximum time (in seconds) to wait for the
465
486
  pipeline to start.
466
487
  :param wait: Set True to wait for the pipeline to start. True by default
@@ -468,21 +489,57 @@ metrics"""
468
489
  :raises RuntimeError: If the pipeline is not in STOPPED state.
469
490
  """
470
491
 
471
- self.client.start_pipeline(self.name, wait=wait, timeout_s=timeout_s)
492
+ self.client.start_pipeline(
493
+ self.name, bootstrap_policy=bootstrap_policy, wait=wait, timeout_s=timeout_s
494
+ )
472
495
 
473
- def start_paused(self, wait: bool = True, timeout_s: Optional[float] = None):
496
+ def start_paused(
497
+ self,
498
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
499
+ wait: bool = True,
500
+ timeout_s: Optional[float] = None,
501
+ ):
474
502
  """
475
503
  Starts the pipeline in the paused state.
476
504
  """
477
505
 
478
- self.client.start_pipeline_as_paused(self.name, wait=wait, timeout_s=timeout_s)
506
+ return self.client.start_pipeline_as_paused(
507
+ self.name, bootstrap_policy=bootstrap_policy, wait=wait, timeout_s=timeout_s
508
+ )
479
509
 
480
- def start_standby(self, wait: bool = True, timeout_s: Optional[float] = None):
510
+ def start_standby(
511
+ self,
512
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
513
+ wait: bool = True,
514
+ timeout_s: Optional[float] = None,
515
+ ):
481
516
  """
482
517
  Starts the pipeline in the standby state.
483
518
  """
484
519
 
485
- self.client.start_pipeline_as_standby(self.name, wait=wait, timeout_s=timeout_s)
520
+ self.client.start_pipeline_as_standby(
521
+ self.name, bootstrap_policy=bootstrap_policy, wait=wait, timeout_s=timeout_s
522
+ )
523
+
524
+ def restart(
525
+ self,
526
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
527
+ timeout_s: Optional[float] = None,
528
+ ):
529
+ """
530
+ Restarts the pipeline.
531
+
532
+ This method forcibly **STOPS** the pipeline regardless of its current
533
+ state and then starts it again. No checkpoints are made when stopping
534
+ the pipeline.
535
+
536
+ :param bootstrap_policy: The bootstrap policy to use.
537
+ :param timeout_s: The maximum time (in seconds) to wait for the
538
+ pipeline to restart.
539
+ """
540
+
541
+ self.stop(force=True, timeout_s=timeout_s)
542
+ self.start(bootstrap_policy=bootstrap_policy, timeout_s=timeout_s)
486
543
 
487
544
  def pause(self, wait: bool = True, timeout_s: Optional[float] = None):
488
545
  """
@@ -517,6 +574,18 @@ metrics"""
517
574
  self.name, force=force, wait=wait, timeout_s=timeout_s
518
575
  )
519
576
 
577
+ def approve(self):
578
+ """
579
+ Approves the pipeline to proceed with bootstrapping.
580
+
581
+ This method is used when a pipeline has been started with
582
+ `bootstrap_policy=BootstrapPolicy.AWAIT_APPROVAL` and is currently in the
583
+ AWAITINGAPPROVAL state. The pipeline will wait for explicit user approval
584
+ before proceeding with the bootstrapping process.
585
+ """
586
+
587
+ self.client.approve_pipeline(self.name)
588
+
520
589
  def resume(self, wait: bool = True, timeout_s: Optional[float] = None):
521
590
  """
522
591
  Resumes the pipeline from the PAUSED state. If the pipeline is already
@@ -627,7 +696,7 @@ metrics"""
627
696
  err.message = f"Pipeline with name {name} not found"
628
697
  raise err
629
698
 
630
- def checkpoint(self, wait: bool = False, timeout_s=300) -> int:
699
+ def checkpoint(self, wait: bool = False, timeout_s: Optional[float] = None) -> int:
631
700
  """
632
701
  Checkpoints this pipeline.
633
702
 
@@ -649,7 +718,7 @@ metrics"""
649
718
 
650
719
  while True:
651
720
  elapsed = time.monotonic() - start
652
- if elapsed > timeout_s:
721
+ if timeout_s is not None and elapsed > timeout_s:
653
722
  raise TimeoutError(
654
723
  f"""timeout ({timeout_s}s) reached while waiting for \
655
724
  pipeline '{self.name}' to make checkpoint '{seq}'"""
@@ -685,7 +754,9 @@ pipeline '{self.name}' to make checkpoint '{seq}'"""
685
754
  if seq < success:
686
755
  return CheckpointStatus.Unknown
687
756
 
688
- def sync_checkpoint(self, wait: bool = False, timeout_s=300) -> str:
757
+ def sync_checkpoint(
758
+ self, wait: bool = False, timeout_s: Optional[float] = None
759
+ ) -> str:
689
760
  """
690
761
  Syncs this checkpoint to object store.
691
762
 
@@ -707,7 +778,7 @@ pipeline '{self.name}' to make checkpoint '{seq}'"""
707
778
 
708
779
  while True:
709
780
  elapsed = time.monotonic() - start
710
- if elapsed > timeout_s:
781
+ if timeout_s is not None and elapsed > timeout_s:
711
782
  raise TimeoutError(
712
783
  f"""timeout ({timeout_s}s) reached while waiting for \
713
784
  pipeline '{self.name}' to sync checkpoint '{uuid}'"""
@@ -1177,6 +1248,14 @@ pipeline '{self.name}' to sync checkpoint '{uuid}'"""
1177
1248
  self.refresh(PipelineFieldSelector.STATUS)
1178
1249
  return DeploymentRuntimeStatus.from_str(self._inner.deployment_runtime_status)
1179
1250
 
1251
+ def deployment_runtime_status_details(self) -> Optional[dict]:
1252
+ """
1253
+ Return the deployment runtime status details.
1254
+ """
1255
+
1256
+ self.refresh(PipelineFieldSelector.STATUS)
1257
+ return self._inner.deployment_runtime_status_details
1258
+
1180
1259
  def deployment_error(self) -> Mapping[str, Any]:
1181
1260
  """
1182
1261
  Return the deployment error of the pipeline.
@@ -20,20 +20,20 @@ def requests_verify_from_env() -> str | bool:
20
20
  if env_feldera_tls_insecure is not None and FELDERA_HTTPS_TLS_CERT is not None:
21
21
  logging.warning(
22
22
  "environment variables FELDERA_HTTPS_TLS_CERT and "
23
- "FELDERA_TLS_INSECURE both are set."
24
- "\nFELDERA_HTTPS_TLS_CERT takes priority."
23
+ + "FELDERA_TLS_INSECURE both are set."
24
+ + "\nFELDERA_HTTPS_TLS_CERT takes priority."
25
25
  )
26
26
 
27
27
  if env_feldera_tls_insecure is None:
28
- FELDERA_TLS_INSECURE = False
28
+ feldera_tls_insecure = False
29
29
  else:
30
- FELDERA_TLS_INSECURE = env_feldera_tls_insecure.strip().lower() in (
30
+ feldera_tls_insecure = env_feldera_tls_insecure.strip().lower() in (
31
31
  "1",
32
32
  "true",
33
33
  "yes",
34
34
  )
35
35
 
36
- requests_verify = not FELDERA_TLS_INSECURE
36
+ requests_verify = not feldera_tls_insecure
37
37
  if FELDERA_HTTPS_TLS_CERT is not None:
38
38
  requests_verify = FELDERA_HTTPS_TLS_CERT
39
39
 
@@ -56,7 +56,7 @@ class HttpRequests:
56
56
  """
57
57
  self.headers["Content-Type"] = content_type
58
58
 
59
- prev_resp: Optional[requests.Response] = None
59
+ prev_resp: requests.Response | None = None
60
60
 
61
61
  try:
62
62
  conn_timeout = self.config.connection_timeout
@@ -156,7 +156,7 @@ class HttpRequests:
156
156
  body: Optional[
157
157
  Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
158
158
  ] = None,
159
- content_type: Optional[str] = "application/json",
159
+ content_type: str = "application/json",
160
160
  params: Optional[Mapping[str, Any]] = None,
161
161
  stream: bool = False,
162
162
  serialize: bool = True,
@@ -177,7 +177,7 @@ class HttpRequests:
177
177
  body: Optional[
178
178
  Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
179
179
  ] = None,
180
- content_type: Optional[str] = "application/json",
180
+ content_type: str = "application/json",
181
181
  params: Optional[Mapping[str, Any]] = None,
182
182
  ) -> Any:
183
183
  return self.send_request(requests.patch, path, body, content_type, params)
@@ -188,7 +188,7 @@ class HttpRequests:
188
188
  body: Optional[
189
189
  Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str], str]
190
190
  ] = None,
191
- content_type: Optional[str] = "application/json",
191
+ content_type: str = "application/json",
192
192
  params: Optional[Mapping[str, Any]] = None,
193
193
  ) -> Any:
194
194
  return self.send_request(requests.put, path, body, content_type, params)
@@ -39,7 +39,7 @@ class Config:
39
39
  )
40
40
  self.url: str = BASE_URL
41
41
  self.api_key: Optional[str] = os.environ.get("FELDERA_API_KEY", api_key)
42
- self.version: Optional[str] = version or "v0"
42
+ self.version: str = version or "v0"
43
43
  self.timeout: Optional[float] = timeout
44
44
  self.connection_timeout: Optional[float] = connection_timeout
45
45
  env_verify = requests_verify_from_env()
@@ -6,8 +6,9 @@ import json
6
6
  from decimal import Decimal
7
7
  from typing import Generator, Mapping
8
8
  from urllib.parse import quote
9
+ import requests
9
10
 
10
- from feldera.enums import PipelineFieldSelector
11
+ from feldera.enums import BootstrapPolicy, PipelineFieldSelector, PipelineStatus
11
12
  from feldera.rest.config import Config
12
13
  from feldera.rest.feldera_config import FelderaConfig
13
14
  from feldera.rest.errors import FelderaTimeoutError, FelderaAPIError
@@ -110,16 +111,20 @@ class FelderaClient:
110
111
 
111
112
  return Pipeline.from_dict(resp)
112
113
 
113
- def get_runtime_config(self, pipeline_name) -> dict:
114
+ def get_runtime_config(self, pipeline_name) -> Mapping[str, Any]:
114
115
  """
115
116
  Get the runtime config of a pipeline by name
116
117
 
117
118
  :param pipeline_name: The name of the pipeline
118
119
  """
119
120
 
120
- resp: dict = self.http.get(f"/pipelines/{pipeline_name}")
121
+ resp: Mapping[str, Any] = self.http.get(f"/pipelines/{pipeline_name}")
121
122
 
122
- return resp.get("runtime_config")
123
+ runtime_config: Mapping[str, Any] | None = resp.get("runtime_config")
124
+ if runtime_config is None:
125
+ raise ValueError(f"Pipeline {pipeline_name} has no runtime config")
126
+
127
+ return runtime_config
123
128
 
124
129
  def pipelines(self) -> list[Pipeline]:
125
130
  """
@@ -175,7 +180,7 @@ class FelderaClient:
175
180
  self,
176
181
  pipeline_name: str,
177
182
  state: str,
178
- timeout_s: float = 300.0,
183
+ timeout_s: Optional[float] = None,
179
184
  start: bool = True,
180
185
  ):
181
186
  start_time = time.monotonic()
@@ -211,6 +216,46 @@ Reason: The pipeline is in a STOPPED state due to the following error:
211
216
  )
212
217
  time.sleep(0.1)
213
218
 
219
+ def __wait_for_pipeline_state_one_of(
220
+ self,
221
+ pipeline_name: str,
222
+ states: list[str],
223
+ timeout_s: float | None = None,
224
+ start: bool = True,
225
+ ) -> PipelineStatus:
226
+ start_time = time.monotonic()
227
+ states = [state.lower() for state in states]
228
+
229
+ while True:
230
+ if timeout_s is not None:
231
+ elapsed = time.monotonic() - start_time
232
+ if elapsed > timeout_s:
233
+ raise TimeoutError(
234
+ f"Timed out waiting for pipeline {pipeline_name} to"
235
+ f"transition to one of the states: {states}"
236
+ )
237
+
238
+ resp = self.get_pipeline(pipeline_name, PipelineFieldSelector.STATUS)
239
+ status = resp.deployment_status
240
+
241
+ if status.lower() in states:
242
+ return PipelineStatus.from_str(status)
243
+ elif (
244
+ status == "Stopped"
245
+ and len(resp.deployment_error or {}) > 0
246
+ and resp.deployment_desired_status == "Stopped"
247
+ ):
248
+ err_msg = "Unable to START the pipeline:\n" if start else ""
249
+ raise RuntimeError(
250
+ f"""{err_msg}Unable to transition the pipeline to one of the states: {states}.
251
+ Reason: The pipeline is in a STOPPED state due to the following error:
252
+ {resp.deployment_error.get("message", "")}"""
253
+ )
254
+ logging.debug(
255
+ "still starting %s, waiting for 100 more milliseconds", pipeline_name
256
+ )
257
+ time.sleep(0.1)
258
+
214
259
  def create_pipeline(self, pipeline: Pipeline) -> Pipeline:
215
260
  """
216
261
  Create a pipeline if it doesn't exist and wait for it to compile
@@ -342,36 +387,36 @@ Reason: The pipeline is in a STOPPED state due to the following error:
342
387
  yield chunk.decode("utf-8")
343
388
 
344
389
  def activate_pipeline(
345
- self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = 300
346
- ):
390
+ self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = None
391
+ ) -> Optional[PipelineStatus]:
347
392
  """
348
393
 
349
394
  :param pipeline_name: The name of the pipeline to activate
350
395
  :param wait: Set True to wait for the pipeline to activate. True by
351
396
  default
352
397
  :param timeout_s: The amount of time in seconds to wait for the
353
- pipeline to activate. 300 seconds by default.
398
+ pipeline to activate.
354
399
  """
355
400
 
356
- if timeout_s is None:
357
- timeout_s = 300
358
-
359
401
  self.http.post(
360
402
  path=f"/pipelines/{pipeline_name}/activate",
361
403
  )
362
404
 
363
405
  if not wait:
364
- return
406
+ return None
365
407
 
366
- self.__wait_for_pipeline_state(pipeline_name, "running", timeout_s)
408
+ return self.__wait_for_pipeline_state_one_of(
409
+ pipeline_name, ["running", "AwaitingApproval"], timeout_s
410
+ )
367
411
 
368
412
  def _inner_start_pipeline(
369
413
  self,
370
414
  pipeline_name: str,
371
415
  initial: str = "running",
416
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
372
417
  wait: bool = True,
373
- timeout_s: Optional[float] = 300,
374
- ):
418
+ timeout_s: Optional[float] = None,
419
+ ) -> Optional[PipelineStatus]:
375
420
  """
376
421
 
377
422
  :param pipeline_name: The name of the pipeline to start
@@ -379,67 +424,88 @@ Reason: The pipeline is in a STOPPED state due to the following error:
379
424
  by default.
380
425
  :param wait: Set True to wait for the pipeline to start. True by default
381
426
  :param timeout_s: The amount of time in seconds to wait for the
382
- pipeline to start. 300 seconds by default.
427
+ pipeline to start.
383
428
  """
384
429
 
385
- if timeout_s is None:
386
- timeout_s = 300
430
+ params = {"initial": initial}
431
+ if bootstrap_policy is not None:
432
+ params["bootstrap_policy"] = bootstrap_policy.value
387
433
 
388
434
  self.http.post(
389
435
  path=f"/pipelines/{pipeline_name}/start",
390
- params={"initial": initial},
436
+ params=params,
391
437
  )
392
438
 
393
439
  if not wait:
394
- return
440
+ return None
395
441
 
396
- self.__wait_for_pipeline_state(pipeline_name, initial, timeout_s)
442
+ return self.__wait_for_pipeline_state_one_of(
443
+ pipeline_name, [initial, "AwaitingApproval"], timeout_s
444
+ )
397
445
 
398
446
  def start_pipeline(
399
- self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = 300
400
- ):
447
+ self,
448
+ pipeline_name: str,
449
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
450
+ wait: bool = True,
451
+ timeout_s: Optional[float] = None,
452
+ ) -> Optional[PipelineStatus]:
401
453
  """
402
454
 
403
455
  :param pipeline_name: The name of the pipeline to start
404
456
  :param wait: Set True to wait for the pipeline to start.
405
457
  True by default
406
458
  :param timeout_s: The amount of time in seconds to wait for the
407
- pipeline to start. 300 seconds by default.
459
+ pipeline to start.
408
460
  """
409
461
 
410
- self._inner_start_pipeline(pipeline_name, "running", wait, timeout_s)
462
+ return self._inner_start_pipeline(
463
+ pipeline_name, "running", bootstrap_policy, wait, timeout_s
464
+ )
411
465
 
412
466
  def start_pipeline_as_paused(
413
- self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = 300
414
- ):
467
+ self,
468
+ pipeline_name: str,
469
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
470
+ wait: bool = True,
471
+ timeout_s: float | None = None,
472
+ ) -> Optional[PipelineStatus]:
415
473
  """
416
474
  :param pipeline_name: The name of the pipeline to start as paused.
417
475
  :param wait: Set True to wait for the pipeline to start as pause.
418
476
  True by default
419
477
  :param timeout_s: The amount of time in seconds to wait for the
420
- pipeline to start. 300 seconds by default.
478
+ pipeline to start.
421
479
  """
422
480
 
423
- self._inner_start_pipeline(pipeline_name, "paused", wait, timeout_s)
481
+ return self._inner_start_pipeline(
482
+ pipeline_name, "paused", bootstrap_policy, wait, timeout_s
483
+ )
424
484
 
425
485
  def start_pipeline_as_standby(
426
- self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = 300
486
+ self,
487
+ pipeline_name: str,
488
+ bootstrap_policy: Optional[BootstrapPolicy] = None,
489
+ wait: bool = True,
490
+ timeout_s: Optional[float] = None,
427
491
  ):
428
492
  """
429
493
  :param pipeline_name: The name of the pipeline to start as standby.
430
494
  :param wait: Set True to wait for the pipeline to start as standby.
431
495
  True by default
432
496
  :param timeout_s: The amount of time in seconds to wait for the
433
- pipeline to start. 300 seconds by default.
497
+ pipeline to start.
434
498
  """
435
499
 
436
- self._inner_start_pipeline(pipeline_name, "standby", wait, timeout_s)
500
+ self._inner_start_pipeline(
501
+ pipeline_name, "standby", bootstrap_policy, wait, timeout_s
502
+ )
437
503
 
438
504
  def resume_pipeline(
439
505
  self,
440
506
  pipeline_name: str,
441
507
  wait: bool = True,
442
- timeout_s: Optional[float] = 300,
508
+ timeout_s: Optional[float] = None,
443
509
  ):
444
510
  """
445
511
  Resume a pipeline
@@ -447,12 +513,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
447
513
  :param pipeline_name: The name of the pipeline to stop
448
514
  :param wait: Set True to wait for the pipeline to pause. True by default
449
515
  :param timeout_s: The amount of time in seconds to wait for the pipeline
450
- to pause. 300 seconds by default.
516
+ to pause.
451
517
  """
452
518
 
453
- if timeout_s is None:
454
- timeout_s = 300
455
-
456
519
  self.http.post(
457
520
  path=f"/pipelines/{pipeline_name}/resume",
458
521
  )
@@ -466,7 +529,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
466
529
  self,
467
530
  pipeline_name: str,
468
531
  wait: bool = True,
469
- timeout_s: Optional[float] = 300,
532
+ timeout_s: Optional[float] = None,
470
533
  ):
471
534
  """
472
535
  Pause a pipeline
@@ -476,12 +539,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
476
539
  STOPPED state due to a failure.
477
540
  :param wait: Set True to wait for the pipeline to pause. True by default
478
541
  :param timeout_s: The amount of time in seconds to wait for the pipeline
479
- to pause. 300 seconds by default.
542
+ to pause.
480
543
  """
481
544
 
482
- if timeout_s is None:
483
- timeout_s = 300
484
-
485
545
  self.http.post(
486
546
  path=f"/pipelines/{pipeline_name}/pause",
487
547
  )
@@ -491,12 +551,20 @@ Reason: The pipeline is in a STOPPED state due to the following error:
491
551
 
492
552
  self.__wait_for_pipeline_state(pipeline_name, "paused", timeout_s)
493
553
 
554
+ def approve_pipeline(
555
+ self,
556
+ pipeline_name: str,
557
+ ):
558
+ self.http.post(
559
+ path=f"/pipelines/{pipeline_name}/approve",
560
+ )
561
+
494
562
  def stop_pipeline(
495
563
  self,
496
564
  pipeline_name: str,
497
565
  force: bool,
498
566
  wait: bool = True,
499
- timeout_s: Optional[float] = 300,
567
+ timeout_s: Optional[float] = None,
500
568
  ):
501
569
  """
502
570
  Stop a pipeline
@@ -506,12 +574,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
506
574
  Set False to automatically checkpoint before stopping.
507
575
  :param wait: Set True to wait for the pipeline to stop. True by default
508
576
  :param timeout_s: The amount of time in seconds to wait for the pipeline
509
- to stop. Default is 300 seconds.
577
+ to stop.
510
578
  """
511
579
 
512
- if timeout_s is None:
513
- timeout_s = 300
514
-
515
580
  params = {"force": str(force).lower()}
516
581
 
517
582
  self.http.post(
@@ -524,7 +589,12 @@ Reason: The pipeline is in a STOPPED state due to the following error:
524
589
 
525
590
  start = time.monotonic()
526
591
 
527
- while time.monotonic() - start < timeout_s:
592
+ while True:
593
+ if timeout_s is not None and time.monotonic() - start > timeout_s:
594
+ raise FelderaTimeoutError(
595
+ f"timeout error: pipeline '{pipeline_name}' did not stop in {timeout_s} seconds"
596
+ )
597
+
528
598
  status = self.get_pipeline(
529
599
  pipeline_name, PipelineFieldSelector.STATUS
530
600
  ).deployment_status
@@ -538,29 +608,26 @@ Reason: The pipeline is in a STOPPED state due to the following error:
538
608
  )
539
609
  time.sleep(0.1)
540
610
 
541
- raise FelderaTimeoutError(
542
- f"timeout error: pipeline '{pipeline_name}' did not stop in {timeout_s} seconds"
543
- )
544
-
545
- def clear_storage(self, pipeline_name: str, timeout_s: Optional[float] = 300):
611
+ def clear_storage(self, pipeline_name: str, timeout_s: Optional[float] = None):
546
612
  """
547
613
  Clears the storage from the pipeline.
548
614
  This operation cannot be canceled.
549
615
 
550
616
  :param pipeline_name: The name of the pipeline
551
617
  :param timeout_s: The amount of time in seconds to wait for the storage
552
- to clear. Default is 300 seconds.
618
+ to clear.
553
619
  """
554
- if timeout_s is None:
555
- timeout_s = 300
556
-
557
620
  self.http.post(
558
621
  path=f"/pipelines/{pipeline_name}/clear",
559
622
  )
560
623
 
561
624
  start = time.monotonic()
562
625
 
563
- while time.monotonic() - start < timeout_s:
626
+ while True:
627
+ if timeout_s is not None and time.monotonic() - start > timeout_s:
628
+ raise FelderaTimeoutError(
629
+ f"timeout error: pipeline '{pipeline_name}' did not clear storage in {timeout_s} seconds"
630
+ )
564
631
  status = self.get_pipeline(
565
632
  pipeline_name, PipelineFieldSelector.STATUS
566
633
  ).storage_status
@@ -574,10 +641,6 @@ Reason: The pipeline is in a STOPPED state due to the following error:
574
641
  )
575
642
  time.sleep(0.1)
576
643
 
577
- raise FelderaTimeoutError(
578
- f"timeout error: pipeline '{pipeline_name}' did not clear storage in {timeout_s} seconds"
579
- )
580
-
581
644
  def start_transaction(self, pipeline_name: str) -> int:
582
645
  """
583
646
  Start a new transaction.
@@ -773,15 +836,15 @@ Reason: The pipeline is in a STOPPED state due to the following error:
773
836
  _validate_no_none_keys_in_map(datum.get("insert", {}))
774
837
  _validate_no_none_keys_in_map(datum.get("delete", {}))
775
838
  else:
776
- data: dict = data
839
+ data: Mapping[str, Any] = data
777
840
  _validate_no_none_keys_in_map(data.get("insert", {}))
778
841
  _validate_no_none_keys_in_map(data.get("delete", {}))
779
842
  else:
780
843
  _validate_no_none_keys_in_map(data)
781
844
 
782
845
  # python sends `True` which isn't accepted by the backend
783
- array = _prepare_boolean_input(array)
784
- force = _prepare_boolean_input(force)
846
+ array: str = _prepare_boolean_input(array)
847
+ force: str = _prepare_boolean_input(force)
785
848
 
786
849
  params = {
787
850
  "force": force,
@@ -821,7 +884,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
821
884
  return token
822
885
 
823
886
  def wait_for_token(
824
- self, pipeline_name: str, token: str, timeout_s: Optional[float] = 600
887
+ self, pipeline_name: str, token: str, timeout_s: Optional[float] = None
825
888
  ):
826
889
  """
827
890
  Blocks until all records represented by this completion token have
@@ -830,7 +893,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
830
893
  :param pipeline_name: The name of the pipeline
831
894
  :param token: The token to check for completion
832
895
  :param timeout_s: The amount of time in seconds to wait for the pipeline
833
- to process these records. Default 600s
896
+ to process these records.
834
897
  """
835
898
 
836
899
  params = {
@@ -849,8 +912,8 @@ Reason: The pipeline is in a STOPPED state due to the following error:
849
912
  if time.monotonic() > end:
850
913
  raise FelderaTimeoutError(
851
914
  f"timeout error: pipeline '{pipeline_name}' did not"
852
- f" process records represented by token {token} within"
853
- f" {timeout_s}"
915
+ + f" process records represented by token {token} within"
916
+ + f" {timeout_s}"
854
917
  )
855
918
 
856
919
  resp = self.http.get(
@@ -914,7 +977,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
914
977
 
915
978
  table_name = f'"{table_name}"' if case_sensitive else table_name
916
979
 
917
- resp = self.http.post(
980
+ resp: requests.Response = self.http.post(
918
981
  path=f"/pipelines/{quote(pipeline_name, safe='')}/egress/{quote(table_name, safe='')}",
919
982
  params=params,
920
983
  stream=True,
@@ -1019,7 +1082,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
1019
1082
 
1020
1083
  def query_as_json(
1021
1084
  self, pipeline_name: str, query: str
1022
- ) -> Generator[dict, None, None]:
1085
+ ) -> Generator[Mapping[str, Any], None, None]:
1023
1086
  """
1024
1087
  Executes an ad-hoc query on the specified pipeline and returns the result as a generator that yields
1025
1088
  rows of the query as Python dictionaries.
@@ -69,6 +69,7 @@ class Pipeline:
69
69
  self.deployment_runtime_desired_status: Optional[str] = None
70
70
  self.deployment_runtime_desired_status_since: Optional[str] = None
71
71
  self.deployment_runtime_status: Optional[str] = None
72
+ self.deployment_runtime_status_details: Optional[dict] = None
72
73
  self.deployment_runtime_status_since: Optional[str] = None
73
74
 
74
75
  @classmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feldera
3
- Version: 0.162.0
3
+ Version: 0.164.0
4
4
  Summary: The feldera python client
5
5
  Author-email: Feldera Team <dev@feldera.com>
6
6
  License: MIT
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  name = "feldera"
7
7
  readme = "README.md"
8
8
  description = "The feldera python client"
9
- version = "0.162.0"
9
+ version = "0.164.0"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
12
12
  authors = [
@@ -1,64 +0,0 @@
1
- from threading import Thread
2
- from typing import Callable, Optional
3
-
4
- import pandas as pd
5
- from feldera import FelderaClient
6
- from feldera._helpers import dataframe_from_response
7
- from feldera.enums import PipelineFieldSelector
8
-
9
-
10
- class CallbackRunner(Thread):
11
- def __init__(
12
- self,
13
- client: FelderaClient,
14
- pipeline_name: str,
15
- view_name: str,
16
- callback: Callable[[pd.DataFrame, int], None],
17
- ):
18
- super().__init__()
19
- self.daemon = True
20
- self.client: FelderaClient = client
21
- self.pipeline_name: str = pipeline_name
22
- self.view_name: str = view_name
23
- self.callback: Callable[[pd.DataFrame, int], None] = callback
24
- self.schema: Optional[dict] = None
25
-
26
- def run(self):
27
- """
28
- The main loop of the thread. Listens for data and calls the callback function on each chunk of data received.
29
-
30
- :meta private:
31
- """
32
-
33
- pipeline = self.client.get_pipeline(
34
- self.pipeline_name, PipelineFieldSelector.ALL
35
- )
36
-
37
- schemas = pipeline.tables + pipeline.views
38
- for schema in schemas:
39
- if schema.name == self.view_name:
40
- self.schema = schema
41
- break
42
-
43
- if self.schema is None:
44
- raise ValueError(
45
- f"Table or View {self.view_name} not found in the pipeline schema."
46
- )
47
-
48
- gen_obj = self.client.listen_to_pipeline(
49
- self.pipeline_name,
50
- self.view_name,
51
- format="json",
52
- case_sensitive=self.schema.case_sensitive,
53
- )
54
-
55
- iterator = gen_obj()
56
-
57
- for chunk in iterator:
58
- chunk: dict = chunk
59
- data: Optional[list[dict]] = chunk.get("json_data")
60
- seq_no: Optional[int] = chunk.get("sequence_number")
61
- if data is not None and seq_no is not None:
62
- self.callback(
63
- dataframe_from_response([data], self.schema.fields), seq_no
64
- )
File without changes
File without changes
File without changes
File without changes
File without changes