dara-core 1.20.2__py3-none-any.whl → 1.21.0__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 +9 -0
- dara/core/definitions.py +21 -34
- dara/core/interactivity/actions.py +29 -28
- dara/core/interactivity/switch_variable.py +2 -2
- dara/core/internal/execute_action.py +75 -6
- dara/core/internal/routing.py +9 -46
- 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 +212 -89
- dara/core/router/__init__.py +4 -0
- dara/core/router/compat.py +77 -0
- dara/core/router/components.py +109 -0
- dara/core/router/router.py +868 -0
- dara/core/umd/{dara.core.umd.js → dara.core.umd.cjs} +30052 -17828
- dara/core/umd/style.css +52 -9
- dara/core/visual/components/__init__.py +19 -11
- dara/core/visual/components/menu.py +4 -0
- dara/core/visual/components/menu_link.py +36 -0
- dara/core/visual/components/powered_by_causalens.py +9 -0
- dara/core/visual/components/sidebar_frame.py +1 -0
- {dara_core-1.20.2.dist-info → dara_core-1.21.0.dist-info}/METADATA +10 -10
- {dara_core-1.20.2.dist-info → dara_core-1.21.0.dist-info}/RECORD +28 -22
- {dara_core-1.20.2.dist-info → dara_core-1.21.0.dist-info}/LICENSE +0 -0
- {dara_core-1.20.2.dist-info → dara_core-1.21.0.dist-info}/WHEEL +0 -0
- {dara_core-1.20.2.dist-info → dara_core-1.21.0.dist-info}/entry_points.txt +0 -0
dara/core/main.py
CHANGED
|
@@ -16,24 +16,31 @@ 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
|
|
23
|
+
from collections.abc import Mapping
|
|
21
24
|
from concurrent.futures import ThreadPoolExecutor
|
|
22
25
|
from contextlib import asynccontextmanager
|
|
23
26
|
from importlib.util import find_spec
|
|
24
27
|
from inspect import iscoroutine
|
|
25
28
|
from pathlib import Path
|
|
26
|
-
from typing import Optional
|
|
29
|
+
from typing import Annotated, Any, Dict, List, Optional
|
|
30
|
+
from urllib.parse import unquote
|
|
27
31
|
|
|
28
32
|
from anyio import create_task_group
|
|
29
|
-
from fastapi import FastAPI, HTTPException, Request
|
|
30
|
-
from fastapi
|
|
31
|
-
from fastapi.
|
|
33
|
+
from fastapi import Body, Depends, FastAPI, HTTPException, Request
|
|
34
|
+
from fastapi import Path as PathParam
|
|
35
|
+
from fastapi.encoders import ENCODERS_BY_TYPE, jsonable_encoder
|
|
32
36
|
from fastapi.staticfiles import StaticFiles
|
|
33
37
|
from prometheus_client import start_http_server
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
from starlette.responses import FileResponse
|
|
34
40
|
from starlette.templating import Jinja2Templates, _TemplateResponse
|
|
35
41
|
|
|
36
42
|
from dara.core.auth import auth_router
|
|
43
|
+
from dara.core.auth.routes import verify_session
|
|
37
44
|
from dara.core.configuration import Configuration, ConfigurationBuilder
|
|
38
45
|
from dara.core.defaults import (
|
|
39
46
|
blank_template,
|
|
@@ -44,18 +51,21 @@ from dara.core.defaults import (
|
|
|
44
51
|
from dara.core.internal.cache_store import CacheStore
|
|
45
52
|
from dara.core.internal.cgroup import get_cpu_count, set_memory_limit
|
|
46
53
|
from dara.core.internal.custom_response import CustomResponse
|
|
47
|
-
from dara.core.internal.devtools import send_error_for_session
|
|
54
|
+
from dara.core.internal.devtools import print_stacktrace, send_error_for_session
|
|
48
55
|
from dara.core.internal.encoder_registry import encoder_registry
|
|
56
|
+
from dara.core.internal.execute_action import CURRENT_ACTION_ID, execute_action_sync
|
|
57
|
+
from dara.core.internal.normalization import NormalizedPayload, denormalize, normalize
|
|
49
58
|
from dara.core.internal.pool import TaskPool
|
|
50
59
|
from dara.core.internal.registries import (
|
|
51
60
|
action_def_registry,
|
|
61
|
+
action_registry,
|
|
52
62
|
auth_registry,
|
|
53
63
|
component_registry,
|
|
54
64
|
config_registry,
|
|
55
65
|
custom_ws_handlers_registry,
|
|
56
66
|
latest_value_registry,
|
|
57
67
|
sessions_registry,
|
|
58
|
-
|
|
68
|
+
static_kwargs_registry,
|
|
59
69
|
utils_registry,
|
|
60
70
|
websocket_registry,
|
|
61
71
|
)
|
|
@@ -64,7 +74,7 @@ from dara.core.internal.routing import create_router, error_decorator
|
|
|
64
74
|
from dara.core.internal.settings import get_settings
|
|
65
75
|
from dara.core.internal.tasks import TaskManager
|
|
66
76
|
from dara.core.internal.utils import enforce_sso, import_config
|
|
67
|
-
from dara.core.internal.websocket import WebsocketManager
|
|
77
|
+
from dara.core.internal.websocket import WS_CHANNEL, WebsocketManager
|
|
68
78
|
from dara.core.js_tooling.js_utils import (
|
|
69
79
|
BuildCache,
|
|
70
80
|
BuildMode,
|
|
@@ -72,6 +82,16 @@ from dara.core.js_tooling.js_utils import (
|
|
|
72
82
|
rebuild_js,
|
|
73
83
|
)
|
|
74
84
|
from dara.core.logging import LoggingMiddleware, dev_logger, eng_logger, http_logger
|
|
85
|
+
from dara.core.router import convert_template_to_router
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CacheStaticFiles(StaticFiles):
|
|
89
|
+
async def get_response(self, path, scope):
|
|
90
|
+
response = await super().get_response(path, scope)
|
|
91
|
+
# add 1 year cache for static assets
|
|
92
|
+
if isinstance(response, FileResponse) and path.endswith('.css') or path.endswith('.umd.js'):
|
|
93
|
+
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
|
94
|
+
return response
|
|
75
95
|
|
|
76
96
|
|
|
77
97
|
def _start_application(config: Configuration):
|
|
@@ -94,18 +114,9 @@ def _start_application(config: Configuration):
|
|
|
94
114
|
import fastapi_vite_dara
|
|
95
115
|
import fastapi_vite_dara.config
|
|
96
116
|
|
|
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
|
|
117
|
+
# If --enable-hmr or --reload enabled, set live reload to true
|
|
118
|
+
if os.environ.get('DARA_HMR_MODE') == 'TRUE' or os.environ.get('DARA_LIVE_RELOAD') == 'TRUE':
|
|
119
|
+
config.live_reload = True
|
|
109
120
|
|
|
110
121
|
# Configure the default executor for threads run via the async loop
|
|
111
122
|
loop = asyncio.get_event_loop()
|
|
@@ -259,26 +270,6 @@ def _start_application(config: Configuration):
|
|
|
259
270
|
for key, value in encoder_registry.items():
|
|
260
271
|
ENCODERS_BY_TYPE[key] = value['serialize']
|
|
261
272
|
|
|
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
273
|
# Loop over registered components and add to the registry
|
|
283
274
|
eng_logger.info(f'Registering components [{", ".join([c.name for c in config.components])}]')
|
|
284
275
|
for component in config.components:
|
|
@@ -292,37 +283,64 @@ def _start_application(config: Configuration):
|
|
|
292
283
|
dev_logger.info(f'Using {config.auth_config.__class__.__name__} auth configuration')
|
|
293
284
|
auth_registry.register('auth_config', config.auth_config)
|
|
294
285
|
|
|
286
|
+
# Handle pages/router compatibility
|
|
287
|
+
if len(config.pages) > 0:
|
|
288
|
+
if len(config.router.children) > 0:
|
|
289
|
+
raise ValueError(
|
|
290
|
+
'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.'
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
eng_logger.info('Registering template')
|
|
295
|
+
|
|
296
|
+
# Add the default templates
|
|
297
|
+
if config.template == 'default':
|
|
298
|
+
eng_logger.info('Registering template "default"')
|
|
299
|
+
config.router = convert_template_to_router(default_template(config))
|
|
300
|
+
elif config.template == 'blank':
|
|
301
|
+
eng_logger.info('Registering template "blank"')
|
|
302
|
+
config.router = convert_template_to_router(blank_template(config))
|
|
303
|
+
elif config.template == 'top':
|
|
304
|
+
eng_logger.info('Registering template "top"')
|
|
305
|
+
config.router = convert_template_to_router(top_template(config))
|
|
306
|
+
elif config.template == 'top-menu':
|
|
307
|
+
eng_logger.info('Registering template "top-menu"')
|
|
308
|
+
config.router = convert_template_to_router(top_menu_template(config))
|
|
309
|
+
else:
|
|
310
|
+
# Loop over user defined templates and add to the registry
|
|
311
|
+
for name, renderer in config.template_renderers.items():
|
|
312
|
+
if name == config.template:
|
|
313
|
+
eng_logger.info(f'Registering custom template "{name}"')
|
|
314
|
+
config.router = convert_template_to_router(renderer(config))
|
|
315
|
+
break
|
|
316
|
+
else:
|
|
317
|
+
raise ValueError(f'Unknown template renderer: {config.template}')
|
|
318
|
+
except Exception as e:
|
|
319
|
+
traceback.print_exc()
|
|
320
|
+
dev_logger.error(
|
|
321
|
+
'Something went wrong when building application template, there is most likely an issue in the application logic',
|
|
322
|
+
e,
|
|
323
|
+
)
|
|
324
|
+
sys.exit(1)
|
|
325
|
+
|
|
326
|
+
# Generate a new build_cache
|
|
295
327
|
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))
|
|
328
|
+
build_cache = BuildCache.from_config(config)
|
|
329
|
+
build_diff = build_cache.get_diff()
|
|
317
330
|
|
|
331
|
+
# Only build if there's pages to build, otherwise assume Dara is only used for API
|
|
332
|
+
if len(config.router.children) > 0:
|
|
333
|
+
dev_logger.debug(
|
|
334
|
+
'Building JS...',
|
|
335
|
+
extra={
|
|
336
|
+
'New build cache': build_cache.model_dump(),
|
|
337
|
+
'Difference from last cache': build_diff.model_dump(),
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
rebuild_js(build_cache, build_diff)
|
|
318
341
|
except Exception as e:
|
|
319
|
-
import traceback
|
|
320
|
-
|
|
321
342
|
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
|
-
)
|
|
343
|
+
dev_logger.error('Error building JS', error=e)
|
|
326
344
|
sys.exit(1)
|
|
327
345
|
|
|
328
346
|
# Root routes
|
|
@@ -352,42 +370,147 @@ def _start_application(config: Configuration):
|
|
|
352
370
|
start_pprof_server(port=profiling_port)
|
|
353
371
|
|
|
354
372
|
# Serve statics, only if we have any pages defined
|
|
355
|
-
if len(config.
|
|
356
|
-
app.mount('/static',
|
|
373
|
+
if len(config.router.children) > 0:
|
|
374
|
+
app.mount('/static', CacheStaticFiles(directory=config.static_files_dir), name='static')
|
|
357
375
|
|
|
358
376
|
# Mount Routers
|
|
359
377
|
app.include_router(auth_router, prefix='/api/auth')
|
|
360
378
|
app.include_router(core_api_router, prefix='/api/core')
|
|
361
379
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
380
|
+
if len(config.router.children) > 0:
|
|
381
|
+
BASE_DIR = Path(__file__).parent
|
|
382
|
+
jinja_templates = Jinja2Templates(directory=str(Path(BASE_DIR, 'jinja')))
|
|
383
|
+
jinja_templates.env.globals['vite_hmr_client'] = fastapi_vite_dara.vite_hmr_client
|
|
384
|
+
jinja_templates.env.globals['vite_asset'] = fastapi_vite_dara.vite_asset
|
|
385
|
+
jinja_templates.env.globals['static_url'] = fastapi_vite_dara.config.settings.static_url
|
|
386
|
+
jinja_templates.env.globals['base_url'] = os.getenv('DARA_BASE_URL', '')
|
|
387
|
+
jinja_templates.env.globals['entry'] = '_entry.tsx'
|
|
388
|
+
|
|
389
|
+
# Compile the router, executing all page functions etc
|
|
390
|
+
try:
|
|
391
|
+
config.router.compile()
|
|
392
|
+
except Exception as e:
|
|
393
|
+
traceback.print_exc()
|
|
394
|
+
dev_logger.error('Error compiling router', error=e)
|
|
395
|
+
sys.exit(1)
|
|
396
|
+
|
|
397
|
+
dev_logger.info('Registering pages:')
|
|
398
|
+
# TODO: convert this to use the logger?
|
|
399
|
+
config.router.print_route_tree()
|
|
400
|
+
route_map = config.router.to_route_map()
|
|
401
|
+
|
|
402
|
+
class ActionPayload(BaseModel):
|
|
403
|
+
uid: str
|
|
404
|
+
definition_uid: str
|
|
405
|
+
values: NormalizedPayload[Mapping[str, Any]]
|
|
406
|
+
|
|
407
|
+
class RouteDataRequestBody(BaseModel):
|
|
408
|
+
action_payloads: List[ActionPayload] = Field(default_factory=list)
|
|
409
|
+
ws_channel: Optional[str] = None
|
|
410
|
+
params: Dict[str, str] = Field(default_factory=dict)
|
|
411
|
+
|
|
412
|
+
@app.post('/api/core/route/{route_id}', dependencies=[Depends(verify_session)])
|
|
413
|
+
async def get_route_data(route_id: Annotated[str, PathParam()], body: Annotated[RouteDataRequestBody, Body()]):
|
|
414
|
+
# unquote route_id since it can be url-encoded
|
|
415
|
+
route_id = unquote(route_id)
|
|
416
|
+
|
|
417
|
+
# TODO: This will accept DV inputs etc and stream those back
|
|
418
|
+
route_data = route_map.get(route_id)
|
|
419
|
+
|
|
420
|
+
if route_data is None:
|
|
421
|
+
raise HTTPException(status_code=404, detail=f'Route {route_id} not found')
|
|
422
|
+
|
|
423
|
+
action_results: Dict[str, Any] = {}
|
|
424
|
+
|
|
425
|
+
if len(body.action_payloads) > 0:
|
|
426
|
+
store: CacheStore = utils_registry.get('Store')
|
|
427
|
+
task_mgr: TaskManager = utils_registry.get('TaskManager')
|
|
428
|
+
registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
|
|
429
|
+
|
|
430
|
+
# Ws channel can be null for top-level layouts rendered above the ws client
|
|
431
|
+
if body.ws_channel is not None:
|
|
432
|
+
WS_CHANNEL.set(body.ws_channel)
|
|
433
|
+
|
|
434
|
+
# Run actions in order to guarantee execution order
|
|
435
|
+
for action_payload in body.action_payloads:
|
|
436
|
+
action_def = await registry_mgr.get(action_registry, action_payload.definition_uid)
|
|
437
|
+
static_kwargs = await registry_mgr.get(static_kwargs_registry, action_payload.uid)
|
|
438
|
+
|
|
439
|
+
CURRENT_ACTION_ID.set(action_payload.uid)
|
|
440
|
+
values = denormalize(action_payload.values.data, action_payload.values.lookup)
|
|
441
|
+
try:
|
|
442
|
+
action_results[action_payload.uid] = await execute_action_sync(
|
|
443
|
+
action_def,
|
|
444
|
+
inp={'params': body.params, 'route': route_data.definition},
|
|
445
|
+
values=values,
|
|
446
|
+
static_kwargs=static_kwargs,
|
|
447
|
+
store=store,
|
|
448
|
+
task_mgr=task_mgr,
|
|
449
|
+
)
|
|
450
|
+
except BaseException as e:
|
|
451
|
+
assert route_data.definition is not None
|
|
452
|
+
route_path = route_data.definition.full_path
|
|
453
|
+
action_name = str(action_def.resolver)
|
|
454
|
+
raise HTTPException(
|
|
455
|
+
status_code=500,
|
|
456
|
+
detail={
|
|
457
|
+
'error': str(e),
|
|
458
|
+
'stacktrace': print_stacktrace(e),
|
|
459
|
+
'path': route_path,
|
|
460
|
+
'action_name': action_name,
|
|
461
|
+
},
|
|
462
|
+
) from e
|
|
463
|
+
|
|
464
|
+
normalized_template, lookup = normalize(jsonable_encoder(route_data.content))
|
|
465
|
+
return {'template': {'data': normalized_template, 'lookup': lookup}, 'action_results': action_results}
|
|
466
|
+
|
|
467
|
+
# Catch-all, must add after adding the last api route
|
|
468
|
+
@app.get('/api/{rest_of_path:path}')
|
|
469
|
+
async def not_found():
|
|
470
|
+
raise HTTPException(status_code=404, detail='API endpoint not found')
|
|
471
|
+
|
|
472
|
+
# Prepare static template data
|
|
473
|
+
template_data = {
|
|
474
|
+
'auth_components': config.auth_config.component_config.model_dump(),
|
|
475
|
+
'actions': action_def_registry.get_all().items(),
|
|
476
|
+
'components': {k: comp.model_dump(exclude={'func'}) for k, comp in component_registry.get_all().items()},
|
|
477
|
+
'application_name': get_settings().project_name,
|
|
478
|
+
'enable_devtools': config.enable_devtools,
|
|
479
|
+
'live_reload': config.live_reload,
|
|
480
|
+
# TODO: this will become some backendstore-variable instead, prepopulated in here
|
|
481
|
+
'theme': config.theme,
|
|
482
|
+
'title': config.title,
|
|
483
|
+
'context_components': config.context_components,
|
|
484
|
+
# For backwards compatibility
|
|
485
|
+
'powered_by_causalens': config.powered_by_causalens,
|
|
486
|
+
'router': config.router,
|
|
487
|
+
'build_mode': build_cache.build_config.mode,
|
|
488
|
+
'build_dev': build_cache.build_config.dev,
|
|
489
|
+
}
|
|
490
|
+
json_template_data = json.dumps(jsonable_encoder(template_data))
|
|
365
491
|
|
|
366
|
-
if len(config.pages) > 0:
|
|
367
|
-
dev_logger.info(f'Registering pages: [{", ".join(list(config.pages.keys()))}]')
|
|
368
492
|
# For any unmatched route then serve the app to the user if we have any pages to serve
|
|
369
493
|
# (Required for the chosen routing system in the UI)
|
|
370
494
|
|
|
495
|
+
context = {
|
|
496
|
+
'dara_data': json_template_data,
|
|
497
|
+
}
|
|
498
|
+
template_name = 'index.html'
|
|
499
|
+
|
|
371
500
|
# Auto-js mode - serve the built template with UMDs
|
|
372
501
|
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)
|
|
502
|
+
template_name = 'index_autojs.html'
|
|
503
|
+
context.update(build_autojs_template(build_cache, config))
|
|
380
504
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
505
|
+
@app.get('/{full_path:path}', include_in_schema=False, response_class=_TemplateResponse)
|
|
506
|
+
async def serve_app(request: Request):
|
|
507
|
+
return jinja_templates.TemplateResponse(request, template_name, context=context)
|
|
384
508
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return jinja_templates.TemplateResponse(request, 'index.html') # type: ignore
|
|
509
|
+
else:
|
|
510
|
+
# Catch-all, must be at the very end
|
|
511
|
+
@app.get('/api/{rest_of_path:path}')
|
|
512
|
+
async def not_found():
|
|
513
|
+
raise HTTPException(status_code=404, detail='API endpoint not found')
|
|
391
514
|
|
|
392
515
|
return app
|
|
393
516
|
|
|
@@ -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,109 @@
|
|
|
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: not implemented yet
|
|
55
|
+
# prefetch: Literal['none', 'intent', 'render', 'viewport'] = 'none'
|
|
56
|
+
# """
|
|
57
|
+
# Defines the data and module prefetching behavior for the link.
|
|
58
|
+
# - none — default, no prefetching
|
|
59
|
+
# - intent — prefetches when the user hovers or focuses the link
|
|
60
|
+
# - render — prefetches when the link renders
|
|
61
|
+
# - viewport — prefetches when the link is in the viewport, very useful for mobile
|
|
62
|
+
# """
|
|
63
|
+
|
|
64
|
+
relative: Literal['route', 'path'] = 'route'
|
|
65
|
+
"""
|
|
66
|
+
Defines the relative path behavior for the link.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
Link(to='..') # default, relative='route'
|
|
70
|
+
Link(to='..', relative='path')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Consider a route hierarchy where a parent route pattern is "blog" and a child route pattern is "blog/:slug/edit".
|
|
74
|
+
- 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".
|
|
75
|
+
- path — relative to the path so "..." will only remove one URL segment up to "/blog/:slug"
|
|
76
|
+
Note that index routes and layout routes do not have paths so they are not included in the relative path calculation.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
replace: bool = False
|
|
80
|
+
"""
|
|
81
|
+
Replaces the current entry in the history stack instead of pushing a new one.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
# with a history stack like this
|
|
85
|
+
A -> B
|
|
86
|
+
|
|
87
|
+
# normal link click pushes a new entry
|
|
88
|
+
A -> B -> C
|
|
89
|
+
|
|
90
|
+
# but with `replace`, B is replaced by C
|
|
91
|
+
A -> C
|
|
92
|
+
```
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
to: Union[str, RouterPath]
|
|
96
|
+
"""
|
|
97
|
+
Can be a string or RouterPath object
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
active_css: Annotated[Optional[Any], BeforeValidator(transform_raw_css)] = None
|
|
101
|
+
inactive_css: Annotated[Optional[Any], BeforeValidator(transform_raw_css)] = None
|
|
102
|
+
|
|
103
|
+
# TODO: add scroll restoration if it works?
|
|
104
|
+
|
|
105
|
+
def __init__(self, *children: ComponentInstance, **kwargs):
|
|
106
|
+
components = list(children)
|
|
107
|
+
if 'children' not in kwargs:
|
|
108
|
+
kwargs['children'] = components
|
|
109
|
+
super().__init__(**kwargs)
|