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.
Files changed (80) hide show
  1. pulse/__init__.py +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/code_analysis.py +38 -0
  6. pulse/codegen/codegen.py +61 -62
  7. pulse/codegen/templates/route.py +100 -56
  8. pulse/component.py +128 -6
  9. pulse/components/for_.py +30 -4
  10. pulse/components/if_.py +28 -5
  11. pulse/components/react_router.py +61 -3
  12. pulse/context.py +39 -5
  13. pulse/cookies.py +108 -4
  14. pulse/decorators.py +193 -24
  15. pulse/env.py +56 -2
  16. pulse/form.py +198 -5
  17. pulse/helpers.py +7 -1
  18. pulse/hooks/core.py +135 -5
  19. pulse/hooks/effects.py +61 -77
  20. pulse/hooks/init.py +60 -1
  21. pulse/hooks/runtime.py +241 -0
  22. pulse/hooks/setup.py +77 -0
  23. pulse/hooks/stable.py +58 -1
  24. pulse/hooks/state.py +107 -20
  25. pulse/js/__init__.py +41 -25
  26. pulse/js/array.py +9 -6
  27. pulse/js/console.py +15 -12
  28. pulse/js/date.py +9 -6
  29. pulse/js/document.py +5 -2
  30. pulse/js/error.py +7 -4
  31. pulse/js/json.py +9 -6
  32. pulse/js/map.py +8 -5
  33. pulse/js/math.py +9 -6
  34. pulse/js/navigator.py +5 -2
  35. pulse/js/number.py +9 -6
  36. pulse/js/obj.py +16 -13
  37. pulse/js/object.py +9 -6
  38. pulse/js/promise.py +19 -13
  39. pulse/js/pulse.py +28 -25
  40. pulse/js/react.py +190 -44
  41. pulse/js/regexp.py +7 -4
  42. pulse/js/set.py +8 -5
  43. pulse/js/string.py +9 -6
  44. pulse/js/weakmap.py +8 -5
  45. pulse/js/weakset.py +8 -5
  46. pulse/js/window.py +6 -3
  47. pulse/messages.py +5 -0
  48. pulse/middleware.py +147 -76
  49. pulse/plugin.py +76 -5
  50. pulse/queries/client.py +186 -39
  51. pulse/queries/common.py +52 -3
  52. pulse/queries/infinite_query.py +154 -2
  53. pulse/queries/mutation.py +127 -7
  54. pulse/queries/query.py +112 -11
  55. pulse/react_component.py +66 -3
  56. pulse/reactive.py +314 -30
  57. pulse/reactive_extensions.py +106 -26
  58. pulse/render_session.py +304 -173
  59. pulse/request.py +46 -11
  60. pulse/routing.py +140 -4
  61. pulse/serializer.py +71 -0
  62. pulse/state.py +177 -9
  63. pulse/test_helpers.py +15 -0
  64. pulse/transpiler/__init__.py +13 -3
  65. pulse/transpiler/assets.py +66 -0
  66. pulse/transpiler/dynamic_import.py +131 -0
  67. pulse/transpiler/emit_context.py +49 -0
  68. pulse/transpiler/function.py +6 -2
  69. pulse/transpiler/imports.py +33 -27
  70. pulse/transpiler/js_module.py +64 -8
  71. pulse/transpiler/py_module.py +1 -7
  72. pulse/transpiler/transpiler.py +4 -0
  73. pulse/user_session.py +119 -18
  74. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  75. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  76. pulse/js/react_dom.py +0 -30
  77. pulse/transpiler/react_component.py +0 -51
  78. pulse_framework-0.1.54.dist-info/RECORD +0 -124
  79. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  80. {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 get_registered_imports
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
- Configuration for code generation.
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
- web_dir (str): Root directory for the web output.
32
- pulse_dir (str): Name of the Pulse app directory.
33
- pulse_path (Path): Full path to the generated app directory.
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
- Precedence:
50
- 1) Explicit `base_dir` if provided
51
- 2) Env var `PULSE_APP_FILE` (directory of the file)
52
- 3) Env var `PULSE_APP_DIR`
53
- 4) Current working directory
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 (e.g. `<app_dir>/pulse-web`)."""
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
- asset_import_paths = self._copy_local_files()
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) -> dict[str, str]:
161
- """Copy all registered local files to the assets directory.
187
+ def _copy_local_files(self) -> None:
188
+ """Copy all registered local assets to the assets directory.
162
189
 
163
- Collects all Import objects with is_local=True and copies their
164
- source files to the assets folder, returning an import path mapping.
190
+ Uses the unified asset registry which tracks local files from both
191
+ Import objects and DynamicImport expressions.
165
192
  """
166
- imports = get_registered_imports()
167
- local_imports = [imp for imp in imports if imp.is_local]
193
+ assets = get_registered_assets()
168
194
 
169
- if not local_imports:
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
- asset_filename = imp.asset_filename()
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 imp.source_path.exists():
184
- shutil.copy2(imp.source_path, dest_path)
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 {imp.source_path} -> {dest_path}")
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
- asset_filenames=asset_import_paths,
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
 
@@ -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
- if asset_filenames and src in asset_filenames:
57
- import_src = asset_prefix + asset_filenames[src]
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
- asset_filenames: dict[str, str] | None = None,
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
- asset_filenames: Mapping of original source paths to asset filenames
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
- # Note: Lazy component support is not yet implemented.
246
- # Components now register via the unified registry.
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
- # Add core Pulse imports
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
- # Generate output sections
258
- output_parts: list[str] = []
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
- imports_section = _generate_imports_section(
261
- all_imports, asset_filenames, asset_prefix
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
- output_parts.append("")
316
+ if funcs:
317
+ output_parts.append(_generate_functions_section(funcs))
318
+ output_parts.append("")
267
319
 
268
- if constants:
269
- output_parts.append(_generate_constants_section(constants))
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
- if funcs:
273
- output_parts.append(_generate_functions_section(funcs))
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
- output_parts.append("")
333
+ output_parts.append("")
290
334
 
291
- # Headers function
292
- output_parts.append("""// Action and loader headers are not returned automatically
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
- return "\n".join(output_parts)
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
- fn: Callable[P, Any]
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
- self.fn = rewrite_init_blocks(fn)
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
- self._takes_children = _takes_children(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
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
- if self._takes_children and args:
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.fn)!r})"
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, passing `(item)` or `(item, index)`.
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
- The callable `fn` may accept either a single positional argument (the item)
33
- or two positional arguments (the item and its index), similar to JavaScript's
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)