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.
- dara/core/__init__.py +8 -42
- dara/core/configuration.py +33 -4
- dara/core/defaults.py +7 -0
- dara/core/definitions.py +22 -35
- dara/core/interactivity/actions.py +29 -28
- dara/core/interactivity/plain_variable.py +6 -2
- dara/core/interactivity/switch_variable.py +2 -2
- dara/core/internal/execute_action.py +75 -6
- dara/core/internal/routing.py +526 -354
- dara/core/internal/tasks.py +1 -1
- dara/core/jinja/index.html +97 -1
- dara/core/jinja/index_autojs.html +116 -10
- dara/core/js_tooling/js_utils.py +35 -14
- dara/core/main.py +137 -89
- dara/core/persistence.py +6 -2
- dara/core/router/__init__.py +5 -0
- dara/core/router/compat.py +77 -0
- dara/core/router/components.py +143 -0
- dara/core/router/dependency_graph.py +62 -0
- dara/core/router/router.py +887 -0
- dara/core/umd/{dara.core.umd.js → dara.core.umd.cjs} +62588 -46966
- dara/core/umd/style.css +52 -9
- dara/core/visual/components/__init__.py +16 -11
- dara/core/visual/components/menu.py +4 -0
- dara/core/visual/components/menu_link.py +1 -0
- dara/core/visual/components/powered_by_causalens.py +9 -0
- dara/core/visual/components/sidebar_frame.py +1 -0
- dara/core/visual/dynamic_component.py +1 -1
- {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/METADATA +10 -10
- {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/RECORD +33 -26
- {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/LICENSE +0 -0
- {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/WHEEL +0 -0
- {dara_core-1.20.3.dist-info → dara_core-1.21.1.dist-info}/entry_points.txt +0 -0
dara/core/main.py
CHANGED
|
@@ -16,8 +16,10 @@ limitations under the License.
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import json
|
|
19
20
|
import os
|
|
20
21
|
import sys
|
|
22
|
+
import traceback
|
|
21
23
|
from concurrent.futures import ThreadPoolExecutor
|
|
22
24
|
from contextlib import asynccontextmanager
|
|
23
25
|
from importlib.util import find_spec
|
|
@@ -27,10 +29,10 @@ from typing import Optional
|
|
|
27
29
|
|
|
28
30
|
from anyio import create_task_group
|
|
29
31
|
from fastapi import FastAPI, HTTPException, Request
|
|
30
|
-
from fastapi.encoders import ENCODERS_BY_TYPE
|
|
31
|
-
from fastapi.responses import HTMLResponse
|
|
32
|
+
from fastapi.encoders import ENCODERS_BY_TYPE, jsonable_encoder
|
|
32
33
|
from fastapi.staticfiles import StaticFiles
|
|
33
34
|
from prometheus_client import start_http_server
|
|
35
|
+
from starlette.responses import FileResponse
|
|
34
36
|
from starlette.templating import Jinja2Templates, _TemplateResponse
|
|
35
37
|
|
|
36
38
|
from dara.core.auth import auth_router
|
|
@@ -55,12 +57,11 @@ from dara.core.internal.registries import (
|
|
|
55
57
|
custom_ws_handlers_registry,
|
|
56
58
|
latest_value_registry,
|
|
57
59
|
sessions_registry,
|
|
58
|
-
template_registry,
|
|
59
60
|
utils_registry,
|
|
60
61
|
websocket_registry,
|
|
61
62
|
)
|
|
62
63
|
from dara.core.internal.registry_lookup import RegistryLookup
|
|
63
|
-
from dara.core.internal.routing import
|
|
64
|
+
from dara.core.internal.routing import core_api_router, create_loader_route, error_decorator
|
|
64
65
|
from dara.core.internal.settings import get_settings
|
|
65
66
|
from dara.core.internal.tasks import TaskManager
|
|
66
67
|
from dara.core.internal.utils import enforce_sso, import_config
|
|
@@ -72,6 +73,16 @@ from dara.core.js_tooling.js_utils import (
|
|
|
72
73
|
rebuild_js,
|
|
73
74
|
)
|
|
74
75
|
from dara.core.logging import LoggingMiddleware, dev_logger, eng_logger, http_logger
|
|
76
|
+
from dara.core.router import convert_template_to_router
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CacheStaticFiles(StaticFiles):
|
|
80
|
+
async def get_response(self, path, scope):
|
|
81
|
+
response = await super().get_response(path, scope)
|
|
82
|
+
# add 1 year cache for static assets
|
|
83
|
+
if isinstance(response, FileResponse) and path.endswith('.css') or path.endswith('.umd.js'):
|
|
84
|
+
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
|
85
|
+
return response
|
|
75
86
|
|
|
76
87
|
|
|
77
88
|
def _start_application(config: Configuration):
|
|
@@ -94,18 +105,9 @@ def _start_application(config: Configuration):
|
|
|
94
105
|
import fastapi_vite_dara
|
|
95
106
|
import fastapi_vite_dara.config
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
jinja_templates.env.globals['vite_hmr_client'] = fastapi_vite_dara.vite_hmr_client
|
|
101
|
-
jinja_templates.env.globals['vite_asset'] = fastapi_vite_dara.vite_asset
|
|
102
|
-
jinja_templates.env.globals['static_url'] = fastapi_vite_dara.config.settings.static_url
|
|
103
|
-
jinja_templates.env.globals['base_url'] = os.getenv('DARA_BASE_URL', '')
|
|
104
|
-
jinja_templates.env.globals['entry'] = '_entry.tsx'
|
|
105
|
-
|
|
106
|
-
# If --enable-hmr or --reload enabled, set live reload to true
|
|
107
|
-
if os.environ.get('DARA_HMR_MODE') == 'TRUE' or os.environ.get('DARA_LIVE_RELOAD') == 'TRUE':
|
|
108
|
-
config.live_reload = True
|
|
108
|
+
# If --enable-hmr or --reload enabled, set live reload to true
|
|
109
|
+
if os.environ.get('DARA_HMR_MODE') == 'TRUE' or os.environ.get('DARA_LIVE_RELOAD') == 'TRUE':
|
|
110
|
+
config.live_reload = True
|
|
109
111
|
|
|
110
112
|
# Configure the default executor for threads run via the async loop
|
|
111
113
|
loop = asyncio.get_event_loop()
|
|
@@ -259,26 +261,6 @@ def _start_application(config: Configuration):
|
|
|
259
261
|
for key, value in encoder_registry.items():
|
|
260
262
|
ENCODERS_BY_TYPE[key] = value['serialize']
|
|
261
263
|
|
|
262
|
-
# Generate a new build_cache
|
|
263
|
-
try:
|
|
264
|
-
build_cache = BuildCache.from_config(config)
|
|
265
|
-
build_diff = build_cache.get_diff()
|
|
266
|
-
|
|
267
|
-
# Only build if there's pages to build, otherwise assume Dara is only used for API
|
|
268
|
-
if len(config.pages) > 0:
|
|
269
|
-
dev_logger.debug(
|
|
270
|
-
'Building JS...',
|
|
271
|
-
extra={
|
|
272
|
-
'New build cache': build_cache.model_dump(),
|
|
273
|
-
'Difference from last cache': build_diff.model_dump(),
|
|
274
|
-
},
|
|
275
|
-
)
|
|
276
|
-
rebuild_js(build_cache, build_diff)
|
|
277
|
-
|
|
278
|
-
except Exception as e:
|
|
279
|
-
dev_logger.error('Error building JS', error=e)
|
|
280
|
-
sys.exit(1)
|
|
281
|
-
|
|
282
264
|
# Loop over registered components and add to the registry
|
|
283
265
|
eng_logger.info(f'Registering components [{", ".join([c.name for c in config.components])}]')
|
|
284
266
|
for component in config.components:
|
|
@@ -292,37 +274,64 @@ def _start_application(config: Configuration):
|
|
|
292
274
|
dev_logger.info(f'Using {config.auth_config.__class__.__name__} auth configuration')
|
|
293
275
|
auth_registry.register('auth_config', config.auth_config)
|
|
294
276
|
|
|
277
|
+
# Handle pages/router compatibility
|
|
278
|
+
if len(config.pages) > 0:
|
|
279
|
+
if len(config.router.children) > 0:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
'ConfigurationBuilder.add_page is not compatible with the ConfigurationBuilder.router, `add_page` is supported for backwards compatibility but the recommended API going forward is using `config.router` directly.'
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
eng_logger.info('Registering template')
|
|
286
|
+
|
|
287
|
+
# Add the default templates
|
|
288
|
+
if config.template == 'default':
|
|
289
|
+
eng_logger.info('Registering template "default"')
|
|
290
|
+
config.router = convert_template_to_router(default_template(config))
|
|
291
|
+
elif config.template == 'blank':
|
|
292
|
+
eng_logger.info('Registering template "blank"')
|
|
293
|
+
config.router = convert_template_to_router(blank_template(config))
|
|
294
|
+
elif config.template == 'top':
|
|
295
|
+
eng_logger.info('Registering template "top"')
|
|
296
|
+
config.router = convert_template_to_router(top_template(config))
|
|
297
|
+
elif config.template == 'top-menu':
|
|
298
|
+
eng_logger.info('Registering template "top-menu"')
|
|
299
|
+
config.router = convert_template_to_router(top_menu_template(config))
|
|
300
|
+
else:
|
|
301
|
+
# Loop over user defined templates and add to the registry
|
|
302
|
+
for name, renderer in config.template_renderers.items():
|
|
303
|
+
if name == config.template:
|
|
304
|
+
eng_logger.info(f'Registering custom template "{name}"')
|
|
305
|
+
config.router = convert_template_to_router(renderer(config))
|
|
306
|
+
break
|
|
307
|
+
else:
|
|
308
|
+
raise ValueError(f'Unknown template renderer: {config.template}')
|
|
309
|
+
except Exception as e:
|
|
310
|
+
traceback.print_exc()
|
|
311
|
+
dev_logger.error(
|
|
312
|
+
'Something went wrong when building application template, there is most likely an issue in the application logic',
|
|
313
|
+
e,
|
|
314
|
+
)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
|
|
317
|
+
# Generate a new build_cache
|
|
295
318
|
try:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
# Add the default templates
|
|
299
|
-
if config.template == 'default':
|
|
300
|
-
eng_logger.info('Registering template "default"')
|
|
301
|
-
template_registry.register('default', default_template(config))
|
|
302
|
-
elif config.template == 'blank':
|
|
303
|
-
eng_logger.info('Registering template "blank"')
|
|
304
|
-
template_registry.register('blank', blank_template(config))
|
|
305
|
-
elif config.template == 'top':
|
|
306
|
-
eng_logger.info('Registering template "top"')
|
|
307
|
-
template_registry.register('top', top_template(config))
|
|
308
|
-
elif config.template == 'top-menu':
|
|
309
|
-
eng_logger.info('Registering template "top-menu"')
|
|
310
|
-
template_registry.register('top-menu', top_menu_template(config))
|
|
311
|
-
else:
|
|
312
|
-
# Loop over user defined templates and add to the registry
|
|
313
|
-
for name, renderer in config.template_renderers.items():
|
|
314
|
-
if name == config.template:
|
|
315
|
-
eng_logger.info(f'Registering custom template "{name}"')
|
|
316
|
-
template_registry.register(name, renderer(config))
|
|
319
|
+
build_cache = BuildCache.from_config(config)
|
|
320
|
+
build_diff = build_cache.get_diff()
|
|
317
321
|
|
|
322
|
+
# Only build if there's pages to build, otherwise assume Dara is only used for API
|
|
323
|
+
if len(config.router.children) > 0:
|
|
324
|
+
dev_logger.debug(
|
|
325
|
+
'Building JS...',
|
|
326
|
+
extra={
|
|
327
|
+
'New build cache': build_cache.model_dump(),
|
|
328
|
+
'Difference from last cache': build_diff.model_dump(),
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
rebuild_js(build_cache, build_diff)
|
|
318
332
|
except Exception as e:
|
|
319
|
-
import traceback
|
|
320
|
-
|
|
321
333
|
traceback.print_exc()
|
|
322
|
-
dev_logger.error(
|
|
323
|
-
'Something went wrong when building application template, there is most likely an issue in the application logic',
|
|
324
|
-
e,
|
|
325
|
-
)
|
|
334
|
+
dev_logger.error('Error building JS', error=e)
|
|
326
335
|
sys.exit(1)
|
|
327
336
|
|
|
328
337
|
# Root routes
|
|
@@ -334,9 +343,6 @@ def _start_application(config: Configuration):
|
|
|
334
343
|
"""
|
|
335
344
|
return {'status': 'ok'}
|
|
336
345
|
|
|
337
|
-
# Register the core routes of the application
|
|
338
|
-
core_api_router = create_router(config)
|
|
339
|
-
|
|
340
346
|
# Start metrics server in a daemon thread
|
|
341
347
|
if os.environ.get('DARA_DISABLE_METRICS') != 'TRUE' and os.environ.get('DARA_TEST_FLAG', None) is None:
|
|
342
348
|
port = int(os.environ.get('DARA_METRICS_PORT', '10000'))
|
|
@@ -352,42 +358,84 @@ def _start_application(config: Configuration):
|
|
|
352
358
|
start_pprof_server(port=profiling_port)
|
|
353
359
|
|
|
354
360
|
# Serve statics, only if we have any pages defined
|
|
355
|
-
if len(config.
|
|
356
|
-
app.mount('/static',
|
|
361
|
+
if len(config.router.children) > 0:
|
|
362
|
+
app.mount('/static', CacheStaticFiles(directory=config.static_files_dir), name='static')
|
|
357
363
|
|
|
358
364
|
# Mount Routers
|
|
359
365
|
app.include_router(auth_router, prefix='/api/auth')
|
|
360
366
|
app.include_router(core_api_router, prefix='/api/core')
|
|
361
367
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
368
|
+
if len(config.router.children) > 0:
|
|
369
|
+
BASE_DIR = Path(__file__).parent
|
|
370
|
+
jinja_templates = Jinja2Templates(directory=str(Path(BASE_DIR, 'jinja')))
|
|
371
|
+
jinja_templates.env.globals['vite_hmr_client'] = fastapi_vite_dara.vite_hmr_client
|
|
372
|
+
jinja_templates.env.globals['vite_asset'] = fastapi_vite_dara.vite_asset
|
|
373
|
+
jinja_templates.env.globals['static_url'] = fastapi_vite_dara.config.settings.static_url
|
|
374
|
+
jinja_templates.env.globals['base_url'] = os.getenv('DARA_BASE_URL', '')
|
|
375
|
+
jinja_templates.env.globals['entry'] = '_entry.tsx'
|
|
376
|
+
|
|
377
|
+
# Compile the router, executing all page functions etc
|
|
378
|
+
try:
|
|
379
|
+
config.router.compile()
|
|
380
|
+
except Exception as e:
|
|
381
|
+
traceback.print_exc()
|
|
382
|
+
dev_logger.error('Error compiling router', error=e)
|
|
383
|
+
sys.exit(1)
|
|
384
|
+
|
|
385
|
+
dev_logger.info('Registering pages:')
|
|
386
|
+
# TODO: convert this to use the logger?
|
|
387
|
+
config.router.print_route_tree()
|
|
388
|
+
|
|
389
|
+
# Add the page data loader route
|
|
390
|
+
create_loader_route(config, app)
|
|
391
|
+
|
|
392
|
+
# Catch-all, must add after adding the last api route
|
|
393
|
+
@app.get('/api/{rest_of_path:path}')
|
|
394
|
+
async def not_found():
|
|
395
|
+
raise HTTPException(status_code=404, detail='API endpoint not found')
|
|
396
|
+
|
|
397
|
+
# Prepare static template data
|
|
398
|
+
template_data = {
|
|
399
|
+
'auth_components': config.auth_config.component_config.model_dump(),
|
|
400
|
+
'actions': action_def_registry.get_all().items(),
|
|
401
|
+
'components': {k: comp.model_dump(exclude={'func'}) for k, comp in component_registry.get_all().items()},
|
|
402
|
+
'application_name': get_settings().project_name,
|
|
403
|
+
'enable_devtools': config.enable_devtools,
|
|
404
|
+
'live_reload': config.live_reload,
|
|
405
|
+
# TODO: this will become some backendstore-variable instead, prepopulated in here
|
|
406
|
+
'theme': config.theme,
|
|
407
|
+
'title': config.title,
|
|
408
|
+
'context_components': config.context_components,
|
|
409
|
+
# For backwards compatibility
|
|
410
|
+
'powered_by_causalens': config.powered_by_causalens,
|
|
411
|
+
'router': config.router,
|
|
412
|
+
'build_mode': build_cache.build_config.mode,
|
|
413
|
+
'build_dev': build_cache.build_config.dev,
|
|
414
|
+
}
|
|
415
|
+
json_template_data = json.dumps(jsonable_encoder(template_data))
|
|
365
416
|
|
|
366
|
-
if len(config.pages) > 0:
|
|
367
|
-
dev_logger.info(f'Registering pages: [{", ".join(list(config.pages.keys()))}]')
|
|
368
417
|
# For any unmatched route then serve the app to the user if we have any pages to serve
|
|
369
418
|
# (Required for the chosen routing system in the UI)
|
|
370
419
|
|
|
420
|
+
context = {
|
|
421
|
+
'dara_data': json_template_data,
|
|
422
|
+
}
|
|
423
|
+
template_name = 'index.html'
|
|
424
|
+
|
|
371
425
|
# Auto-js mode - serve the built template with UMDs
|
|
372
426
|
if build_cache.build_config.mode == BuildMode.AUTO_JS:
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
with open(template_path, encoding='utf-8') as fp:
|
|
376
|
-
template = fp.read()
|
|
377
|
-
|
|
378
|
-
# Generate tags for the template
|
|
379
|
-
template = build_autojs_template(template, build_cache, config)
|
|
427
|
+
template_name = 'index_autojs.html'
|
|
428
|
+
context.update(build_autojs_template(build_cache, config))
|
|
380
429
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
430
|
+
@app.get('/{full_path:path}', include_in_schema=False, response_class=_TemplateResponse)
|
|
431
|
+
async def serve_app(request: Request):
|
|
432
|
+
return jinja_templates.TemplateResponse(request, template_name, context=context)
|
|
384
433
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return jinja_templates.TemplateResponse(request, 'index.html') # type: ignore
|
|
434
|
+
else:
|
|
435
|
+
# Catch-all, must be at the very end
|
|
436
|
+
@app.get('/api/{rest_of_path:path}')
|
|
437
|
+
async def not_found():
|
|
438
|
+
raise HTTPException(status_code=404, detail='API endpoint not found')
|
|
391
439
|
|
|
392
440
|
return app
|
|
393
441
|
|
dara/core/persistence.py
CHANGED
|
@@ -415,7 +415,7 @@ class BackendStore(PersistenceStore):
|
|
|
415
415
|
|
|
416
416
|
await self.backend.subscribe(_on_value)
|
|
417
417
|
|
|
418
|
-
async def write_partial(self, data: Union[List[Dict[str, Any]], Any], notify: bool = True):
|
|
418
|
+
async def write_partial(self, data: Union[List[Dict[str, Any]], Any], notify: bool = True, in_place: bool = False):
|
|
419
419
|
"""
|
|
420
420
|
Apply partial updates to the store using JSON Patch operations or automatic diffing.
|
|
421
421
|
|
|
@@ -424,6 +424,10 @@ class BackendStore(PersistenceStore):
|
|
|
424
424
|
|
|
425
425
|
:param data: Either a list of JSON patch operations (RFC 6902) or a full object to diff against current value
|
|
426
426
|
:param notify: whether to broadcast the patches to clients
|
|
427
|
+
:param in_place: whether to apply the patches in-place or return a new value.
|
|
428
|
+
When set to True, the value will be mutated when applying the patches rather than deep-cloned.
|
|
429
|
+
This is recommended when the updated value is large and deep-cloning it can be expensive; however, users should exercise
|
|
430
|
+
caution when using the option as previous results retrieved from `store.read()` will potentially be mutated, depending on the backend used.
|
|
427
431
|
"""
|
|
428
432
|
if self.readonly:
|
|
429
433
|
raise ValueError('Cannot write to a read-only store')
|
|
@@ -451,7 +455,7 @@ class BackendStore(PersistenceStore):
|
|
|
451
455
|
|
|
452
456
|
# Apply patches to current value
|
|
453
457
|
try:
|
|
454
|
-
updated_value = jsonpatch.apply_patch(current_value, patches)
|
|
458
|
+
updated_value = jsonpatch.apply_patch(current_value, patches, in_place=in_place)
|
|
455
459
|
except (jsonpatch.InvalidJsonPatch, jsonpatch.JsonPatchException) as e:
|
|
456
460
|
raise ValueError(f'Invalid JSON patch operation: {e}') from e
|
|
457
461
|
else:
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from dara.core.definitions import ComponentInstance
|
|
4
|
+
|
|
5
|
+
from .components import Outlet
|
|
6
|
+
from .router import Router
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def convert_template_to_router(template):
|
|
10
|
+
"""
|
|
11
|
+
Convert old template system to new Router structure.
|
|
12
|
+
|
|
13
|
+
The conversion maps:
|
|
14
|
+
- Template.layout becomes the content of a root LayoutRoute, but with transformations:
|
|
15
|
+
- RouterContent components are replaced with Outlet components
|
|
16
|
+
- RouterContent.routes are extracted and become PageRoute/IndexRoute children
|
|
17
|
+
- Routes with route='/' become IndexRoute, others become PageRoute
|
|
18
|
+
- Route paths are normalized (leading slashes removed)
|
|
19
|
+
|
|
20
|
+
:param template: Template object with layout component containing RouterContent
|
|
21
|
+
:return: Router instance with converted structure
|
|
22
|
+
"""
|
|
23
|
+
from dara.core.definitions import TemplateRouterContent
|
|
24
|
+
from dara.core.visual.components.router_content import RouterContent
|
|
25
|
+
|
|
26
|
+
router = Router()
|
|
27
|
+
extracted_routes: List[TemplateRouterContent] = []
|
|
28
|
+
|
|
29
|
+
# Transform the layout: replace RouterContent with Outlet and extract routes
|
|
30
|
+
def transform_component(component):
|
|
31
|
+
"""Recursively transform components, replacing RouterContent with Outlet"""
|
|
32
|
+
# For other components, recursively transform their children/content
|
|
33
|
+
if isinstance(component, ComponentInstance):
|
|
34
|
+
if isinstance(component, RouterContent):
|
|
35
|
+
extracted_routes.extend(component.routes)
|
|
36
|
+
return Outlet()
|
|
37
|
+
|
|
38
|
+
for attr in component.model_fields_set:
|
|
39
|
+
value = getattr(component, attr, None)
|
|
40
|
+
if isinstance(value, ComponentInstance):
|
|
41
|
+
setattr(component, attr, transform_component(value))
|
|
42
|
+
elif isinstance(value, list) and len(value) > 0 and isinstance(value[0], ComponentInstance):
|
|
43
|
+
setattr(component, attr, [transform_component(item) for item in value])
|
|
44
|
+
|
|
45
|
+
return component
|
|
46
|
+
|
|
47
|
+
# Create root layout route using transformed template layout
|
|
48
|
+
root_layout = router.add_layout(content=transform_component(template.layout))
|
|
49
|
+
|
|
50
|
+
# Convert extracted routes to appropriate route types
|
|
51
|
+
for route_content in extracted_routes:
|
|
52
|
+
# Normalize route path: remove leading slash and handle root
|
|
53
|
+
route_path = route_content.route
|
|
54
|
+
if route_path.startswith('/'):
|
|
55
|
+
route_path = route_path[1:]
|
|
56
|
+
|
|
57
|
+
# NOTE: here it's safe to use the name as the id, as in the old api the name was unique
|
|
58
|
+
# but use 'index' for empty strings
|
|
59
|
+
|
|
60
|
+
# Root path becomes index route
|
|
61
|
+
if route_path in {'', '/'}:
|
|
62
|
+
root_layout.add_index(
|
|
63
|
+
content=route_content.content,
|
|
64
|
+
name=route_content.name,
|
|
65
|
+
id=route_content.name or 'index',
|
|
66
|
+
on_load=route_content.on_load,
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
root_layout.add_page(
|
|
70
|
+
path=route_path,
|
|
71
|
+
content=route_content.content,
|
|
72
|
+
name=route_content.name,
|
|
73
|
+
id=route_content.name or 'index',
|
|
74
|
+
on_load=route_content.on_load,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return router
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal, Optional, Union
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, BeforeValidator
|
|
4
|
+
|
|
5
|
+
from dara.core.definitions import ComponentInstance, JsComponentDef, StyledComponentInstance, transform_raw_css
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RouterPath(BaseModel):
|
|
9
|
+
path: str
|
|
10
|
+
"""
|
|
11
|
+
A URL pathname, beginning with '/'.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
search: Optional[str] = None
|
|
15
|
+
"""
|
|
16
|
+
A URL search string, beginning with '?'.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
hash: Optional[str] = None
|
|
20
|
+
"""
|
|
21
|
+
A URL hash string, beginning with '#'.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
OutletDef = JsComponentDef(name='Outlet', js_module='@darajs/core', py_module='dara.core')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Outlet(ComponentInstance):
|
|
29
|
+
"""
|
|
30
|
+
Outlet component is a placeholder for the content of the current route.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
LinkDef = JsComponentDef(name='Link', js_module='@darajs/core', py_module='dara.core')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Link(StyledComponentInstance):
|
|
38
|
+
"""
|
|
39
|
+
Link component is a wrapper around the NavLink component that displays a link to the specified route.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
case_sensitive: bool = False
|
|
43
|
+
|
|
44
|
+
end: bool = True
|
|
45
|
+
"""
|
|
46
|
+
Changes the matching logic for the 'active' state to only match the end of the 'to' prop.
|
|
47
|
+
If the URL is longer, it will not be considered active. Defaults to True.
|
|
48
|
+
|
|
49
|
+
For example, NavLink(to='/tasks') while on '/tasks/123' will:
|
|
50
|
+
- with `end=False`, be considered active because of the partial match of the `/tasks` part
|
|
51
|
+
- with `end=True`, be considered inactive because of the missing '123' part
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# TODO: consider making it True by default in Dara v2
|
|
55
|
+
prefetch: bool = False
|
|
56
|
+
"""
|
|
57
|
+
Whether to prefetch the navigation data when user intends to navigate to the link.
|
|
58
|
+
When set to True, whenever the user hovers or focuses the link, the navigation data will be prefetched
|
|
59
|
+
and cached for a short period of time to speed up navigation.
|
|
60
|
+
Defaults to False.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
relative: Literal['route', 'path'] = 'route'
|
|
64
|
+
"""
|
|
65
|
+
Defines the relative path behavior for the link.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
Link(to='..') # default, relative='route'
|
|
69
|
+
Link(to='..', relative='path')
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Consider a route hierarchy where a parent route pattern is "blog" and a child route pattern is "blog/:slug/edit".
|
|
73
|
+
- route — default, resolves the link relative to the route pattern. In the example above, a relative link of "..." will remove both :slug/edit segments back to "/blog".
|
|
74
|
+
- path — relative to the path so "..." will only remove one URL segment up to "/blog/:slug"
|
|
75
|
+
Note that index routes and layout routes do not have paths so they are not included in the relative path calculation.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
replace: bool = False
|
|
79
|
+
"""
|
|
80
|
+
Replaces the current entry in the history stack instead of pushing a new one.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
# with a history stack like this
|
|
84
|
+
A -> B
|
|
85
|
+
|
|
86
|
+
# normal link click pushes a new entry
|
|
87
|
+
A -> B -> C
|
|
88
|
+
|
|
89
|
+
# but with `replace`, B is replaced by C
|
|
90
|
+
A -> C
|
|
91
|
+
```
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
to: Union[str, RouterPath]
|
|
95
|
+
"""
|
|
96
|
+
Can be a string or RouterPath object
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
active_css: Annotated[Optional[Any], BeforeValidator(transform_raw_css)] = None
|
|
100
|
+
inactive_css: Annotated[Optional[Any], BeforeValidator(transform_raw_css)] = None
|
|
101
|
+
|
|
102
|
+
# TODO: add scroll restoration if it works?
|
|
103
|
+
|
|
104
|
+
def __init__(self, *children: ComponentInstance, **kwargs):
|
|
105
|
+
components = list(children)
|
|
106
|
+
if 'children' not in kwargs:
|
|
107
|
+
kwargs['children'] = components
|
|
108
|
+
super().__init__(**kwargs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
MenuLinkDef = JsComponentDef(name='MenuLink', js_module='@darajs/core', py_module='dara.core')
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MenuLink(Link):
|
|
115
|
+
"""
|
|
116
|
+
Styled version of Link component, ready to be used with e.g. the built-in SideBarFrame component.
|
|
117
|
+
Accepts all the same props as the Link component, can be used as a drop-in replacement.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from dara.core.visual.components import MenuLink, SideBarFrame
|
|
121
|
+
from dara.core.router import Router, Outlet
|
|
122
|
+
|
|
123
|
+
router = Router()
|
|
124
|
+
|
|
125
|
+
def RootLayout():
|
|
126
|
+
routes = router.get_navigable_routes()
|
|
127
|
+
|
|
128
|
+
return SideBarFrame(
|
|
129
|
+
side_bar=Stack(
|
|
130
|
+
*[MenuLink(Text(path['name']), to=path['path']) for path in routes],
|
|
131
|
+
),
|
|
132
|
+
content=Outlet(),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
root = router.add_layout(content=RootLayout)
|
|
136
|
+
```
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, *children: ComponentInstance, **kwargs):
|
|
140
|
+
els = list(children)
|
|
141
|
+
if 'children' not in kwargs:
|
|
142
|
+
kwargs['children'] = els
|
|
143
|
+
super().__init__(**kwargs)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, SerializeAsAny
|
|
4
|
+
|
|
5
|
+
from dara.core.definitions import ComponentInstance
|
|
6
|
+
from dara.core.interactivity.derived_variable import DerivedVariable
|
|
7
|
+
from dara.core.visual.dynamic_component import PyComponentInstance
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DependencyGraph(BaseModel):
|
|
11
|
+
"""
|
|
12
|
+
Data structure representing dependencies for derived state on a page
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
derived_variables: SerializeAsAny[Dict[str, DerivedVariable]] = Field(default_factory=dict)
|
|
16
|
+
"""
|
|
17
|
+
Map of DerivedVariable instances
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
py_components: SerializeAsAny[Dict[str, PyComponentInstance]] = Field(default_factory=dict)
|
|
21
|
+
"""
|
|
22
|
+
Map of PyComponentInstance instances
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def from_component(component: ComponentInstance) -> 'DependencyGraph':
|
|
27
|
+
"""
|
|
28
|
+
Create a DependencyGraph from a ComponentInstance
|
|
29
|
+
"""
|
|
30
|
+
graph = DependencyGraph()
|
|
31
|
+
_analyze_component_dependencies(component, graph)
|
|
32
|
+
return graph
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _analyze_component_dependencies(component: ComponentInstance, graph: DependencyGraph) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Recursively analyze a component tree to build a dependency graph of DerivedVariables and PyComponentInstances.
|
|
38
|
+
"""
|
|
39
|
+
# The component itself is a PyComponentInstance
|
|
40
|
+
if isinstance(component, PyComponentInstance):
|
|
41
|
+
if component.uid not in graph.py_components:
|
|
42
|
+
graph.py_components[component.uid] = component
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# otherwise check each field
|
|
46
|
+
for attr in component.model_fields_set:
|
|
47
|
+
value = getattr(component, attr, None)
|
|
48
|
+
|
|
49
|
+
# Handle encountered variables and py_components
|
|
50
|
+
if isinstance(value, DerivedVariable) and value.uid not in graph.derived_variables:
|
|
51
|
+
graph.derived_variables[value.uid] = value
|
|
52
|
+
elif isinstance(value, PyComponentInstance) and value.uid not in graph.py_components:
|
|
53
|
+
graph.py_components[value.uid] = value
|
|
54
|
+
# Recursion cases:
|
|
55
|
+
# component instances
|
|
56
|
+
elif isinstance(value, ComponentInstance):
|
|
57
|
+
_analyze_component_dependencies(value, graph)
|
|
58
|
+
# component lists
|
|
59
|
+
elif isinstance(value, list):
|
|
60
|
+
for item in value:
|
|
61
|
+
if isinstance(item, ComponentInstance):
|
|
62
|
+
_analyze_component_dependencies(item, graph)
|