dara-core 1.16.18__py3-none-any.whl → 1.16.20__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/defaults.py CHANGED
@@ -39,6 +39,8 @@ from dara.core.visual.components import (
39
39
  DynamicComponent,
40
40
  DynamicComponentDef,
41
41
  Fallback,
42
+ For,
43
+ ForDef,
42
44
  Menu,
43
45
  MenuDef,
44
46
  ProgressTracker,
@@ -77,6 +79,7 @@ CORE_COMPONENTS: Dict[str, ComponentTypeAnnotation] = {
77
79
  TopBarFrame.__name__: TopBarFrameDef,
78
80
  Fallback.Default.py_component: DefaultFallbackDef,
79
81
  Fallback.Row.py_component: RowFallbackDef,
82
+ For.__name__: ForDef,
80
83
  }
81
84
 
82
85
  # These actions are provided by the core JS of this module
@@ -1114,10 +1114,9 @@ class ActionCtx:
1114
1114
 
1115
1115
  ```python
1116
1116
 
1117
- from dara.core import action, ConfigurationBuilder, DataVariable, DownloadContent
1117
+ from dara.core import action, ConfigurationBuilder, DataVariable
1118
1118
  from dara.components.components import Button, Stack
1119
1119
 
1120
-
1121
1120
  # generate data, alternatively you could load it from a file
1122
1121
  df = pandas.DataFrame(data={'x': [1, 2, 3], 'y':[4, 5, 6]})
1123
1122
  my_var = DataVariable(df)
@@ -1126,18 +1125,14 @@ class ActionCtx:
1126
1125
 
1127
1126
  @action
1128
1127
  async def download_csv(ctx: action.Ctx, my_var_value: DataFrame) -> str:
1129
- # The file can be created and saved dynamically here, it should then return a string with a path to it
1130
- # To get the component value, e.g. a select component would return the selected value
1131
- component_value = ctx.input
1132
-
1133
1128
  # Getting the value of data passed as extras to the action
1134
1129
  data = my_var_value
1135
1130
 
1136
1131
  # save the data to csv
1137
1132
  data.to_csv('<PATH_TO_CSV.csv>')
1138
1133
 
1139
- # Instruct the frontend to download the file
1140
- await ctx.download_file(path='<PATH_TO_CSV.csv>', cleanup=False)
1134
+ # Instruct the frontend to download the file and clean up afterwards
1135
+ await ctx.download_file(path='<PATH_TO_CSV.csv>', cleanup=True)
1141
1136
 
1142
1137
 
1143
1138
  def test_page():
@@ -0,0 +1,88 @@
1
+ from typing import List, Optional
2
+
3
+ from pydantic import Field, SerializerFunctionWrapHandler, model_serializer
4
+
5
+ from .non_data_variable import NonDataVariable
6
+
7
+
8
+ class LoopVariable(NonDataVariable):
9
+ """
10
+ A LoopVariable is a type of variable that represents an item in a list.
11
+ It should be constructed using a parent Variable's `.list_item` property.
12
+ It should only be used in conjunction with the `For` component.
13
+
14
+ By default, the entire value is used as the item and the index in the list is used as the unique key.
15
+
16
+ ```python
17
+ from dara.core import Variable
18
+ from dara.core.visual.components import For
19
+
20
+ my_list = Variable([1, 2, 3])
21
+
22
+ # Renders a list of Text component where each item is the corresponding item in the list
23
+ For(
24
+ items=my_list,
25
+ renderer=Text(text=my_list.list_item)
26
+ )
27
+ ```
28
+
29
+ Most of the time, you'll want to store objects in a list. You should then use the `get` property to access specific
30
+ properties of the object and the `key` on the `For` component to specify the unique key.
31
+
32
+ ```python
33
+ from dara.core import Variable
34
+ from dara.core.visual.components import For
35
+
36
+ my_list = Variable([{'id': 1, 'name': 'John', 'age': 30}, {'id': 2, 'name': 'Jane', 'age': 25}])
37
+
38
+ # Renders a list of Text component where each item is the corresponding item in the list
39
+ For(
40
+ items=my_list,
41
+ renderer=Text(text=my_list.list_item.get('name')),
42
+ key_accessor='id'
43
+ )
44
+ ```
45
+
46
+ Alternatively, you can use index access instead of `get` to access specific properties of the object.
47
+ Both `get` and `[]` are equivalent.
48
+ """
49
+
50
+ nested: List[str] = Field(default_factory=list)
51
+
52
+ def __init__(self, uid: Optional[str] = None, nested: Optional[List[str]] = None):
53
+ if nested is None:
54
+ nested = []
55
+ super().__init__(uid=uid, nested=nested)
56
+
57
+ def get(self, key: str):
58
+ """
59
+ Access a nested property of the current item in the list.
60
+
61
+ ```python
62
+ from dara.core import Variable
63
+
64
+ my_list_of_objects = Variable([
65
+ {'id': 1, 'name': 'John', 'data': {'city': 'London', 'country': 'UK'}},
66
+ {'id': 2, 'name': 'Jane', 'data': {'city': 'Paris', 'country': 'France'}},
67
+ ])
68
+
69
+ # Represents the item 'name' property
70
+ my_list_of_objects.list_item.get('name')
71
+
72
+ # Represents the item 'data.country' property
73
+ my_list_of_objects.list_item.get('data').get('country')
74
+ ```
75
+ """
76
+ return self.model_copy(update={'nested': [*self.nested, key]}, deep=True)
77
+
78
+ def __getitem__(self, key: str):
79
+ return self.get(key)
80
+
81
+ @property
82
+ def list_item(self):
83
+ raise RuntimeError('LoopVariable does not support list_item')
84
+
85
+ @model_serializer(mode='wrap')
86
+ def ser_model(self, nxt: SerializerFunctionWrapHandler):
87
+ parent_dict = nxt(self)
88
+ return {**parent_dict, '__typename': 'LoopVariable', 'uid': str(parent_dict['uid'])}
@@ -34,3 +34,38 @@ class NonDataVariable(AnyVariable, abc.ABC):
34
34
 
35
35
  def __init__(self, uid: Optional[str] = None, **kwargs) -> None:
36
36
  super().__init__(uid=uid, **kwargs)
37
+
38
+ @property
39
+ def list_item(self):
40
+ """
41
+ Get a LoopVariable that represents the current item in the list.
42
+ Should only be used in conjunction with the `For` component.
43
+
44
+ Note that it is a type of a Variable so it can be used in places where a regular Variable is expected.
45
+
46
+ By default, the entire list item is used as the item.
47
+
48
+ `LoopVariable` supports nested property access using `get` or index access i.e. `[]`.
49
+ You can mix and match those two methods to access nested properties as they are equivalent.
50
+
51
+ ```python
52
+ my_list = Variable(['foo', 'bar', 'baz'])
53
+
54
+ # Represents the entire item in the list
55
+ my_list.list_item
56
+
57
+ my_list_of_objects = Variable([
58
+ {'id': 1, 'name': 'John', 'data': {'city': 'London', 'country': 'UK'}},
59
+ {'id': 2, 'name': 'Jane', 'data': {'city': 'Paris', 'country': 'France'}},
60
+ ])
61
+
62
+ # Represents the item 'name' property
63
+ my_list_of_objects.list_item['name']
64
+
65
+ # Represents the item 'data.country' property
66
+ my_list_of_objects.list_item.get('data')['country']
67
+ """
68
+
69
+ from .loop_variable import LoopVariable
70
+
71
+ return LoopVariable()
@@ -161,7 +161,7 @@ def normalize(obj: JsonLike, check_root: bool = True) -> Tuple[JsonLike, Mapping
161
161
  if check_root and _is_referrable(obj):
162
162
  identifier = _get_identifier(obj)
163
163
  # Don't check root again otherwise we end up in an infinite loop, we know it's referrable
164
- _normalized, _lookup = normalize(obj, False)
164
+ _normalized, _lookup = normalize(obj, check_root=False)
165
165
  lookup[identifier] = _normalized
166
166
  lookup.update(_lookup)
167
167
  output = Placeholder(__ref=identifier)
@@ -487,7 +487,12 @@ def create_router(config: Configuration):
487
487
  if inspect.iscoroutine(result):
488
488
  result = await result
489
489
 
490
- return result
490
+ # Get the current key and sequence number for this store
491
+ store = store_entry.store
492
+ key = await store._get_key()
493
+ sequence_number = store.sequence_number.get(key, 0)
494
+
495
+ return {'value': result, 'sequence_number': sequence_number}
491
496
 
492
497
  @core_api_router.post('/store', dependencies=[Depends(verify_session)])
493
498
  async def sync_backend_store(ws_channel: str = Body(), values: Dict[str, Any] = Body()):
@@ -496,7 +501,7 @@ def create_router(config: Configuration):
496
501
  async def _write(store_uid: str, value: Any):
497
502
  WS_CHANNEL.set(ws_channel)
498
503
  store_entry: BackendStoreEntry = await registry_mgr.get(backend_store_registry, store_uid)
499
- result = store_entry.store.write(value)
504
+ result = store_entry.store.write(value, ignore_channel=ws_channel)
500
505
 
501
506
  # Backend implementation could return a coroutine
502
507
  if inspect.iscoroutine(result):
dara/core/persistence.py CHANGED
@@ -1,11 +1,23 @@
1
1
  import abc
2
2
  import json
3
3
  import os
4
- from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Literal, Optional, Set
4
+ from typing import (
5
+ TYPE_CHECKING,
6
+ Any,
7
+ Awaitable,
8
+ Callable,
9
+ Dict,
10
+ List,
11
+ Literal,
12
+ Optional,
13
+ Set,
14
+ Union,
15
+ )
5
16
  from uuid import uuid4
6
17
 
7
18
  import aiorwlock
8
19
  import anyio
20
+ import jsonpatch
9
21
  from pydantic import (
10
22
  BaseModel,
11
23
  Field,
@@ -17,7 +29,6 @@ from pydantic import (
17
29
 
18
30
  from dara.core.auth.definitions import USER
19
31
  from dara.core.internal.utils import run_user_handler
20
- from dara.core.internal.websocket import WS_CHANNEL
21
32
  from dara.core.logging import dev_logger
22
33
 
23
34
  if TYPE_CHECKING:
@@ -189,6 +200,9 @@ class BackendStore(PersistenceStore):
189
200
 
190
201
  default_value: Any = Field(default=None, exclude=True)
191
202
  initialized_scopes: Set[str] = Field(default_factory=set, exclude=True)
203
+ sequence_number: Dict[str, int] = Field(
204
+ default_factory=dict, exclude=True
205
+ ) # Track sequence numbers per user for patch validation
192
206
 
193
207
  def __init__(
194
208
  self,
@@ -233,6 +247,8 @@ class BackendStore(PersistenceStore):
233
247
  self.initialized_scopes.add('global')
234
248
  if not await run_user_handler(self.backend.has, args=(key,)):
235
249
  await run_user_handler(self.backend.write, (key, self.default_value))
250
+ # Initialize sequence number for this key
251
+ self.sequence_number[key] = 0
236
252
 
237
253
  return key
238
254
 
@@ -246,6 +262,8 @@ class BackendStore(PersistenceStore):
246
262
  self.initialized_scopes.add(user_key)
247
263
  if not await run_user_handler(self.backend.has, args=(user_key,)):
248
264
  await run_user_handler(self.backend.write, (user_key, self.default_value))
265
+ # Initialize sequence number for this key
266
+ self.sequence_number[user_key] = 0
249
267
 
250
268
  return user_key
251
269
 
@@ -290,48 +308,65 @@ class BackendStore(PersistenceStore):
290
308
 
291
309
  return utils_registry.get('WebsocketManager')
292
310
 
293
- def _create_msg(self, value: Any) -> Dict[str, Any]:
311
+ def _create_msg(self, scope_key: str, **payload) -> Dict[str, Any]:
294
312
  """
295
313
  Create a message to send to the frontend.
296
- :param value: value to send
314
+ :param scope_key: scope key for sequence number
315
+ :param payload: either value=... or patches=...
297
316
  """
298
- return {'store_uid': self.uid, 'value': value}
317
+ if not payload or len(payload) != 1:
318
+ raise ValueError("Exactly one of 'value' or 'patches' must be provided")
319
+
320
+ return {
321
+ 'store_uid': self.uid,
322
+ 'sequence_number': self.sequence_number.get(scope_key, 0),
323
+ **payload,
324
+ }
299
325
 
300
- async def _notify_user(self, user_identifier: str, value: Any, ignore_current_channel: bool = True):
326
+ def _get_next_sequence_number(self, key: str) -> int:
301
327
  """
302
- Notify a given user about the new value for this store.
328
+ Get the next sequence number for this store.
303
329
 
330
+ :param key: key for the store
331
+ """
332
+ current = self.sequence_number.get(key, 0)
333
+ self.sequence_number[key] = current + 1
334
+ return self.sequence_number[key]
335
+
336
+ async def _notify_user(self, user_identifier: str, ignore_channel: Optional[str] = None, **payload):
337
+ """
338
+ Notify a given user about updates to this store.
304
339
  :param user_identifier: user to notify
305
- :param value: value to notify about
306
- :param ignore_current_channel: if True, ignore the current websocket channel
340
+ :param ignore_channel: if specified, ignore the specified channel
341
+ :param payload: either value=... or patches=...
307
342
  """
308
343
  return await self.ws_mgr.send_message_to_user(
309
344
  user_identifier,
310
- self._create_msg(value),
311
- ignore_channel=WS_CHANNEL.get() if ignore_current_channel else None,
345
+ self._create_msg(user_identifier, **payload),
346
+ ignore_channel=ignore_channel,
312
347
  )
313
348
 
314
- async def _notify_global(self, value: Any, ignore_current_channel: bool = True):
349
+ async def _notify_global(self, ignore_channel: Optional[str] = None, **payload):
315
350
  """
316
- Notify all users about the new value for this store.
317
-
318
- :param value: value to notify about
319
- :param ignore_current_channel: if True, ignore the current websocket channel
351
+ Notify all users about updates to this store.
352
+ :param ignore_channel: if specified, ignore the specified channel
353
+ :param payload: either value=... or patches=...
320
354
  """
321
355
  return await self.ws_mgr.broadcast(
322
- self._create_msg(value),
323
- ignore_channel=WS_CHANNEL.get() if ignore_current_channel else None,
356
+ self._create_msg('global', **payload),
357
+ ignore_channel=ignore_channel,
324
358
  )
325
359
 
326
- async def _notify_value(self, value: Any):
360
+ async def _notify_value(self, value: Any, ignore_channel: Optional[str] = None):
327
361
  """
328
362
  Notify all clients about the new value for this store.
329
363
  Broadcasts to all users if scope is global or sends to the current user if scope is user.
330
364
 
331
365
  :param value: value to notify about
366
+ :param ignore_channel: if passed, ignore the specified channel when broadcasting
332
367
  """
333
368
  if self.scope == 'global':
334
- return await self._notify_global(value)
369
+ return await self._notify_global(value=value, ignore_channel=ignore_channel)
335
370
 
336
371
  # For user scope, we need to find channels for the user and notify them
337
372
  user = USER.get()
@@ -340,7 +375,26 @@ class BackendStore(PersistenceStore):
340
375
  return
341
376
 
342
377
  user_identifier = user.identity_id or user.identity_name
343
- return await self._notify_user(user_identifier, value)
378
+ return await self._notify_user(user_identifier, value=value, ignore_channel=ignore_channel)
379
+
380
+ async def _notify_patches(self, patches: List[Dict[str, Any]]):
381
+ """
382
+ Notify all clients about partial updates to this store.
383
+ Broadcasts to all users if scope is global or sends to the current user if scope is user.
384
+
385
+ :param patches: list of JSON patch operations
386
+ """
387
+ if self.scope == 'global':
388
+ return await self._notify_global(patches=patches)
389
+
390
+ # For user scope, we need to find channels for the user and notify them
391
+ user = USER.get()
392
+
393
+ if not user:
394
+ return
395
+
396
+ user_identifier = user.identity_id or user.identity_name
397
+ return await self._notify_user(user_identifier, patches=patches)
344
398
 
345
399
  async def init(self, variable: 'Variable'):
346
400
  """
@@ -356,12 +410,67 @@ class BackendStore(PersistenceStore):
356
410
  async def _on_value(key: str, value: Any):
357
411
  # here we explicitly DON'T ignore the current channel, in case we created this variable inside e.g. a py_component we want to notify its creator as well
358
412
  if user := self._get_user(key):
359
- return await self._notify_user(user, value, ignore_current_channel=False)
360
- return await self._notify_global(value, ignore_current_channel=False)
413
+ return await self._notify_user(user, value=value)
414
+ return await self._notify_global(value=value)
361
415
 
362
416
  await self.backend.subscribe(_on_value)
363
417
 
364
- async def write(self, value: Any, notify=True):
418
+ async def write_partial(self, data: Union[List[Dict[str, Any]], Any], notify: bool = True):
419
+ """
420
+ Apply partial updates to the store using JSON Patch operations or automatic diffing.
421
+
422
+ If scope='user', the patches are applied for the current user so the method can only
423
+ be used in authenticated contexts.
424
+
425
+ :param data: Either a list of JSON patch operations (RFC 6902) or a full object to diff against current value
426
+ :param notify: whether to broadcast the patches to clients
427
+ """
428
+ if self.readonly:
429
+ raise ValueError('Cannot write to a read-only store')
430
+
431
+ key = await self._get_key()
432
+
433
+ # Read current value
434
+ current_value = await run_user_handler(self.backend.read, (key,))
435
+
436
+ if current_value is None:
437
+ # If no current value, create an empty dict as the base
438
+ current_value = {}
439
+
440
+ # Determine if data is patches or a full object
441
+ if isinstance(data, list) and all(isinstance(item, dict) and 'op' in item for item in data):
442
+ # Data is a list of patch operations
443
+ patches = data
444
+
445
+ if not isinstance(current_value, (dict, list)):
446
+ # JSON patches can only be applied to structured data (objects/arrays)
447
+ raise ValueError(
448
+ f'Cannot apply JSON patches to non-structured data. '
449
+ f'Current value is of type {type(current_value).__name__}, but patches require dict or list.'
450
+ )
451
+
452
+ # Apply patches to current value
453
+ try:
454
+ updated_value = jsonpatch.apply_patch(current_value, patches)
455
+ except (jsonpatch.InvalidJsonPatch, jsonpatch.JsonPatchException) as e:
456
+ raise ValueError(f'Invalid JSON patch operation: {e}') from e
457
+ else:
458
+ # Data is a full object - generate patches by diffing
459
+ patches = jsonpatch.make_patch(current_value, data).patch
460
+ updated_value = data
461
+
462
+ # Write updated value back to store
463
+ await run_user_handler(self.backend.write, (key, updated_value))
464
+ # Increment sequence number for this update
465
+ self._get_next_sequence_number(key)
466
+
467
+ if notify:
468
+ # Notify clients about the patches, not the full value
469
+ await self._notify_patches(patches)
470
+
471
+ return updated_value
472
+
473
+ async def write(self, value: Any, notify=True, ignore_channel: Optional[str] = None):
365
474
  """
366
475
  Persist a value to the store.
367
476
 
@@ -370,16 +479,21 @@ class BackendStore(PersistenceStore):
370
479
 
371
480
  :param value: value to write
372
481
  :param notify: whether to broadcast the new value to clients
482
+ :param ignore_channel: if passed, ignore the specified websocket channel when broadcasting
373
483
  """
374
484
  if self.readonly:
375
485
  raise ValueError('Cannot write to a read-only store')
376
486
 
377
487
  key = await self._get_key()
378
488
 
489
+ res = await run_user_handler(self.backend.write, (key, value))
490
+ # Increment sequence number for this update
491
+ self._get_next_sequence_number(key)
492
+
379
493
  if notify:
380
- await self._notify_value(value)
494
+ await self._notify_value(value, ignore_channel=ignore_channel)
381
495
 
382
- return await run_user_handler(self.backend.write, (key, value))
496
+ return res
383
497
 
384
498
  async def read(self):
385
499
  """