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.
@@ -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[str] = Field(default_factory=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[str] | None = None,
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: str):
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[str] = Field(default_factory=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[str] | None = None,
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: str):
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
- :param key: the key to access; must be a string
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