dara-core 1.20.3__py3-none-any.whl → 1.21.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.
Files changed (33) hide show
  1. dara/core/__init__.py +8 -42
  2. dara/core/configuration.py +33 -4
  3. dara/core/defaults.py +7 -0
  4. dara/core/definitions.py +22 -35
  5. dara/core/interactivity/actions.py +29 -28
  6. dara/core/interactivity/plain_variable.py +6 -2
  7. dara/core/interactivity/switch_variable.py +2 -2
  8. dara/core/internal/execute_action.py +75 -6
  9. dara/core/internal/routing.py +526 -354
  10. dara/core/internal/tasks.py +1 -1
  11. dara/core/jinja/index.html +97 -1
  12. dara/core/jinja/index_autojs.html +116 -10
  13. dara/core/js_tooling/js_utils.py +35 -14
  14. dara/core/main.py +137 -89
  15. dara/core/persistence.py +6 -2
  16. dara/core/router/__init__.py +5 -0
  17. dara/core/router/compat.py +77 -0
  18. dara/core/router/components.py +143 -0
  19. dara/core/router/dependency_graph.py +62 -0
  20. dara/core/router/router.py +887 -0
  21. dara/core/umd/{dara.core.umd.js → dara.core.umd.cjs} +62588 -46966
  22. dara/core/umd/style.css +52 -9
  23. dara/core/visual/components/__init__.py +16 -11
  24. dara/core/visual/components/menu.py +4 -0
  25. dara/core/visual/components/menu_link.py +1 -0
  26. dara/core/visual/components/powered_by_causalens.py +9 -0
  27. dara/core/visual/components/sidebar_frame.py +1 -0
  28. dara/core/visual/dynamic_component.py +1 -1
  29. {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/METADATA +10 -10
  30. {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/RECORD +33 -26
  31. {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/LICENSE +0 -0
  32. {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/WHEEL +0 -0
  33. {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/entry_points.txt +0 -0
@@ -16,27 +16,34 @@ limitations under the License.
16
16
  """
17
17
 
18
18
  import inspect
19
+ import json
20
+ import math
19
21
  import os
22
+ import traceback
20
23
  from collections.abc import Mapping
21
24
  from functools import wraps
22
25
  from importlib.metadata import version
23
- from typing import Any, Callable, Dict, List, Optional
26
+ from typing import Annotated, Any, Callable, Dict, List, Literal, Optional, Union
27
+ from urllib.parse import unquote
24
28
 
25
29
  import anyio
30
+ from anyio.streams.memory import MemoryObjectSendStream
26
31
  from fastapi import (
27
32
  APIRouter,
28
33
  Body,
29
34
  Depends,
35
+ FastAPI,
30
36
  File,
31
37
  Form,
32
38
  HTTPException,
39
+ Path,
33
40
  Response,
34
41
  UploadFile,
35
42
  )
36
43
  from fastapi.encoders import jsonable_encoder
37
44
  from fastapi.responses import StreamingResponse
38
45
  from pandas import DataFrame
39
- from pydantic import BaseModel
46
+ from pydantic import BaseModel, Field
40
47
  from starlette.background import BackgroundTask
41
48
  from starlette.status import HTTP_415_UNSUPPORTED_MEDIA_TYPE
42
49
 
@@ -47,8 +54,9 @@ from dara.core.interactivity.any_data_variable import upload
47
54
  from dara.core.interactivity.filtering import FilterQuery, Pagination
48
55
  from dara.core.interactivity.server_variable import ServerVariable
49
56
  from dara.core.internal.cache_store import CacheStore
57
+ from dara.core.internal.devtools import print_stacktrace
50
58
  from dara.core.internal.download import DownloadRegistryEntry
51
- from dara.core.internal.execute_action import CURRENT_ACTION_ID
59
+ from dara.core.internal.execute_action import CURRENT_ACTION_ID, execute_action_sync
52
60
  from dara.core.internal.normalization import NormalizedPayload, denormalize, normalize
53
61
  from dara.core.internal.pandas_utils import data_response_to_json, df_to_json, is_data_response
54
62
  from dara.core.internal.registries import (
@@ -61,12 +69,10 @@ from dara.core.internal.registries import (
61
69
  latest_value_registry,
62
70
  server_variable_registry,
63
71
  static_kwargs_registry,
64
- template_registry,
65
72
  upload_resolver_registry,
66
73
  utils_registry,
67
74
  )
68
75
  from dara.core.internal.registry_lookup import RegistryLookup
69
- from dara.core.internal.settings import get_settings
70
76
  from dara.core.internal.tasks import TaskManager, TaskManagerError
71
77
  from dara.core.internal.utils import get_cache_scope
72
78
  from dara.core.internal.websocket import WS_CHANNEL, ws_handler
@@ -106,419 +112,585 @@ def error_decorator(handler: Callable[..., Any]):
106
112
  return _inner_func
107
113
 
108
114
 
109
- def create_router(config: Configuration):
110
- """
111
- Create the main Dara core API router
115
+ core_api_router = APIRouter()
112
116
 
113
- :param config: Dara app configuration
114
- """
115
- core_api_router = APIRouter()
116
117
 
117
- @core_api_router.get('/actions', dependencies=[Depends(verify_session)])
118
- async def get_actions():
119
- return action_def_registry.get_all().items()
118
+ @core_api_router.get('/actions', dependencies=[Depends(verify_session)])
119
+ async def get_actions():
120
+ return action_def_registry.get_all().items()
120
121
 
121
- class ActionRequestBody(BaseModel):
122
- values: NormalizedPayload[Mapping[str, Any]]
123
- """Dynamic kwarg values"""
124
122
 
125
- input: Any = None
126
- """Input from the component"""
123
+ class ActionRequestBody(BaseModel):
124
+ values: NormalizedPayload[Mapping[str, Any]]
125
+ """Dynamic kwarg values"""
127
126
 
128
- ws_channel: str
129
- """Websocket channel assigned to the client"""
127
+ input: Any = None
128
+ """Input from the component"""
130
129
 
131
- uid: str
132
- """Instance uid"""
130
+ ws_channel: str
131
+ """Websocket channel assigned to the client"""
133
132
 
134
- execution_id: str
135
- """Execution id, unique to this request"""
133
+ uid: str
134
+ """Instance uid"""
136
135
 
137
- @core_api_router.post('/action/{uid}', dependencies=[Depends(verify_session)])
138
- async def get_action(uid: str, body: ActionRequestBody):
139
- store: CacheStore = utils_registry.get('Store')
140
- task_mgr: TaskManager = utils_registry.get('TaskManager')
141
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
142
- action_def: ActionResolverDef = await registry_mgr.get(action_registry, uid)
136
+ execution_id: str
137
+ """Execution id, unique to this request"""
143
138
 
144
- CURRENT_ACTION_ID.set(body.uid)
145
- WS_CHANNEL.set(body.ws_channel)
146
139
 
147
- # Denormalize the values
148
- values = denormalize(body.values.data, body.values.lookup)
140
+ @core_api_router.post('/action/{uid}', dependencies=[Depends(verify_session)])
141
+ async def get_action(uid: str, body: ActionRequestBody):
142
+ store: CacheStore = utils_registry.get('Store')
143
+ task_mgr: TaskManager = utils_registry.get('TaskManager')
144
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
145
+ action_def: ActionResolverDef = await registry_mgr.get(action_registry, uid)
146
+
147
+ CURRENT_ACTION_ID.set(body.uid)
148
+ WS_CHANNEL.set(body.ws_channel)
149
+
150
+ # Denormalize the values
151
+ values = denormalize(body.values.data, body.values.lookup)
152
+
153
+ # Fetch static kwargs
154
+ static_kwargs = await registry_mgr.get(static_kwargs_registry, body.uid)
155
+
156
+ # Execute the action - kick off a background task to run the handler
157
+ response = await action_def.execute_action(
158
+ action_def,
159
+ body.input,
160
+ values,
161
+ static_kwargs,
162
+ body.execution_id,
163
+ body.ws_channel,
164
+ store,
165
+ task_mgr,
166
+ )
167
+
168
+ if isinstance(response, BaseTask):
169
+ await task_mgr.run_task(response, body.ws_channel)
170
+ return {'task_id': response.task_id}
171
+
172
+ return {'execution_id': response}
173
+
174
+
175
+ @core_api_router.get('/download') # explicitly unauthenticated
176
+ async def get_download(code: str):
177
+ store: CacheStore = utils_registry.get('Store')
178
+
179
+ try:
180
+ data_entry = await store.get(DownloadRegistryEntry, key=code)
149
181
 
150
- # Fetch static kwargs
182
+ # If not found directly in the store, use the override registry
183
+ # to check if we can get the download entry from there
184
+ if data_entry is None:
185
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
186
+ # NOTE: This will throw a Value/KeyError if the code is not found so no need to rethrow
187
+ data_entry = await registry_mgr.get(download_code_registry, code)
188
+ # We managed to find one from the lookup,
189
+ # remove it from the registry immediately because it's one time use
190
+ download_code_registry.remove(code)
191
+
192
+ async_file, cleanup = await data_entry.download(data_entry)
193
+
194
+ file_name = os.path.basename(data_entry.file_path)
195
+
196
+ # This mirrors builtin's FastAPI FileResponse implementation
197
+ async def stream_file():
198
+ has_content = True
199
+ chunk_size = 64 * 1024
200
+ while has_content:
201
+ chunk = await async_file.read(chunk_size)
202
+ has_content = chunk_size == len(chunk)
203
+ yield chunk
204
+
205
+ return StreamingResponse(
206
+ content=stream_file(),
207
+ headers={'Content-Disposition': f'attachment; filename={file_name}'},
208
+ background=BackgroundTask(cleanup),
209
+ )
210
+
211
+ except (KeyError, ValueError) as e:
212
+ raise ValueError('Invalid or expired download code') from e
213
+
214
+
215
+ @core_api_router.get('/components/{name}/definition', dependencies=[Depends(verify_session)])
216
+ async def get_component_definition(name: str):
217
+ """
218
+ Attempt to refetch a component definition from the backend.
219
+ This is used when a component isn't immediately available in the initial registry,
220
+ e.g. when it was added by a py_component.
221
+
222
+ :param name: the name of component
223
+ """
224
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
225
+ component = await registry_mgr.get(component_registry, name)
226
+ return component.model_dump(exclude={'func'})
227
+
228
+
229
+ class ComponentRequestBody(BaseModel):
230
+ # Dynamic kwarg values
231
+ values: NormalizedPayload[Mapping[str, Any]]
232
+ # Instance uid
233
+ uid: str
234
+ # Websocket channel assigned to the client
235
+ ws_channel: str
236
+
237
+
238
+ @core_api_router.post('/components/{component}', dependencies=[Depends(verify_session)])
239
+ async def get_component(component: str, body: ComponentRequestBody):
240
+ CURRENT_COMPONENT_ID.set(body.uid)
241
+ WS_CHANNEL.set(body.ws_channel)
242
+ store: CacheStore = utils_registry.get('Store')
243
+ task_mgr: TaskManager = utils_registry.get('TaskManager')
244
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
245
+ comp_def = await registry_mgr.get(component_registry, component)
246
+
247
+ if isinstance(comp_def, PyComponentDef):
151
248
  static_kwargs = await registry_mgr.get(static_kwargs_registry, body.uid)
249
+ values = denormalize(body.values.data, body.values.lookup)
152
250
 
153
- # Execute the action - kick off a background task to run the handler
154
- response = await action_def.execute_action(
155
- action_def,
156
- body.input,
157
- values,
158
- static_kwargs,
159
- body.execution_id,
160
- body.ws_channel,
161
- store,
162
- task_mgr,
251
+ response = await comp_def.render_component(comp_def, store, task_mgr, values, static_kwargs)
252
+
253
+ dev_logger.debug(
254
+ f'PyComponent {comp_def.func.__name__ if comp_def.func else "anonymous"}',
255
+ 'return value',
256
+ {'value': response},
163
257
  )
164
258
 
165
259
  if isinstance(response, BaseTask):
166
260
  await task_mgr.run_task(response, body.ws_channel)
167
261
  return {'task_id': response.task_id}
168
262
 
169
- return {'execution_id': response}
263
+ return response
264
+
265
+ raise HTTPException(status_code=400, detail='Requesting this type of component is not supported')
170
266
 
171
- @core_api_router.get('/download') # explicitly unauthenticated
172
- async def get_download(code: str):
267
+
268
+ @core_api_router.get('/derived-variable/{uid}/latest', dependencies=[Depends(verify_session)])
269
+ async def get_latest_derived_variable(uid: str):
270
+ try:
173
271
  store: CacheStore = utils_registry.get('Store')
272
+ latest_value_entry = latest_value_registry.get(uid)
273
+ variable_entry = derived_variable_registry.get(uid)
174
274
 
175
- try:
176
- data_entry = await store.get(DownloadRegistryEntry, key=code)
177
-
178
- # If not found directly in the store, use the override registry
179
- # to check if we can get the download entry from there
180
- if data_entry is None:
181
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
182
- # NOTE: This will throw a Value/KeyError if the code is not found so no need to rethrow
183
- data_entry = await registry_mgr.get(download_code_registry, code)
184
- # We managed to find one from the lookup,
185
- # remove it from the registry immediately because it's one time use
186
- download_code_registry.remove(code)
187
-
188
- async_file, cleanup = await data_entry.download(data_entry)
189
-
190
- file_name = os.path.basename(data_entry.file_path)
191
-
192
- # This mirrors builtin's FastAPI FileResponse implementation
193
- async def stream_file():
194
- has_content = True
195
- chunk_size = 64 * 1024
196
- while has_content:
197
- chunk = await async_file.read(chunk_size)
198
- has_content = chunk_size == len(chunk)
199
- yield chunk
200
-
201
- return StreamingResponse(
202
- content=stream_file(),
203
- headers={'Content-Disposition': f'attachment; filename={file_name}'},
204
- background=BackgroundTask(cleanup),
205
- )
206
-
207
- except (KeyError, ValueError) as e:
208
- raise ValueError('Invalid or expired download code') from e
209
-
210
- @core_api_router.get('/config', dependencies=[Depends(verify_session)])
211
- async def get_config():
212
- return {
213
- **config.model_dump(
214
- include={
215
- 'enable_devtools',
216
- 'live_reload',
217
- 'template',
218
- 'theme',
219
- 'title',
220
- 'context_components',
221
- 'powered_by_causalens',
222
- }
223
- ),
224
- 'application_name': get_settings().project_name,
225
- }
275
+ # Lookup the latest key in the cache
276
+ scope = get_cache_scope(variable_entry.cache.cache_type if variable_entry.cache else None)
277
+ latest_key = await store.get(latest_value_entry, key=scope)
226
278
 
227
- @core_api_router.get('/auth-config')
228
- async def get_auth_config():
229
- return {
230
- 'auth_components': config.auth_config.component_config.model_dump(),
231
- }
279
+ if latest_key is None:
280
+ return None
232
281
 
233
- @core_api_router.get('/components', dependencies=[Depends(verify_session)])
234
- async def get_components(name: Optional[str] = None):
235
- """
236
- If name is passed, will try to register the component
282
+ # Lookup latest value for that key
283
+ latest_value = await store.get_or_wait(variable_entry, key=latest_key)
237
284
 
238
- :param name: the name of component
239
- """
240
- if name is not None:
241
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
242
- await registry_mgr.get(component_registry, name)
243
-
244
- return {k: comp.model_dump(exclude={'func'}) for k, comp in component_registry.get_all().items()}
245
-
246
- class ComponentRequestBody(BaseModel):
247
- # Dynamic kwarg values
248
- values: NormalizedPayload[Mapping[str, Any]]
249
- # Instance uid
250
- uid: str
251
- # Websocket channel assigned to the client
252
- ws_channel: str
253
-
254
- @core_api_router.post('/components/{component}', dependencies=[Depends(verify_session)])
255
- async def get_component(component: str, body: ComponentRequestBody):
256
- CURRENT_COMPONENT_ID.set(body.uid)
257
- WS_CHANNEL.set(body.ws_channel)
285
+ dev_logger.debug(
286
+ f'DerivedVariable {variable_entry.uid[:3]}..{variable_entry.uid[-3:]}',
287
+ 'latest value',
288
+ {'value': latest_value, 'uid': uid},
289
+ )
290
+ return latest_value
291
+
292
+ except KeyError as err:
293
+ raise ValueError(f'Could not find latest value for derived variable with uid: {uid}') from err
294
+
295
+
296
+ class TabularRequestBody(BaseModel):
297
+ filters: Optional[FilterQuery] = None
298
+ ws_channel: str
299
+ dv_values: Optional[NormalizedPayload[List[Any]]] = None
300
+ """DerivedVariable values if variable is a DerivedVariable"""
301
+ force_key: Optional[str] = None
302
+ """Optional force key if variable is a DerivedVariable and a recalculation is forced"""
303
+
304
+
305
+ @core_api_router.post('/tabular-variable/{uid}', dependencies=[Depends(verify_session)])
306
+ async def get_tabular_variable(
307
+ uid: str,
308
+ body: TabularRequestBody,
309
+ offset: Optional[int] = None,
310
+ limit: Optional[int] = None,
311
+ order_by: Optional[str] = None,
312
+ index: Optional[str] = None,
313
+ ):
314
+ """
315
+ Generic endpoint for getting tabular data from a variable.
316
+ Supports ServerVariables and DerivedVariables.
317
+ """
318
+ WS_CHANNEL.set(body.ws_channel)
319
+
320
+ try:
321
+ pagination = Pagination(offset=offset, limit=limit, orderBy=order_by, index=index)
322
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
323
+
324
+ # ServerVariable
325
+ if body.dv_values is None:
326
+ server_variable_entry = await registry_mgr.get(server_variable_registry, uid)
327
+ data_response = await ServerVariable.get_tabular_data(server_variable_entry, body.filters, pagination)
328
+ return Response(data_response_to_json(data_response), media_type='application/json')
329
+
330
+ # DerivedVariable
258
331
  store: CacheStore = utils_registry.get('Store')
259
332
  task_mgr: TaskManager = utils_registry.get('TaskManager')
260
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
261
- comp_def = await registry_mgr.get(component_registry, component)
333
+ variable_def = await registry_mgr.get(derived_variable_registry, uid)
334
+ values = denormalize(body.dv_values.data, body.dv_values.lookup)
262
335
 
263
- if isinstance(comp_def, PyComponentDef):
264
- static_kwargs = await registry_mgr.get(static_kwargs_registry, body.uid)
265
- values = denormalize(body.values.data, body.values.lookup)
336
+ result = await variable_def.get_tabular_data(
337
+ variable_def, store, task_mgr, values, body.force_key, pagination, body.filters
338
+ )
266
339
 
267
- response = await comp_def.render_component(comp_def, store, task_mgr, values, static_kwargs)
340
+ if isinstance(result, BaseTask):
341
+ await task_mgr.run_task(result, body.ws_channel)
342
+ return {'task_id': result.task_id}
268
343
 
269
- dev_logger.debug(
270
- f'PyComponent {comp_def.func.__name__ if comp_def.func else "anonymous"}',
271
- 'return value',
272
- {'value': response},
273
- )
344
+ return Response(data_response_to_json(result), media_type='application/json')
345
+ except NonTabularDataError as e:
346
+ raise HTTPException(status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=str(e)) from e
274
347
 
275
- if isinstance(response, BaseTask):
276
- await task_mgr.run_task(response, body.ws_channel)
277
- return {'task_id': response.task_id}
278
348
 
279
- return response
349
+ @core_api_router.get('/server-variable/{uid}/sequence', dependencies=[Depends(verify_session)])
350
+ async def get_server_variable_sequence(
351
+ uid: str,
352
+ ):
353
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
354
+ server_variable_entry = await registry_mgr.get(server_variable_registry, uid)
355
+ seq_num = await ServerVariable.get_sequence_number(server_variable_entry)
356
+ return {'sequence_number': seq_num}
280
357
 
281
- raise HTTPException(status_code=400, detail='Requesting this type of component is not supported')
282
358
 
283
- @core_api_router.get('/derived-variable/{uid}/latest', dependencies=[Depends(verify_session)])
284
- async def get_latest_derived_variable(uid: str):
285
- try:
286
- store: CacheStore = utils_registry.get('Store')
287
- latest_value_entry = latest_value_registry.get(uid)
288
- variable_entry = derived_variable_registry.get(uid)
289
-
290
- # Lookup the latest key in the cache
291
- scope = get_cache_scope(variable_entry.cache.cache_type if variable_entry.cache else None)
292
- latest_key = await store.get(latest_value_entry, key=scope)
293
-
294
- if latest_key is None:
295
- return None
296
-
297
- # Lookup latest value for that key
298
- latest_value = await store.get_or_wait(variable_entry, key=latest_key)
299
-
300
- dev_logger.debug(
301
- f'DerivedVariable {variable_entry.uid[:3]}..{variable_entry.uid[-3:]}',
302
- 'latest value',
303
- {'value': latest_value, 'uid': uid},
304
- )
305
- return latest_value
306
-
307
- except KeyError as err:
308
- raise ValueError(f'Could not find latest value for derived variable with uid: {uid}') from err
309
-
310
- class TabularRequestBody(BaseModel):
311
- filters: Optional[FilterQuery] = None
312
- ws_channel: str
313
- dv_values: Optional[NormalizedPayload[List[Any]]] = None
314
- """DerivedVariable values if variable is a DerivedVariable"""
315
- force_key: Optional[str] = None
316
- """Optional force key if variable is a DerivedVariable and a recalculation is forced"""
317
-
318
- @core_api_router.post('/tabular-variable/{uid}', dependencies=[Depends(verify_session)])
319
- async def get_tabular_variable(
320
- uid: str,
321
- body: TabularRequestBody,
322
- offset: Optional[int] = None,
323
- limit: Optional[int] = None,
324
- order_by: Optional[str] = None,
325
- index: Optional[str] = None,
326
- ):
327
- """
328
- Generic endpoint for getting tabular data from a variable.
329
- Supports ServerVariables and DerivedVariables.
330
- """
331
- WS_CHANNEL.set(body.ws_channel)
359
+ @core_api_router.post('/data/upload', dependencies=[Depends(verify_session)])
360
+ async def upload_data(
361
+ data_uid: Optional[str] = None,
362
+ data: UploadFile = File(),
363
+ resolver_id: Optional[str] = Form(default=None),
364
+ ):
365
+ """
366
+ Upload endpoint.
367
+ Can run a custom resolver_id (if previously registered, otherwise runs a default one)
368
+ and update a data variable with its return value (if target is specified).
369
+ """
370
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
332
371
 
333
- try:
334
- pagination = Pagination(offset=offset, limit=limit, orderBy=order_by, index=index)
335
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
372
+ if data_uid is None and resolver_id is None:
373
+ raise HTTPException(
374
+ 400,
375
+ 'Neither resolver_id or data_uid specified, at least one of them is required',
376
+ )
336
377
 
337
- # ServerVariable
338
- if body.dv_values is None:
339
- server_variable_entry = await registry_mgr.get(server_variable_registry, uid)
340
- data_response = await ServerVariable.get_tabular_data(server_variable_entry, body.filters, pagination)
341
- return Response(data_response_to_json(data_response), media_type='application/json')
378
+ try:
379
+ # If resolver id is provided, run the custom
380
+ if resolver_id:
381
+ upload_resolver_def: UploadResolverDef = await registry_mgr.get(upload_resolver_registry, resolver_id)
382
+ await upload_resolver_def.upload(data, data_uid, resolver_id)
383
+ else:
384
+ # Run the default logic as a fallback, e.g. programmatic upload
385
+ await upload(data, data_uid, resolver_id)
342
386
 
343
- # DerivedVariable
344
- store: CacheStore = utils_registry.get('Store')
345
- task_mgr: TaskManager = utils_registry.get('TaskManager')
346
- variable_def = await registry_mgr.get(derived_variable_registry, uid)
347
- values = denormalize(body.dv_values.data, body.dv_values.lookup)
387
+ return {'status': 'SUCCESS'}
388
+ except Exception as e:
389
+ raise HTTPException(status_code=400, detail=str(e)) from e
348
390
 
349
- result = await variable_def.get_tabular_data(
350
- variable_def, store, task_mgr, values, body.force_key, pagination, body.filters
351
- )
352
391
 
353
- if isinstance(result, BaseTask):
354
- await task_mgr.run_task(result, body.ws_channel)
355
- return {'task_id': result.task_id}
392
+ class DerivedStateRequestBody(BaseModel):
393
+ values: NormalizedPayload[List[Any]]
394
+ force_key: Optional[str] = None
395
+ ws_channel: str
356
396
 
357
- return Response(data_response_to_json(result), media_type='application/json')
358
- except NonTabularDataError as e:
359
- raise HTTPException(status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=str(e)) from e
360
397
 
361
- @core_api_router.get('/server-variable/{uid}/sequence', dependencies=[Depends(verify_session)])
362
- async def get_server_variable_sequence(
363
- uid: str,
364
- ):
365
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
366
- server_variable_entry = await registry_mgr.get(server_variable_registry, uid)
367
- seq_num = await ServerVariable.get_sequence_number(server_variable_entry)
368
- return {'sequence_number': seq_num}
369
-
370
- @core_api_router.post('/data/upload', dependencies=[Depends(verify_session)])
371
- async def upload_data(
372
- data_uid: Optional[str] = None,
373
- data: UploadFile = File(),
374
- resolver_id: Optional[str] = Form(default=None),
375
- ):
376
- """
377
- Upload endpoint.
378
- Can run a custom resolver_id (if previously registered, otherwise runs a default one)
379
- and update a data variable with its return value (if target is specified).
380
- """
381
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
398
+ @core_api_router.post('/derived-variable/{uid}', dependencies=[Depends(verify_session)])
399
+ async def get_derived_variable(uid: str, body: DerivedStateRequestBody):
400
+ task_mgr: TaskManager = utils_registry.get('TaskManager')
401
+ store: CacheStore = utils_registry.get('Store')
402
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
403
+ variable_def = await registry_mgr.get(derived_variable_registry, uid)
382
404
 
383
- if data_uid is None and resolver_id is None:
384
- raise HTTPException(
385
- 400,
386
- 'Neither resolver_id or data_uid specified, at least one of them is required',
387
- )
405
+ values = denormalize(body.values.data, body.values.lookup)
388
406
 
389
- try:
390
- # If resolver id is provided, run the custom
391
- if resolver_id:
392
- upload_resolver_def: UploadResolverDef = await registry_mgr.get(upload_resolver_registry, resolver_id)
393
- await upload_resolver_def.upload(data, data_uid, resolver_id)
394
- else:
395
- # Run the default logic as a fallback, e.g. programmatic upload
396
- await upload(data, data_uid, resolver_id)
397
-
398
- return {'status': 'SUCCESS'}
399
- except Exception as e:
400
- raise HTTPException(status_code=400, detail=str(e)) from e
401
-
402
- class DerivedStateRequestBody(BaseModel):
403
- values: NormalizedPayload[List[Any]]
404
- force_key: Optional[str] = None
405
- ws_channel: str
406
-
407
- @core_api_router.post('/derived-variable/{uid}', dependencies=[Depends(verify_session)])
408
- async def get_derived_variable(uid: str, body: DerivedStateRequestBody):
409
- task_mgr: TaskManager = utils_registry.get('TaskManager')
410
- store: CacheStore = utils_registry.get('Store')
411
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
412
- variable_def = await registry_mgr.get(derived_variable_registry, uid)
407
+ result = await variable_def.get_value(variable_def, store, task_mgr, values, body.force_key)
413
408
 
414
- values = denormalize(body.values.data, body.values.lookup)
409
+ response: Any = result
415
410
 
416
- result = await variable_def.get_value(variable_def, store, task_mgr, values, body.force_key)
411
+ WS_CHANNEL.set(body.ws_channel)
417
412
 
418
- response: Any = result
413
+ if isinstance(result['value'], BaseTask):
414
+ # Kick off the task
415
+ await task_mgr.run_task(result['value'], body.ws_channel)
416
+ response = {
417
+ 'task_id': result['value'].task_id,
418
+ 'cache_key': result['cache_key'],
419
+ }
419
420
 
420
- WS_CHANNEL.set(body.ws_channel)
421
+ dev_logger.debug(
422
+ f'DerivedVariable {variable_def.uid[:3]}..{variable_def.uid[-3:]}',
423
+ 'return value',
424
+ {'value': response, 'uid': uid},
425
+ )
421
426
 
422
- if isinstance(result['value'], BaseTask):
423
- # Kick off the task
424
- await task_mgr.run_task(result['value'], body.ws_channel)
425
- response = {
426
- 'task_id': result['value'].task_id,
427
- 'cache_key': result['cache_key'],
428
- }
427
+ # Return {cache_key: <cache_key>, value: <value>}
428
+ return response
429
429
 
430
- dev_logger.debug(
431
- f'DerivedVariable {variable_def.uid[:3]}..{variable_def.uid[-3:]}',
432
- 'return value',
433
- {'value': response, 'uid': uid},
434
- )
435
430
 
436
- # Return {cache_key: <cache_key>, value: <value>}
437
- return response
431
+ @core_api_router.get('/store/{store_uid}', dependencies=[Depends(verify_session)])
432
+ async def read_backend_store(store_uid: str):
433
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
434
+ store_entry: BackendStoreEntry = await registry_mgr.get(backend_store_registry, store_uid)
435
+ result = store_entry.store.read()
438
436
 
439
- @core_api_router.get('/store/{store_uid}', dependencies=[Depends(verify_session)])
440
- async def read_backend_store(store_uid: str):
441
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
437
+ # Backend implementation could return a coroutine
438
+ if inspect.iscoroutine(result):
439
+ result = await result
440
+
441
+ # Get the current key and sequence number for this store
442
+ store = store_entry.store
443
+ key = await store._get_key()
444
+ sequence_number = store.sequence_number.get(key, 0)
445
+
446
+ return {'value': result, 'sequence_number': sequence_number}
447
+
448
+
449
+ @core_api_router.post('/store', dependencies=[Depends(verify_session)])
450
+ async def sync_backend_store(ws_channel: str = Body(), values: Dict[str, Any] = Body()):
451
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
452
+
453
+ async def _write(store_uid: str, value: Any):
454
+ WS_CHANNEL.set(ws_channel)
442
455
  store_entry: BackendStoreEntry = await registry_mgr.get(backend_store_registry, store_uid)
443
- result = store_entry.store.read()
456
+ result = store_entry.store.write(value, ignore_channel=ws_channel)
444
457
 
445
458
  # Backend implementation could return a coroutine
446
459
  if inspect.iscoroutine(result):
447
- result = await result
460
+ await result
448
461
 
449
- # Get the current key and sequence number for this store
450
- store = store_entry.store
451
- key = await store._get_key()
452
- sequence_number = store.sequence_number.get(key, 0)
462
+ async with anyio.create_task_group() as tg:
463
+ for store_uid, value in values.items():
464
+ tg.start_soon(_write, store_uid, value)
453
465
 
454
- return {'value': result, 'sequence_number': sequence_number}
455
466
 
456
- @core_api_router.post('/store', dependencies=[Depends(verify_session)])
457
- async def sync_backend_store(ws_channel: str = Body(), values: Dict[str, Any] = Body()):
458
- registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
467
+ @core_api_router.get('/tasks/{task_id}', dependencies=[Depends(verify_session)])
468
+ async def get_task_result(task_id: str):
469
+ try:
470
+ task_mgr: TaskManager = utils_registry.get('TaskManager')
471
+ res = await task_mgr.get_result(task_id)
459
472
 
460
- async def _write(store_uid: str, value: Any):
461
- WS_CHANNEL.set(ws_channel)
462
- store_entry: BackendStoreEntry = await registry_mgr.get(backend_store_registry, store_uid)
463
- result = store_entry.store.write(value, ignore_channel=ws_channel)
473
+ dev_logger.debug(
474
+ f'Retrieving result for Task {task_id}',
475
+ 'return value',
476
+ {'value': res},
477
+ )
464
478
 
465
- # Backend implementation could return a coroutine
466
- if inspect.iscoroutine(result):
467
- await result
479
+ # Serialize dataframes correctly, either direct or as a DataResponse
480
+ if isinstance(res, DataFrame):
481
+ return Response(df_to_json(res), media_type='application/json')
482
+ elif is_data_response(res):
483
+ return Response(data_response_to_json(res), media_type='application/json')
468
484
 
469
- async with anyio.create_task_group() as tg:
470
- for store_uid, value in values.items():
471
- tg.start_soon(_write, store_uid, value)
485
+ return res
486
+ except KeyError as err:
487
+ raise HTTPException(status_code=404, detail=str(err)) from err
488
+ except Exception as err:
489
+ raise ValueError(f'The result for task id {task_id} could not be found') from err
472
490
 
473
- @core_api_router.get('/tasks/{task_id}', dependencies=[Depends(verify_session)])
474
- async def get_task_result(task_id: str):
475
- try:
476
- task_mgr: TaskManager = utils_registry.get('TaskManager')
477
- res = await task_mgr.get_result(task_id)
478
491
 
479
- dev_logger.debug(
480
- f'Retrieving result for Task {task_id}',
481
- 'return value',
482
- {'value': res},
483
- )
492
+ @core_api_router.delete('/tasks/{task_id}', dependencies=[Depends(verify_session)])
493
+ async def cancel_task(task_id: str):
494
+ try:
495
+ task_mgr: TaskManager = utils_registry.get('TaskManager')
496
+ return await task_mgr.cancel_task(task_id)
497
+ except TaskManagerError as e:
498
+ dev_logger.error(
499
+ f'The task id {task_id} could not be found, it may have already been cancelled',
500
+ e,
501
+ )
484
502
 
485
- # Serialize dataframes correctly, either direct or as a DataResponse
486
- if isinstance(res, DataFrame):
487
- return Response(df_to_json(res), media_type='application/json')
488
- elif is_data_response(res):
489
- return Response(data_response_to_json(res), media_type='application/json')
490
503
 
491
- return res
492
- except Exception as err:
493
- raise ValueError(f'The result for task id {task_id} could not be found') from err
504
+ @core_api_router.get('/version', dependencies=[Depends(verify_session)])
505
+ async def get_version():
506
+ return {'version': version('dara_core')}
494
507
 
495
- @core_api_router.delete('/tasks/{task_id}', dependencies=[Depends(verify_session)])
496
- async def cancel_task(task_id: str):
497
- try:
508
+
509
+ # Add the main websocket connection
510
+ core_api_router.add_api_websocket_route('/ws', ws_handler)
511
+
512
+
513
+ class ActionPayload(BaseModel):
514
+ uid: str
515
+ definition_uid: str
516
+ values: NormalizedPayload[Mapping[str, Any]]
517
+
518
+
519
+ class DerivedVariablePayload(BaseModel):
520
+ uid: str
521
+ values: NormalizedPayload[List[Any]]
522
+
523
+
524
+ class PyComponentPayload(BaseModel):
525
+ uid: str
526
+ name: str
527
+ values: NormalizedPayload[Mapping[str, Any]]
528
+
529
+
530
+ class RouteDataRequestBody(BaseModel):
531
+ action_payloads: List[ActionPayload] = Field(default_factory=list)
532
+ derived_variable_payloads: List[DerivedVariablePayload] = Field(default_factory=list)
533
+ py_component_payloads: List[PyComponentPayload] = Field(default_factory=list)
534
+ ws_channel: str
535
+ params: Dict[str, str] = Field(default_factory=dict)
536
+
537
+
538
+ class Result(BaseModel):
539
+ ok: bool
540
+ value: Any
541
+
542
+ @staticmethod
543
+ def success(value: Any) -> 'Result':
544
+ return Result(ok=True, value=value)
545
+
546
+ @staticmethod
547
+ def error(error: str) -> 'Result':
548
+ return Result(ok=False, value=error)
549
+
550
+
551
+ class DerivedVariableChunk(BaseModel):
552
+ type: Literal['derived_variable'] = 'derived_variable'
553
+ uid: str
554
+ result: Result
555
+
556
+
557
+ class PyComponentChunk(BaseModel):
558
+ type: Literal['py_component'] = 'py_component'
559
+ uid: str
560
+ result: Result
561
+
562
+
563
+ Chunk = Union[DerivedVariableChunk, PyComponentChunk]
564
+
565
+
566
+ def create_loader_route(config: Configuration, app: FastAPI):
567
+ route_map = config.router.to_route_map()
568
+
569
+ @app.post('/api/core/route/{route_id}', dependencies=[Depends(verify_session)])
570
+ async def get_route_data(route_id: Annotated[str, Path()], body: Annotated[RouteDataRequestBody, Body()]):
571
+ # unquote route_id since it can be url-encoded
572
+ route_id = unquote(route_id)
573
+
574
+ route_data = route_map.get(route_id)
575
+
576
+ if route_data is None:
577
+ raise HTTPException(status_code=404, detail=f'Route {route_id} not found')
578
+
579
+ action_results: Dict[str, Any] = {}
580
+
581
+ if len(body.action_payloads) > 0:
582
+ store: CacheStore = utils_registry.get('Store')
498
583
  task_mgr: TaskManager = utils_registry.get('TaskManager')
499
- return await task_mgr.cancel_task(task_id)
500
- except TaskManagerError as e:
501
- dev_logger.error(
502
- f'The task id {task_id} could not be found, it may have already been cancelled',
503
- e,
504
- )
505
-
506
- @core_api_router.get('/template/{template}', dependencies=[Depends(verify_session)])
507
- async def get_template(template: str):
508
- try:
509
- selected_template = template_registry.get(template)
510
- normalized_template, lookup = normalize(jsonable_encoder(selected_template))
511
- return {'data': normalized_template, 'lookup': lookup}
512
- except KeyError as err:
513
- raise HTTPException(status_code=404, detail=f'Template: {template}, not found in registry') from err
514
- except Exception as e:
515
- dev_logger.error('Something went wrong while trying to get the template', e)
516
-
517
- @core_api_router.get('/version', dependencies=[Depends(verify_session)])
518
- async def get_version():
519
- return {'version': version('dara_core')}
520
-
521
- # Add the main websocket connection
522
- core_api_router.add_api_websocket_route('/ws', ws_handler)
523
-
524
- return core_api_router
584
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
585
+
586
+ WS_CHANNEL.set(body.ws_channel)
587
+
588
+ # Run actions in order to guarantee execution order
589
+ for action_payload in body.action_payloads:
590
+ action_def = await registry_mgr.get(action_registry, action_payload.definition_uid)
591
+ static_kwargs = await registry_mgr.get(static_kwargs_registry, action_payload.uid)
592
+
593
+ CURRENT_ACTION_ID.set(action_payload.uid)
594
+ values = denormalize(action_payload.values.data, action_payload.values.lookup)
595
+ try:
596
+ action_results[action_payload.uid] = await execute_action_sync(
597
+ action_def,
598
+ inp={'params': body.params, 'route': route_data.definition},
599
+ values=values,
600
+ static_kwargs=static_kwargs,
601
+ store=store,
602
+ task_mgr=task_mgr,
603
+ )
604
+ except BaseException as e:
605
+ assert route_data.definition is not None
606
+ route_path = route_data.definition.full_path
607
+ action_name = str(action_def.resolver)
608
+ raise HTTPException(
609
+ status_code=500,
610
+ detail={
611
+ 'error': str(e),
612
+ 'stacktrace': print_stacktrace(e),
613
+ 'path': route_path,
614
+ 'action_name': action_name,
615
+ },
616
+ ) from e
617
+
618
+ async def process_variables(send_stream: MemoryObjectSendStream[Chunk]):
619
+ for payload in body.derived_variable_payloads:
620
+ try:
621
+ # Run the usual DV endpoint logic
622
+ result = await get_derived_variable(
623
+ uid=payload.uid,
624
+ body=DerivedStateRequestBody(
625
+ values=payload.values,
626
+ ws_channel=body.ws_channel,
627
+ force_key=None,
628
+ ),
629
+ )
630
+ await send_stream.send(DerivedVariableChunk(uid=payload.uid, result=Result.success(result)))
631
+ except BaseException as e:
632
+ dev_logger.error(f'Error streaming derived_variable {payload.uid}', error=e)
633
+ await send_stream.send(
634
+ DerivedVariableChunk(uid=payload.uid, result=Result.error(str(e))),
635
+ )
636
+
637
+ async def process_py_components(send_stream: MemoryObjectSendStream[Chunk]):
638
+ for payload in body.py_component_payloads:
639
+ try:
640
+ result = await get_component(
641
+ component=payload.name,
642
+ body=ComponentRequestBody(
643
+ uid=payload.uid,
644
+ values=payload.values,
645
+ ws_channel=body.ws_channel,
646
+ ),
647
+ )
648
+ await send_stream.send(PyComponentChunk(uid=payload.uid, result=Result.success(result)))
649
+ except BaseException as e:
650
+ dev_logger.error(f'Error streaming py_component {payload.name}', error=e)
651
+ await send_stream.send(
652
+ PyComponentChunk(uid=payload.uid, result=Result(ok=False, value=str(e))),
653
+ )
654
+
655
+ normalized_template, lookup = normalize(jsonable_encoder(route_data.content))
656
+
657
+ # Setup the stream response
658
+ async def stream():
659
+ try:
660
+
661
+ def create_chunk(x):
662
+ return json.dumps(x) + '\r\n'
663
+
664
+ # 1. Send the template and actions
665
+ yield create_chunk(
666
+ {
667
+ 'type': 'template',
668
+ 'template': {
669
+ 'data': normalized_template,
670
+ 'lookup': lookup,
671
+ },
672
+ }
673
+ )
674
+ yield create_chunk({'type': 'actions', 'actions': jsonable_encoder(action_results)})
675
+
676
+ # 2. Optionally, if there are DVs or py_components to preload, process them in the background and stream them back as they arrive
677
+ if len(body.derived_variable_payloads) > 0 or len(body.py_component_payloads) > 0:
678
+ send_stream, receive_stream = anyio.create_memory_object_stream[Chunk](max_buffer_size=math.inf)
679
+
680
+ async def process_derived_state():
681
+ async with send_stream, anyio.create_task_group() as tg:
682
+ if len(body.derived_variable_payloads) > 0:
683
+ tg.start_soon(process_variables, send_stream)
684
+ if len(body.py_component_payloads) > 0:
685
+ tg.start_soon(process_py_components, send_stream)
686
+
687
+ async with anyio.create_task_group() as tg:
688
+ tg.start_soon(process_derived_state)
689
+
690
+ async for item in receive_stream:
691
+ yield create_chunk(jsonable_encoder(item))
692
+ except Exception as e:
693
+ traceback.print_exc()
694
+ dev_logger.error(f'Error streaming loader data for route {route_id}', error=e)
695
+
696
+ return StreamingResponse(content=stream(), media_type='application/x-ndjson')