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
@@ -0,0 +1,887 @@
1
+ import inspect
2
+ import re
3
+ from abc import abstractmethod
4
+ from typing import Any, Callable, Dict, List, Literal, Optional, TypedDict, Union
5
+ from urllib.parse import quote
6
+ from uuid import uuid4
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ Field,
11
+ PrivateAttr,
12
+ SerializeAsAny,
13
+ SerializerFunctionWrapHandler,
14
+ field_validator,
15
+ model_serializer,
16
+ )
17
+
18
+ from dara.core.base_definitions import Action
19
+ from dara.core.definitions import ComponentInstance
20
+ from dara.core.interactivity import Variable
21
+ from dara.core.persistence import PersistenceStore # noqa: F401
22
+
23
+ from .dependency_graph import DependencyGraph
24
+
25
+ # Matches :param or :param? (captures the name without the colon)
26
+ PARAM_REGEX = re.compile(r':([\w-]+)\??')
27
+
28
+ # Matches a trailing * (wildcard)
29
+ STAR_REGEX = re.compile(r'\*$')
30
+
31
+
32
+ def find_patterns(path: str) -> List[str]:
33
+ """
34
+ Extract param names from a React-Router-style path.
35
+
36
+ Examples:
37
+ >>> find_patterns("/:foo/:bar")
38
+ ['foo', 'bar']
39
+ >>> find_patterns("/:foo/:bar?")
40
+ ['foo', 'bar']
41
+ >>> find_patterns("/foo/*")
42
+ ['*']
43
+ >>> find_patterns("/*")
44
+ ['*']
45
+ """
46
+ params = PARAM_REGEX.findall(path)
47
+
48
+ if STAR_REGEX.search(path):
49
+ params.append('*')
50
+
51
+ return params
52
+
53
+
54
+ def validate_full_path(value: str):
55
+ if value.startswith('/api'):
56
+ raise ValueError(f'/api is a reserved prefix, router paths cannot start with it - found "{value}"')
57
+
58
+ params = find_patterns(value)
59
+
60
+ seen_params = set()
61
+ for param in params:
62
+ if param in seen_params:
63
+ raise ValueError(
64
+ f'Duplicate path param found - found "{param}" more than once in "{value}". Param names must be unique'
65
+ )
66
+ seen_params.add(param)
67
+
68
+
69
+ class _PathParamStore(PersistenceStore):
70
+ """
71
+ Internal store for path parameters.
72
+ Should not be used directly, Variables with this store can only be used within
73
+ a page with matching path params.
74
+ """
75
+
76
+ param_name: str
77
+
78
+ async def init(self, variable: 'Variable'):
79
+ # noop
80
+ pass
81
+
82
+
83
+ class RouteData(BaseModel):
84
+ """
85
+ Data structure representing a route in the router
86
+ """
87
+
88
+ content: Optional[ComponentInstance] = None
89
+ on_load: Optional[Action] = None
90
+ definition: Optional['BaseRoute'] = None
91
+ dependency_graph: Optional[DependencyGraph] = Field(default=None, exclude=True)
92
+
93
+
94
+ class BaseRoute(BaseModel):
95
+ """
96
+ Base class for all route types.
97
+ """
98
+
99
+ case_sensitive: bool = False
100
+ """
101
+ Whether the route is case sensitive
102
+ """
103
+
104
+ id: Optional[str] = Field(default=None, pattern=r'^[a-zA-Z0-9-_]+$')
105
+ """
106
+ Unique identifier for the route.
107
+ Must only contain alphanumeric characters, dashes and underscores.
108
+ """
109
+
110
+ name: Optional[str] = None
111
+ """
112
+ Name of the route, used for window.title display. If not set, defaults to
113
+ the route path.
114
+ """
115
+
116
+ metadata: dict = Field(default_factory=dict, exclude=True)
117
+ """
118
+ Metadata for the route. This is used to store arbitrary data that can be used by the application.
119
+ """
120
+
121
+ on_load: SerializeAsAny[Optional[Action]] = Field(default=None)
122
+ """
123
+ Action to execute when the route is loaded.
124
+ Guaranteed to be executed before the route content is rendered.
125
+ """
126
+
127
+ uid: str = Field(default_factory=lambda: uuid4().hex, exclude=True)
128
+ """
129
+ Internal unique identifier for the route
130
+ """
131
+
132
+ compiled_data: Optional[RouteData] = Field(default=None, exclude=True, repr=False)
133
+ """
134
+ Internal compiled data for the route
135
+ """
136
+
137
+ _parent: Optional['BaseRoute'] = PrivateAttr(default=None)
138
+ """
139
+ Internal parent pointer for the route
140
+ """
141
+
142
+ @field_validator('id', mode='before')
143
+ @classmethod
144
+ def convert_id(cls, value: Any):
145
+ # will be failed by string validation
146
+ if not isinstance(value, str):
147
+ return value
148
+ # matches legacy page.name handling
149
+ return value.lower().strip().replace(' ', '-')
150
+
151
+ def _attach_to_parent(self, parent):
152
+ """Internal method to attach route to parent"""
153
+ self._parent = parent
154
+ # Recursively attach any existing children
155
+ children = getattr(self, 'children', None)
156
+ if children:
157
+ for child in children:
158
+ child._attach_to_parent(self)
159
+
160
+ @property
161
+ def parent(self):
162
+ """
163
+ Parent route of this route.
164
+ Note that for routes not yet attached to a router or parent, this will return None.
165
+ """
166
+ return self._parent
167
+
168
+ def get_identifier(self):
169
+ """
170
+ Get the unique identifier for the route.
171
+ If the route has an id, it will be returned. Otherwise, the route path and internal uid will be used.
172
+ """
173
+ if self.id:
174
+ return self.id
175
+
176
+ if path := self.full_path:
177
+ return path.replace('/', '_') + '_' + self.uid
178
+
179
+ raise ValueError('Identifier cannot be determined, route is not attached to a router')
180
+
181
+ def get_name(self):
182
+ """
183
+ Get the human-readable name of the route.
184
+ If the route has a name, it will be returned.
185
+ Otherwise, attempts to derive from content function name or generates from path.
186
+ """
187
+ if self.name:
188
+ return self.name
189
+
190
+ if content := getattr(self, 'content', None):
191
+ # If content is callable, use its name
192
+ if callable(content):
193
+ return content.__name__
194
+ # If content is ComponentInstance, generate name from path
195
+ elif isinstance(content, ComponentInstance):
196
+ path = self.full_path
197
+ if path and path != '/':
198
+ # Convert path to readable name (e.g., '/about/team' -> 'About Team')
199
+ return ' '.join(
200
+ word.capitalize() for word in path.strip('/').replace('-', ' ').replace('_', ' ').split('/')
201
+ )
202
+
203
+ return self.full_path or self.get_identifier()
204
+
205
+ @abstractmethod
206
+ def compile(self):
207
+ """
208
+ Compile the route, validating it and generating compiled data
209
+ """
210
+ path = self.full_path
211
+ if path:
212
+ validate_full_path(path)
213
+
214
+ @property
215
+ def route_data(self) -> RouteData:
216
+ """
217
+ Compiled route data for this route.
218
+ Raises ValueError if the route has not been compiled yet.
219
+ """
220
+ if self.compiled_data is None:
221
+ raise ValueError(f'Route {self.full_path} has not been compiled')
222
+ return self.compiled_data
223
+
224
+ @property
225
+ def full_path(self) -> Optional[str]:
226
+ """
227
+ Compute the full path from root to this route.
228
+ Returns None if route is not attached to a router.
229
+ """
230
+ if not hasattr(self, '_parent') or self._parent is None:
231
+ return None # Route is not attached
232
+
233
+ path_segments = []
234
+ current = self
235
+
236
+ while current is not None:
237
+ route_path = getattr(current, 'path', None)
238
+ if route_path:
239
+ path_segments.append(route_path)
240
+ current = getattr(current, '_parent', None)
241
+
242
+ path_segments.reverse()
243
+ full = '/' + '/'.join(path_segments) if path_segments else '/'
244
+ return full.replace('//', '/')
245
+
246
+ @property
247
+ def is_attached(self) -> bool:
248
+ """Check if this route is attached to a router"""
249
+ return hasattr(self, '_parent') and self._parent is not None
250
+
251
+ @model_serializer(mode='wrap')
252
+ def ser_model(self, nxt: SerializerFunctionWrapHandler) -> dict:
253
+ props = nxt(self)
254
+ props['__typename'] = self.__class__.__name__
255
+ props['full_path'] = self.full_path
256
+ props['id'] = quote(self.get_identifier())
257
+ props['name'] = self.get_name()
258
+
259
+ assert self.compiled_data is not None
260
+ props['dependency_graph'] = self.compiled_data.dependency_graph
261
+ return props
262
+
263
+
264
+ class HasChildRoutes(BaseModel):
265
+ """
266
+ Mixin class for objects that can have child routes.
267
+ """
268
+
269
+ children: SerializeAsAny[List['BaseRoute']] = Field(default_factory=list)
270
+ """
271
+ List of child routes.
272
+ Should not be set directly. Use `set_children` or one of the `add_*` methods instead.
273
+ """
274
+
275
+ def __init__(self, *children: list[BaseRoute], **kwargs):
276
+ routes = list(children)
277
+ if 'children' not in kwargs:
278
+ kwargs['children'] = routes
279
+ super().__init__(**kwargs)
280
+
281
+ def model_post_init(self, __context):
282
+ """Automatically attach all children after construction"""
283
+ for child in self.children:
284
+ child._attach_to_parent(self)
285
+
286
+ def set_children(self, children: list[BaseRoute]):
287
+ """
288
+ Set the children of the router.
289
+
290
+ :param children: list of child routes
291
+ """
292
+ self.children = children
293
+ for child in self.children:
294
+ child._attach_to_parent(self)
295
+
296
+ def add_page(
297
+ self,
298
+ *,
299
+ path: str,
300
+ content: Union[Callable[..., ComponentInstance], ComponentInstance],
301
+ case_sensitive: bool = False,
302
+ name: Optional[str] = None,
303
+ id: Optional[str] = None,
304
+ metadata: Optional[dict] = None,
305
+ on_load: Optional[Action] = None,
306
+ ):
307
+ """
308
+ Standard route with a unique URL segment and content to render
309
+
310
+ :param path: URL segment
311
+ :param content: component to render
312
+ :param case_sensitive: whether the route is case sensitive
313
+ :param name: unique name for the route, used for window.name display. If not set, defaults to the route path.
314
+ :param id: unique id for the route
315
+ :param metadata: metadata for the route
316
+ """
317
+ if metadata is None:
318
+ metadata = {}
319
+
320
+ route = PageRoute(
321
+ path=path,
322
+ content=content,
323
+ case_sensitive=case_sensitive,
324
+ name=name,
325
+ id=id,
326
+ metadata=metadata,
327
+ on_load=on_load,
328
+ )
329
+ route._attach_to_parent(self)
330
+ self.children.append(route)
331
+ return route
332
+
333
+ def add_layout(
334
+ self,
335
+ *,
336
+ content: Union[Callable[..., ComponentInstance], ComponentInstance],
337
+ case_sensitive: bool = False,
338
+ id: Optional[str] = None,
339
+ metadata: Optional[dict] = None,
340
+ on_load: Optional[Action] = None,
341
+ ):
342
+ """
343
+ Layout route creates a route with a layout component to render without adding any segments to the URL
344
+
345
+ ```python
346
+ from dara.core import ConfigurationBuilder
347
+
348
+ config = ConfigurationBuilder()
349
+
350
+ # no path on this parent route, just the layout
351
+ marketing_group = config.router.add_layout(content=MarketingLayout)
352
+ marketing_group.add_index(content=MarketingHome)
353
+ marketing_group.add_page(path='contact', content=MarketingContact)
354
+
355
+ projects_group = config.router.add_prefix(path='projects')
356
+ projects_group.add_index(content=ProjectsHome)
357
+ # again, no path, just a component for the layout
358
+ single_project_group = projects_group.add_layout(content=ProjectLayout)
359
+ single_project_group.add_page(path=':pid', content=Project)
360
+ single_project_group.add_page(path=':pid/edit', content=EditProject)
361
+ ```
362
+
363
+ Note that:
364
+ - MarketingHome and MarketingContact will be rendered into the MarketingLayout outlet
365
+ - Project and EditProject will be rendered into the ProjectLayout outlet while ProjectsHome will not.
366
+
367
+ :param content: layout component to render
368
+ :param case_sensitive: whether the route is case sensitive
369
+ :param id: unique id for the route
370
+ :param metadata: metadata for the route
371
+ """
372
+ if metadata is None:
373
+ metadata = {}
374
+
375
+ route = LayoutRoute(
376
+ content=content,
377
+ case_sensitive=case_sensitive,
378
+ id=id,
379
+ metadata=metadata,
380
+ on_load=on_load,
381
+ )
382
+ route._attach_to_parent(self)
383
+ self.children.append(route)
384
+ return route
385
+
386
+ def add_prefix(
387
+ self,
388
+ *,
389
+ path: str,
390
+ case_sensitive: bool = False,
391
+ id: Optional[str] = None,
392
+ metadata: Optional[dict] = None,
393
+ on_load: Optional[Action] = None,
394
+ ):
395
+ """
396
+ Prefix route creates a group of routes with a common prefix without a specific component to render
397
+
398
+ ```python
399
+ from dara.core import ConfigurationBuilder
400
+
401
+ config = ConfigurationBuilder()
402
+
403
+ # no component, just a prefix
404
+ projects_group = config.router.add_prefix(path='projects')
405
+ projects_group.add_index(content=ProjectsHome)
406
+ projects_group.add_page(path=':pid', content=ProjectHome)
407
+ projects_group.add_page(path=':pid/edit', content=ProjectEdit)
408
+ ```
409
+
410
+ This creates the routes /projects, /projects/:pid, and /projects/:pid/edit without introducing a layout component.
411
+
412
+ :param path: prefix path
413
+ :param case_sensitive: whether the route is case sensitive
414
+ :param id: unique id for the route
415
+ :param metadata: metadata for the route
416
+ """
417
+ if metadata is None:
418
+ metadata = {}
419
+ route = PrefixRoute(path=path, case_sensitive=case_sensitive, id=id, metadata=metadata, on_load=on_load)
420
+ route._attach_to_parent(self)
421
+ self.children.append(route)
422
+ return route
423
+
424
+ def add_index(
425
+ self,
426
+ *,
427
+ content: Union[Callable[..., ComponentInstance], ComponentInstance],
428
+ case_sensitive: bool = False,
429
+ name: Optional[str] = None,
430
+ id: Optional[str] = None,
431
+ metadata: Optional[dict] = None,
432
+ on_load: Optional[Action] = None,
433
+ ):
434
+ """
435
+ Index routes render into their parent's Outlet() at their parent URL (like a default child route).
436
+ Index routes can't have children.
437
+
438
+ ```python
439
+ from dara.core import ConfigurationBuilder
440
+
441
+ config = ConfigurationBuilder()
442
+
443
+ # renders at '/'
444
+ config.router.add_index(content=Home)
445
+
446
+ dashboard_group = config.router.add_page(path='dashboard', content=Dashboard)
447
+ # renders at '/dashboard'
448
+ dashboard_group.add_index(content=DashboardHome)
449
+ dashboard_group.add_page(path='settings', content=DashboardSettings)
450
+ ```
451
+
452
+ :param case_sensitive: whether the route is case sensitive
453
+ :param name: unique name for the route, used for window.name display. If not set, defaults to the route path.
454
+ :param id: unique id for the route
455
+ :param metadata: metadata for the route
456
+ """
457
+ if metadata is None:
458
+ metadata = {}
459
+ route = IndexRoute(
460
+ content=content, case_sensitive=case_sensitive, id=id, metadata=metadata, on_load=on_load, name=name
461
+ )
462
+ route._attach_to_parent(self)
463
+ self.children.append(route)
464
+ return route
465
+
466
+
467
+ class IndexRoute(BaseRoute):
468
+ """
469
+ Index routes render into their parent's Outlet() at their parent URL (like a default child route).
470
+ Index routes can't have children.
471
+
472
+ ```python
473
+ from dara.core import ConfigurationBuilder
474
+
475
+ config = ConfigurationBuilder()
476
+
477
+ # renders at '/'
478
+ config.router.add_index(content=Home)
479
+
480
+ dashboard_group = config.router.add_page(path='dashboard', content=Dashboard)
481
+ # renders at '/dashboard'
482
+ dashboard_group.add_index(content=DashboardHome)
483
+ dashboard_group.add_page(path='settings', content=DashboardSettings)
484
+ ```
485
+ """
486
+
487
+ index: Literal[True] = True
488
+ content: Union[Callable[..., ComponentInstance], ComponentInstance] = Field(exclude=True)
489
+
490
+ def compile(self):
491
+ super().compile()
492
+ content = _execute_route_func(self.content, self.full_path)
493
+
494
+ # Analyze component dependencies
495
+ dependency_graph = DependencyGraph.from_component(content)
496
+
497
+ self.compiled_data = RouteData(
498
+ content=content, on_load=self.on_load, definition=self, dependency_graph=dependency_graph
499
+ )
500
+
501
+
502
+ class PageRoute(BaseRoute, HasChildRoutes):
503
+ """
504
+ Standard route with a unique URL segment and content to render.
505
+ """
506
+
507
+ path: str
508
+ content: Union[Callable[..., ComponentInstance], ComponentInstance] = Field(exclude=True)
509
+
510
+ def __init__(self, *children: BaseRoute, **kwargs):
511
+ routes = list(children)
512
+ if 'children' not in kwargs:
513
+ kwargs['children'] = routes
514
+ super().__init__(**kwargs)
515
+
516
+ def compile(self):
517
+ super().compile()
518
+ content = _execute_route_func(self.content, self.full_path)
519
+
520
+ # Analyze component dependencies
521
+ dependency_graph = DependencyGraph.from_component(content)
522
+
523
+ self.compiled_data = RouteData(
524
+ content=content, on_load=self.on_load, definition=self, dependency_graph=dependency_graph
525
+ )
526
+ for child in self.children:
527
+ child.compile()
528
+
529
+
530
+ class LayoutRoute(BaseRoute, HasChildRoutes):
531
+ """
532
+ Layout route creates a route with a layout component to render without adding any segments to the URL
533
+
534
+ ```python
535
+ from dara.core import ConfigurationBuilder
536
+
537
+
538
+ config = ConfigurationBuilder()
539
+
540
+ # no path on this parent route, just the layout
541
+ marketing_group = config.router.add_layout(content=MarketingLayout)
542
+ marketing_group.add_index(content=MarketingHome)
543
+ marketing_group.add_page(path='contact', content=MarketingContact)
544
+
545
+ projects_group = config.router.add_prefix(path='projects')
546
+ projects_group.add_index(content=ProjectsHome)
547
+ # again, no path, just a component for the layout
548
+ single_project_group = projects_group.add_layout(content=ProjectLayout)
549
+ single_project_group.add_page(path=':pid', content=Project)
550
+ single_project_group.add_page(path=':pid/edit', content=EditProject)
551
+ ```
552
+
553
+ Note that:
554
+ - Home and Contact will be rendered into the MarketingLayout outlet
555
+ - Project and EditProject will be rendered into the ProjectLayout outlet while ProjectsHome will not.
556
+ """
557
+
558
+ content: Union[Callable[..., ComponentInstance], ComponentInstance] = Field(exclude=True)
559
+
560
+ def __init__(self, *children: BaseRoute, **kwargs):
561
+ routes = list(children)
562
+ if 'children' not in kwargs:
563
+ kwargs['children'] = routes
564
+ super().__init__(**kwargs)
565
+
566
+ def compile(self):
567
+ super().compile()
568
+
569
+ content = _execute_route_func(self.content, self.full_path)
570
+ dependency_graph = DependencyGraph.from_component(content)
571
+
572
+ self.compiled_data = RouteData(
573
+ on_load=self.on_load, content=content, definition=self, dependency_graph=dependency_graph
574
+ )
575
+ for child in self.children:
576
+ child.compile()
577
+
578
+
579
+ class PrefixRoute(BaseRoute, HasChildRoutes):
580
+ """
581
+ Prefix route creates a group of routes with a common prefix without a specific component to render
582
+
583
+ ```python
584
+ from dara.core import ConfigurationBuilder
585
+
586
+ config = ConfigurationBuilder()
587
+
588
+ # no component, just a prefix
589
+ projects_group = config.router.add_prefix(path='projects')
590
+ projects_group.add_index(content=ProjectsHome)
591
+ projects_group.add_page(path=':pid', content=ProjectHome)
592
+ projects_group.add_page(path=':pid/edit', content=ProjectEdit)
593
+ ```
594
+
595
+ This creates the routes /projects, /projects/:pid, and /projects/:pid/edit without introducing a layout component.
596
+ """
597
+
598
+ path: str
599
+
600
+ def __init__(self, *children: BaseRoute, **kwargs):
601
+ routes = list(children)
602
+ if 'children' not in kwargs:
603
+ kwargs['children'] = routes
604
+ super().__init__(**kwargs)
605
+
606
+ def compile(self):
607
+ super().compile()
608
+ self.compiled_data = RouteData(on_load=self.on_load, definition=self)
609
+ for child in self.children:
610
+ child.compile()
611
+
612
+
613
+ class Router(HasChildRoutes):
614
+ """
615
+ Router is the main class for defining routes in a Dara application.
616
+ You can choose to construct a Router with the object API or a fluent API, depending on your needs and preference.
617
+
618
+ Fluent API (building routes gradually using methods):
619
+
620
+ ```python
621
+ from dara.core import ConfigurationBuilder
622
+
623
+ config = ConfigurationBuilder()
624
+
625
+ # Add home and public pages
626
+ config.router.add_index(content=HomePage)
627
+ config.router.add_page(path='about', content=AboutPage)
628
+
629
+ # Add a layout that wraps authenticated routes
630
+ dashboard_layout = config.router.add_layout(content=DashboardLayout)
631
+ dashboard_layout.add_page(path='dashboard', content=DashboardHome)
632
+ dashboard_layout.add_page(path='profile', content=UserProfile)
633
+
634
+ # Add a prefix group for blog routes
635
+ blog_group = config.router.add_prefix(path='blog')
636
+ blog_group.add_index(content=BlogHome) # renders at '/blog'
637
+ blog_group.add_page(path='post/:id', content=BlogPost) # renders at '/blog/post/:id'
638
+ ```
639
+
640
+ Object API (defining children directly):
641
+
642
+ ```python
643
+ from dara.core import ConfigurationBuilder
644
+ from dara.core.router import IndexRoute, PageRoute, LayoutRoute, PrefixRoute
645
+
646
+ config = ConfigurationBuilder()
647
+ config.router.set_children([
648
+ # Home page
649
+ IndexRoute(content=HomePage),
650
+
651
+ # Public pages
652
+ PageRoute(path='about', content=AboutPage),
653
+
654
+ # Layout that wraps authenticated routes
655
+ LayoutRoute(
656
+ content=DashboardLayout,
657
+ children=[
658
+ PageRoute(path='dashboard', content=DashboardHome),
659
+ PageRoute(path='profile', content=UserProfile)
660
+ ]
661
+ ),
662
+
663
+ # Prefix group for blog routes
664
+ PrefixRoute(
665
+ path='blog',
666
+ children=[
667
+ IndexRoute(content=BlogHome), # renders at '/blog'
668
+ PageRoute(path='post/:id', content=BlogPost) # renders at '/blog/post/:id'
669
+ ]
670
+ )
671
+ ])
672
+ ```
673
+
674
+ Both approaches create the same routing structure. The fluent API is more readable for
675
+ building routes step by step, while the object API is more declarative and compact.
676
+ """
677
+
678
+ def __init__(self, *children: BaseRoute, **kwargs):
679
+ routes = list(children)
680
+ if 'children' not in kwargs:
681
+ kwargs['children'] = routes
682
+ super().__init__(**kwargs)
683
+
684
+ def compile(self):
685
+ """
686
+ Compile the route tree into a data structure ready for matching:
687
+ - executes all page functions
688
+ - validates route paths
689
+ """
690
+ for child in self.children:
691
+ child.compile()
692
+
693
+ def to_route_map(self) -> Dict[str, RouteData]:
694
+ """
695
+ Convert the route tree into a dictionary of route data keyed by unique route identifiers.
696
+ """
697
+ routes: Dict[str, RouteData] = {}
698
+
699
+ def _walk(route: BaseRoute):
700
+ identifier = route.get_identifier()
701
+
702
+ routes[identifier] = route.route_data
703
+
704
+ if isinstance(route, HasChildRoutes):
705
+ for child in route.children:
706
+ _walk(child)
707
+
708
+ for route in self.children:
709
+ _walk(route)
710
+
711
+ return routes
712
+
713
+ class NavigableRoute(TypedDict):
714
+ path: str
715
+ name: str
716
+ id: str
717
+ metadata: dict
718
+
719
+ def get_navigable_routes(self) -> List[NavigableRoute]:
720
+ """
721
+ Get a flattened list of all navigable routes (PageRoute and IndexRoute only).
722
+
723
+ This method filters out layout and prefix routes to return only routes that
724
+ represent actual pages users can navigate to, making it perfect for building menus.
725
+
726
+ Returns:
727
+ List of dictionaries containing route information:
728
+ - path: Full URL path
729
+ - name: Route name (for display)
730
+ - id: Route identifier
731
+ - metadata: Route metadata
732
+ """
733
+ navigable_routes = []
734
+
735
+ def _collect_navigable(route: BaseRoute):
736
+ # Only include routes that represent actual navigable pages
737
+ if isinstance(route, (PageRoute, IndexRoute)):
738
+ route_info = {
739
+ 'path': route.full_path,
740
+ 'name': route.get_name(),
741
+ 'id': route.get_identifier(),
742
+ 'metadata': route.metadata,
743
+ }
744
+ navigable_routes.append(route_info)
745
+
746
+ # Recursively process children
747
+ if isinstance(route, HasChildRoutes):
748
+ for child in route.children:
749
+ _collect_navigable(child)
750
+
751
+ for route in self.children:
752
+ _collect_navigable(route)
753
+
754
+ return navigable_routes
755
+
756
+ def print_route_tree(self):
757
+ """
758
+ Print a visual representation of the route tree showing:
759
+ - Route hierarchy with indentation
760
+ - Full paths for each route
761
+ - Content/layout functions
762
+ - Index routes marked with (index)
763
+
764
+ Example output:
765
+ ```
766
+ Router
767
+ ├─ / (index) [HomePage]
768
+ ├─ /about [AboutPage]
769
+ ├─ <MarketingLayout>
770
+ │ ├─ / (index) [MarketingHome]
771
+ │ └─ /contact [MarketingContact]
772
+ └─ /my-api/
773
+ ├─ /my-api/users [UsersPage]
774
+ └─ /my-api/posts/:id [PostDetail]
775
+ ```
776
+ """
777
+ print('Router')
778
+ self._print_routes(self.children, prefix='')
779
+
780
+ def _print_routes(self, routes: List['BaseRoute'], prefix: str = ''):
781
+ """Helper method to recursively print route tree structure"""
782
+
783
+ def _format_content(content: Union[Callable[..., ComponentInstance], ComponentInstance]):
784
+ if isinstance(content, ComponentInstance):
785
+ return content.__class__.__name__
786
+ return content.__name__
787
+
788
+ for i, route in enumerate(routes):
789
+ is_last = i == len(routes) - 1
790
+
791
+ # Determine the tree characters
792
+ if is_last:
793
+ current_prefix = prefix + '└─ '
794
+ next_prefix = prefix + ' '
795
+ else:
796
+ current_prefix = prefix + '├─ '
797
+ next_prefix = prefix + '│ '
798
+
799
+ # Build the route description based on route type
800
+ route_content = getattr(route, 'content', None)
801
+ route_path = getattr(route, 'path', None)
802
+
803
+ if isinstance(route, IndexRoute):
804
+ # Index route: show path with (index) marker
805
+ full_path = route.full_path or '/'
806
+ route_info = f'{full_path} (index)'
807
+ if route_content:
808
+ content_name = _format_content(route_content)
809
+ route_info += f' [{content_name}]'
810
+ elif isinstance(route, LayoutRoute):
811
+ # Layout route: show in angle brackets
812
+ if route_content:
813
+ content_name = _format_content(route_content)
814
+ route_info = f'<{content_name}>'
815
+ else:
816
+ route_info = '<Layout>'
817
+ elif isinstance(route, PrefixRoute):
818
+ # Prefix route: show path with trailing slash
819
+ full_path = route.full_path or f'/{route_path}'
820
+ if not full_path.endswith('/'):
821
+ full_path += '/'
822
+ route_info = full_path
823
+ else:
824
+ # Page route: show path and content
825
+ full_path = route.full_path or f'/{route_path}'
826
+ route_info = full_path
827
+ if route_content:
828
+ content_name = _format_content(route_content)
829
+ route_info += f' [{content_name}]'
830
+
831
+ print(current_prefix + route_info)
832
+
833
+ # Recursively print children if they exist
834
+ route_children = getattr(route, 'children', None)
835
+ if route_children:
836
+ self._print_routes(route_children, next_prefix)
837
+
838
+
839
+ def _execute_route_func(
840
+ content: Union[Callable[..., ComponentInstance], ComponentInstance], path: Optional[str]
841
+ ) -> ComponentInstance:
842
+ """
843
+ Executes a route function or returns a ComponentInstance directly.
844
+ For callables, injects path params into the function signature based on patterns in the path.
845
+ For ComponentInstance objects, returns them directly (ignoring any path params).
846
+ """
847
+ assert path is not None, 'Path should not be None, internal error'
848
+
849
+ # If content is already a ComponentInstance, return it directly
850
+ if isinstance(content, ComponentInstance):
851
+ return content
852
+
853
+ # Handle callable case (existing logic)
854
+ path_params = find_patterns(path)
855
+ kwargs = {}
856
+ signature = inspect.signature(content)
857
+
858
+ for name, param in signature.parameters.items():
859
+ typ = param.annotation
860
+
861
+ if name in {'self', 'cls'}:
862
+ continue
863
+
864
+ # Reserved name 'splat' for the '*' param
865
+ if name == 'splat' and '*' in path_params:
866
+ kwargs[name] = Variable(store=_PathParamStore(param_name='*'))
867
+ continue
868
+
869
+ if name not in path_params:
870
+ raise ValueError(
871
+ f'Invalid page function signature. Kwarg "{name}: {typ}" found but param ":{name}" is missing in path "{path}"'
872
+ )
873
+ if not (inspect.isclass(typ) and issubclass(typ, Variable)):
874
+ raise ValueError(
875
+ f'Invalid page function signature. Kwarg "{name}" found with invalid signature "{type}". Page functions can only accept kwargs annotated with "Variable" corresponding to path params defined on the route'
876
+ )
877
+ kwargs[name] = Variable(store=_PathParamStore(param_name=name))
878
+ return content(**kwargs)
879
+
880
+
881
+ # required to make pydantic happy
882
+ IndexRoute.model_rebuild()
883
+ PageRoute.model_rebuild()
884
+ LayoutRoute.model_rebuild()
885
+ PrefixRoute.model_rebuild()
886
+ RouteData.model_rebuild()
887
+ Router.model_rebuild()