dara-core 1.24.3__py3-none-any.whl → 1.25.1__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.
- dara/core/_assets/auto_js/dara.core.umd.cjs +1060 -311
- dara/core/interactivity/__init__.py +5 -0
- dara/core/interactivity/derived_variable.py +20 -3
- dara/core/interactivity/plain_variable.py +20 -5
- dara/core/interactivity/stream_event.py +321 -0
- dara/core/interactivity/stream_utils.py +69 -0
- dara/core/interactivity/stream_variable.py +354 -0
- dara/core/internal/dependency_resolution.py +18 -6
- dara/core/internal/normalization.py +17 -3
- dara/core/internal/registries.py +4 -0
- dara/core/internal/registry.py +1 -0
- dara/core/internal/routing.py +55 -0
- dara/core/main.py +2 -0
- dara/core/router/dependency_graph.py +18 -0
- {dara_core-1.24.3.dist-info → dara_core-1.25.1.dist-info}/METADATA +10 -10
- {dara_core-1.24.3.dist-info → dara_core-1.25.1.dist-info}/RECORD +19 -16
- {dara_core-1.24.3.dist-info → dara_core-1.25.1.dist-info}/LICENSE +0 -0
- {dara_core-1.24.3.dist-info → dara_core-1.25.1.dist-info}/WHEEL +0 -0
- {dara_core-1.24.3.dist-info → dara_core-1.25.1.dist-info}/entry_points.txt +0 -0
|
@@ -49,6 +49,8 @@ from dara.core.interactivity.non_data_variable import NonDataVariable
|
|
|
49
49
|
from dara.core.interactivity.plain_variable import Variable
|
|
50
50
|
from dara.core.interactivity.server_variable import ServerVariable
|
|
51
51
|
from dara.core.interactivity.state_variable import StateVariable
|
|
52
|
+
from dara.core.interactivity.stream_event import ReconnectException, StreamEvent
|
|
53
|
+
from dara.core.interactivity.stream_variable import StreamVariable
|
|
52
54
|
from dara.core.interactivity.switch_variable import SwitchVariable
|
|
53
55
|
from dara.core.interactivity.url_variable import UrlVariable
|
|
54
56
|
|
|
@@ -62,6 +64,8 @@ __all__ = [
|
|
|
62
64
|
'NonDataVariable',
|
|
63
65
|
'Variable',
|
|
64
66
|
'StateVariable',
|
|
67
|
+
'StreamEvent',
|
|
68
|
+
'StreamVariable',
|
|
65
69
|
'SwitchVariable',
|
|
66
70
|
'DerivedVariable',
|
|
67
71
|
'DerivedDataVariable',
|
|
@@ -74,6 +78,7 @@ __all__ = [
|
|
|
74
78
|
'NavigateTo',
|
|
75
79
|
'NavigateToImpl',
|
|
76
80
|
'Notify',
|
|
81
|
+
'ReconnectException',
|
|
77
82
|
'ResetVariables',
|
|
78
83
|
'TriggerVariable',
|
|
79
84
|
'UpdateVariable',
|
|
@@ -22,6 +22,7 @@ import uuid
|
|
|
22
22
|
from collections.abc import Awaitable, Callable
|
|
23
23
|
from inspect import Parameter, signature
|
|
24
24
|
from typing import (
|
|
25
|
+
TYPE_CHECKING,
|
|
25
26
|
Any,
|
|
26
27
|
Generic,
|
|
27
28
|
Protocol,
|
|
@@ -41,6 +42,15 @@ from pydantic import (
|
|
|
41
42
|
)
|
|
42
43
|
from typing_extensions import TypedDict, TypeVar, runtime_checkable
|
|
43
44
|
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from dara.core.interactivity.loop_variable import LoopVariable
|
|
47
|
+
|
|
48
|
+
# Type alias for static type checking
|
|
49
|
+
NestedKey = str | LoopVariable
|
|
50
|
+
else:
|
|
51
|
+
# At runtime, use Any to avoid forward reference issues with Pydantic
|
|
52
|
+
NestedKey = Any
|
|
53
|
+
|
|
44
54
|
from dara.core.base_definitions import (
|
|
45
55
|
BaseCachePolicy,
|
|
46
56
|
BaseTask,
|
|
@@ -172,7 +182,7 @@ class DerivedVariable(ClientVariable, Generic[VariableType]):
|
|
|
172
182
|
variables: list[AnyVariable]
|
|
173
183
|
polling_interval: int | None
|
|
174
184
|
deps: list[AnyVariable] | None = Field(validate_default=True)
|
|
175
|
-
nested: list[
|
|
185
|
+
nested: list[NestedKey] = Field(default_factory=list)
|
|
176
186
|
uid: str
|
|
177
187
|
model_config = ConfigDict(extra='forbid', use_enum_values=True, arbitrary_types_allowed=True)
|
|
178
188
|
|
|
@@ -185,7 +195,7 @@ class DerivedVariable(ClientVariable, Generic[VariableType]):
|
|
|
185
195
|
polling_interval: int | None = None,
|
|
186
196
|
deps: list[AnyVariable] | None = None,
|
|
187
197
|
uid: str | None = None,
|
|
188
|
-
nested: list[
|
|
198
|
+
nested: list[NestedKey] | None = None,
|
|
189
199
|
filter_resolver: FilterResolver | None = None,
|
|
190
200
|
**kwargs,
|
|
191
201
|
):
|
|
@@ -271,7 +281,14 @@ class DerivedVariable(ClientVariable, Generic[VariableType]):
|
|
|
271
281
|
|
|
272
282
|
return deps
|
|
273
283
|
|
|
274
|
-
def get(self, key:
|
|
284
|
+
def get(self, key: NestedKey):
|
|
285
|
+
"""
|
|
286
|
+
Create a copy of this DerivedVariable that points to a nested key.
|
|
287
|
+
|
|
288
|
+
The key can be a string or a LoopVariable for dynamic access within a For loop.
|
|
289
|
+
|
|
290
|
+
:param key: the key to access; can be a string or LoopVariable
|
|
291
|
+
"""
|
|
275
292
|
return self.model_copy(update={'nested': [*self.nested, key]}, deep=True)
|
|
276
293
|
|
|
277
294
|
def trigger(self, force: bool = True):
|
|
@@ -21,7 +21,7 @@ import warnings
|
|
|
21
21
|
from collections.abc import Callable
|
|
22
22
|
from contextlib import contextmanager
|
|
23
23
|
from contextvars import ContextVar
|
|
24
|
-
from typing import Any, Generic
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Generic
|
|
25
25
|
|
|
26
26
|
from fastapi.encoders import jsonable_encoder
|
|
27
27
|
from pydantic import (
|
|
@@ -40,6 +40,15 @@ from dara.core.internal.utils import call_async
|
|
|
40
40
|
from dara.core.logging import dev_logger
|
|
41
41
|
from dara.core.persistence import BackendStore, BrowserStore, PersistenceStore
|
|
42
42
|
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from dara.core.interactivity.loop_variable import LoopVariable
|
|
45
|
+
|
|
46
|
+
# Type alias for static type checking
|
|
47
|
+
NestedKey = str | LoopVariable
|
|
48
|
+
else:
|
|
49
|
+
# At runtime, use Any to avoid forward reference issues with Pydantic
|
|
50
|
+
NestedKey = Any
|
|
51
|
+
|
|
43
52
|
VARIABLE_INIT_OVERRIDE = ContextVar[Callable[[dict], dict] | None]('VARIABLE_INIT_OVERRIDE', default=None)
|
|
44
53
|
|
|
45
54
|
VariableType = TypeVar('VariableType')
|
|
@@ -56,7 +65,7 @@ class Variable(ClientVariable, Generic[VariableType, PersistenceStoreType_co]):
|
|
|
56
65
|
default: VariableType | None = None
|
|
57
66
|
store: SerializeAsAny[PersistenceStoreType_co | None] = None
|
|
58
67
|
uid: str
|
|
59
|
-
nested: list[
|
|
68
|
+
nested: list[NestedKey] = Field(default_factory=list)
|
|
60
69
|
model_config = ConfigDict(extra='forbid')
|
|
61
70
|
|
|
62
71
|
def __init__(
|
|
@@ -65,7 +74,7 @@ class Variable(ClientVariable, Generic[VariableType, PersistenceStoreType_co]):
|
|
|
65
74
|
persist_value: bool | None = False,
|
|
66
75
|
uid: str | None = None,
|
|
67
76
|
store: PersistenceStoreType_co | None = None,
|
|
68
|
-
nested: list[
|
|
77
|
+
nested: list[NestedKey] | None = None,
|
|
69
78
|
**kwargs,
|
|
70
79
|
):
|
|
71
80
|
"""
|
|
@@ -144,11 +153,13 @@ class Variable(ClientVariable, Generic[VariableType, PersistenceStoreType_co]):
|
|
|
144
153
|
yield
|
|
145
154
|
VARIABLE_INIT_OVERRIDE.reset(token)
|
|
146
155
|
|
|
147
|
-
def get(self, key:
|
|
156
|
+
def get(self, key: NestedKey):
|
|
148
157
|
"""
|
|
149
158
|
Create a copy of this Variable that points to a nested key. This is useful when
|
|
150
159
|
storing e.g. a dictionary in a Variable and wanting to access a specific key.
|
|
151
160
|
|
|
161
|
+
The key can be a string or a LoopVariable for dynamic access within a For loop.
|
|
162
|
+
|
|
152
163
|
```python
|
|
153
164
|
from dara.core import Variable, UpdateVariable
|
|
154
165
|
from dara_dashboarding_extension import Input, Text, Stack, Button
|
|
@@ -177,7 +188,11 @@ class Variable(ClientVariable, Generic[VariableType, PersistenceStoreType_co]):
|
|
|
177
188
|
)
|
|
178
189
|
)
|
|
179
190
|
|
|
180
|
-
|
|
191
|
+
# Dynamic access using LoopVariable
|
|
192
|
+
items = Variable([{'id': 'a'}, {'id': 'b'}])
|
|
193
|
+
For(items=items, renderer=Input(value=state.get(items.list_item.get('id'))))
|
|
194
|
+
|
|
195
|
+
:param key: the key to access; can be a string or LoopVariable
|
|
181
196
|
```
|
|
182
197
|
"""
|
|
183
198
|
return self.model_copy(update={'nested': [*self.nested, key]}, deep=True)
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2023 Impulse Innovations Limited
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StreamEventType(str, Enum):
|
|
27
|
+
"""Types of events that can be sent from a StreamVariable."""
|
|
28
|
+
|
|
29
|
+
# === Keyed mode events (require key_accessor) ===
|
|
30
|
+
ADD = 'add'
|
|
31
|
+
"""Add one or more items to the keyed collection."""
|
|
32
|
+
|
|
33
|
+
REMOVE = 'remove'
|
|
34
|
+
"""Remove one or more items by key from the keyed collection."""
|
|
35
|
+
|
|
36
|
+
CLEAR = 'clear'
|
|
37
|
+
"""Clear all items from the keyed collection."""
|
|
38
|
+
|
|
39
|
+
REPLACE = 'replace'
|
|
40
|
+
"""Atomically replace all items in the keyed collection."""
|
|
41
|
+
|
|
42
|
+
# === Custom state mode events ===
|
|
43
|
+
JSON_SNAPSHOT = 'json_snapshot'
|
|
44
|
+
"""Replace entire state with arbitrary JSON data."""
|
|
45
|
+
|
|
46
|
+
JSON_PATCH = 'json_patch'
|
|
47
|
+
"""Apply JSON Patch operations (RFC 6902) to the current state."""
|
|
48
|
+
|
|
49
|
+
# === Control events ===
|
|
50
|
+
RECONNECT = 'reconnect'
|
|
51
|
+
"""Signal to client to reconnect (sent when ReconnectException is raised)."""
|
|
52
|
+
|
|
53
|
+
ERROR = 'error'
|
|
54
|
+
"""Signal an error occurred in the stream."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class StreamEvent(BaseModel):
|
|
58
|
+
"""
|
|
59
|
+
An event emitted by a StreamVariable generator.
|
|
60
|
+
|
|
61
|
+
StreamEvents are used to update the client-side state of a StreamVariable.
|
|
62
|
+
|
|
63
|
+
**Keyed mode** (when ``key_accessor`` is set on StreamVariable):
|
|
64
|
+
|
|
65
|
+
- ``replace(*items)``: Atomically replace all items (recommended for initial state)
|
|
66
|
+
- ``add(*items)``: Add/update items by key
|
|
67
|
+
- ``remove(*keys)``: Remove items by key
|
|
68
|
+
- ``clear()``: Clear all items
|
|
69
|
+
|
|
70
|
+
**Custom state mode** (for arbitrary JSON state):
|
|
71
|
+
|
|
72
|
+
- ``json_snapshot(data)``: Replace entire state
|
|
73
|
+
- ``json_patch(operations)``: Apply RFC 6902 JSON Patch operations
|
|
74
|
+
|
|
75
|
+
Examples
|
|
76
|
+
--------
|
|
77
|
+
Keyed collection (e.g., events with unique IDs):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
async def events_stream():
|
|
81
|
+
# Use replace() for initial state to avoid flash of empty content
|
|
82
|
+
yield StreamEvent.replace(*await fetch_initial_events())
|
|
83
|
+
async for event in live_feed:
|
|
84
|
+
yield StreamEvent.add(event)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Custom state with JSON patches:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
async def dashboard_stream():
|
|
91
|
+
yield StreamEvent.json_snapshot({'items': {}, 'count': 0})
|
|
92
|
+
yield StreamEvent.json_patch([
|
|
93
|
+
{"op": "add", "path": "/items/123", "value": item},
|
|
94
|
+
{"op": "replace", "path": "/count", "value": 1}
|
|
95
|
+
])
|
|
96
|
+
```
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
type: StreamEventType
|
|
100
|
+
data: Any = None
|
|
101
|
+
|
|
102
|
+
# === Keyed mode events ===
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def add(cls, *items: Any) -> StreamEvent:
|
|
106
|
+
"""
|
|
107
|
+
Add one or more items to the keyed collection.
|
|
108
|
+
|
|
109
|
+
Items are keyed using the ``key_accessor`` property path defined on the StreamVariable.
|
|
110
|
+
If an item with the same key exists, it will be updated.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
*items: One or more items to add. Each item must have the property
|
|
114
|
+
specified by ``key_accessor``.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
StreamEvent with type ADD
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: If no items are provided
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
```python
|
|
124
|
+
yield StreamEvent.add(event)
|
|
125
|
+
yield StreamEvent.add(event1, event2, event3)
|
|
126
|
+
yield StreamEvent.add(*events_list)
|
|
127
|
+
```
|
|
128
|
+
"""
|
|
129
|
+
if not items:
|
|
130
|
+
raise ValueError('StreamEvent.add() requires at least one item')
|
|
131
|
+
|
|
132
|
+
# Single item: send as-is, multiple items: send as list
|
|
133
|
+
data = items[0] if len(items) == 1 else list(items)
|
|
134
|
+
return cls(type=StreamEventType.ADD, data=data)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def remove(cls, *keys: str | int) -> StreamEvent:
|
|
138
|
+
"""
|
|
139
|
+
Remove one or more items by key from the keyed collection.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
*keys: One or more keys to remove.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
StreamEvent with type REMOVE
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: If no keys are provided
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
```python
|
|
152
|
+
yield StreamEvent.remove('item-1')
|
|
153
|
+
yield StreamEvent.remove('item-1', 'item-2', 'item-3')
|
|
154
|
+
yield StreamEvent.remove(*keys_to_remove)
|
|
155
|
+
```
|
|
156
|
+
"""
|
|
157
|
+
if not keys:
|
|
158
|
+
raise ValueError('StreamEvent.remove() requires at least one key')
|
|
159
|
+
|
|
160
|
+
# Single key: send as-is, multiple keys: send as list
|
|
161
|
+
data = keys[0] if len(keys) == 1 else list(keys)
|
|
162
|
+
return cls(type=StreamEventType.REMOVE, data=data)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def clear(cls) -> StreamEvent:
|
|
166
|
+
"""
|
|
167
|
+
Clear all items from the keyed collection.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
StreamEvent with type CLEAR
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
```python
|
|
174
|
+
yield StreamEvent.clear() # Empty the collection
|
|
175
|
+
```
|
|
176
|
+
"""
|
|
177
|
+
return cls(type=StreamEventType.CLEAR, data=None)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def replace(cls, *items: Any) -> StreamEvent:
|
|
181
|
+
"""
|
|
182
|
+
Atomically replace all items in the keyed collection.
|
|
183
|
+
|
|
184
|
+
This is the recommended way to set initial state in keyed mode.
|
|
185
|
+
Unlike ``clear()`` followed by ``add()``, this avoids a flash of empty
|
|
186
|
+
content because the swap happens in a single update.
|
|
187
|
+
|
|
188
|
+
Calling with no arguments is equivalent to ``clear()``.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
*items: Items to replace the collection with. Each item must have
|
|
192
|
+
the property specified by ``key_accessor``.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
StreamEvent with type REPLACE
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
```python
|
|
199
|
+
# Set initial state without flash of empty content
|
|
200
|
+
yield StreamEvent.replace(*await fetch_all_events())
|
|
201
|
+
|
|
202
|
+
# Then stream live updates
|
|
203
|
+
async for event in live_feed:
|
|
204
|
+
yield StreamEvent.add(event)
|
|
205
|
+
|
|
206
|
+
# Equivalent to clear()
|
|
207
|
+
yield StreamEvent.replace()
|
|
208
|
+
```
|
|
209
|
+
"""
|
|
210
|
+
return cls(type=StreamEventType.REPLACE, data=list(items))
|
|
211
|
+
|
|
212
|
+
# === Custom state mode events ===
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def json_snapshot(cls, data: Any) -> StreamEvent:
|
|
216
|
+
"""
|
|
217
|
+
Replace the entire state with new JSON data.
|
|
218
|
+
|
|
219
|
+
Use this for custom state structures or as the first event
|
|
220
|
+
in a stream to establish initial state.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
data: The new state (any JSON-serializable structure)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
StreamEvent with type JSON_SNAPSHOT
|
|
227
|
+
|
|
228
|
+
Example:
|
|
229
|
+
```python
|
|
230
|
+
yield StreamEvent.json_snapshot({'items': {}, 'meta': {'count': 0}})
|
|
231
|
+
yield StreamEvent.json_snapshot(await api.get_current_state())
|
|
232
|
+
```
|
|
233
|
+
"""
|
|
234
|
+
return cls(type=StreamEventType.JSON_SNAPSHOT, data=data)
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def json_patch(cls, operations: list[dict[str, Any]]) -> StreamEvent:
|
|
238
|
+
"""
|
|
239
|
+
Apply JSON Patch operations (RFC 6902) to the current state.
|
|
240
|
+
|
|
241
|
+
This allows fine-grained updates to complex state structures without
|
|
242
|
+
replacing the entire state.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
operations: List of JSON Patch operations. Each operation is a dict
|
|
246
|
+
with keys like 'op', 'path', and 'value'.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
StreamEvent with type JSON_PATCH
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
```python
|
|
253
|
+
yield StreamEvent.json_patch([
|
|
254
|
+
{"op": "add", "path": "/items/-", "value": new_item},
|
|
255
|
+
{"op": "replace", "path": "/count", "value": 5},
|
|
256
|
+
{"op": "remove", "path": "/items/0"}
|
|
257
|
+
])
|
|
258
|
+
```
|
|
259
|
+
"""
|
|
260
|
+
return cls(type=StreamEventType.JSON_PATCH, data=operations)
|
|
261
|
+
|
|
262
|
+
# === Control events ===
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def reconnect(cls) -> StreamEvent:
|
|
266
|
+
"""
|
|
267
|
+
Signal to the client that it should reconnect.
|
|
268
|
+
|
|
269
|
+
This is sent automatically when ReconnectException is raised in the generator.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
StreamEvent with type RECONNECT
|
|
273
|
+
"""
|
|
274
|
+
return cls(type=StreamEventType.RECONNECT, data=None)
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def error(cls, message: str) -> StreamEvent:
|
|
278
|
+
"""
|
|
279
|
+
Signal that an error occurred in the stream.
|
|
280
|
+
|
|
281
|
+
This is sent automatically when an unhandled exception occurs in the generator.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
message: Error message to send to the client
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
StreamEvent with type ERROR
|
|
288
|
+
"""
|
|
289
|
+
return cls(type=StreamEventType.ERROR, data=message)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ReconnectException(Exception):
|
|
293
|
+
"""
|
|
294
|
+
Exception to signal a recoverable error - client should reconnect with backoff.
|
|
295
|
+
|
|
296
|
+
Use this to distinguish between recoverable and fatal errors in your stream:
|
|
297
|
+
|
|
298
|
+
- **Recoverable errors** (raise ``ReconnectException``): Temporary issues like
|
|
299
|
+
network timeouts, upstream service unavailable, connection drops. The client
|
|
300
|
+
will automatically retry with exponential backoff.
|
|
301
|
+
|
|
302
|
+
- **Fatal errors** (raise any other exception): Permanent issues like invalid
|
|
303
|
+
configuration, authentication failures, resource not found. The error message
|
|
304
|
+
is shown to the user and the stream stops.
|
|
305
|
+
|
|
306
|
+
Example:
|
|
307
|
+
```python
|
|
308
|
+
async def events_stream(invocation_id: str):
|
|
309
|
+
try:
|
|
310
|
+
async for event in upstream_api.stream_events(invocation_id):
|
|
311
|
+
yield StreamEvent.add(event)
|
|
312
|
+
except ConnectionError:
|
|
313
|
+
# Network issue - recoverable, trigger reconnect with backoff
|
|
314
|
+
raise ReconnectException()
|
|
315
|
+
except AuthenticationError:
|
|
316
|
+
# Auth failed - fatal, show error to user
|
|
317
|
+
raise ValueError("Invalid API credentials")
|
|
318
|
+
```
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
pass
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import signal
|
|
3
|
+
from collections.abc import AsyncIterator, Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dara.core.logging import dev_logger
|
|
7
|
+
|
|
8
|
+
# Global set to track active connections
|
|
9
|
+
_active_connections: set[asyncio.Task] = set()
|
|
10
|
+
_shutdown_event = asyncio.Event()
|
|
11
|
+
_original_sigint_handler: Any | None = None
|
|
12
|
+
_original_sigterm_handler: Any | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _chained_signal_handler(signum, frame):
|
|
16
|
+
dev_logger.info(f'[dara-core] Shutting down {len(_active_connections)} streaming connections...')
|
|
17
|
+
_shutdown_event.set()
|
|
18
|
+
|
|
19
|
+
# Cancel all active streaming connections immediately
|
|
20
|
+
cancelled_count = 0
|
|
21
|
+
for task in _active_connections.copy():
|
|
22
|
+
if not task.done():
|
|
23
|
+
task.cancel()
|
|
24
|
+
cancelled_count += 1
|
|
25
|
+
|
|
26
|
+
if cancelled_count > 0:
|
|
27
|
+
dev_logger.info(f'[dara-core] Cancelled {cancelled_count} streaming connections')
|
|
28
|
+
|
|
29
|
+
# Call the original handler if it exists and isn't the default
|
|
30
|
+
original_handler = _original_sigint_handler if signum == signal.SIGINT else _original_sigterm_handler
|
|
31
|
+
|
|
32
|
+
if original_handler and original_handler != signal.SIG_DFL and callable(original_handler):
|
|
33
|
+
original_handler(signum, frame)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def setup_signal_handlers():
|
|
37
|
+
"""Setup signal handlers that chain with existing ones."""
|
|
38
|
+
global _original_sigint_handler, _original_sigterm_handler
|
|
39
|
+
|
|
40
|
+
# Save existing handlers
|
|
41
|
+
_original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
42
|
+
_original_sigterm_handler = signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
43
|
+
|
|
44
|
+
# Install our chained handlers - only if they're not already installed
|
|
45
|
+
if _original_sigint_handler is not _chained_signal_handler:
|
|
46
|
+
signal.signal(signal.SIGINT, _chained_signal_handler)
|
|
47
|
+
|
|
48
|
+
if _original_sigterm_handler is not _chained_signal_handler:
|
|
49
|
+
signal.signal(signal.SIGTERM, _chained_signal_handler)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def track_stream(func: Callable[[], AsyncIterator[Any]]):
|
|
53
|
+
"""
|
|
54
|
+
Decorator to track active streaming connections.
|
|
55
|
+
Keeps track of the current task in active_connections while it's live.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
async def wrapper():
|
|
59
|
+
current_task = asyncio.current_task()
|
|
60
|
+
assert current_task is not None, 'No current task found'
|
|
61
|
+
_active_connections.add(current_task)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
async for item in func():
|
|
65
|
+
yield item
|
|
66
|
+
finally:
|
|
67
|
+
_active_connections.discard(current_task)
|
|
68
|
+
|
|
69
|
+
return wrapper
|