pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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 (80) hide show
  1. pulse/__init__.py +10 -24
  2. pulse/app.py +3 -25
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/queries/client.py +7 -7
  40. pulse/queries/effect.py +16 -0
  41. pulse/queries/infinite_query.py +138 -29
  42. pulse/queries/mutation.py +1 -15
  43. pulse/queries/protocol.py +136 -0
  44. pulse/queries/query.py +610 -174
  45. pulse/queries/store.py +11 -14
  46. pulse/react_component.py +167 -14
  47. pulse/reactive.py +19 -1
  48. pulse/reactive_extensions.py +5 -5
  49. pulse/render_session.py +185 -59
  50. pulse/renderer.py +80 -158
  51. pulse/routing.py +1 -18
  52. pulse/transpiler/__init__.py +131 -0
  53. pulse/transpiler/builtins.py +731 -0
  54. pulse/transpiler/constants.py +110 -0
  55. pulse/transpiler/context.py +26 -0
  56. pulse/transpiler/errors.py +2 -0
  57. pulse/transpiler/function.py +250 -0
  58. pulse/transpiler/ids.py +16 -0
  59. pulse/transpiler/imports.py +409 -0
  60. pulse/transpiler/js_module.py +274 -0
  61. pulse/transpiler/modules/__init__.py +30 -0
  62. pulse/transpiler/modules/asyncio.py +38 -0
  63. pulse/transpiler/modules/json.py +20 -0
  64. pulse/transpiler/modules/math.py +320 -0
  65. pulse/transpiler/modules/re.py +466 -0
  66. pulse/transpiler/modules/tags.py +268 -0
  67. pulse/transpiler/modules/typing.py +59 -0
  68. pulse/transpiler/nodes.py +1216 -0
  69. pulse/transpiler/py_module.py +119 -0
  70. pulse/transpiler/transpiler.py +938 -0
  71. pulse/transpiler/utils.py +4 -0
  72. pulse/types/event_handler.py +3 -2
  73. pulse/vdom.py +212 -13
  74. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  75. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  76. pulse/codegen/imports.py +0 -204
  77. pulse/css.py +0 -155
  78. pulse_framework-0.1.44.dist-info/RECORD +0 -79
  79. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
@@ -1,266 +1,353 @@
1
- from collections.abc import Iterable, Sequence
2
- from typing import TypedDict, TypeVarTuple
1
+ """Route code generation using the javascript_v2 import system."""
3
2
 
4
- from mako.template import Template
5
-
6
- from pulse.codegen.imports import Imports, ImportStatement
7
- from pulse.codegen.js import ExternalJsFunction, JsFunction
8
- from pulse.codegen.utils import NameRegistry
9
- from pulse.react_component import ReactComponent
10
- from pulse.routing import Layout, Route
11
-
12
- Args = TypeVarTuple("Args")
13
-
14
-
15
- class ComponentInfo(TypedDict):
16
- key: str
17
- expr: str
18
- src: str
19
- name: str
20
- default: bool
21
- lazy: bool
22
- dynamic: str
23
-
24
-
25
- class CssModuleImport(TypedDict):
26
- id: str
27
- import_path: str
3
+ from __future__ import annotations
28
4
 
5
+ import os
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
29
8
 
30
- class CssModuleCtx(TypedDict):
31
- id: str
32
- identifier: str
33
-
34
-
35
- class RouteTemplate:
36
- """
37
- Helper to resolve names and build import statements before rendering a route file.
9
+ from pulse.react_component import ReactComponent
10
+ from pulse.transpiler.constants import JsConstant
11
+ from pulse.transpiler.function import AnyJsFunction, JsFunction, registered_functions
12
+ from pulse.transpiler.imports import Import, registered_imports
13
+
14
+
15
+ def _generate_import_statement(src: str, imports: list[Import]) -> str:
16
+ """Generate import statement(s) for a source module."""
17
+ default_imports: list[Import] = []
18
+ namespace_imports: list[Import] = []
19
+ named_imports: list[Import] = []
20
+ type_imports: list[Import] = []
21
+ has_side_effect = False
22
+
23
+ for imp in imports:
24
+ if imp.is_side_effect:
25
+ has_side_effect = True
26
+ elif imp.is_namespace:
27
+ namespace_imports.append(imp)
28
+ elif imp.is_default:
29
+ if imp.is_type_only:
30
+ type_imports.append(imp)
31
+ else:
32
+ default_imports.append(imp)
33
+ else:
34
+ if imp.is_type_only:
35
+ type_imports.append(imp)
36
+ else:
37
+ named_imports.append(imp)
38
+
39
+ lines: list[str] = []
40
+
41
+ # Namespace import (only one allowed per source)
42
+ if namespace_imports:
43
+ imp = namespace_imports[0]
44
+ lines.append(f'import * as {imp.js_name} from "{src}";')
45
+
46
+ # Default import (only one allowed per source)
47
+ if default_imports:
48
+ imp = default_imports[0]
49
+ lines.append(f'import {imp.js_name} from "{src}";')
50
+
51
+ # Named imports
52
+ if named_imports:
53
+ members = [f"{imp.name} as {imp.js_name}" for imp in named_imports]
54
+ lines.append(f'import {{ {", ".join(members)} }} from "{src}";')
55
+
56
+ # Type imports
57
+ if type_imports:
58
+ type_members: list[str] = []
59
+ for imp in type_imports:
60
+ if imp.is_default:
61
+ type_members.append(f"default as {imp.js_name}")
62
+ else:
63
+ type_members.append(f"{imp.name} as {imp.js_name}")
64
+ lines.append(f'import type {{ {", ".join(type_members)} }} from "{src}";')
65
+
66
+ # Side-effect only import (only if no other imports)
67
+ if (
68
+ has_side_effect
69
+ and not default_imports
70
+ and not namespace_imports
71
+ and not named_imports
72
+ and not type_imports
73
+ ):
74
+ lines.append(f'import "{src}";')
75
+
76
+ return "\n".join(lines)
77
+
78
+
79
+ def _generate_imports_section(imports: Sequence[Import]) -> str:
80
+ """Generate the full imports section with deduplication and topological ordering."""
81
+ if not imports:
82
+ return ""
83
+
84
+ # Deduplicate imports by ID
85
+ seen_ids: set[str] = set()
86
+ unique_imports: list[Import] = []
87
+ for imp in imports:
88
+ if imp.id not in seen_ids:
89
+ seen_ids.add(imp.id)
90
+ unique_imports.append(imp)
91
+
92
+ # Group by source
93
+ grouped: dict[str, list[Import]] = {}
94
+ for imp in unique_imports:
95
+ if imp.src not in grouped:
96
+ grouped[imp.src] = []
97
+ grouped[imp.src].append(imp)
98
+
99
+ # Topological sort using Import.before constraints (Kahn's algorithm)
100
+ keys = list(grouped.keys())
101
+ if not keys:
102
+ return ""
103
+
104
+ index = {k: i for i, k in enumerate(keys)} # for stability
105
+ indegree: dict[str, int] = {k: 0 for k in keys}
106
+ adj: dict[str, list[str]] = {k: [] for k in keys}
107
+
108
+ for src, src_imports in grouped.items():
109
+ for imp in src_imports:
110
+ for before_src in imp.before:
111
+ if before_src in adj:
112
+ adj[src].append(before_src)
113
+ indegree[before_src] += 1
114
+
115
+ queue = [k for k, d in indegree.items() if d == 0]
116
+ queue.sort(key=lambda k: index[k])
117
+ ordered: list[str] = []
118
+
119
+ while queue:
120
+ u = queue.pop(0)
121
+ ordered.append(u)
122
+ for v in adj[u]:
123
+ indegree[v] -= 1
124
+ if indegree[v] == 0:
125
+ queue.append(v)
126
+ queue.sort(key=lambda k: index[k])
127
+
128
+ # Fall back to insertion order if cycle detected
129
+ if len(ordered) != len(keys):
130
+ ordered = keys
131
+
132
+ lines: list[str] = []
133
+ for src in ordered:
134
+ stmt = _generate_import_statement(src, grouped[src])
135
+ if stmt:
136
+ lines.append(stmt)
137
+
138
+ return "\n".join(lines)
139
+
140
+
141
+ def _collect_function_graph(
142
+ functions: Sequence[AnyJsFunction],
143
+ ) -> tuple[list[JsConstant], list[AnyJsFunction]]:
144
+ """Collect all constants and functions in dependency order (depth-first)."""
145
+ seen_funcs: set[str] = set()
146
+ seen_consts: set[str] = set()
147
+ all_funcs: list[AnyJsFunction] = []
148
+ all_consts: list[JsConstant] = []
149
+
150
+ def walk(fn: AnyJsFunction) -> None:
151
+ if fn.id in seen_funcs:
152
+ return
153
+ seen_funcs.add(fn.id)
154
+
155
+ for dep in fn.deps.values():
156
+ if isinstance(dep, JsFunction):
157
+ walk(dep) # pyright: ignore[reportUnknownArgumentType]
158
+ elif isinstance(dep, JsConstant):
159
+ if dep.id not in seen_consts:
160
+ seen_consts.add(dep.id)
161
+ all_consts.append(dep)
162
+
163
+ all_funcs.append(fn)
164
+
165
+ for fn in functions:
166
+ walk(fn)
167
+
168
+ return all_consts, all_funcs
169
+
170
+
171
+ def _generate_constants_section(constants: Sequence[JsConstant]) -> str:
172
+ """Generate the constants section."""
173
+ if not constants:
174
+ return ""
175
+
176
+ lines: list[str] = ["// Constants"]
177
+ for const in constants:
178
+ js_value = const.expr.emit()
179
+ lines.append(f"const {const.js_name} = {js_value};")
180
+
181
+ return "\n".join(lines)
182
+
183
+
184
+ def _generate_functions_section(functions: Sequence[AnyJsFunction]) -> str:
185
+ """Generate the functions section with actual transpiled code."""
186
+ if not functions:
187
+ return ""
188
+
189
+ lines: list[str] = ["// Functions"]
190
+ for fn in functions:
191
+ js_code = fn.transpile()
192
+ lines.append(js_code)
193
+
194
+ return "\n".join(lines)
195
+
196
+
197
+ def _generate_registry_section(
198
+ all_imports: Sequence[Import],
199
+ lazy_components: Sequence[tuple[ReactComponent[...], Import]] | None = None,
200
+ prop_components: Sequence[ReactComponent[...]] | None = None,
201
+ functions: Sequence[AnyJsFunction] | None = None,
202
+ ) -> str:
203
+ """Generate the unified registry containing all imports for runtime lookup."""
204
+ lines: list[str] = []
205
+
206
+ # Unified Registry - contains all imports that need to be looked up at runtime
207
+ lines.append("// Unified Registry")
208
+ lines.append("const __registry = {")
209
+
210
+ # Add non-type, non-side-effect imports to the registry
211
+ seen_js_names: set[str] = set()
212
+ for imp in all_imports:
213
+ if imp.is_side_effect or imp.is_type_only:
214
+ continue
215
+ if imp.js_name in seen_js_names:
216
+ continue
217
+ seen_js_names.add(imp.js_name)
218
+ lines.append(f' "{imp.js_name}": {imp.js_name},')
219
+
220
+ # Add components with prop access (e.g., AppShell.Header)
221
+ # These need separate registry entries because the lookup key includes the prop
222
+ for comp in prop_components or []:
223
+ if comp.prop:
224
+ # Key is "ImportName_123.PropName", value is ImportName_123.PropName
225
+ key = f"{comp.import_.js_name}.{comp.prop}"
226
+ lines.append(f' "{key}": {key},')
227
+
228
+ # Add lazy components with RenderLazy wrapper
229
+ for comp, render_lazy_imp in lazy_components or []:
230
+ attr = "default" if comp.is_default else comp.name
231
+ prop_accessor = f".{comp.prop}" if comp.prop else ""
232
+ dynamic = f"({{ default: m.{attr}{prop_accessor} }})"
233
+ # Key includes prop if present (e.g., "AppShell_123.Header")
234
+ key = comp.import_.js_name
235
+ if comp.prop:
236
+ key = f"{key}.{comp.prop}"
237
+ lines.append(
238
+ f' "{key}": {render_lazy_imp.js_name}(() => import("{comp.src}").then((m) => {dynamic})),'
239
+ )
240
+
241
+ # Add transpiled functions to the registry
242
+ for fn in functions or []:
243
+ lines.append(f' "{fn.js_name}": {fn.js_name},')
244
+
245
+ lines.append("};")
246
+
247
+ return "\n".join(lines)
248
+
249
+
250
+ def generate_route(
251
+ path: str,
252
+ components: Sequence[ReactComponent[...]] | None = None,
253
+ route_file_path: Path | None = None,
254
+ css_dir: Path | None = None,
255
+ ) -> str:
256
+ """Generate a route file with all imports and components.
38
257
 
39
- - Maintains a per-file NameRegistry seeded with RESERVED_NAMES (plus user-provided)
40
- - Uses Imports to avoid collisions across default/named/type imports
41
- - Computes SSR expressions and lazy dynamic selectors for React components
42
- - Reserves identifiers for local JS functions
258
+ Args:
259
+ path: The route path (e.g., "/users/:id")
260
+ components: React components used in the route
261
+ route_file_path: Path where the route file will be written (for computing relative imports)
262
+ css_dir: Path to the CSS output directory (for computing relative CSS imports)
43
263
  """
44
-
45
- names: NameRegistry
46
- _imports: Imports
47
-
48
- def __init__(self, reserved_names: Iterable[str] | None = None) -> None:
49
- initial = set(reserved_names or []).union(RESERVED_NAMES)
50
- self.names = NameRegistry(initial)
51
- self._imports = Imports([], names=self.names)
52
- self.components_by_key: dict[str, ComponentInfo] = {}
53
- self._js_local_names: dict[str, str] = {}
54
- self.needs_render_lazy: bool = False
55
- self._css_modules: dict[str, CssModuleCtx] = {}
56
-
57
- def add_components(self, components: "Sequence[ReactComponent[...]]") -> None:
58
- for comp in components:
59
- if comp.lazy:
60
- self.needs_render_lazy = True
61
- # We still register the name as it's an easy way to guarantee a unique component key
62
- ident = self.names.register(comp.name)
63
- if ident != comp.name:
64
- comp.alias = ident
65
- else:
66
- # For SSR-capable components, import the symbol and compute expression
67
- ident = self._imports.import_(
68
- comp.src, comp.name, is_default=comp.is_default
69
- )
70
- if ident != comp.name:
71
- comp.alias = ident
72
-
73
- key = comp.expr
74
- existing = self.components_by_key.get(key)
75
- if existing:
76
- same_import = (
77
- existing["src"] == comp.src
78
- and existing["name"] == comp.name
79
- and existing["default"] == comp.is_default
80
- )
81
- if not same_import:
82
- raise RuntimeError(
83
- "Invariant violation: two React components ended up with the same key. This is a Pulse bug, please raise an issue: https://github.com/erwinkn/pulse-ui"
84
- )
85
-
86
- self.components_by_key[key] = {
87
- "key": key,
88
- "lazy": comp.lazy,
89
- "expr": comp.expr,
90
- "src": comp.src,
91
- "name": comp.name,
92
- "default": comp.is_default,
93
- "dynamic": dynamic_selector(comp),
94
- }
95
-
96
- # Register component-level extra imports (e.g., side-effect CSS)
97
- extra_imports = getattr(comp, "extra_imports", None) or []
98
- for stmt in extra_imports:
99
- if isinstance(stmt, ImportStatement):
100
- self._imports.add_statement(stmt)
101
-
102
- def add_css_modules(self, modules: Sequence[CssModuleImport]) -> None:
103
- for mod in modules:
104
- if mod["id"] in self._css_modules:
105
- continue
106
- identifier = self._imports.import_(
107
- mod["import_path"], mod["id"], is_default=True
108
- )
109
- self._css_modules[mod["id"]] = {
110
- "id": mod["id"],
111
- "identifier": identifier,
112
- }
113
-
114
- def add_css_imports(self, imports: Sequence[str]) -> None:
115
- for spec in imports:
116
- stmt = ImportStatement(spec, side_effect=True)
117
- self._imports.add_statement(stmt)
118
-
119
- def add_external_js(self, fns: Sequence[ExternalJsFunction[*Args, object]]) -> None:
120
- for fn in fns:
121
- self._imports.import_(fn.src, fn.name, is_default=True)
122
- # TODO: update fn in case of aliasing
123
-
124
- def reserve_js_function_names(
125
- self, js_functions: Sequence[JsFunction[*Args, object]]
126
- ) -> None:
127
- for j in js_functions:
128
- self._js_local_names[j.name] = self.names.register(j.name)
129
- # TODO: update fn in case of aliasing
130
-
131
- def context(self) -> dict[str, object]:
132
- # Deterministic order of import sources with ordering constraints
133
- import_sources = self._imports.ordered_sources()
134
- return {
135
- "import_sources": import_sources,
136
- "components_ctx": list(self.components_by_key.values()),
137
- "local_js_names": self._js_local_names,
138
- "needs_render_lazy": self.needs_render_lazy,
139
- "css_modules_ctx": list(self._css_modules.values()),
140
- }
141
-
142
-
143
- def dynamic_selector(comp: "ReactComponent[...]"):
144
- # Dynamic import mapping for lazy usage on the client
145
- attr = "default" if comp.is_default else comp.name
146
- prop_accessor = f".{comp.prop}" if comp.prop else ""
147
- return f"({{ default: m.{attr}{prop_accessor} }})"
148
-
149
-
150
- # Constants and functions defined in the template below. We need to avoid name conflicts with imports
151
- RESERVED_NAMES = [
152
- "externalComponents",
153
- "path",
154
- "RouteComponent",
155
- "hasAnyHeaders",
156
- "headers",
157
- "HeadersArgs",
158
- "PulseView",
159
- "ComponentRegistry",
160
- "RenderLazy",
161
- "cssModules",
162
- ]
163
-
164
- TEMPLATE = Template(
165
- """import { PulseView, type ComponentRegistry${", " + "RenderLazy" if needs_render_lazy else ""} } from "pulse-ui-client";
166
- import type { HeadersArgs } from "react-router";
167
-
168
- % if import_sources:
169
- // Component and helper imports
170
- % for import_source in import_sources:
171
- % if import_source.default_import:
172
- import ${import_source.default_import} from "${import_source.src}";
173
- % endif
174
- % if import_source.values:
175
- import { ${', '.join([f"{v.name}{f' as {v.alias}' if v.alias else ''}" for v in import_source.values])} } from "${import_source.src}";
176
- % endif
177
- % if import_source.types:
178
- import type { ${', '.join([f"{t.name}{f' as {t.alias}' if t.alias else ''}" for t in import_source.types])} } from "${import_source.src}";
179
- % endif
180
- % if import_source.side_effect and (not import_source.default_import) and (not import_source.values) and (not import_source.types):
181
- import "${import_source.src}";
182
- % endif
183
- % endfor
184
- % endif
185
-
186
- // Component registry
187
- % if css_modules_ctx:
188
- const cssModules = {
189
- % for mod in css_modules_ctx:
190
- "${mod['id']}": ${mod['identifier']},
191
- % endfor
192
- };
193
- % else:
194
- const cssModules = {};
195
- % endif
196
-
197
- % if components_ctx:
198
- const externalComponents: ComponentRegistry = {
199
- % for c in components_ctx:
200
- % if c['lazy']:
201
- "${c['key']}": RenderLazy(() => import("${c['src']}").then((m) => ${c['dynamic']})),
202
- % else:
203
- "${c['key']}": ${c['expr']},
204
- % endif
205
- % endfor
206
- };
207
- % else:
208
- // No components needed for this route
209
- const externalComponents: ComponentRegistry = {};
210
- % endif
211
-
212
- const path = "${route.unique_path()}";
213
-
214
- export default function RouteComponent() {
264
+ # Collect lazy component import IDs to exclude from registered_imports
265
+ lazy_import_ids: set[str] = set()
266
+ for comp in components or []:
267
+ if comp.lazy:
268
+ lazy_import_ids.add(comp.import_.id)
269
+
270
+ # Add core Pulse imports - store references to use their js_name later
271
+ pulse_view_import = Import.named("PulseView", "pulse-ui-client")
272
+
273
+ # Check if we need RenderLazy
274
+ render_lazy_import: Import | None = None
275
+ if any(c.lazy for c in (components or [])):
276
+ render_lazy_import = Import.named("RenderLazy", "pulse-ui-client")
277
+
278
+ # Process components: add non-lazy imports and collect metadata
279
+ prop_components: list[ReactComponent[...]] = []
280
+ lazy_components: list[tuple[ReactComponent[...], Import]] = []
281
+ for comp in components or []:
282
+ if comp.lazy:
283
+ if render_lazy_import is not None:
284
+ lazy_components.append((comp, render_lazy_import))
285
+ else:
286
+ # Force registration by accessing the import
287
+ _ = comp.import_
288
+ if comp.prop:
289
+ prop_components.append(comp)
290
+
291
+ # Collect function graph (constants + functions in dependency order)
292
+ constants, funcs = _collect_function_graph(registered_functions())
293
+
294
+ # Get all registered imports, excluding lazy ones
295
+ all_imports = [imp for imp in registered_imports() if imp.id not in lazy_import_ids]
296
+
297
+ # Update src for local CSS imports to use relative paths from the route file
298
+ if route_file_path is not None and css_dir is not None:
299
+ from pulse.transpiler.imports import CssImport
300
+
301
+ route_dir = route_file_path.parent
302
+ for imp in all_imports:
303
+ if isinstance(imp, CssImport) and imp.is_local:
304
+ generated_filename = imp.generated_filename
305
+ assert generated_filename is not None
306
+ css_file_path = css_dir / generated_filename
307
+ rel_path = Path(os.path.relpath(css_file_path, route_dir))
308
+ imp.src = rel_path.as_posix()
309
+
310
+ # Generate output sections
311
+ output_parts: list[str] = []
312
+
313
+ imports_section = _generate_imports_section(all_imports)
314
+ if imports_section:
315
+ output_parts.append(imports_section)
316
+
317
+ output_parts.append("")
318
+
319
+ if constants:
320
+ output_parts.append(_generate_constants_section(constants))
321
+ output_parts.append("")
322
+
323
+ if funcs:
324
+ output_parts.append(_generate_functions_section(funcs))
325
+ output_parts.append("")
326
+
327
+ output_parts.append(
328
+ _generate_registry_section(all_imports, lazy_components, prop_components, funcs)
329
+ )
330
+ output_parts.append("")
331
+
332
+ # Route component
333
+ pulse_view_js = pulse_view_import.js_name
334
+ output_parts.append(f'''const path = "{path}";
335
+
336
+ export default function RouteComponent() {{
215
337
  return (
216
- <PulseView key={path} externalComponents={externalComponents} path={path} cssModules={cssModules} />
338
+ <{pulse_view_js} key={{path}} registry={{__registry}} path={{path}} />
217
339
  );
218
- }
340
+ }}''')
341
+ output_parts.append("")
219
342
 
220
- // Action and loader headers are not returned automatically
221
- function hasAnyHeaders(headers: Headers): boolean {
343
+ # Headers function
344
+ output_parts.append("""// Action and loader headers are not returned automatically
345
+ function hasAnyHeaders(headers) {
222
346
  return [...headers].length > 0;
223
347
  }
224
348
 
225
- export function headers({
226
- actionHeaders,
227
- loaderHeaders,
228
- }: HeadersArgs) {
229
- return hasAnyHeaders(actionHeaders)
230
- ? actionHeaders
231
- : loaderHeaders;
232
- }
233
- """
234
- )
235
-
236
- # Back-compat alias
237
- ROUTE_TEMPLATE = TEMPLATE
349
+ export function headers({ actionHeaders, loaderHeaders }) {
350
+ return hasAnyHeaders(actionHeaders) ? actionHeaders : loaderHeaders;
351
+ }""")
238
352
 
239
-
240
- def render_route(
241
- *,
242
- route: Route | Layout,
243
- components: Sequence[ReactComponent[...]] | None = None,
244
- css_modules: Sequence[CssModuleImport] | None = None,
245
- css_imports: Sequence[str] | None = None,
246
- js_functions: Sequence[JsFunction[*Args, object]] | None = None,
247
- external_js: Sequence[ExternalJsFunction[*Args, object]] | None = None,
248
- reserved_names: Iterable[str] | None = None,
249
- ) -> str:
250
- comps = list(components or [])
251
-
252
- jt = RouteTemplate(reserved_names=reserved_names)
253
- jt.add_components(comps)
254
- modules = list(css_modules or [])
255
- if modules:
256
- jt.add_css_modules(modules)
257
- imports = list(css_imports or [])
258
- if imports:
259
- jt.add_css_imports(imports)
260
- if external_js:
261
- jt.add_external_js(list(external_js))
262
- if js_functions:
263
- jt.reserve_js_function_names(list(js_functions))
264
-
265
- ctx = jt.context() | {"route": route}
266
- return str(TEMPLATE.render_unicode(**ctx))
353
+ return "\n".join(output_parts)
pulse/form.py CHANGED
@@ -278,7 +278,7 @@ class ManualForm(Disposable):
278
278
 
279
279
 
280
280
  class FormStorage(HookState):
281
- __slots__: tuple[str, ...] = ("forms", "prev_forms", "render_mark")
281
+ __slots__ = ("forms", "prev_forms", "render_mark") # pyright: ignore[reportUnannotatedClassAttribute]
282
282
  render_mark: int
283
283
 
284
284
  def __init__(self) -> None:
pulse/helpers.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import inspect
3
+ import linecache
3
4
  import os
4
5
  import socket
5
6
  from abc import ABC, abstractmethod
@@ -8,7 +9,6 @@ from functools import wraps
8
9
  from typing import (
9
10
  Any,
10
11
  ParamSpec,
11
- Protocol,
12
12
  Self,
13
13
  TypedDict,
14
14
  TypeVar,
@@ -43,20 +43,52 @@ def values_equal(a: Any, b: Any) -> bool:
43
43
  return False
44
44
 
45
45
 
46
- T = TypeVar("T")
47
- P = ParamSpec("P")
46
+ def getsourcecode(obj: Any) -> str:
47
+ """Get source code for an object, handling stale cache issues after module renames.
48
+
49
+ This is a wrapper around inspect.getsource() that handles cases where the
50
+ linecache has stale entries after module renames or when source files have moved.
51
+ """
52
+ # Try to get source first without clearing cache (common case)
53
+ try:
54
+ return inspect.getsource(obj)
55
+ except OSError:
56
+ # If that fails, it might be a stale cache issue after module rename
57
+ # Clear cache and try again
58
+ linecache.clearcache()
59
+ try:
60
+ return inspect.getsource(obj)
61
+ except OSError:
62
+ # Still failing - code object might have a stale filename
63
+ # Get the actual source file from the module and update cache manually
64
+ module = inspect.getmodule(obj)
65
+ if module and hasattr(module, "__file__") and module.__file__:
66
+ module_file = module.__file__
67
+ if module_file.endswith(".pyc"):
68
+ module_file = module_file[:-1]
69
+ if os.path.exists(module_file):
70
+ # Read the file and update cache with code object's filename
71
+ with open(module_file, "r", encoding="utf-8") as f:
72
+ lines = f.readlines()
73
+ code_filename = obj.__code__.co_filename
74
+ linecache.cache[code_filename] = (
75
+ len(lines),
76
+ None,
77
+ lines,
78
+ code_filename,
79
+ )
80
+ # Try again after updating cache
81
+ return inspect.getsource(obj)
82
+ raise
48
83
 
49
84
 
50
- JsFunction = Callable[P, T]
85
+ T = TypeVar("T")
86
+ P = ParamSpec("P")
51
87
 
52
88
  # In case we refine it later
53
89
  CSSProperties = dict[str, Any]
54
90
 
55
91
 
56
- # Will be replaced by a JS transpiler type
57
- class JsObject(Protocol): ...
58
-
59
-
60
92
  MISSING = object()
61
93
 
62
94