pulse-framework 0.1.62__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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/routing.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TypedDict, cast, override
|
|
5
|
+
|
|
6
|
+
from pulse.component import Component
|
|
7
|
+
from pulse.env import env
|
|
8
|
+
from pulse.reactive_extensions import ReactiveDict
|
|
9
|
+
|
|
10
|
+
# angle brackets cannot appear in a regular URL path, this ensures no name conflicts
|
|
11
|
+
LAYOUT_INDICATOR = "<layout>"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PathParameters:
|
|
16
|
+
"""
|
|
17
|
+
Represents the parameters extracted from a URL path.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
21
|
+
splat: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PathSegment:
|
|
25
|
+
is_splat: bool
|
|
26
|
+
is_optional: bool
|
|
27
|
+
is_dynamic: bool
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
def __init__(self, part: str):
|
|
31
|
+
if not part:
|
|
32
|
+
raise InvalidRouteError("Route path segment cannot be empty.")
|
|
33
|
+
|
|
34
|
+
self.is_splat = part == "*"
|
|
35
|
+
self.is_optional = part.endswith("?")
|
|
36
|
+
value = part[:-1] if self.is_optional else part
|
|
37
|
+
self.is_dynamic = value.startswith(":")
|
|
38
|
+
self.name = value[1:] if self.is_dynamic else value
|
|
39
|
+
|
|
40
|
+
# Validate characters
|
|
41
|
+
# The value to validate is the part without ':', '?', or being a splat
|
|
42
|
+
if not self.is_splat and not PATH_SEGMENT_REGEX.match(self.name):
|
|
43
|
+
raise InvalidRouteError(
|
|
44
|
+
f"Path segment '{part}' contains invalid characters."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@override
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"PathSegment('{self.name}', dynamic={self.is_dynamic}, optional={self.is_optional}, splat={self.is_splat})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# According to RFC 3986, a path segment can contain "pchar" characters, which includes:
|
|
53
|
+
# - Unreserved characters: A-Z a-z 0-9 - . _ ~
|
|
54
|
+
# - Sub-delimiters: ! $ & ' ( ) * + , ; =
|
|
55
|
+
# - And ':' and '@'
|
|
56
|
+
# - Percent-encoded characters like %20 are also allowed.
|
|
57
|
+
PATH_SEGMENT_REGEX = re.compile(r"^([a-zA-Z0-9\-._~!$&'()*+,;=:@]|%[0-9a-fA-F]{2})*$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_route_path(path: str) -> list[PathSegment]:
|
|
61
|
+
if path.startswith("/"):
|
|
62
|
+
path = path[1:]
|
|
63
|
+
if path.endswith("/"):
|
|
64
|
+
path = path[:-1]
|
|
65
|
+
|
|
66
|
+
if not path:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
parts = path.split("/")
|
|
70
|
+
segments: list[PathSegment] = []
|
|
71
|
+
for i, part in enumerate(parts):
|
|
72
|
+
segment = PathSegment(part)
|
|
73
|
+
if segment.is_splat and i != len(parts) - 1:
|
|
74
|
+
raise InvalidRouteError(
|
|
75
|
+
f"Splat segment '*' can only be at the end of path '{path}'."
|
|
76
|
+
)
|
|
77
|
+
segments.append(segment)
|
|
78
|
+
return segments
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Normalize to react-router's convention: no leading and trailing slashes. Empty
|
|
82
|
+
# string interpreted as the root.
|
|
83
|
+
def ensure_relative_path(path: str):
|
|
84
|
+
if path.startswith("/"):
|
|
85
|
+
path = path[1:]
|
|
86
|
+
if path.endswith("/"):
|
|
87
|
+
path = path[:-1]
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ensure_absolute_path(path: str):
|
|
92
|
+
if not path.startswith("/"):
|
|
93
|
+
path = "/" + path
|
|
94
|
+
return path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---- Shared helpers ----------------------------------------------------------
|
|
98
|
+
def segments_are_dynamic(segments: list[PathSegment]) -> bool:
|
|
99
|
+
"""Return True if any segment is dynamic, optional, or a catch-all."""
|
|
100
|
+
return any(s.is_dynamic or s.is_optional or s.is_splat for s in segments)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _sanitize_filename(path: str) -> str:
|
|
104
|
+
"""Replace Windows-invalid characters in filenames with safe alternatives."""
|
|
105
|
+
import hashlib
|
|
106
|
+
|
|
107
|
+
# Split path into segments to handle each part individually
|
|
108
|
+
segments = path.split("/")
|
|
109
|
+
sanitized_segments: list[str] = []
|
|
110
|
+
|
|
111
|
+
for segment in segments:
|
|
112
|
+
if not segment:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Check if segment contains Windows-invalid characters
|
|
116
|
+
invalid_chars = '<>:"|?*'
|
|
117
|
+
has_invalid = any(char in segment for char in invalid_chars)
|
|
118
|
+
|
|
119
|
+
if has_invalid:
|
|
120
|
+
# Create a collision-safe filename by replacing invalid chars and adding hash
|
|
121
|
+
# Remove extension temporarily for hashing
|
|
122
|
+
name, ext = segment.rsplit(".", 1) if "." in segment else (segment, "")
|
|
123
|
+
|
|
124
|
+
# Replace invalid characters with underscores
|
|
125
|
+
sanitized_name = name
|
|
126
|
+
for char in invalid_chars:
|
|
127
|
+
sanitized_name = sanitized_name.replace(char, "_")
|
|
128
|
+
|
|
129
|
+
# Add hash of original segment to prevent collisions
|
|
130
|
+
original_hash = hashlib.md5(segment.encode()).hexdigest()[:8]
|
|
131
|
+
sanitized_name = f"{sanitized_name}_{original_hash}"
|
|
132
|
+
|
|
133
|
+
# Reattach extension
|
|
134
|
+
segment = f"{sanitized_name}.{ext}" if ext else sanitized_name
|
|
135
|
+
|
|
136
|
+
sanitized_segments.append(segment)
|
|
137
|
+
|
|
138
|
+
return "/".join(sanitized_segments)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def route_or_ancestors_have_dynamic(node: "Route | Layout") -> bool:
|
|
142
|
+
"""Check whether this node or any ancestor Route contains dynamic segments."""
|
|
143
|
+
current = node
|
|
144
|
+
while current is not None:
|
|
145
|
+
if isinstance(current, Route) and segments_are_dynamic(current.segments):
|
|
146
|
+
return True
|
|
147
|
+
current = current.parent
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Route:
|
|
152
|
+
"""Defines a route in the application.
|
|
153
|
+
|
|
154
|
+
Routes map URL paths to components that render the page content.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
path: URL path pattern (e.g., "/users/:id"). Supports static segments,
|
|
158
|
+
dynamic parameters (`:id`), optional parameters (`:id?`), and
|
|
159
|
+
catch-all segments (`*`).
|
|
160
|
+
render: Component function to render for this route. Must be a
|
|
161
|
+
zero-argument component.
|
|
162
|
+
children: Nested child routes. Child paths are relative to parent.
|
|
163
|
+
dev: If True, route is only included in dev mode. Defaults to False.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
path: Normalized relative path (no leading/trailing slashes).
|
|
167
|
+
segments: Parsed path segments.
|
|
168
|
+
render: Component to render.
|
|
169
|
+
children: Nested routes.
|
|
170
|
+
is_index: True if this is an index route (empty path).
|
|
171
|
+
is_dynamic: True if path contains dynamic or optional segments.
|
|
172
|
+
dev: Whether route is dev-only.
|
|
173
|
+
|
|
174
|
+
Path Syntax:
|
|
175
|
+
- Static: `/users` - Exact match
|
|
176
|
+
- Dynamic: `:id` - Named parameter (available in pathParams)
|
|
177
|
+
- Optional: `:id?` - Optional parameter
|
|
178
|
+
- Catch-all: `*` - Match remaining path (must be last segment)
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
```python
|
|
182
|
+
ps.Route(
|
|
183
|
+
"/users",
|
|
184
|
+
render=users_page,
|
|
185
|
+
children=[
|
|
186
|
+
ps.Route(":id", render=user_detail),
|
|
187
|
+
ps.Route(":id/edit", render=user_edit),
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
path: str
|
|
194
|
+
segments: list[PathSegment]
|
|
195
|
+
render: Component[[]]
|
|
196
|
+
children: Sequence["Route | Layout"]
|
|
197
|
+
is_index: bool
|
|
198
|
+
is_dynamic: bool
|
|
199
|
+
dev: bool
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
path: str,
|
|
204
|
+
render: Component[[]],
|
|
205
|
+
children: "Sequence[Route | Layout] | None" = None,
|
|
206
|
+
dev: bool = False,
|
|
207
|
+
):
|
|
208
|
+
self.path = ensure_relative_path(path)
|
|
209
|
+
self.segments = parse_route_path(path)
|
|
210
|
+
|
|
211
|
+
self.render = render
|
|
212
|
+
self.children = children or []
|
|
213
|
+
self.dev = dev
|
|
214
|
+
self.parent: Route | Layout | None = None
|
|
215
|
+
|
|
216
|
+
self.is_index = self.path == ""
|
|
217
|
+
self.is_dynamic = any(
|
|
218
|
+
seg.is_dynamic or seg.is_optional for seg in self.segments
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _path_list(self, include_layouts: bool = False) -> list[str]:
|
|
222
|
+
# Question marks cause problems for the URL of our prerendering requests +
|
|
223
|
+
# React-Router file loading
|
|
224
|
+
path = self.path.replace("?", "^")
|
|
225
|
+
if self.parent:
|
|
226
|
+
return [*self.parent._path_list(include_layouts=include_layouts), path] # pyright: ignore[reportPrivateUsage]
|
|
227
|
+
return [path]
|
|
228
|
+
|
|
229
|
+
def unique_path(self):
|
|
230
|
+
# Return absolute path with leading '/'
|
|
231
|
+
return ensure_absolute_path("/".join(self._path_list()))
|
|
232
|
+
|
|
233
|
+
def file_path(self) -> str:
|
|
234
|
+
path = "/".join(self._path_list(include_layouts=False))
|
|
235
|
+
if self.is_index:
|
|
236
|
+
path += "index"
|
|
237
|
+
path += ".jsx"
|
|
238
|
+
# Replace Windows-invalid characters in filenames
|
|
239
|
+
return _sanitize_filename(path)
|
|
240
|
+
|
|
241
|
+
@override
|
|
242
|
+
def __repr__(self) -> str:
|
|
243
|
+
return (
|
|
244
|
+
f"Route(path='{self.path or ''}'"
|
|
245
|
+
+ (f", children={len(self.children)}" if self.children else "")
|
|
246
|
+
+ ")"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def default_route_info(self) -> "RouteInfo":
|
|
250
|
+
"""Return a default RouteInfo for this route.
|
|
251
|
+
|
|
252
|
+
Only valid for non-dynamic routes. Raises InvalidRouteError if the
|
|
253
|
+
route contains any dynamic (":name"), optional ("segment?"), or
|
|
254
|
+
catch-all ("*") segments. Also rejects if any ancestor Route is dynamic.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
# Disallow optional, dynamic, and catch-all segments on self and ancestors
|
|
258
|
+
if route_or_ancestors_have_dynamic(self):
|
|
259
|
+
raise InvalidRouteError(
|
|
260
|
+
f"Cannot build default RouteInfo for dynamic route '{self.path}'."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
pathname = self.unique_path()
|
|
264
|
+
return {
|
|
265
|
+
"pathname": pathname,
|
|
266
|
+
"hash": "",
|
|
267
|
+
"query": "",
|
|
268
|
+
"queryParams": {},
|
|
269
|
+
"pathParams": {},
|
|
270
|
+
"catchall": [],
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def filter_layouts(path_list: list[str]):
|
|
275
|
+
return [p for p in path_list if p != LAYOUT_INDICATOR]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def replace_layout_indicator(path_list: list[str], value: str):
|
|
279
|
+
return [value if p == LAYOUT_INDICATOR else p for p in path_list]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Layout:
|
|
283
|
+
"""Wraps child routes with a shared layout component.
|
|
284
|
+
|
|
285
|
+
Layouts provide persistent UI elements (headers, sidebars, etc.) that
|
|
286
|
+
wrap child routes. The layout component must render an `Outlet` to
|
|
287
|
+
display the matched child route.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
render: Layout component function. Must render `ps.Outlet()` to
|
|
291
|
+
display child content.
|
|
292
|
+
children: Nested routes that will be wrapped by this layout.
|
|
293
|
+
dev: If True, layout is only included in dev mode. Defaults to False.
|
|
294
|
+
|
|
295
|
+
Attributes:
|
|
296
|
+
render: Layout component to render.
|
|
297
|
+
children: Nested routes.
|
|
298
|
+
dev: Whether layout is dev-only.
|
|
299
|
+
|
|
300
|
+
Example:
|
|
301
|
+
```python
|
|
302
|
+
@ps.component
|
|
303
|
+
def AppLayout():
|
|
304
|
+
return ps.div(
|
|
305
|
+
Header(),
|
|
306
|
+
ps.main(ps.Outlet()),
|
|
307
|
+
Footer(),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
ps.Layout(
|
|
311
|
+
render=AppLayout,
|
|
312
|
+
children=[
|
|
313
|
+
ps.Route("/", render=home),
|
|
314
|
+
ps.Route("/about", render=about),
|
|
315
|
+
],
|
|
316
|
+
)
|
|
317
|
+
```
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
render: Component[...]
|
|
321
|
+
children: Sequence["Route | Layout"]
|
|
322
|
+
dev: bool
|
|
323
|
+
|
|
324
|
+
def __init__(
|
|
325
|
+
self,
|
|
326
|
+
render: "Component[...]",
|
|
327
|
+
children: "Sequence[Route | Layout] | None" = None,
|
|
328
|
+
dev: bool = False,
|
|
329
|
+
):
|
|
330
|
+
self.render = render
|
|
331
|
+
self.children = children or []
|
|
332
|
+
self.dev = dev
|
|
333
|
+
self.parent: Route | Layout | None = None
|
|
334
|
+
# 1-based sibling index assigned by RouteTree at each level
|
|
335
|
+
self.idx: int = 1
|
|
336
|
+
|
|
337
|
+
def _path_list(self, include_layouts: bool = False) -> list[str]:
|
|
338
|
+
path_list = (
|
|
339
|
+
self.parent._path_list(include_layouts=include_layouts)
|
|
340
|
+
if self.parent
|
|
341
|
+
else []
|
|
342
|
+
)
|
|
343
|
+
if include_layouts:
|
|
344
|
+
nb = "" if self.idx == 1 else str(self.idx)
|
|
345
|
+
path_list.append(LAYOUT_INDICATOR + nb)
|
|
346
|
+
return path_list
|
|
347
|
+
|
|
348
|
+
def unique_path(self):
|
|
349
|
+
# Return absolute path with leading '/'
|
|
350
|
+
path = "/".join(self._path_list(include_layouts=True))
|
|
351
|
+
return f"/{path}"
|
|
352
|
+
|
|
353
|
+
def file_path(self) -> str:
|
|
354
|
+
path_list = self._path_list(include_layouts=True)
|
|
355
|
+
# Map layout indicators (with optional numeric suffix) to directory names
|
|
356
|
+
# e.g., "<layout>" -> "layout" and "<layout>2" -> "layout2"
|
|
357
|
+
converted: list[str] = []
|
|
358
|
+
for seg in path_list:
|
|
359
|
+
if seg.startswith(LAYOUT_INDICATOR):
|
|
360
|
+
suffix = seg[len(LAYOUT_INDICATOR) :]
|
|
361
|
+
converted.append("layout" + suffix)
|
|
362
|
+
else:
|
|
363
|
+
converted.append(seg)
|
|
364
|
+
# Place file within the current layout's directory
|
|
365
|
+
path = "/".join([*converted, "_layout.tsx"])
|
|
366
|
+
# Replace Windows-invalid characters in filenames
|
|
367
|
+
return _sanitize_filename(path)
|
|
368
|
+
|
|
369
|
+
@override
|
|
370
|
+
def __repr__(self) -> str:
|
|
371
|
+
return f"Layout(children={len(self.children)})"
|
|
372
|
+
|
|
373
|
+
def default_route_info(self) -> "RouteInfo":
|
|
374
|
+
"""Return a default RouteInfo corresponding to this layout's URL path.
|
|
375
|
+
|
|
376
|
+
The layout itself does not contribute a path segment. The resulting
|
|
377
|
+
pathname is the URL path formed by its ancestor routes. This method
|
|
378
|
+
raises InvalidRouteError if any ancestor route includes dynamic,
|
|
379
|
+
optional, or catch-all segments because defaults cannot be derived.
|
|
380
|
+
"""
|
|
381
|
+
# Walk up the tree to ensure there are no dynamic segments in ancestor routes
|
|
382
|
+
if route_or_ancestors_have_dynamic(self):
|
|
383
|
+
raise InvalidRouteError(
|
|
384
|
+
"Cannot build default RouteInfo for layout under a dynamic route."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Build pathname from ancestor route path segments (exclude layout indicators)
|
|
388
|
+
path_list = self._path_list(include_layouts=False)
|
|
389
|
+
pathname = ensure_absolute_path("/".join(path_list))
|
|
390
|
+
return {
|
|
391
|
+
"pathname": pathname,
|
|
392
|
+
"hash": "",
|
|
393
|
+
"query": "",
|
|
394
|
+
"queryParams": {},
|
|
395
|
+
"pathParams": {},
|
|
396
|
+
"catchall": [],
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def filter_dev_routes(routes: Sequence[Route | Layout]) -> list[Route | Layout]:
|
|
401
|
+
"""
|
|
402
|
+
Filter out routes with dev=True.
|
|
403
|
+
|
|
404
|
+
This function removes all routes marked with dev=True from the route tree.
|
|
405
|
+
Should only be called when env != "dev".
|
|
406
|
+
"""
|
|
407
|
+
filtered: list[Route | Layout] = []
|
|
408
|
+
for route in routes:
|
|
409
|
+
# Skip dev-only routes
|
|
410
|
+
if route.dev:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
# Recursively filter children
|
|
414
|
+
if route.children:
|
|
415
|
+
filtered_children = filter_dev_routes(route.children)
|
|
416
|
+
# Create a copy of the route with filtered children
|
|
417
|
+
if isinstance(route, Route):
|
|
418
|
+
filtered_route = Route(
|
|
419
|
+
path=route.path,
|
|
420
|
+
render=route.render,
|
|
421
|
+
children=filtered_children,
|
|
422
|
+
dev=route.dev,
|
|
423
|
+
)
|
|
424
|
+
else: # Layout
|
|
425
|
+
filtered_route = Layout(
|
|
426
|
+
render=route.render,
|
|
427
|
+
children=filtered_children,
|
|
428
|
+
dev=route.dev,
|
|
429
|
+
)
|
|
430
|
+
filtered.append(filtered_route)
|
|
431
|
+
else:
|
|
432
|
+
filtered.append(route)
|
|
433
|
+
return filtered
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class InvalidRouteError(Exception):
|
|
437
|
+
"""Raised for invalid route configurations.
|
|
438
|
+
|
|
439
|
+
Examples of invalid configurations:
|
|
440
|
+
- Empty path segments
|
|
441
|
+
- Invalid characters in path
|
|
442
|
+
- Catch-all (*) not at end of path
|
|
443
|
+
- Attempting to get default RouteInfo for dynamic routes
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
...
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class RouteTree:
|
|
450
|
+
tree: list[Route | Layout]
|
|
451
|
+
flat_tree: dict[str, Route | Layout]
|
|
452
|
+
|
|
453
|
+
def __init__(self, routes: Sequence[Route | Layout]) -> None:
|
|
454
|
+
# Filter out dev routes when not in dev environment
|
|
455
|
+
if env.pulse_env != "dev":
|
|
456
|
+
routes = filter_dev_routes(routes)
|
|
457
|
+
self.tree = list(routes)
|
|
458
|
+
self.flat_tree = {}
|
|
459
|
+
|
|
460
|
+
def _flatten_route_tree(route: Route | Layout):
|
|
461
|
+
key = route.unique_path()
|
|
462
|
+
if key in self.flat_tree:
|
|
463
|
+
if isinstance(route, Layout):
|
|
464
|
+
raise RuntimeError(f"Multiple layouts have the same path '{key}'")
|
|
465
|
+
else:
|
|
466
|
+
raise RuntimeError(f"Multiple routes have the same path '{key}'")
|
|
467
|
+
|
|
468
|
+
self.flat_tree[key] = route
|
|
469
|
+
layout_count = 0
|
|
470
|
+
for child in route.children:
|
|
471
|
+
if isinstance(child, Layout):
|
|
472
|
+
layout_count += 1
|
|
473
|
+
child.idx = layout_count
|
|
474
|
+
child.parent = route
|
|
475
|
+
_flatten_route_tree(child)
|
|
476
|
+
|
|
477
|
+
layout_count = 0
|
|
478
|
+
for route in routes:
|
|
479
|
+
if isinstance(route, Layout):
|
|
480
|
+
layout_count += 1
|
|
481
|
+
route.idx = layout_count
|
|
482
|
+
_flatten_route_tree(route)
|
|
483
|
+
|
|
484
|
+
def find(self, path: str):
|
|
485
|
+
path = ensure_absolute_path(path)
|
|
486
|
+
route = self.flat_tree.get(path)
|
|
487
|
+
if not route:
|
|
488
|
+
raise ValueError(f"No route found for path '{path}'")
|
|
489
|
+
return route
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class RouteInfo(TypedDict):
|
|
493
|
+
"""TypedDict containing current route information.
|
|
494
|
+
|
|
495
|
+
Provides access to URL components and parsed parameters for the
|
|
496
|
+
current route. Available via `use_route()` hook in components.
|
|
497
|
+
|
|
498
|
+
Attributes:
|
|
499
|
+
pathname: Current URL path (e.g., "/users/123").
|
|
500
|
+
hash: URL hash fragment after # (e.g., "section1").
|
|
501
|
+
query: Raw query string after ? (e.g., "page=2&sort=name").
|
|
502
|
+
queryParams: Parsed query parameters as dict (e.g., {"page": "2"}).
|
|
503
|
+
pathParams: Dynamic path parameters (e.g., {"id": "123"} for ":id").
|
|
504
|
+
catchall: Catch-all segments as list (e.g., ["a", "b"] for "a/b").
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
pathname: str
|
|
508
|
+
hash: str
|
|
509
|
+
query: str
|
|
510
|
+
queryParams: dict[str, str]
|
|
511
|
+
pathParams: dict[str, str]
|
|
512
|
+
catchall: list[str]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class RouteContext:
|
|
516
|
+
"""Runtime context for the current route.
|
|
517
|
+
|
|
518
|
+
Provides reactive access to the current route's URL components and
|
|
519
|
+
parameters. Accessible via `ps.route()` in components.
|
|
520
|
+
|
|
521
|
+
Attributes:
|
|
522
|
+
info: Current route info (reactive, auto-updates on navigation).
|
|
523
|
+
pulse_route: Route or Layout definition for this context.
|
|
524
|
+
|
|
525
|
+
Properties:
|
|
526
|
+
pathname: Current URL path (e.g., "/users/123").
|
|
527
|
+
hash: URL hash fragment (without #).
|
|
528
|
+
query: Raw query string (without ?).
|
|
529
|
+
queryParams: Parsed query parameters as dict.
|
|
530
|
+
pathParams: Dynamic path parameters (e.g., {"id": "123"}).
|
|
531
|
+
catchall: Catch-all segments as list.
|
|
532
|
+
|
|
533
|
+
Example:
|
|
534
|
+
```python
|
|
535
|
+
@ps.component
|
|
536
|
+
def UserProfile():
|
|
537
|
+
ctx = ps.route()
|
|
538
|
+
user_id = ctx.pathParams.get("id")
|
|
539
|
+
return ps.div(f"User: {user_id}")
|
|
540
|
+
```
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
info: RouteInfo
|
|
544
|
+
pulse_route: Route | Layout
|
|
545
|
+
|
|
546
|
+
def __init__(self, info: RouteInfo, pulse_route: Route | Layout):
|
|
547
|
+
self.info = cast(RouteInfo, cast(object, ReactiveDict(info)))
|
|
548
|
+
self.pulse_route = pulse_route
|
|
549
|
+
|
|
550
|
+
def update(self, info: RouteInfo) -> None:
|
|
551
|
+
"""Update the route info with new values.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
info: New route info to apply.
|
|
555
|
+
"""
|
|
556
|
+
self.info.update(info)
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def pathname(self) -> str:
|
|
560
|
+
"""Current URL path (e.g., "/users/123")."""
|
|
561
|
+
return self.info["pathname"]
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def hash(self) -> str:
|
|
565
|
+
"""URL hash fragment (without #)."""
|
|
566
|
+
return self.info["hash"]
|
|
567
|
+
|
|
568
|
+
@property
|
|
569
|
+
def query(self) -> str:
|
|
570
|
+
"""Raw query string (without ?)."""
|
|
571
|
+
return self.info["query"]
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def queryParams(self) -> dict[str, str]:
|
|
575
|
+
"""Parsed query parameters as dict."""
|
|
576
|
+
return self.info["queryParams"]
|
|
577
|
+
|
|
578
|
+
@property
|
|
579
|
+
def pathParams(self) -> dict[str, str]:
|
|
580
|
+
"""Dynamic path parameters (e.g., {"id": "123"} for ":id")."""
|
|
581
|
+
return self.info["pathParams"]
|
|
582
|
+
|
|
583
|
+
@property
|
|
584
|
+
def catchall(self) -> list[str]:
|
|
585
|
+
"""Catch-all segments as list."""
|
|
586
|
+
return self.info["catchall"]
|
|
587
|
+
|
|
588
|
+
@override
|
|
589
|
+
def __str__(self) -> str:
|
|
590
|
+
return f"RouteContext(pathname='{self.pathname}', params={self.pathParams})"
|
|
591
|
+
|
|
592
|
+
@override
|
|
593
|
+
def __repr__(self) -> str:
|
|
594
|
+
return (
|
|
595
|
+
f"RouteContext(pathname='{self.pathname}', hash='{self.hash}', "
|
|
596
|
+
f"query='{self.query}', queryParams={self.queryParams}, "
|
|
597
|
+
f"pathParams={self.pathParams}, catchall={self.catchall})"
|
|
598
|
+
)
|