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.
@@ -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 (equivalent to frontend's object check)
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
- # If the key doesn't exist, return None as we're referring to a path which doesn't exist yet
85
- if not isinstance(result, dict) or key not in result:
86
- return None
87
- result = result[key]
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
- nested = ','.join(cast(list[str], obj['nested']))
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
@@ -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"""
@@ -45,6 +45,7 @@ class RegistryType(str, Enum):
45
45
  CUSTOM_WS_HANDLERS = 'Custom WS handlers'
46
46
  BACKEND_STORE = 'Backend Store'
47
47
  DOWNLOAD_CODE = 'Download Code'
48
+ STREAM_VARIABLE = 'StreamVariable'
48
49
 
49
50
 
50
51
  class Registry(Generic[T]):
@@ -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.24.3
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.3)
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.24.3) ; extra == "all"
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.24.3/img/dara_light.svg?raw=true">
58
+ <img src="https://github.com/causalens/dara/blob/v1.25.1/img/dara_light.svg?raw=true">
59
59
 
60
60
  ![Master tests](https://github.com/causalens/dara/actions/workflows/tests.yml/badge.svg?branch=master)
61
61
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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
- ![Dara App](https://github.com/causalens/dara/blob/v1.24.3/img/components_gallery.png?raw=true)
103
+ ![Dara App](https://github.com/causalens/dara/blob/v1.25.1/img/components_gallery.png?raw=true)
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
- | ![Large Language Model](https://github.com/causalens/dara/blob/v1.24.3/img/llm.png?raw=true) | Demonstrates how to use incorporate a LLM chat box into your decision app to understand model insights |
121
- | ![Plot Interactivity](https://github.com/causalens/dara/blob/v1.24.3/img/plot_interactivity.png?raw=true) | 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
- | ![Graph Editor](https://github.com/causalens/dara/blob/v1.24.3/img/graph_viewer.png?raw=true) | 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. |
120
+ | ![Large Language Model](https://github.com/causalens/dara/blob/v1.25.1/img/llm.png?raw=true) | Demonstrates how to use incorporate a LLM chat box into your decision app to understand model insights |
121
+ | ![Plot Interactivity](https://github.com/causalens/dara/blob/v1.25.1/img/plot_interactivity.png?raw=true) | 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
+ | ![Graph Editor](https://github.com/causalens/dara/blob/v1.25.1/img/graph_viewer.png?raw=true) | 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.24.3/CONTRIBUTING.md) file.
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.24.3/LICENSE).
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