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 +3 -0
- dara/core/interactivity/actions.py +3 -8
- dara/core/interactivity/loop_variable.py +88 -0
- dara/core/interactivity/non_data_variable.py +35 -0
- dara/core/internal/normalization.py +1 -1
- dara/core/internal/routing.py +7 -2
- dara/core/persistence.py +140 -26
- dara/core/umd/dara.core.umd.js +2696 -6641
- dara/core/visual/components/__init__.py +3 -0
- dara/core/visual/components/for_cmp.py +150 -0
- {dara_core-1.16.18.dist-info → dara_core-1.16.20.dist-info}/METADATA +11 -10
- {dara_core-1.16.18.dist-info → dara_core-1.16.20.dist-info}/RECORD +15 -13
- {dara_core-1.16.18.dist-info → dara_core-1.16.20.dist-info}/LICENSE +0 -0
- {dara_core-1.16.18.dist-info → dara_core-1.16.20.dist-info}/WHEEL +0 -0
- {dara_core-1.16.18.dist-info → dara_core-1.16.20.dist-info}/entry_points.txt +0 -0
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
|
|
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=
|
|
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)
|
dara/core/internal/routing.py
CHANGED
|
@@ -487,7 +487,12 @@ def create_router(config: Configuration):
|
|
|
487
487
|
if inspect.iscoroutine(result):
|
|
488
488
|
result = await result
|
|
489
489
|
|
|
490
|
-
|
|
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
|
|
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,
|
|
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
|
|
314
|
+
:param scope_key: scope key for sequence number
|
|
315
|
+
:param payload: either value=... or patches=...
|
|
297
316
|
"""
|
|
298
|
-
|
|
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
|
-
|
|
326
|
+
def _get_next_sequence_number(self, key: str) -> int:
|
|
301
327
|
"""
|
|
302
|
-
|
|
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
|
|
306
|
-
:param
|
|
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(
|
|
311
|
-
ignore_channel=
|
|
345
|
+
self._create_msg(user_identifier, **payload),
|
|
346
|
+
ignore_channel=ignore_channel,
|
|
312
347
|
)
|
|
313
348
|
|
|
314
|
-
async def _notify_global(self,
|
|
349
|
+
async def _notify_global(self, ignore_channel: Optional[str] = None, **payload):
|
|
315
350
|
"""
|
|
316
|
-
Notify all users about
|
|
317
|
-
|
|
318
|
-
:param
|
|
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(
|
|
323
|
-
ignore_channel=
|
|
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
|
|
360
|
-
return await self._notify_global(value
|
|
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
|
|
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
|
|
496
|
+
return res
|
|
383
497
|
|
|
384
498
|
async def read(self):
|
|
385
499
|
"""
|