pulse-framework 0.1.54__py3-none-any.whl → 0.1.56__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 +5 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/code_analysis.py +38 -0
- pulse/codegen/codegen.py +61 -62
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +128 -6
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +41 -25
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +190 -44
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +13 -3
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/function.py +6 -2
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +64 -8
- pulse/transpiler/py_module.py +1 -7
- pulse/transpiler/transpiler.py +4 -0
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/js/react_dom.py +0 -30
- pulse/transpiler/react_component.py +0 -51
- pulse_framework-0.1.54.dist-info/RECORD +0 -124
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/codegen/codegen.py
CHANGED
|
@@ -14,7 +14,7 @@ from pulse.codegen.templates.routes_ts import (
|
|
|
14
14
|
)
|
|
15
15
|
from pulse.env import env
|
|
16
16
|
from pulse.routing import Layout, Route, RouteTree
|
|
17
|
-
from pulse.transpiler import
|
|
17
|
+
from pulse.transpiler.assets import get_registered_assets
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from pulse.app import ConnectionStatusConfig
|
|
@@ -24,13 +24,32 @@ logger = logging.getLogger(__file__)
|
|
|
24
24
|
|
|
25
25
|
@dataclass
|
|
26
26
|
class CodegenConfig:
|
|
27
|
-
"""
|
|
28
|
-
|
|
27
|
+
"""Configuration for code generation output paths.
|
|
28
|
+
|
|
29
|
+
Controls where generated React Router files are written. All paths
|
|
30
|
+
can be relative (resolved against base_dir) or absolute.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
web_dir: Root directory for web output. Defaults to "web".
|
|
34
|
+
pulse_dir: Subdirectory for generated Pulse files. Defaults to "pulse".
|
|
35
|
+
base_dir: Base directory for resolving relative paths. If not provided,
|
|
36
|
+
resolved from PULSE_APP_FILE, PULSE_APP_DIR, or cwd.
|
|
29
37
|
|
|
30
38
|
Attributes:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
web_dir: Root directory for web output.
|
|
40
|
+
pulse_dir: Subdirectory name for generated files.
|
|
41
|
+
base_dir: Explicit base directory, if provided.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
```python
|
|
45
|
+
app = ps.App(
|
|
46
|
+
codegen=ps.CodegenConfig(
|
|
47
|
+
web_dir="frontend",
|
|
48
|
+
pulse_dir="generated",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
# Generated files will be at: frontend/app/generated/
|
|
52
|
+
```
|
|
34
53
|
"""
|
|
35
54
|
|
|
36
55
|
web_dir: Path | str = "web"
|
|
@@ -46,11 +65,14 @@ class CodegenConfig:
|
|
|
46
65
|
def resolved_base_dir(self) -> Path:
|
|
47
66
|
"""Resolve the base directory where relative paths should be anchored.
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
Returns:
|
|
69
|
+
Resolved base directory path.
|
|
70
|
+
|
|
71
|
+
Resolution precedence:
|
|
72
|
+
1. Explicit `base_dir` if provided
|
|
73
|
+
2. Directory of PULSE_APP_FILE env var
|
|
74
|
+
3. PULSE_APP_DIR env var
|
|
75
|
+
4. Current working directory
|
|
54
76
|
"""
|
|
55
77
|
if isinstance(self.base_dir, Path):
|
|
56
78
|
return self.base_dir
|
|
@@ -64,7 +86,11 @@ class CodegenConfig:
|
|
|
64
86
|
|
|
65
87
|
@property
|
|
66
88
|
def web_root(self) -> Path:
|
|
67
|
-
"""Absolute path to the web root directory
|
|
89
|
+
"""Absolute path to the web root directory.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Absolute path to web_dir (e.g., `<base_dir>/web`).
|
|
93
|
+
"""
|
|
68
94
|
wd = Path(self.web_dir)
|
|
69
95
|
if wd.is_absolute():
|
|
70
96
|
return wd
|
|
@@ -72,7 +98,12 @@ class CodegenConfig:
|
|
|
72
98
|
|
|
73
99
|
@property
|
|
74
100
|
def pulse_path(self) -> Path:
|
|
75
|
-
"""Full path to the generated app directory.
|
|
101
|
+
"""Full path to the generated Pulse app directory.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Absolute path where generated files are written
|
|
105
|
+
(e.g., `<web_root>/app/<pulse_dir>`).
|
|
106
|
+
"""
|
|
76
107
|
return self.web_root / "app" / self.pulse_dir
|
|
77
108
|
|
|
78
109
|
|
|
@@ -123,7 +154,7 @@ class Codegen:
|
|
|
123
154
|
self._copied_files = set()
|
|
124
155
|
|
|
125
156
|
# Copy all registered local files to the assets directory
|
|
126
|
-
|
|
157
|
+
self._copy_local_files()
|
|
127
158
|
|
|
128
159
|
# Keep track of all generated files
|
|
129
160
|
generated_files = set(
|
|
@@ -137,11 +168,7 @@ class Codegen:
|
|
|
137
168
|
self.generate_routes_ts(),
|
|
138
169
|
self.generate_routes_runtime_ts(),
|
|
139
170
|
*(
|
|
140
|
-
self.generate_route(
|
|
141
|
-
route,
|
|
142
|
-
server_address=server_address,
|
|
143
|
-
asset_import_paths=asset_import_paths,
|
|
144
|
-
)
|
|
171
|
+
self.generate_route(route, server_address=server_address)
|
|
145
172
|
for route in self.routes.flat_tree.values()
|
|
146
173
|
),
|
|
147
174
|
]
|
|
@@ -157,52 +184,27 @@ class Codegen:
|
|
|
157
184
|
except Exception as e:
|
|
158
185
|
logger.warning(f"Could not remove stale file {path}: {e}")
|
|
159
186
|
|
|
160
|
-
def _copy_local_files(self) ->
|
|
161
|
-
"""Copy all registered local
|
|
187
|
+
def _copy_local_files(self) -> None:
|
|
188
|
+
"""Copy all registered local assets to the assets directory.
|
|
162
189
|
|
|
163
|
-
|
|
164
|
-
|
|
190
|
+
Uses the unified asset registry which tracks local files from both
|
|
191
|
+
Import objects and DynamicImport expressions.
|
|
165
192
|
"""
|
|
166
|
-
|
|
167
|
-
local_imports = [imp for imp in imports if imp.is_local]
|
|
193
|
+
assets = get_registered_assets()
|
|
168
194
|
|
|
169
|
-
if not
|
|
170
|
-
return
|
|
195
|
+
if not assets:
|
|
196
|
+
return
|
|
171
197
|
|
|
172
198
|
self.assets_folder.mkdir(parents=True, exist_ok=True)
|
|
173
|
-
asset_import_paths: dict[str, str] = {}
|
|
174
|
-
|
|
175
|
-
for imp in local_imports:
|
|
176
|
-
if imp.source_path is None:
|
|
177
|
-
continue
|
|
178
199
|
|
|
179
|
-
|
|
180
|
-
dest_path = self.assets_folder / asset_filename
|
|
200
|
+
for asset in assets:
|
|
201
|
+
dest_path = self.assets_folder / asset.asset_filename
|
|
181
202
|
|
|
182
203
|
# Copy file if source exists
|
|
183
|
-
if
|
|
184
|
-
shutil.copy2(
|
|
204
|
+
if asset.source_path.exists():
|
|
205
|
+
shutil.copy2(asset.source_path, dest_path)
|
|
185
206
|
self._copied_files.add(dest_path)
|
|
186
|
-
logger.debug(f"Copied {
|
|
187
|
-
|
|
188
|
-
# Store just the asset filename - the relative path is computed per-route
|
|
189
|
-
asset_import_paths[imp.src] = asset_filename
|
|
190
|
-
|
|
191
|
-
return asset_import_paths
|
|
192
|
-
|
|
193
|
-
def _compute_asset_prefix(self, route_file_path: str) -> str:
|
|
194
|
-
"""Compute the relative path prefix from a route file to the assets folder.
|
|
195
|
-
|
|
196
|
-
Args:
|
|
197
|
-
route_file_path: The route's file path (e.g., "users/_id_xxx.jsx")
|
|
198
|
-
|
|
199
|
-
Returns:
|
|
200
|
-
The relative path prefix (e.g., "../assets/" or "../../assets/")
|
|
201
|
-
"""
|
|
202
|
-
# Count directory depth: each "/" in the path adds one level
|
|
203
|
-
depth = route_file_path.count("/")
|
|
204
|
-
# Add 1 for the routes/ or layouts/ folder itself
|
|
205
|
-
return "../" * (depth + 1) + "assets/"
|
|
207
|
+
logger.debug(f"Copied {asset.source_path} -> {dest_path}")
|
|
206
208
|
|
|
207
209
|
def generate_layout_tsx(
|
|
208
210
|
self,
|
|
@@ -281,21 +283,18 @@ class Codegen:
|
|
|
281
283
|
self,
|
|
282
284
|
route: Route | Layout,
|
|
283
285
|
server_address: str,
|
|
284
|
-
asset_import_paths: dict[str, str],
|
|
285
286
|
):
|
|
286
287
|
route_file_path = route.file_path()
|
|
287
288
|
if isinstance(route, Layout):
|
|
288
289
|
output_path = self.output_folder / "layouts" / route_file_path
|
|
290
|
+
full_route_path = f"layouts/{route_file_path}"
|
|
289
291
|
else:
|
|
290
292
|
output_path = self.output_folder / "routes" / route_file_path
|
|
291
|
-
|
|
292
|
-
# Compute asset prefix based on route depth
|
|
293
|
-
asset_prefix = self._compute_asset_prefix(route_file_path)
|
|
293
|
+
full_route_path = f"routes/{route_file_path}"
|
|
294
294
|
|
|
295
295
|
content = generate_route(
|
|
296
296
|
path=route.unique_path(),
|
|
297
|
-
|
|
298
|
-
asset_prefix=asset_prefix,
|
|
297
|
+
route_file_path=full_route_path,
|
|
299
298
|
)
|
|
300
299
|
return write_file_if_changed(output_path, content)
|
|
301
300
|
|
pulse/codegen/templates/route.py
CHANGED
|
@@ -10,24 +10,29 @@ from pulse.transpiler import (
|
|
|
10
10
|
collect_function_graph,
|
|
11
11
|
emit,
|
|
12
12
|
get_registered_imports,
|
|
13
|
+
registered_constants,
|
|
13
14
|
registered_functions,
|
|
14
15
|
)
|
|
16
|
+
from pulse.transpiler.emit_context import EmitContext
|
|
15
17
|
from pulse.transpiler.function import AnyJsFunction
|
|
16
18
|
|
|
17
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
|
+
|
|
18
27
|
def _generate_import_statement(
|
|
19
28
|
src: str,
|
|
20
29
|
imports: list[Import],
|
|
21
|
-
asset_filenames: dict[str, str] | None = None,
|
|
22
|
-
asset_prefix: str = "../assets/",
|
|
23
30
|
) -> str:
|
|
24
31
|
"""Generate import statement(s) for a source module.
|
|
25
32
|
|
|
26
33
|
Args:
|
|
27
34
|
src: The original source path (may be remapped for local imports)
|
|
28
35
|
imports: List of Import objects for this source
|
|
29
|
-
asset_filenames: Mapping of original source paths to asset filenames
|
|
30
|
-
asset_prefix: Relative path prefix from route file to assets folder
|
|
31
36
|
"""
|
|
32
37
|
default_imports: list[Import] = []
|
|
33
38
|
namespace_imports: list[Import] = []
|
|
@@ -53,8 +58,10 @@ def _generate_import_statement(
|
|
|
53
58
|
|
|
54
59
|
# Remap source path if this is a local import
|
|
55
60
|
import_src = src
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
for imp in imports:
|
|
62
|
+
if imp.asset:
|
|
63
|
+
import_src = imp.asset.import_path()
|
|
64
|
+
break
|
|
58
65
|
|
|
59
66
|
lines: list[str] = []
|
|
60
67
|
|
|
@@ -98,17 +105,11 @@ def _generate_import_statement(
|
|
|
98
105
|
return "\n".join(lines)
|
|
99
106
|
|
|
100
107
|
|
|
101
|
-
def _generate_imports_section(
|
|
102
|
-
imports: Sequence[Import],
|
|
103
|
-
asset_filenames: dict[str, str] | None = None,
|
|
104
|
-
asset_prefix: str = "../assets/",
|
|
105
|
-
) -> str:
|
|
108
|
+
def _generate_imports_section(imports: Sequence[Import]) -> str:
|
|
106
109
|
"""Generate the full imports section with deduplication and topological ordering.
|
|
107
110
|
|
|
108
111
|
Args:
|
|
109
|
-
imports: List of Import objects to generate
|
|
110
|
-
asset_filenames: Mapping of original source paths to asset filenames
|
|
111
|
-
asset_prefix: Relative path prefix from route file to assets folder
|
|
112
|
+
imports: List of Import objects to generate (should be eager imports only)
|
|
112
113
|
"""
|
|
113
114
|
if not imports:
|
|
114
115
|
return ""
|
|
@@ -163,15 +164,46 @@ def _generate_imports_section(
|
|
|
163
164
|
|
|
164
165
|
lines: list[str] = []
|
|
165
166
|
for src in ordered:
|
|
166
|
-
stmt = _generate_import_statement(
|
|
167
|
-
src, grouped[src], asset_filenames, asset_prefix
|
|
168
|
-
)
|
|
167
|
+
stmt = _generate_import_statement(src, grouped[src])
|
|
169
168
|
if stmt:
|
|
170
169
|
lines.append(stmt)
|
|
171
170
|
|
|
172
171
|
return "\n".join(lines)
|
|
173
172
|
|
|
174
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
|
+
|
|
175
207
|
def _generate_constants_section(constants: Sequence[Constant]) -> str:
|
|
176
208
|
"""Generate the constants section."""
|
|
177
209
|
if not constants:
|
|
@@ -232,64 +264,76 @@ def _generate_registry_section(
|
|
|
232
264
|
|
|
233
265
|
def generate_route(
|
|
234
266
|
path: str,
|
|
235
|
-
|
|
236
|
-
asset_prefix: str = "../assets/",
|
|
267
|
+
route_file_path: str,
|
|
237
268
|
) -> str:
|
|
238
269
|
"""Generate a route file with all registered imports, functions, and components.
|
|
239
270
|
|
|
240
271
|
Args:
|
|
241
272
|
path: The route path (e.g., "/users/:id")
|
|
242
|
-
|
|
243
|
-
asset_prefix: Relative path prefix from route file to assets folder
|
|
273
|
+
route_file_path: Path from pulse root (e.g., "routes/users/index.tsx")
|
|
244
274
|
"""
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
247
303
|
|
|
248
|
-
|
|
249
|
-
pulse_view_import = Import("PulseView", "pulse-ui-client")
|
|
250
|
-
|
|
251
|
-
# Collect function graph (constants + functions in dependency order)
|
|
252
|
-
constants, funcs = collect_function_graph(registered_functions())
|
|
253
|
-
|
|
254
|
-
# Get all registered imports
|
|
255
|
-
all_imports = list(get_registered_imports())
|
|
304
|
+
output_parts.append("")
|
|
256
305
|
|
|
257
|
-
|
|
258
|
-
|
|
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("")
|
|
259
311
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if imports_section:
|
|
264
|
-
output_parts.append(imports_section)
|
|
312
|
+
if constants:
|
|
313
|
+
output_parts.append(_generate_constants_section(constants))
|
|
314
|
+
output_parts.append("")
|
|
265
315
|
|
|
266
|
-
|
|
316
|
+
if funcs:
|
|
317
|
+
output_parts.append(_generate_functions_section(funcs))
|
|
318
|
+
output_parts.append("")
|
|
267
319
|
|
|
268
|
-
|
|
269
|
-
output_parts.append(
|
|
320
|
+
# Generate the unified registry including all imports, constants and functions
|
|
321
|
+
output_parts.append(_generate_registry_section(all_imports, constants, funcs))
|
|
270
322
|
output_parts.append("")
|
|
271
323
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
output_parts.append(""
|
|
275
|
-
|
|
276
|
-
# Generate the unified registry including all imports, constants and functions
|
|
277
|
-
output_parts.append(_generate_registry_section(all_imports, constants, funcs))
|
|
278
|
-
output_parts.append("")
|
|
279
|
-
|
|
280
|
-
# Route component
|
|
281
|
-
pulse_view_js = pulse_view_import.js_name
|
|
282
|
-
output_parts.append(f'''const path = "{path}";
|
|
324
|
+
# Route component
|
|
325
|
+
pulse_view_js = pulse_view_import.js_name
|
|
326
|
+
output_parts.append(f'''const path = "{path}";
|
|
283
327
|
|
|
284
328
|
export default function RouteComponent() {{
|
|
285
329
|
return (
|
|
286
330
|
<{pulse_view_js} key={{path}} registry={{__registry}} path={{path}} />
|
|
287
331
|
);
|
|
288
332
|
}}''')
|
|
289
|
-
|
|
333
|
+
output_parts.append("")
|
|
290
334
|
|
|
291
|
-
|
|
292
|
-
|
|
335
|
+
# Headers function
|
|
336
|
+
output_parts.append("""// Action and loader headers are not returned automatically
|
|
293
337
|
function hasAnyHeaders(headers) {
|
|
294
338
|
return [...headers].length > 0;
|
|
295
339
|
}
|
|
@@ -298,4 +342,4 @@ export function headers({ actionHeaders, loaderHeaders }) {
|
|
|
298
342
|
return hasAnyHeaders(actionHeaders) ? actionHeaders : loaderHeaders;
|
|
299
343
|
}""")
|
|
300
344
|
|
|
301
|
-
|
|
345
|
+
return "\n".join(output_parts)
|
pulse/component.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
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
|
+
|
|
1
7
|
from __future__ import annotations
|
|
2
8
|
|
|
3
9
|
from collections.abc import Callable
|
|
4
10
|
from inspect import Parameter, signature
|
|
11
|
+
from types import CodeType
|
|
5
12
|
from typing import Any, Generic, ParamSpec, TypeVar, overload, override
|
|
6
13
|
|
|
14
|
+
from pulse.code_analysis import is_stub_function
|
|
7
15
|
from pulse.hooks.init import rewrite_init_blocks
|
|
8
16
|
from pulse.transpiler.nodes import (
|
|
9
17
|
Children,
|
|
@@ -18,23 +26,89 @@ from pulse.transpiler.vdom import VDOMNode
|
|
|
18
26
|
P = ParamSpec("P")
|
|
19
27
|
_T = TypeVar("_T")
|
|
20
28
|
|
|
29
|
+
_COMPONENT_CODES: set[CodeType] = set()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_component_code(code: CodeType) -> bool:
|
|
33
|
+
return code in _COMPONENT_CODES
|
|
34
|
+
|
|
21
35
|
|
|
22
36
|
class Component(Generic[P]):
|
|
23
|
-
|
|
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
|
|
24
60
|
name: str
|
|
25
|
-
_takes_children: bool
|
|
61
|
+
_takes_children: bool | None
|
|
26
62
|
|
|
27
63
|
def __init__(self, fn: Callable[P, Any], name: str | None = None) -> None:
|
|
28
|
-
|
|
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
|
|
29
71
|
self.name = name or _infer_component_name(fn)
|
|
30
|
-
|
|
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
|
|
31
90
|
|
|
32
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
|
+
"""
|
|
33
105
|
key = kwargs.get("key")
|
|
34
106
|
if key is not None and not isinstance(key, str):
|
|
35
107
|
raise ValueError("key must be a string or None")
|
|
36
108
|
|
|
37
|
-
|
|
109
|
+
# Access self.fn to trigger lazy init (sets _takes_children)
|
|
110
|
+
_ = self.fn
|
|
111
|
+
if self._takes_children is True and args:
|
|
38
112
|
flattened = flatten_children(
|
|
39
113
|
args, # pyright: ignore[reportArgumentType]
|
|
40
114
|
parent_name=f"<{self.name}>",
|
|
@@ -46,7 +120,7 @@ class Component(Generic[P]):
|
|
|
46
120
|
|
|
47
121
|
@override
|
|
48
122
|
def __repr__(self) -> str:
|
|
49
|
-
return f"Component(name={self.name!r}, fn={_callable_qualname(self.
|
|
123
|
+
return f"Component(name={self.name!r}, fn={_callable_qualname(self._raw_fn)!r})"
|
|
50
124
|
|
|
51
125
|
@override
|
|
52
126
|
def __str__(self) -> str:
|
|
@@ -67,6 +141,53 @@ def component(
|
|
|
67
141
|
def component(
|
|
68
142
|
fn: Callable[P, Any] | None = None, *, name: str | None = None
|
|
69
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
|
+
|
|
70
191
|
def decorator(fn: Callable[P, Any]) -> Component[P]:
|
|
71
192
|
return Component(fn, name)
|
|
72
193
|
|
|
@@ -112,4 +233,5 @@ __all__ = [
|
|
|
112
233
|
"Primitive",
|
|
113
234
|
"VDOMNode",
|
|
114
235
|
"component",
|
|
236
|
+
"is_component_code",
|
|
115
237
|
]
|
pulse/components/for_.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
"""For loop component for mapping items to elements.
|
|
2
|
+
|
|
3
|
+
Provides a declarative way to render lists, similar to JavaScript's Array.map().
|
|
4
|
+
"""
|
|
5
|
+
|
|
1
6
|
from collections.abc import Callable, Iterable
|
|
2
7
|
from inspect import Parameter, signature
|
|
3
8
|
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
@@ -27,11 +32,32 @@ def For(items: Iterable[T], fn: Callable[[T, int], Element]) -> list[Element]: .
|
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
def For(items: Iterable[T], fn: Callable[..., Element]) -> list[Element]:
|
|
30
|
-
"""Map items to elements,
|
|
35
|
+
"""Map items to elements, like JavaScript's Array.map().
|
|
36
|
+
|
|
37
|
+
Iterates over `items` and calls `fn` for each one, returning a list of
|
|
38
|
+
elements. The mapper function can accept either one argument (item) or
|
|
39
|
+
two arguments (item, index).
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
items: Iterable of items to map over.
|
|
43
|
+
fn: Mapper function that receives `(item)` or `(item, index)` and
|
|
44
|
+
returns an Element. If `fn` has a `*args` parameter, it receives
|
|
45
|
+
both item and index.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A list of Elements, one for each item.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
Single argument (item only)::
|
|
52
|
+
|
|
53
|
+
ps.For(users, lambda user: UserCard(user=user, key=user.id))
|
|
54
|
+
|
|
55
|
+
With index::
|
|
56
|
+
|
|
57
|
+
ps.For(items, lambda item, i: ps.li(f"{i}: {item}", key=str(i)))
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Array.map. If `fn` declares `*args`, it will receive `(item, index)`.
|
|
59
|
+
Note:
|
|
60
|
+
In transpiled `@javascript` code, `For` compiles to `.map()`.
|
|
35
61
|
"""
|
|
36
62
|
try:
|
|
37
63
|
sig = signature(fn)
|