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
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
from collections.abc import AsyncGenerator, Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Generic, Literal
|
|
25
|
+
|
|
26
|
+
from fastapi import Request
|
|
27
|
+
from pydantic import ConfigDict, Field, SerializerFunctionWrapHandler, field_validator, model_serializer
|
|
28
|
+
from typing_extensions import TypeVar
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from dara.core.interactivity.loop_variable import LoopVariable
|
|
32
|
+
|
|
33
|
+
# Type alias for static type checking
|
|
34
|
+
NestedKey = str | LoopVariable
|
|
35
|
+
else:
|
|
36
|
+
# At runtime, use Any to avoid forward reference issues with Pydantic
|
|
37
|
+
NestedKey = Any
|
|
38
|
+
|
|
39
|
+
from dara.core.base_definitions import BaseTask
|
|
40
|
+
from dara.core.interactivity.any_variable import AnyVariable
|
|
41
|
+
from dara.core.interactivity.client_variable import ClientVariable
|
|
42
|
+
from dara.core.interactivity.stream_event import ReconnectException, StreamEvent
|
|
43
|
+
from dara.core.internal.cache_store import CacheStore
|
|
44
|
+
from dara.core.internal.tasks import TaskManager
|
|
45
|
+
from dara.core.logging import dev_logger
|
|
46
|
+
|
|
47
|
+
VariableType = TypeVar('VariableType', default=Any)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StreamVariable(ClientVariable, Generic[VariableType]):
|
|
51
|
+
"""
|
|
52
|
+
A StreamVariable represents a stream of events that are accumulated on the client.
|
|
53
|
+
|
|
54
|
+
It takes an async generator function that yields StreamEvents, and dependencies
|
|
55
|
+
(other variables). When dependencies change, a new stream is opened. Old streams
|
|
56
|
+
might be cleaned up when unused to save resources.
|
|
57
|
+
|
|
58
|
+
The stream is managed via Server-Sent Events (SSE) and the client handles
|
|
59
|
+
reconnection automatically with exponential backoff.
|
|
60
|
+
|
|
61
|
+
IMPORTANT: Handling Reconnection
|
|
62
|
+
--------------------------------
|
|
63
|
+
Stream functions MUST be idempotent - they should produce the correct state even
|
|
64
|
+
when called multiple times due to reconnection. Streams can disconnect and reconnect
|
|
65
|
+
at any time (network issues, browser tab suspension, server restarts). Your stream
|
|
66
|
+
function runs from the beginning on each reconnection.
|
|
67
|
+
|
|
68
|
+
Always start with ``StreamEvent.replace()`` (keyed mode) or ``StreamEvent.json_snapshot()``
|
|
69
|
+
(custom mode) to set the full initial state atomically. This ensures clients always
|
|
70
|
+
converge to the correct state regardless of when they connect, and avoids a flash of
|
|
71
|
+
empty content on reconnection.
|
|
72
|
+
|
|
73
|
+
Keyed mode (when ``key_accessor`` is set):
|
|
74
|
+
|
|
75
|
+
- Items are stored in a dict keyed by the accessor, exposed as a list
|
|
76
|
+
- Use ``replace()``, ``add()``, ``remove()``, ``clear()`` events
|
|
77
|
+
|
|
78
|
+
Custom state mode (no ``key_accessor``):
|
|
79
|
+
|
|
80
|
+
- State can be any JSON structure
|
|
81
|
+
- Use ``json_snapshot()``, ``json_patch()`` events
|
|
82
|
+
|
|
83
|
+
Examples
|
|
84
|
+
--------
|
|
85
|
+
Keyed event stream (e.g., events with unique IDs):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from dara.core import StreamVariable, StreamEvent
|
|
89
|
+
|
|
90
|
+
async def events_stream(invocation_id: str):
|
|
91
|
+
# Set initial state atomically (handles reconnection, no flash of empty)
|
|
92
|
+
initial_events = await api.get_events(invocation_id)
|
|
93
|
+
yield StreamEvent.replace(*initial_events)
|
|
94
|
+
|
|
95
|
+
# Stream new events
|
|
96
|
+
async for event in api.stream_events(invocation_id):
|
|
97
|
+
yield StreamEvent.add(event)
|
|
98
|
+
|
|
99
|
+
events = StreamVariable(
|
|
100
|
+
events_stream,
|
|
101
|
+
variables=[invocation_id_var],
|
|
102
|
+
key_accessor='id',
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Use in template
|
|
106
|
+
For(items=events, renderer=EventCard(events.list_item))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Custom state with JSON patches:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
async def dashboard_stream(dashboard_id: str):
|
|
113
|
+
# Set initial state atomically (handles reconnection)
|
|
114
|
+
current_state = await api.get_dashboard_state(dashboard_id)
|
|
115
|
+
yield StreamEvent.json_snapshot(current_state)
|
|
116
|
+
|
|
117
|
+
async for update in api.stream_updates(dashboard_id):
|
|
118
|
+
yield StreamEvent.json_patch([
|
|
119
|
+
{"op": "add", "path": f"/items/{update.id}", "value": update},
|
|
120
|
+
{"op": "replace", "path": "/count", "value": update.count}
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
dashboard = StreamVariable(dashboard_stream, variables=[dashboard_id_var])
|
|
124
|
+
```
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
__typename: Literal['StreamVariable'] = 'StreamVariable'
|
|
128
|
+
uid: str
|
|
129
|
+
variables: list[AnyVariable] = Field(default_factory=list)
|
|
130
|
+
key_accessor: str | None = None
|
|
131
|
+
nested: list[NestedKey] = Field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(extra='forbid')
|
|
134
|
+
|
|
135
|
+
@field_validator('variables')
|
|
136
|
+
@classmethod
|
|
137
|
+
def validate_variables(cls, variables: list[AnyVariable]) -> list[AnyVariable]:
|
|
138
|
+
"""Validate that variables don't contain unsupported types."""
|
|
139
|
+
from dara.core.interactivity.derived_variable import DerivedVariable
|
|
140
|
+
from dara.core.internal.registries import derived_variable_registry
|
|
141
|
+
|
|
142
|
+
for v in variables:
|
|
143
|
+
# Disallow StreamVariable -> StreamVariable dependency
|
|
144
|
+
if isinstance(v, StreamVariable):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
'StreamVariable cannot depend on another StreamVariable. '
|
|
147
|
+
'Compose generators in Python instead. Example:\n\n'
|
|
148
|
+
'async def enriched_events(id):\n'
|
|
149
|
+
' async for event in source_stream(id):\n'
|
|
150
|
+
' yield StreamEvent.add(enrich(event))\n\n'
|
|
151
|
+
'events = StreamVariable(enriched_events, variables=[id_var])'
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Disallow DerivedVariable with run_as_task=True
|
|
155
|
+
if isinstance(v, DerivedVariable):
|
|
156
|
+
entry = derived_variable_registry.get(str(v.uid))
|
|
157
|
+
if entry is not None and entry.run_as_task:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
'StreamVariable cannot depend on a DerivedVariable with run_as_task=True. '
|
|
160
|
+
'Task-based variables resolve asynchronously which is incompatible with '
|
|
161
|
+
'streaming. Remove run_as_task=True or compute the value in the stream function.'
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return variables
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
func: Callable[..., AsyncGenerator[StreamEvent, None]],
|
|
169
|
+
variables: list[AnyVariable] | None = None,
|
|
170
|
+
key_accessor: str | None = None,
|
|
171
|
+
uid: str | None = None,
|
|
172
|
+
nested: list[NestedKey] | None = None,
|
|
173
|
+
**kwargs,
|
|
174
|
+
):
|
|
175
|
+
"""
|
|
176
|
+
Create a new StreamVariable.
|
|
177
|
+
|
|
178
|
+
:param func: Async generator function that yields StreamEvents.
|
|
179
|
+
The function receives resolved values of `variables` as arguments.
|
|
180
|
+
:param variables: List of variables whose resolved values are passed to `func`.
|
|
181
|
+
When any of these change, a new stream is opened.
|
|
182
|
+
:param key_accessor: Property path to extract unique ID from items for deduplication.
|
|
183
|
+
Required when using StreamEvent.add(). E.g., 'id' or 'data.id'.
|
|
184
|
+
:param uid: Unique identifier for this variable. Auto-generated if not provided.
|
|
185
|
+
:param nested: Internal use - tracks nested path for .get() chains.
|
|
186
|
+
"""
|
|
187
|
+
if variables is None:
|
|
188
|
+
variables = []
|
|
189
|
+
if nested is None:
|
|
190
|
+
nested = []
|
|
191
|
+
|
|
192
|
+
super().__init__(
|
|
193
|
+
uid=uid,
|
|
194
|
+
variables=variables,
|
|
195
|
+
key_accessor=key_accessor,
|
|
196
|
+
nested=nested,
|
|
197
|
+
**kwargs,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Register with the stream variable registry
|
|
201
|
+
from dara.core.internal.registries import stream_variable_registry
|
|
202
|
+
|
|
203
|
+
stream_variable_registry.register(
|
|
204
|
+
str(self.uid),
|
|
205
|
+
StreamVariableRegistryEntry(
|
|
206
|
+
uid=str(self.uid),
|
|
207
|
+
func=func,
|
|
208
|
+
variables=variables,
|
|
209
|
+
key_accessor=key_accessor,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def get(self, *keys: NestedKey) -> StreamVariable[Any]:
|
|
214
|
+
"""
|
|
215
|
+
Access a nested value within the stream's accumulated state.
|
|
216
|
+
|
|
217
|
+
This is useful when the stream accumulates complex state (via snapshot/patch)
|
|
218
|
+
and you want to access a specific part of it.
|
|
219
|
+
|
|
220
|
+
Keys can be strings or LoopVariables for dynamic access within a For loop.
|
|
221
|
+
|
|
222
|
+
Example::
|
|
223
|
+
|
|
224
|
+
dashboard = StreamVariable(dashboard_stream, variables=[id_var])
|
|
225
|
+
|
|
226
|
+
# Access nested value
|
|
227
|
+
Text(dashboard.get('meta', 'count'))
|
|
228
|
+
|
|
229
|
+
# Dynamic access using LoopVariable
|
|
230
|
+
items = Variable([{'id': 'a'}, {'id': 'b'}])
|
|
231
|
+
For(items=items, renderer=Text(dashboard.get(items.list_item.get('id'))))
|
|
232
|
+
|
|
233
|
+
:param keys: One or more keys to traverse into the nested structure.
|
|
234
|
+
Can be strings or LoopVariables.
|
|
235
|
+
:return: A new StreamVariable pointing to the nested path.
|
|
236
|
+
"""
|
|
237
|
+
from dara.core.interactivity.loop_variable import LoopVariable
|
|
238
|
+
|
|
239
|
+
# Preserve LoopVariables as-is, convert other types to strings
|
|
240
|
+
processed_keys: list[NestedKey] = [k if isinstance(k, LoopVariable) else str(k) for k in keys]
|
|
241
|
+
return self.model_copy(
|
|
242
|
+
update={'nested': [*self.nested, *processed_keys]},
|
|
243
|
+
deep=True,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def list_item(self):
|
|
248
|
+
"""
|
|
249
|
+
Get a LoopVariable that represents the current item when iterating.
|
|
250
|
+
|
|
251
|
+
This is used with the For component to access the current item in the loop.
|
|
252
|
+
|
|
253
|
+
Example::
|
|
254
|
+
|
|
255
|
+
For(items=events, renderer=EventCard(events.list_item))
|
|
256
|
+
"""
|
|
257
|
+
from dara.core.interactivity.loop_variable import LoopVariable
|
|
258
|
+
|
|
259
|
+
return LoopVariable()
|
|
260
|
+
|
|
261
|
+
@model_serializer(mode='wrap')
|
|
262
|
+
def ser_model(self, nxt: SerializerFunctionWrapHandler) -> dict:
|
|
263
|
+
parent_dict = nxt(self)
|
|
264
|
+
return {
|
|
265
|
+
**parent_dict,
|
|
266
|
+
'__typename': 'StreamVariable',
|
|
267
|
+
'uid': str(parent_dict['uid']),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass
|
|
272
|
+
class StreamVariableRegistryEntry:
|
|
273
|
+
"""Registry entry for a StreamVariable."""
|
|
274
|
+
|
|
275
|
+
uid: str
|
|
276
|
+
func: Callable[..., AsyncGenerator[StreamEvent, None]]
|
|
277
|
+
variables: list[AnyVariable]
|
|
278
|
+
key_accessor: str | None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class StreamVariableModeError(Exception):
|
|
282
|
+
"""Raised when a StreamEvent is used with the wrong mode (keyed vs custom)."""
|
|
283
|
+
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# Event types that require key_accessor (keyed mode)
|
|
288
|
+
_KEYED_MODE_EVENT_TYPES = {'add', 'remove', 'replace', 'clear'}
|
|
289
|
+
# Event types for custom mode (no key_accessor)
|
|
290
|
+
_CUSTOM_MODE_EVENT_TYPES = {'json_snapshot', 'json_patch'}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _validate_event_mode(event: StreamEvent, key_accessor: str | None) -> None:
|
|
294
|
+
"""
|
|
295
|
+
Validate that the event type matches the StreamVariable mode.
|
|
296
|
+
|
|
297
|
+
Raises StreamVariableModeError if:
|
|
298
|
+
- Keyed mode event (add/remove/replace/clear) is used without key_accessor
|
|
299
|
+
- Custom mode event (json_snapshot/json_patch) is used with key_accessor
|
|
300
|
+
"""
|
|
301
|
+
event_type = event.type.value if hasattr(event.type, 'value') else str(event.type)
|
|
302
|
+
|
|
303
|
+
if event_type in _KEYED_MODE_EVENT_TYPES and key_accessor is None:
|
|
304
|
+
raise StreamVariableModeError(
|
|
305
|
+
f'StreamEvent.{event_type}() requires key_accessor to be set on StreamVariable. '
|
|
306
|
+
f"Either set key_accessor='your_id_field' on the StreamVariable, "
|
|
307
|
+
f'or use StreamEvent.json_snapshot()/json_patch() for custom state mode.'
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if event_type in _CUSTOM_MODE_EVENT_TYPES and key_accessor is not None:
|
|
311
|
+
raise StreamVariableModeError(
|
|
312
|
+
f'StreamEvent.{event_type}() is for custom state mode but key_accessor is set. '
|
|
313
|
+
f'Either remove key_accessor from StreamVariable, '
|
|
314
|
+
f'or use StreamEvent.add()/remove()/replace()/clear() for keyed mode.'
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def run_stream(
|
|
319
|
+
entry: StreamVariableRegistryEntry, request: Request, values: list[Any], store: CacheStore, task_mgr: TaskManager
|
|
320
|
+
):
|
|
321
|
+
"""Run a StreamVariable."""
|
|
322
|
+
# dynamic import due to circular import
|
|
323
|
+
from dara.core.internal.dependency_resolution import (
|
|
324
|
+
resolve_dependency,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
resolved_values = await asyncio.gather(*[resolve_dependency(v, store, task_mgr) for v in values])
|
|
328
|
+
|
|
329
|
+
has_tasks = any(isinstance(v, BaseTask) for v in resolved_values)
|
|
330
|
+
if has_tasks:
|
|
331
|
+
raise NotImplementedError('StreamVariable does not support tasks')
|
|
332
|
+
|
|
333
|
+
generator = None
|
|
334
|
+
try:
|
|
335
|
+
generator = entry.func(*resolved_values)
|
|
336
|
+
async for event in generator:
|
|
337
|
+
if await request.is_disconnected():
|
|
338
|
+
break
|
|
339
|
+
# Validate event type matches the StreamVariable mode
|
|
340
|
+
_validate_event_mode(event, entry.key_accessor)
|
|
341
|
+
yield f'data: {event.model_dump_json()}\n\n'
|
|
342
|
+
except ReconnectException:
|
|
343
|
+
yield f'data: {StreamEvent.reconnect().model_dump_json()}\n\n'
|
|
344
|
+
except StreamVariableModeError as e:
|
|
345
|
+
dev_logger.error('Stream mode error', error=e)
|
|
346
|
+
yield f'data: {StreamEvent.error(str(e)).model_dump_json()}\n\n'
|
|
347
|
+
except Exception as e:
|
|
348
|
+
dev_logger.error('Stream error', error=e)
|
|
349
|
+
yield f'data: {StreamEvent.error(str(e)).model_dump_json()}\n\n'
|
|
350
|
+
finally:
|
|
351
|
+
# Cleanup: close generator if it's still open
|
|
352
|
+
if generator is not None:
|
|
353
|
+
with contextlib.suppress(Exception):
|
|
354
|
+
await generator.aclose()
|
|
@@ -17,6 +17,7 @@ limitations under the License.
|
|
|
17
17
|
|
|
18
18
|
from typing import Any, Literal, TypeGuard
|
|
19
19
|
|
|
20
|
+
from pydantic import BaseModel
|
|
20
21
|
from typing_extensions import TypedDict
|
|
21
22
|
|
|
22
23
|
from dara.core.base_definitions import BaseTask, PendingTask
|
|
@@ -75,16 +76,27 @@ def _resolve_nested(value: Any, nested: list[str] | None) -> Any:
|
|
|
75
76
|
if not nested or len(nested) == 0:
|
|
76
77
|
return value
|
|
77
78
|
|
|
78
|
-
# Not a dict
|
|
79
|
-
if value is None or not isinstance(value, dict):
|
|
79
|
+
# Not a dict/model
|
|
80
|
+
if value is None or not isinstance(value, (dict, BaseModel)):
|
|
80
81
|
return value
|
|
81
82
|
|
|
82
83
|
result = value
|
|
83
84
|
for key in nested:
|
|
84
|
-
#
|
|
85
|
-
if
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
# Defensive check: LoopVariable should have been resolved by frontend templating
|
|
86
|
+
if isinstance(key, dict):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
'LoopVariable found in nested path during backend resolution. '
|
|
89
|
+
'This should have been resolved by frontend templating.'
|
|
90
|
+
)
|
|
91
|
+
# Can recurse into a dict or a pydantic model
|
|
92
|
+
if isinstance(result, dict):
|
|
93
|
+
# Path does not exist, resolve to None
|
|
94
|
+
if key not in result:
|
|
95
|
+
return None
|
|
96
|
+
result = result[key]
|
|
97
|
+
elif isinstance(result, BaseModel):
|
|
98
|
+
# fall back to None, if key doesn't exist/is None, we'll skip iterating anyway
|
|
99
|
+
result = getattr(result, key, None)
|
|
88
100
|
|
|
89
101
|
return result
|
|
90
102
|
|
|
@@ -21,7 +21,6 @@ from typing import (
|
|
|
21
21
|
Generic,
|
|
22
22
|
TypeGuard,
|
|
23
23
|
TypeVar,
|
|
24
|
-
cast,
|
|
25
24
|
overload,
|
|
26
25
|
)
|
|
27
26
|
|
|
@@ -57,13 +56,26 @@ class Referrable(TypedDict):
|
|
|
57
56
|
|
|
58
57
|
|
|
59
58
|
class ReferrableWithNested(Referrable):
|
|
60
|
-
nested: list[str]
|
|
59
|
+
nested: list[str | dict] # dict represents serialized LoopVariable
|
|
61
60
|
|
|
62
61
|
|
|
63
62
|
class ReferrableWithFilters(Referrable):
|
|
64
63
|
filters: dict
|
|
65
64
|
|
|
66
65
|
|
|
66
|
+
def _serialize_nested_key(key: str | dict) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Serialize a single nested key for identifier computation.
|
|
69
|
+
|
|
70
|
+
Handles both string keys and LoopVariable dicts.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(key, dict) and key.get('__typename') == 'LoopVariable':
|
|
73
|
+
# Format: LoopVar:uid:nested.joined
|
|
74
|
+
loop_nested = ','.join(key.get('nested', []))
|
|
75
|
+
return f'LoopVar:{key["uid"]}:{loop_nested}'
|
|
76
|
+
return str(key)
|
|
77
|
+
|
|
78
|
+
|
|
67
79
|
def _get_identifier(obj: Referrable) -> str:
|
|
68
80
|
"""
|
|
69
81
|
Get a unique identifier from a 'referrable' object
|
|
@@ -74,7 +86,9 @@ def _get_identifier(obj: Referrable) -> str:
|
|
|
74
86
|
|
|
75
87
|
# If it's a Variable with 'nested', the property should be included in the identifier
|
|
76
88
|
if _is_referrable_nested(obj) and len(obj['nested']) > 0:
|
|
77
|
-
|
|
89
|
+
# Handle mixed string/LoopVariable nested
|
|
90
|
+
nested_parts = [_serialize_nested_key(k) for k in obj['nested']]
|
|
91
|
+
nested = ','.join(nested_parts)
|
|
78
92
|
identifier = f'{identifier}:{nested}'
|
|
79
93
|
|
|
80
94
|
return identifier
|
dara/core/internal/registries.py
CHANGED
|
@@ -32,6 +32,7 @@ from dara.core.interactivity.derived_variable import (
|
|
|
32
32
|
LatestValueRegistryEntry,
|
|
33
33
|
)
|
|
34
34
|
from dara.core.interactivity.server_variable import ServerVariableRegistryEntry
|
|
35
|
+
from dara.core.interactivity.stream_variable import StreamVariableRegistryEntry
|
|
35
36
|
from dara.core.internal.download import DownloadDataEntry
|
|
36
37
|
from dara.core.internal.registry import Registry, RegistryType
|
|
37
38
|
from dara.core.internal.websocket import CustomClientMessagePayload
|
|
@@ -75,3 +76,6 @@ backend_store_registry = Registry[BackendStoreEntry](RegistryType.BACKEND_STORE,
|
|
|
75
76
|
|
|
76
77
|
download_code_registry = Registry[DownloadDataEntry](RegistryType.DOWNLOAD_CODE, allow_duplicates=False)
|
|
77
78
|
"""map of download codes -> download data entry, used only to allow overriding download code behaviour via RegistryLookup"""
|
|
79
|
+
|
|
80
|
+
stream_variable_registry = Registry[StreamVariableRegistryEntry](RegistryType.STREAM_VARIABLE, allow_duplicates=False)
|
|
81
|
+
"""map of stream variable uid -> stream variable registry entry"""
|
dara/core/internal/registry.py
CHANGED
dara/core/internal/routing.py
CHANGED
|
@@ -39,6 +39,7 @@ from fastapi import (
|
|
|
39
39
|
HTTPException,
|
|
40
40
|
Path,
|
|
41
41
|
Query,
|
|
42
|
+
Request,
|
|
42
43
|
Response,
|
|
43
44
|
UploadFile,
|
|
44
45
|
)
|
|
@@ -56,6 +57,8 @@ from dara.core.interactivity.actions import ACTION_CONTEXT
|
|
|
56
57
|
from dara.core.interactivity.any_data_variable import upload
|
|
57
58
|
from dara.core.interactivity.filtering import FilterQuery, Pagination
|
|
58
59
|
from dara.core.interactivity.server_variable import ServerVariable
|
|
60
|
+
from dara.core.interactivity.stream_utils import track_stream
|
|
61
|
+
from dara.core.interactivity.stream_variable import run_stream
|
|
59
62
|
from dara.core.internal.cache_store import CacheStore
|
|
60
63
|
from dara.core.internal.devtools import print_stacktrace
|
|
61
64
|
from dara.core.internal.download import DownloadRegistryEntry
|
|
@@ -72,6 +75,7 @@ from dara.core.internal.registries import (
|
|
|
72
75
|
latest_value_registry,
|
|
73
76
|
server_variable_registry,
|
|
74
77
|
static_kwargs_registry,
|
|
78
|
+
stream_variable_registry,
|
|
75
79
|
upload_resolver_registry,
|
|
76
80
|
utils_registry,
|
|
77
81
|
)
|
|
@@ -508,6 +512,57 @@ async def get_version():
|
|
|
508
512
|
core_api_router.add_api_websocket_route('/ws', ws_handler)
|
|
509
513
|
|
|
510
514
|
|
|
515
|
+
class StreamRequestBody(BaseModel):
|
|
516
|
+
"""Request body for StreamVariable SSE endpoint."""
|
|
517
|
+
|
|
518
|
+
values: NormalizedPayload[list[Any]]
|
|
519
|
+
"""Normalized payload of resolved variable values to pass to the stream generator."""
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@core_api_router.post('/stream/{stream_uid}', dependencies=[Depends(verify_session)])
|
|
523
|
+
async def stream_endpoint(
|
|
524
|
+
request: Request,
|
|
525
|
+
stream_uid: str,
|
|
526
|
+
body: StreamRequestBody,
|
|
527
|
+
):
|
|
528
|
+
"""
|
|
529
|
+
SSE endpoint for StreamVariable.
|
|
530
|
+
|
|
531
|
+
Opens a Server-Sent Events connection and streams events from the
|
|
532
|
+
StreamVariable's async generator.
|
|
533
|
+
|
|
534
|
+
:param stream_uid: The UID of the StreamVariable
|
|
535
|
+
:param body: Request body containing resolved variable values
|
|
536
|
+
"""
|
|
537
|
+
registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
|
|
538
|
+
store: CacheStore = utils_registry.get('Store')
|
|
539
|
+
task_mgr: TaskManager = utils_registry.get('TaskManager')
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
entry = await registry_mgr.get(stream_variable_registry, stream_uid)
|
|
543
|
+
except (KeyError, ValueError) as err:
|
|
544
|
+
raise HTTPException(status_code=404, detail=f'StreamVariable {stream_uid} not found') from err
|
|
545
|
+
|
|
546
|
+
# Denormalize the values (resolve any nested structures)
|
|
547
|
+
values = denormalize(body.values.data, body.values.lookup)
|
|
548
|
+
|
|
549
|
+
@track_stream
|
|
550
|
+
async def stream():
|
|
551
|
+
async for event in run_stream(entry, request, values, store, task_mgr):
|
|
552
|
+
yield event
|
|
553
|
+
|
|
554
|
+
return StreamingResponse(
|
|
555
|
+
stream(),
|
|
556
|
+
media_type='text/event-stream',
|
|
557
|
+
headers={
|
|
558
|
+
'Cache-Control': 'no-cache',
|
|
559
|
+
'X-Accel-Buffering': 'no',
|
|
560
|
+
'Connection': 'keep-alive',
|
|
561
|
+
'Content-Type': 'text/event-stream',
|
|
562
|
+
},
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
511
566
|
class ActionPayload(BaseModel):
|
|
512
567
|
uid: str
|
|
513
568
|
definition_uid: str
|
dara/core/main.py
CHANGED
|
@@ -44,6 +44,7 @@ from dara.core.defaults import (
|
|
|
44
44
|
top_template,
|
|
45
45
|
)
|
|
46
46
|
from dara.core.definitions import JsComponentDef
|
|
47
|
+
from dara.core.interactivity.stream_utils import setup_signal_handlers
|
|
47
48
|
from dara.core.internal.cache_store import CacheStore
|
|
48
49
|
from dara.core.internal.cgroup import get_cpu_count, set_memory_limit
|
|
49
50
|
from dara.core.internal.custom_response import CustomResponse
|
|
@@ -136,6 +137,7 @@ def _start_application(config: Configuration):
|
|
|
136
137
|
@asynccontextmanager
|
|
137
138
|
async def lifespan(app: FastAPI):
|
|
138
139
|
# STARTUP
|
|
140
|
+
setup_signal_handlers()
|
|
139
141
|
|
|
140
142
|
# Retrieve the existing Store instance for the application
|
|
141
143
|
# Store must exist before the app starts as instantiating e.g. Variables
|
|
@@ -4,6 +4,14 @@ from dara.core.definitions import ComponentInstance
|
|
|
4
4
|
from dara.core.interactivity.derived_variable import DerivedVariable
|
|
5
5
|
from dara.core.visual.dynamic_component import PyComponentInstance
|
|
6
6
|
|
|
7
|
+
# Control flow components have conditional children that shouldn't be preloaded
|
|
8
|
+
# since only one branch will actually render at runtime
|
|
9
|
+
CONTROL_FLOW_SKIP_ATTRS: dict[str, set[str]] = {
|
|
10
|
+
'If': {'true_children', 'false_children'},
|
|
11
|
+
'Match': {'when', 'default'},
|
|
12
|
+
'For': {'renderer', 'placeholder'},
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
|
|
8
16
|
class DependencyGraph(BaseModel):
|
|
9
17
|
"""
|
|
@@ -33,6 +41,9 @@ class DependencyGraph(BaseModel):
|
|
|
33
41
|
def _analyze_component_dependencies(component: ComponentInstance, graph: DependencyGraph) -> None:
|
|
34
42
|
"""
|
|
35
43
|
Recursively analyze a component tree to build a dependency graph of DerivedVariables and PyComponentInstances.
|
|
44
|
+
|
|
45
|
+
Note: Control flow components (If, Match, For) are treated as boundaries - their conditional
|
|
46
|
+
child properties are not recursed into since only one branch will render at runtime.
|
|
36
47
|
"""
|
|
37
48
|
try:
|
|
38
49
|
from dara.components import Table
|
|
@@ -45,8 +56,15 @@ def _analyze_component_dependencies(component: ComponentInstance, graph: Depende
|
|
|
45
56
|
graph.py_components[component.uid] = component
|
|
46
57
|
return
|
|
47
58
|
|
|
59
|
+
# Get properties to skip for control flow components
|
|
60
|
+
component_name = type(component).__name__
|
|
61
|
+
skip_attrs = CONTROL_FLOW_SKIP_ATTRS.get(component_name, set())
|
|
62
|
+
|
|
48
63
|
# otherwise check each field
|
|
49
64
|
for attr in component.model_fields_set:
|
|
65
|
+
# Skip conditional child properties of control flow components
|
|
66
|
+
if attr in skip_attrs:
|
|
67
|
+
continue
|
|
50
68
|
value = getattr(component, attr, None)
|
|
51
69
|
|
|
52
70
|
# Handle encountered variables and py_components
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: dara-core
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.25.1
|
|
4
4
|
Summary: Dara Framework Core
|
|
5
5
|
Home-page: https://dara.causalens.com/
|
|
6
6
|
License: Apache-2.0
|
|
@@ -21,10 +21,10 @@ Requires-Dist: cachetools (>=5.0.0)
|
|
|
21
21
|
Requires-Dist: certifi (>=2024.7.4)
|
|
22
22
|
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
23
23
|
Requires-Dist: colorama (>=0.4.6,<0.5.0)
|
|
24
|
-
Requires-Dist: create-dara-app (==1.
|
|
24
|
+
Requires-Dist: create-dara-app (==1.25.1)
|
|
25
25
|
Requires-Dist: croniter (>=6.0.0,<7.0.0)
|
|
26
26
|
Requires-Dist: cryptography (>=42.0.4)
|
|
27
|
-
Requires-Dist: dara-components (==1.
|
|
27
|
+
Requires-Dist: dara-components (==1.25.1) ; extra == "all"
|
|
28
28
|
Requires-Dist: exceptiongroup (>=1.1.3,<2.0.0)
|
|
29
29
|
Requires-Dist: fastapi (>=0.115.0,<0.121.0)
|
|
30
30
|
Requires-Dist: fastapi_vite_dara (==0.4.0)
|
|
@@ -55,7 +55,7 @@ Description-Content-Type: text/markdown
|
|
|
55
55
|
|
|
56
56
|
# Dara Application Framework
|
|
57
57
|
|
|
58
|
-
<img src="https://github.com/causalens/dara/blob/v1.
|
|
58
|
+
<img src="https://github.com/causalens/dara/blob/v1.25.1/img/dara_light.svg?raw=true">
|
|
59
59
|
|
|
60
60
|

|
|
61
61
|
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
@@ -100,7 +100,7 @@ source .venv/bin/activate
|
|
|
100
100
|
dara start
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-

|
|
104
104
|
|
|
105
105
|
Note: `pip` installation uses [PEP 660](https://peps.python.org/pep-0660/) `pyproject.toml`-based editable installs which require `pip >= 21.3` and `setuptools >= 64.0.0`. You can upgrade both with:
|
|
106
106
|
|
|
@@ -117,9 +117,9 @@ Explore some of our favorite apps - a great way of getting started and getting t
|
|
|
117
117
|
|
|
118
118
|
| Dara App | Description |
|
|
119
119
|
| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
120
|
-
|  | Demonstrates how to use incorporate a LLM chat box into your decision app to understand model insights |
|
|
121
|
+
|  | Demonstrates how to enable the user to interact with plots, trigger actions based on clicks, mouse movements and other interactions with `Bokeh` or `Plotly` plots |
|
|
122
|
+
|  | Demonstrates how to use the `CausalGraphViewer` component to display your graphs or networks, customising the displayed information through colors and tooltips, and updating the page based on user interaction. |
|
|
123
123
|
|
|
124
124
|
Check out our [App Gallery](https://dara.causalens.com/gallery) for more inspiration!
|
|
125
125
|
|
|
@@ -146,9 +146,9 @@ And the supporting UI packages and tools.
|
|
|
146
146
|
- `ui-utils` - miscellaneous utility functions
|
|
147
147
|
- `ui-widgets` - widget components
|
|
148
148
|
|
|
149
|
-
More information on the repository structure can be found in the [CONTRIBUTING.md](https://github.com/causalens/dara/blob/v1.
|
|
149
|
+
More information on the repository structure can be found in the [CONTRIBUTING.md](https://github.com/causalens/dara/blob/v1.25.1/CONTRIBUTING.md) file.
|
|
150
150
|
|
|
151
151
|
## License
|
|
152
152
|
|
|
153
|
-
Dara is open-source and licensed under the [Apache 2.0 License](https://github.com/causalens/dara/blob/v1.
|
|
153
|
+
Dara is open-source and licensed under the [Apache 2.0 License](https://github.com/causalens/dara/blob/v1.25.1/LICENSE).
|
|
154
154
|
|