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
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 create_router, error_decorator
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
- if len(config.pages) > 0:
98
- BASE_DIR = Path(__file__).parent
99
- jinja_templates = Jinja2Templates(directory=str(Path(BASE_DIR, 'jinja')))
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
- eng_logger.info('Registering template')
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.pages) > 0:
356
- app.mount('/static', StaticFiles(directory=config.static_files_dir), name='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
- @app.get('/api/{rest_of_path:path}')
363
- async def not_found():
364
- raise HTTPException(status_code=404, detail='API endpoint not found')
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
- # Load template
374
- template_path = os.path.join(Path(BASE_DIR, 'jinja'), 'index_autojs.html') # type: ignore
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
- @app.get('/{full_path:path}', include_in_schema=False, response_class=HTMLResponse)
382
- async def serve_app(request: Request): # pyright: ignore[reportRedeclaration]
383
- return HTMLResponse(template)
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
- else:
386
- # Otherwise serve the Vite template
387
-
388
- @app.get('/{full_path:path}', include_in_schema=False, response_class=_TemplateResponse)
389
- async def serve_app(request: Request): # pyright: ignore[reportRedeclaration]
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,5 @@
1
+ # ruff: noqa: F403
2
+ from .compat import *
3
+ from .components import *
4
+ from .dependency_graph import *
5
+ from .router import *
@@ -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)