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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Route code generation using transpiler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from pulse.transpiler import (
|
|
8
|
+
Constant,
|
|
9
|
+
Import,
|
|
10
|
+
collect_function_graph,
|
|
11
|
+
emit,
|
|
12
|
+
get_registered_imports,
|
|
13
|
+
registered_constants,
|
|
14
|
+
registered_functions,
|
|
15
|
+
)
|
|
16
|
+
from pulse.transpiler.emit_context import EmitContext
|
|
17
|
+
from pulse.transpiler.function import AnyJsFunction
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_import_src(imp: Import) -> str:
|
|
21
|
+
"""Get the import source path, remapping to asset path for local imports."""
|
|
22
|
+
if imp.asset:
|
|
23
|
+
return imp.asset.import_path()
|
|
24
|
+
return imp.src
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _generate_import_statement(
|
|
28
|
+
src: str,
|
|
29
|
+
imports: list[Import],
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Generate import statement(s) for a source module.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
src: The original source path (may be remapped for local imports)
|
|
35
|
+
imports: List of Import objects for this source
|
|
36
|
+
"""
|
|
37
|
+
default_imports: list[Import] = []
|
|
38
|
+
namespace_imports: list[Import] = []
|
|
39
|
+
named_imports: list[Import] = []
|
|
40
|
+
type_imports: list[Import] = []
|
|
41
|
+
has_side_effect = False
|
|
42
|
+
|
|
43
|
+
for imp in imports:
|
|
44
|
+
if imp.is_side_effect:
|
|
45
|
+
has_side_effect = True
|
|
46
|
+
elif imp.is_namespace:
|
|
47
|
+
namespace_imports.append(imp)
|
|
48
|
+
elif imp.is_default:
|
|
49
|
+
if imp.is_type:
|
|
50
|
+
type_imports.append(imp)
|
|
51
|
+
else:
|
|
52
|
+
default_imports.append(imp)
|
|
53
|
+
else:
|
|
54
|
+
if imp.is_type:
|
|
55
|
+
type_imports.append(imp)
|
|
56
|
+
else:
|
|
57
|
+
named_imports.append(imp)
|
|
58
|
+
|
|
59
|
+
# Remap source path if this is a local import
|
|
60
|
+
import_src = src
|
|
61
|
+
for imp in imports:
|
|
62
|
+
if imp.asset:
|
|
63
|
+
import_src = imp.asset.import_path()
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
lines: list[str] = []
|
|
67
|
+
|
|
68
|
+
# Namespace import (only one allowed per source)
|
|
69
|
+
if namespace_imports:
|
|
70
|
+
imp = namespace_imports[0]
|
|
71
|
+
lines.append(f'import * as {imp.js_name} from "{import_src}";')
|
|
72
|
+
|
|
73
|
+
# Default import (only one allowed per source)
|
|
74
|
+
if default_imports:
|
|
75
|
+
imp = default_imports[0]
|
|
76
|
+
lines.append(f'import {imp.js_name} from "{import_src}";')
|
|
77
|
+
|
|
78
|
+
# Named imports
|
|
79
|
+
if named_imports:
|
|
80
|
+
members = [f"{imp.name} as {imp.js_name}" for imp in named_imports]
|
|
81
|
+
lines.append(f'import {{ {", ".join(members)} }} from "{import_src}";')
|
|
82
|
+
|
|
83
|
+
# Type imports
|
|
84
|
+
if type_imports:
|
|
85
|
+
type_members: list[str] = []
|
|
86
|
+
for imp in type_imports:
|
|
87
|
+
if imp.is_default:
|
|
88
|
+
type_members.append(f"default as {imp.js_name}")
|
|
89
|
+
else:
|
|
90
|
+
type_members.append(f"{imp.name} as {imp.js_name}")
|
|
91
|
+
lines.append(
|
|
92
|
+
f'import type {{ {", ".join(type_members)} }} from "{import_src}";'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Side-effect only import (only if no other imports)
|
|
96
|
+
if (
|
|
97
|
+
has_side_effect
|
|
98
|
+
and not default_imports
|
|
99
|
+
and not namespace_imports
|
|
100
|
+
and not named_imports
|
|
101
|
+
and not type_imports
|
|
102
|
+
):
|
|
103
|
+
lines.append(f'import "{import_src}";')
|
|
104
|
+
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _generate_imports_section(imports: Sequence[Import]) -> str:
|
|
109
|
+
"""Generate the full imports section with deduplication and topological ordering.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
imports: List of Import objects to generate (should be eager imports only)
|
|
113
|
+
"""
|
|
114
|
+
if not imports:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
# Deduplicate imports by ID
|
|
118
|
+
seen_ids: set[str] = set()
|
|
119
|
+
unique_imports: list[Import] = []
|
|
120
|
+
for imp in imports:
|
|
121
|
+
if imp.id not in seen_ids:
|
|
122
|
+
seen_ids.add(imp.id)
|
|
123
|
+
unique_imports.append(imp)
|
|
124
|
+
|
|
125
|
+
# Group by source
|
|
126
|
+
grouped: dict[str, list[Import]] = {}
|
|
127
|
+
for imp in unique_imports:
|
|
128
|
+
if imp.src not in grouped:
|
|
129
|
+
grouped[imp.src] = []
|
|
130
|
+
grouped[imp.src].append(imp)
|
|
131
|
+
|
|
132
|
+
# Topological sort using Import.before constraints (Kahn's algorithm)
|
|
133
|
+
keys = list(grouped.keys())
|
|
134
|
+
if not keys:
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
index = {k: i for i, k in enumerate(keys)} # for stability
|
|
138
|
+
indegree: dict[str, int] = {k: 0 for k in keys}
|
|
139
|
+
adj: dict[str, list[str]] = {k: [] for k in keys}
|
|
140
|
+
|
|
141
|
+
for src, src_imports in grouped.items():
|
|
142
|
+
for imp in src_imports:
|
|
143
|
+
for before_src in imp.before:
|
|
144
|
+
if before_src in adj:
|
|
145
|
+
adj[src].append(before_src)
|
|
146
|
+
indegree[before_src] += 1
|
|
147
|
+
|
|
148
|
+
queue = [k for k, d in indegree.items() if d == 0]
|
|
149
|
+
queue.sort(key=lambda k: index[k])
|
|
150
|
+
ordered: list[str] = []
|
|
151
|
+
|
|
152
|
+
while queue:
|
|
153
|
+
u = queue.pop(0)
|
|
154
|
+
ordered.append(u)
|
|
155
|
+
for v in adj[u]:
|
|
156
|
+
indegree[v] -= 1
|
|
157
|
+
if indegree[v] == 0:
|
|
158
|
+
queue.append(v)
|
|
159
|
+
queue.sort(key=lambda k: index[k])
|
|
160
|
+
|
|
161
|
+
# Fall back to insertion order if cycle detected
|
|
162
|
+
if len(ordered) != len(keys):
|
|
163
|
+
ordered = keys
|
|
164
|
+
|
|
165
|
+
lines: list[str] = []
|
|
166
|
+
for src in ordered:
|
|
167
|
+
stmt = _generate_import_statement(src, grouped[src])
|
|
168
|
+
if stmt:
|
|
169
|
+
lines.append(stmt)
|
|
170
|
+
|
|
171
|
+
return "\n".join(lines)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _generate_lazy_imports_section(imports: Sequence[Import]) -> str:
|
|
175
|
+
"""Generate lazy import factories for code-splitting.
|
|
176
|
+
|
|
177
|
+
Lazy imports are emitted as factory functions compatible with React.lazy.
|
|
178
|
+
React.lazy requires factories that return { default: Component }.
|
|
179
|
+
|
|
180
|
+
For default imports: () => import("./Chart")
|
|
181
|
+
For named imports: () => import("./Chart").then(m => ({ default: m.LineChart }))
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
imports: List of lazy Import objects
|
|
185
|
+
"""
|
|
186
|
+
if not imports:
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
lines: list[str] = ["// Lazy imports"]
|
|
190
|
+
for imp in imports:
|
|
191
|
+
import_src = _get_import_src(imp)
|
|
192
|
+
|
|
193
|
+
if imp.is_default or imp.is_namespace:
|
|
194
|
+
# Default/namespace: () => import("module") - already has { default }
|
|
195
|
+
factory = f'() => import("{import_src}")'
|
|
196
|
+
else:
|
|
197
|
+
# Named: wrap in { default } for React.lazy compatibility
|
|
198
|
+
factory = (
|
|
199
|
+
f'() => import("{import_src}").then(m => ({{ default: m.{imp.name} }}))'
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
lines.append(f"const {imp.js_name} = {factory};")
|
|
203
|
+
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _generate_constants_section(constants: Sequence[Constant]) -> str:
|
|
208
|
+
"""Generate the constants section."""
|
|
209
|
+
if not constants:
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
lines: list[str] = ["// Constants"]
|
|
213
|
+
for const in constants:
|
|
214
|
+
js_value = emit(const.expr)
|
|
215
|
+
lines.append(f"const {const.js_name} = {js_value};")
|
|
216
|
+
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _generate_functions_section(functions: Sequence[AnyJsFunction]) -> str:
|
|
221
|
+
"""Generate the functions section with actual transpiled code."""
|
|
222
|
+
if not functions:
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
lines: list[str] = ["// Functions"]
|
|
226
|
+
for fn in functions:
|
|
227
|
+
js_code = emit(fn.transpile())
|
|
228
|
+
lines.append(js_code)
|
|
229
|
+
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _generate_registry_section(
|
|
234
|
+
imports: Sequence[Import],
|
|
235
|
+
constants: Sequence[Constant],
|
|
236
|
+
functions: Sequence[AnyJsFunction],
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Generate the unified registry from all registered entities.
|
|
239
|
+
|
|
240
|
+
The registry contains all values that need to be looked up at runtime,
|
|
241
|
+
keyed by their unique ID.
|
|
242
|
+
"""
|
|
243
|
+
lines: list[str] = []
|
|
244
|
+
lines.append("// Unified Registry")
|
|
245
|
+
lines.append("const __registry = {")
|
|
246
|
+
|
|
247
|
+
# Add imports
|
|
248
|
+
for imp in imports:
|
|
249
|
+
if not imp.is_side_effect:
|
|
250
|
+
lines.append(f' "{imp.id}": {imp.js_name},')
|
|
251
|
+
|
|
252
|
+
# Add constants
|
|
253
|
+
for const in constants:
|
|
254
|
+
lines.append(f' "{const.id}": {const.js_name},')
|
|
255
|
+
|
|
256
|
+
# Add functions
|
|
257
|
+
for fn in functions:
|
|
258
|
+
lines.append(f' "{fn.id}": {fn.js_name},')
|
|
259
|
+
|
|
260
|
+
lines.append("};")
|
|
261
|
+
|
|
262
|
+
return "\n".join(lines)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def generate_route(
|
|
266
|
+
path: str,
|
|
267
|
+
route_file_path: str,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Generate a route file with all registered imports, functions, and components.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
path: The route path (e.g., "/users/:id")
|
|
273
|
+
route_file_path: Path from pulse root (e.g., "routes/users/index.tsx")
|
|
274
|
+
"""
|
|
275
|
+
with EmitContext(route_file_path=route_file_path):
|
|
276
|
+
# Add core Pulse imports
|
|
277
|
+
pulse_view_import = Import("PulseView", "pulse-ui-client")
|
|
278
|
+
|
|
279
|
+
# Collect function graph (constants + functions in dependency order)
|
|
280
|
+
fn_constants, funcs = collect_function_graph(registered_functions())
|
|
281
|
+
|
|
282
|
+
# Include all registered constants (not just function dependencies)
|
|
283
|
+
# This ensures constants used as component tags are included
|
|
284
|
+
fn_const_ids = {c.id for c in fn_constants}
|
|
285
|
+
all_constants = list(fn_constants)
|
|
286
|
+
for const in registered_constants():
|
|
287
|
+
if const.id not in fn_const_ids:
|
|
288
|
+
all_constants.append(const)
|
|
289
|
+
constants = all_constants
|
|
290
|
+
|
|
291
|
+
# Get all registered imports and split by lazy flag
|
|
292
|
+
all_imports = list(get_registered_imports())
|
|
293
|
+
eager_imports = [imp for imp in all_imports if not imp.lazy]
|
|
294
|
+
lazy_imports = [imp for imp in all_imports if imp.lazy]
|
|
295
|
+
|
|
296
|
+
# Generate output sections
|
|
297
|
+
output_parts: list[str] = []
|
|
298
|
+
|
|
299
|
+
# Eager imports (ES6 import statements)
|
|
300
|
+
imports_section = _generate_imports_section(eager_imports)
|
|
301
|
+
if imports_section:
|
|
302
|
+
output_parts.append(imports_section)
|
|
303
|
+
|
|
304
|
+
output_parts.append("")
|
|
305
|
+
|
|
306
|
+
# Lazy imports (factory functions)
|
|
307
|
+
lazy_section = _generate_lazy_imports_section(lazy_imports)
|
|
308
|
+
if lazy_section:
|
|
309
|
+
output_parts.append(lazy_section)
|
|
310
|
+
output_parts.append("")
|
|
311
|
+
|
|
312
|
+
if constants:
|
|
313
|
+
output_parts.append(_generate_constants_section(constants))
|
|
314
|
+
output_parts.append("")
|
|
315
|
+
|
|
316
|
+
if funcs:
|
|
317
|
+
output_parts.append(_generate_functions_section(funcs))
|
|
318
|
+
output_parts.append("")
|
|
319
|
+
|
|
320
|
+
# Generate the unified registry including all imports, constants and functions
|
|
321
|
+
output_parts.append(_generate_registry_section(all_imports, constants, funcs))
|
|
322
|
+
output_parts.append("")
|
|
323
|
+
|
|
324
|
+
# Route component
|
|
325
|
+
pulse_view_js = pulse_view_import.js_name
|
|
326
|
+
output_parts.append(f'''const path = "{path}";
|
|
327
|
+
|
|
328
|
+
export default function RouteComponent() {{
|
|
329
|
+
return (
|
|
330
|
+
<{pulse_view_js} key={{path}} registry={{__registry}} path={{path}} />
|
|
331
|
+
);
|
|
332
|
+
}}''')
|
|
333
|
+
output_parts.append("")
|
|
334
|
+
|
|
335
|
+
# Headers function
|
|
336
|
+
output_parts.append("""// Action and loader headers are not returned automatically
|
|
337
|
+
function hasAnyHeaders(headers) {
|
|
338
|
+
return [...headers].length > 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function headers({ actionHeaders, loaderHeaders }) {
|
|
342
|
+
return hasAnyHeaders(actionHeaders) ? actionHeaders : loaderHeaders;
|
|
343
|
+
}""")
|
|
344
|
+
|
|
345
|
+
return "\n".join(output_parts)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from mako.template import Template
|
|
2
|
+
|
|
3
|
+
# Mako template for routes configuration
|
|
4
|
+
ROUTES_CONFIG_TEMPLATE = Template(
|
|
5
|
+
"""import {
|
|
6
|
+
type RouteConfig,
|
|
7
|
+
route,
|
|
8
|
+
layout,
|
|
9
|
+
index,
|
|
10
|
+
} from "@react-router/dev/routes";
|
|
11
|
+
import { rrPulseRouteTree, type RRRouteObject } from "./routes.runtime";
|
|
12
|
+
|
|
13
|
+
function toDevRoute(node: RRRouteObject): any {
|
|
14
|
+
const children = (node.children ?? []).map(toDevRoute);
|
|
15
|
+
if (node.index) return index(node.file!);
|
|
16
|
+
if (node.path !== undefined) {
|
|
17
|
+
return children.length ? route(node.path, node.file!, children) : route(node.path, node.file!);
|
|
18
|
+
}
|
|
19
|
+
// Layout node (pathless)
|
|
20
|
+
return layout(node.file!, children);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const routes = [
|
|
24
|
+
layout("${pulse_dir}/_layout.tsx", rrPulseRouteTree.map(toDevRoute)),
|
|
25
|
+
] satisfies RouteConfig;
|
|
26
|
+
"""
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Runtime route tree for matching (used by main layout loader)
|
|
30
|
+
ROUTES_RUNTIME_TEMPLATE = Template(
|
|
31
|
+
"""import type { RouteObject } from "react-router";
|
|
32
|
+
|
|
33
|
+
export type RRRouteObject = RouteObject & {
|
|
34
|
+
id: string;
|
|
35
|
+
uniquePath?: string;
|
|
36
|
+
children?: RRRouteObject[];
|
|
37
|
+
file: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const rrPulseRouteTree = ${routes_str} satisfies RRRouteObject[];
|
|
41
|
+
"""
|
|
42
|
+
)
|
pulse/codegen/utils.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NameRegistry:
|
|
5
|
+
def __init__(self, names: Iterable[str] | None = None) -> None:
|
|
6
|
+
self.names: set[str] = set(names or [])
|
|
7
|
+
|
|
8
|
+
def register(self, name: str, suffix: str | None = None, allow_rename: bool = True):
|
|
9
|
+
if name not in self.names:
|
|
10
|
+
self.names.add(name)
|
|
11
|
+
return name
|
|
12
|
+
if not allow_rename:
|
|
13
|
+
raise ValueError(f"Duplicate identifier {name}")
|
|
14
|
+
i = 2
|
|
15
|
+
aliased = f"{name}{i}"
|
|
16
|
+
while aliased in self.names:
|
|
17
|
+
i += 1
|
|
18
|
+
aliased = f"{name}{i}"
|
|
19
|
+
self.names.add(aliased)
|
|
20
|
+
return aliased
|
pulse/component.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Component definition and VDOM node types for Pulse.
|
|
2
|
+
|
|
3
|
+
This module provides the core component abstraction for building Pulse UIs,
|
|
4
|
+
including the `@component` decorator and the `Component` class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from inspect import Parameter, signature
|
|
11
|
+
from types import CodeType
|
|
12
|
+
from typing import Any, Generic, ParamSpec, TypeVar, overload, override
|
|
13
|
+
|
|
14
|
+
from pulse.code_analysis import is_stub_function
|
|
15
|
+
from pulse.hooks.init import rewrite_init_blocks
|
|
16
|
+
from pulse.transpiler.nodes import (
|
|
17
|
+
Children,
|
|
18
|
+
Node,
|
|
19
|
+
Primitive,
|
|
20
|
+
PulseNode,
|
|
21
|
+
flatten_children,
|
|
22
|
+
)
|
|
23
|
+
from pulse.transpiler.nodes import Element as Element
|
|
24
|
+
from pulse.transpiler.vdom import VDOMNode
|
|
25
|
+
|
|
26
|
+
P = ParamSpec("P")
|
|
27
|
+
_T = TypeVar("_T")
|
|
28
|
+
|
|
29
|
+
_COMPONENT_CODES: set[CodeType] = set()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_component_code(code: CodeType) -> bool:
|
|
33
|
+
return code in _COMPONENT_CODES
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Component(Generic[P]):
|
|
37
|
+
"""A callable wrapper that turns a function into a Pulse component.
|
|
38
|
+
|
|
39
|
+
Component instances are created by the `@component` decorator. When called,
|
|
40
|
+
they return a `PulseNode` that represents the component in the virtual DOM.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
name: Display name of the component (defaults to function name).
|
|
44
|
+
fn: The underlying render function (lazily initialized for stubs).
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@ps.component
|
|
50
|
+
def Card(title: str):
|
|
51
|
+
return ps.div(ps.h3(title))
|
|
52
|
+
|
|
53
|
+
Card(title="Hello") # Returns a PulseNode
|
|
54
|
+
Card(title="Hello", key="card-1") # With reconciliation key
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_raw_fn: Callable[P, Any]
|
|
59
|
+
_fn: Callable[P, Any] | None
|
|
60
|
+
name: str
|
|
61
|
+
_takes_children: bool | None
|
|
62
|
+
|
|
63
|
+
def __init__(self, fn: Callable[P, Any], name: str | None = None) -> None:
|
|
64
|
+
"""Initialize a Component.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
fn: The function to wrap as a component.
|
|
68
|
+
name: Custom display name. Defaults to the function's `__name__`.
|
|
69
|
+
"""
|
|
70
|
+
self._raw_fn = fn
|
|
71
|
+
self.name = name or _infer_component_name(fn)
|
|
72
|
+
# Only lazy-init for stubs (avoid heavy work for JS module bindings)
|
|
73
|
+
# Real components need immediate rewrite for early error detection
|
|
74
|
+
if is_stub_function(fn):
|
|
75
|
+
self._fn = None
|
|
76
|
+
self._takes_children = None
|
|
77
|
+
else:
|
|
78
|
+
self._fn = rewrite_init_blocks(fn)
|
|
79
|
+
self._takes_children = _takes_children(fn)
|
|
80
|
+
_COMPONENT_CODES.add(self._fn.__code__)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def fn(self) -> Callable[P, Any]:
|
|
84
|
+
"""The render function (lazily initialized for stub functions)."""
|
|
85
|
+
if self._fn is None:
|
|
86
|
+
self._fn = rewrite_init_blocks(self._raw_fn)
|
|
87
|
+
self._takes_children = _takes_children(self._raw_fn)
|
|
88
|
+
_COMPONENT_CODES.add(self._fn.__code__)
|
|
89
|
+
return self._fn
|
|
90
|
+
|
|
91
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> PulseNode:
|
|
92
|
+
"""Invoke the component to create a PulseNode.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
*args: Positional arguments passed to the component function.
|
|
96
|
+
**kwargs: Keyword arguments passed to the component function.
|
|
97
|
+
The special `key` kwarg is used for reconciliation.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A PulseNode representing this component invocation in the VDOM.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If `key` is provided but is not a string.
|
|
104
|
+
"""
|
|
105
|
+
key = kwargs.get("key")
|
|
106
|
+
if key is not None and not isinstance(key, str):
|
|
107
|
+
raise ValueError("key must be a string or None")
|
|
108
|
+
|
|
109
|
+
# Access self.fn to trigger lazy init (sets _takes_children)
|
|
110
|
+
_ = self.fn
|
|
111
|
+
if self._takes_children is True and args:
|
|
112
|
+
flattened = flatten_children(
|
|
113
|
+
args, # pyright: ignore[reportArgumentType]
|
|
114
|
+
parent_name=f"<{self.name}>",
|
|
115
|
+
warn_stacklevel=4,
|
|
116
|
+
)
|
|
117
|
+
args = tuple(flattened) # pyright: ignore[reportAssignmentType]
|
|
118
|
+
|
|
119
|
+
return PulseNode(fn=self.fn, args=args, kwargs=kwargs, key=key, name=self.name)
|
|
120
|
+
|
|
121
|
+
@override
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
return f"Component(name={self.name!r}, fn={_callable_qualname(self._raw_fn)!r})"
|
|
124
|
+
|
|
125
|
+
@override
|
|
126
|
+
def __str__(self) -> str:
|
|
127
|
+
return self.name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@overload
|
|
131
|
+
def component(fn: Callable[P, Any]) -> Component[P]: ...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@overload
|
|
135
|
+
def component(
|
|
136
|
+
fn: None = None, *, name: str | None = None
|
|
137
|
+
) -> Callable[[Callable[P, Any]], Component[P]]: ...
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# The explicit return type is necessary for the type checker to be happy
|
|
141
|
+
def component(
|
|
142
|
+
fn: Callable[P, Any] | None = None, *, name: str | None = None
|
|
143
|
+
) -> Component[P] | Callable[[Callable[P, Any]], Component[P]]:
|
|
144
|
+
"""Decorator that creates a Pulse component from a function.
|
|
145
|
+
|
|
146
|
+
Can be used with or without parentheses. The decorated function becomes
|
|
147
|
+
callable and returns a `PulseNode` when invoked.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
fn: Function to wrap as a component. When used as `@component` without
|
|
151
|
+
parentheses, this is the decorated function.
|
|
152
|
+
name: Custom component name for debugging/dev tools. Defaults to the
|
|
153
|
+
function's `__name__`.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A `Component` instance if `fn` is provided, otherwise a decorator.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
|
|
160
|
+
Basic usage:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
@ps.component
|
|
164
|
+
def Card(title: str):
|
|
165
|
+
return ps.div(ps.h3(title))
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
With custom name:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
@ps.component(name="MyCard")
|
|
172
|
+
def card_impl(title: str):
|
|
173
|
+
return ps.div(ps.h3(title))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
With children (use `*children` parameter):
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@ps.component
|
|
180
|
+
def Container(*children):
|
|
181
|
+
return ps.div(*children, className="container")
|
|
182
|
+
|
|
183
|
+
# Children can be passed via subscript syntax:
|
|
184
|
+
Container()[
|
|
185
|
+
Card(title="First"),
|
|
186
|
+
Card(title="Second"),
|
|
187
|
+
]
|
|
188
|
+
```
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def decorator(fn: Callable[P, Any]) -> Component[P]:
|
|
192
|
+
return Component(fn, name)
|
|
193
|
+
|
|
194
|
+
if fn is not None:
|
|
195
|
+
return decorator(fn)
|
|
196
|
+
return decorator
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _takes_children(fn: Callable[..., Any]) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
sig = signature(fn)
|
|
202
|
+
except (ValueError, TypeError):
|
|
203
|
+
return False
|
|
204
|
+
for p in sig.parameters.values():
|
|
205
|
+
if p.kind == Parameter.VAR_POSITIONAL and p.name == "children":
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ----------------------------------------------------------------------------
|
|
211
|
+
# Formatting helpers
|
|
212
|
+
# ----------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _infer_component_name(fn: Callable[..., Any]) -> str:
|
|
216
|
+
name = getattr(fn, "__name__", None)
|
|
217
|
+
if name:
|
|
218
|
+
return name
|
|
219
|
+
return "Component"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _callable_qualname(fn: Callable[..., Any]) -> str:
|
|
223
|
+
mod = getattr(fn, "__module__", "<unknown>")
|
|
224
|
+
qname = getattr(fn, "__qualname__", getattr(fn, "__name__", "<callable>"))
|
|
225
|
+
return f"{mod}.{qname}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = [
|
|
229
|
+
"Node",
|
|
230
|
+
"Children",
|
|
231
|
+
"Component",
|
|
232
|
+
"Element",
|
|
233
|
+
"Primitive",
|
|
234
|
+
"VDOMNode",
|
|
235
|
+
"component",
|
|
236
|
+
"is_component_code",
|
|
237
|
+
]
|
|
File without changes
|