dara-core 1.24.2__py3-none-any.whl → 1.25.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.
- dara/core/_assets/auto_js/dara.core.umd.cjs +872 -221
- dara/core/interactivity/__init__.py +5 -0
- dara/core/interactivity/stream_event.py +321 -0
- dara/core/interactivity/stream_utils.py +69 -0
- dara/core/interactivity/stream_variable.py +334 -0
- 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-1.24.2.dist-info → dara_core-1.25.0.dist-info}/METADATA +10 -10
- {dara_core-1.24.2.dist-info → dara_core-1.25.0.dist-info}/RECORD +14 -11
- {dara_core-1.24.2.dist-info → dara_core-1.25.0.dist-info}/LICENSE +0 -0
- {dara_core-1.24.2.dist-info → dara_core-1.25.0.dist-info}/WHEEL +0 -0
- {dara_core-1.24.2.dist-info → dara_core-1.25.0.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',
|
|
@@ -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
|