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.
@@ -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