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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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
+ )