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