dara-core 1.20.3__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.3.dist-info → dara_core-1.21.0.dist-info}/METADATA +10 -10
- {dara_core-1.20.3.dist-info → dara_core-1.21.0.dist-info}/RECORD +28 -22
- {dara_core-1.20.3.dist-info → dara_core-1.21.0.dist-info}/LICENSE +0 -0
- {dara_core-1.20.3.dist-info → dara_core-1.21.0.dist-info}/WHEEL +0 -0
- {dara_core-1.20.3.dist-info → dara_core-1.21.0.dist-info}/entry_points.txt +0 -0
|
@@ -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()
|