pulse-framework 0.1.52__py3-none-any.whl → 0.1.54__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/renderer.py CHANGED
@@ -65,48 +65,43 @@ class RenderPropTask(NamedTuple):
65
65
 
66
66
 
67
67
  class RenderTree:
68
- root: Node
68
+ element: Node
69
69
  callbacks: Callbacks
70
- operations: list[VDOMOperation]
71
- _normalized: Node | None
70
+ rendered: bool
72
71
 
73
- def __init__(self, root: Node) -> None:
74
- self.root = root
72
+ def __init__(self, element: Node) -> None:
73
+ self.element = element
75
74
  self.callbacks = {}
76
- self.operations = []
77
- self._normalized = None
75
+ self.rendered = False
78
76
 
79
77
  def render(self) -> VDOM:
78
+ """First render. Returns VDOM."""
80
79
  renderer = Renderer()
81
- vdom, normalized = renderer.render_tree(self.root)
82
- self.root = normalized
80
+ vdom, self.element = renderer.render_tree(self.element)
83
81
  self.callbacks = renderer.callbacks
84
- self._normalized = normalized
82
+ self.rendered = True
85
83
  return vdom
86
84
 
87
- def diff(self, new_tree: Node) -> list[VDOMOperation]:
88
- if self._normalized is None:
89
- raise RuntimeError("RenderTree.render must be called before diff")
85
+ def rerender(self, new_element: Node | None = None) -> list[VDOMOperation]:
86
+ """Re-render and return update operations.
90
87
 
88
+ If new_element is provided, reconciles against it (for testing).
89
+ Otherwise, reconciles against the current element (production use).
90
+ """
91
+ if not self.rendered:
92
+ raise RuntimeError("render() must be called before rerender()")
93
+ target = new_element if new_element is not None else self.element
91
94
  renderer = Renderer()
92
- normalized = renderer.reconcile_tree(self._normalized, new_tree, path="")
93
-
95
+ self.element = renderer.reconcile_tree(self.element, target, path="")
94
96
  self.callbacks = renderer.callbacks
95
- self._normalized = normalized
96
- self.root = normalized
97
-
98
97
  return renderer.operations
99
98
 
100
99
  def unmount(self) -> None:
101
- if self._normalized is not None:
102
- unmount_element(self._normalized)
103
- self._normalized = None
100
+ if self.rendered:
101
+ unmount_element(self.element)
102
+ self.rendered = False
104
103
  self.callbacks.clear()
105
104
 
106
- @property
107
- def normalized(self) -> Node | None:
108
- return self._normalized
109
-
110
105
 
111
106
  class Renderer:
112
107
  def __init__(self) -> None:
@@ -123,13 +118,11 @@ class Renderer:
123
118
  if isinstance(node, Element):
124
119
  return self.render_node(node, path)
125
120
  if isinstance(node, Value):
126
- json_value = coerce_json(node.value, path)
127
- return json_value, json_value
121
+ return node.value, node.value
128
122
  if isinstance(node, Expr):
129
123
  return node.render(), node
130
- if is_json_primitive(node):
131
- return node, node
132
- raise TypeError(f"Unsupported node type: {type(node).__name__}")
124
+ # Pass through any other value - serializer will validate
125
+ return node, node
133
126
 
134
127
  def render_component(
135
128
  self, component: PulseNode, path: str
@@ -148,7 +141,7 @@ class Renderer:
148
141
  if (key_val := key_value(element)) is not None:
149
142
  vdom_node["key"] = key_val
150
143
 
151
- props = element.props or {}
144
+ props = element.props_dict()
152
145
  props_result = self.diff_props({}, props, path, prev_eval=set())
153
146
  if props_result.delta_set:
154
147
  vdom_node["props"] = props_result.delta_set
@@ -188,9 +181,9 @@ class Renderer:
188
181
  path: str = "",
189
182
  ) -> Node:
190
183
  if isinstance(current, Value):
191
- current = coerce_json(current.value, path)
184
+ current = current.value
192
185
  if isinstance(previous, Value):
193
- previous = coerce_json(previous.value, path)
186
+ previous = previous.value
194
187
  if not same_node(previous, current):
195
188
  unmount_element(previous)
196
189
  new_vdom, normalized = self.render_tree(current, path)
@@ -239,8 +232,8 @@ class Renderer:
239
232
  current: Element,
240
233
  path: str,
241
234
  ) -> Element:
242
- prev_props = previous.props or {}
243
- new_props = current.props or {}
235
+ prev_props = previous.props_dict()
236
+ new_props = current.props_dict()
244
237
  prev_eval = eval_keys_for_props(prev_props)
245
238
  props_result = self.diff_props(prev_props, new_props, path, prev_eval)
246
239
 
@@ -389,14 +382,14 @@ class Renderer:
389
382
  continue
390
383
 
391
384
  if isinstance(value, Value):
392
- json_value = coerce_json(value.value, prop_path)
385
+ unwrapped = value.value
393
386
  if normalized is None:
394
387
  normalized = current.copy()
395
- normalized[key] = json_value
388
+ normalized[key] = unwrapped
396
389
  if isinstance(old_value, (Element, PulseNode)):
397
390
  unmount_element(old_value)
398
- if key not in previous or not values_equal(json_value, old_value):
399
- updated[key] = cast(VDOMPropValue, json_value)
391
+ if key not in previous or not values_equal(unwrapped, old_value):
392
+ updated[key] = cast(VDOMPropValue, unwrapped)
400
393
  continue
401
394
 
402
395
  if isinstance(value, Expr):
@@ -422,16 +415,11 @@ class Renderer:
422
415
  updated[key] = CALLBACK_PLACEHOLDER
423
416
  continue
424
417
 
425
- json_value = coerce_json(value, prop_path)
426
418
  if isinstance(old_value, (Element, PulseNode)):
427
419
  unmount_element(old_value)
428
- if normalized is not None:
429
- normalized[key] = json_value
430
- elif json_value is not value:
431
- normalized = current.copy()
432
- normalized[key] = json_value
433
- if key not in previous or not values_equal(json_value, old_value):
434
- updated[key] = cast(VDOMPropValue, json_value)
420
+ # No normalization needed - value passes through unchanged
421
+ if key not in previous or not values_equal(value, old_value):
422
+ updated[key] = cast(VDOMPropValue, value)
435
423
 
436
424
  for key in removed_keys:
437
425
  old_value = previous.get(key)
@@ -488,31 +476,6 @@ def registry_ref(expr: Expr) -> RegistryRef | None:
488
476
  return None
489
477
 
490
478
 
491
- def is_json_primitive(value: Any) -> bool:
492
- return value is None or isinstance(value, (str, int, float, bool))
493
-
494
-
495
- def coerce_json(value: Any, path: str) -> Any:
496
- """Convert Python value to JSON-compatible structure.
497
-
498
- Performs runtime conversions:
499
- - tuple → list
500
- - validates dict keys are strings
501
- """
502
- if is_json_primitive(value):
503
- return value
504
- if isinstance(value, (list, tuple)):
505
- return [coerce_json(v, path) for v in value]
506
- if isinstance(value, dict):
507
- out: dict[str, Any] = {}
508
- for k, v in value.items():
509
- if not isinstance(k, str):
510
- raise TypeError(f"Non-string prop key at {path}: {k!r}")
511
- out[k] = coerce_json(v, path)
512
- return out
513
- raise TypeError(f"Unsupported JSON value at {path}: {type(value).__name__}")
514
-
515
-
516
479
  def prop_requires_eval(value: PropValue) -> bool:
517
480
  if isinstance(value, Value):
518
481
  return False
@@ -611,7 +574,7 @@ def unmount_element(element: Node) -> None:
611
574
  return
612
575
 
613
576
  if isinstance(element, Element):
614
- props = element.props or {}
577
+ props = element.props_dict()
615
578
  for value in props.values():
616
579
  if isinstance(value, (Element, PulseNode)):
617
580
  unmount_element(value)
pulse/serializer.py CHANGED
@@ -86,8 +86,11 @@ def serialize(data: Any) -> Serialized:
86
86
  if isinstance(value, dict):
87
87
  result_dict: dict[str, PlainJSON] = {}
88
88
  for key, entry in value.items():
89
- key_str = str(key) # pyright: ignore[reportUnknownArgumentType]
90
- result_dict[key_str] = process(entry)
89
+ if not isinstance(key, str):
90
+ raise TypeError(
91
+ f"Dict keys must be strings, got {type(key).__name__}: {key!r}" # pyright: ignore[reportUnknownArgumentType]
92
+ )
93
+ result_dict[key] = process(entry)
91
94
  return result_dict
92
95
 
93
96
  if isinstance(value, (list, tuple)):
@@ -21,7 +21,6 @@ from pulse.transpiler.nodes import (
21
21
  Literal,
22
22
  Member,
23
23
  New,
24
- Object,
25
24
  Spread,
26
25
  Subscript,
27
26
  Template,
@@ -173,25 +172,6 @@ def emit_dict(*args: Any, ctx: Transpiler) -> Expr:
173
172
  raise TranspileError("dict() expects at most one argument")
174
173
 
175
174
 
176
- @transformer("obj")
177
- def obj(*args: Any, ctx: Transpiler, **kwargs: Any) -> Expr:
178
- """obj(key=value, ...) -> { key: value, ... }
179
-
180
- Creates a plain JavaScript object literal.
181
- Use this instead of dict() when you need a plain object (e.g., for React props).
182
-
183
- Example:
184
- style=obj(display="block", color="red")
185
- -> style={{ display: "block", color: "red" }}
186
- """
187
- if args:
188
- raise TranspileError("obj() only accepts keyword arguments")
189
- props: list[tuple[str, Expr]] = []
190
- for key, value in kwargs.items():
191
- props.append((key, ctx.emit_expr(value)))
192
- return Object(props)
193
-
194
-
195
175
  @transformer("filter")
196
176
  def emit_filter(*args: Any, ctx: Transpiler) -> Expr:
197
177
  """filter(func, iterable) -> iterable.filter(func)"""
@@ -13,6 +13,7 @@ class TranspileError(Exception):
13
13
  source: str | None
14
14
  filename: str | None
15
15
  func_name: str | None
16
+ source_start_line: int | None
16
17
 
17
18
  def __init__(
18
19
  self,
@@ -22,12 +23,14 @@ class TranspileError(Exception):
22
23
  source: str | None = None,
23
24
  filename: str | None = None,
24
25
  func_name: str | None = None,
26
+ source_start_line: int | None = None,
25
27
  ) -> None:
26
28
  self.message = message
27
29
  self.node = node
28
30
  self.source = source
29
31
  self.filename = filename
30
32
  self.func_name = func_name
33
+ self.source_start_line = source_start_line
31
34
  super().__init__(self._format_message())
32
35
 
33
36
  def _format_message(self) -> str:
@@ -38,25 +41,38 @@ class TranspileError(Exception):
38
41
  loc_parts: list[str] = []
39
42
  if self.func_name:
40
43
  loc_parts.append(f"in {self.func_name}")
44
+ display_lineno = self.node.lineno
45
+ if self.source_start_line is not None:
46
+ display_lineno = self.source_start_line + self.node.lineno - 1
41
47
  if self.filename:
42
- loc_parts.append(f"at {self.filename}:{self.node.lineno}")
48
+ loc_parts.append(f"at {self.filename}:{display_lineno}")
43
49
  else:
44
- loc_parts.append(f"at line {self.node.lineno}")
45
- if hasattr(self.node, "col_offset"):
46
- loc_parts[-1] += f":{self.node.col_offset}"
47
-
48
- if loc_parts:
49
- parts.append(" ".join(loc_parts))
50
+ loc_parts.append(f"at line {display_lineno}")
50
51
 
51
- # Show the source line if available
52
+ display_line = None
53
+ display_col = None
52
54
  if self.source:
53
55
  lines = self.source.splitlines()
54
56
  if 0 < self.node.lineno <= len(lines):
55
57
  source_line = lines[self.node.lineno - 1]
56
- parts.append(f"\n {source_line}")
57
- # Add caret pointing to column
58
+ display_line = source_line.expandtabs(4)
58
59
  if hasattr(self.node, "col_offset"):
59
- parts.append(" " + " " * self.node.col_offset + "^")
60
+ prefix = source_line[: self.node.col_offset]
61
+ display_col = len(prefix.expandtabs(4))
62
+
63
+ if hasattr(self.node, "col_offset"):
64
+ col = display_col if display_col is not None else self.node.col_offset
65
+ loc_parts[-1] += f":{col}"
66
+
67
+ if loc_parts:
68
+ parts.append(" ".join(loc_parts))
69
+
70
+ # Show the source line if available
71
+ if display_line is not None:
72
+ parts.append(f"\n {display_line}")
73
+ # Add caret pointing to column
74
+ if display_col is not None:
75
+ parts.append(" " + " " * display_col + "^")
60
76
 
61
77
  return "\n".join(parts) if len(parts) > 1 else parts[0]
62
78
 
@@ -67,6 +83,7 @@ class TranspileError(Exception):
67
83
  source: str | None = None,
68
84
  filename: str | None = None,
69
85
  func_name: str | None = None,
86
+ source_start_line: int | None = None,
70
87
  ) -> TranspileError:
71
88
  """Return a new TranspileError with additional context."""
72
89
  return TranspileError(
@@ -75,4 +92,5 @@ class TranspileError(Exception):
75
92
  source=source or self.source,
76
93
  filename=filename or self.filename,
77
94
  func_name=func_name or self.func_name,
95
+ source_start_line=source_start_line or self.source_start_line,
78
96
  )
@@ -142,6 +142,10 @@ def _transpile_function_body(
142
142
  # Get and parse source
143
143
  src = getsourcecode(fn)
144
144
  src = textwrap.dedent(src)
145
+ try:
146
+ source_start_line = inspect.getsourcelines(fn)[1]
147
+ except (OSError, TypeError):
148
+ source_start_line = None
145
149
  module = ast.parse(src)
146
150
 
147
151
  # Find the function definition
@@ -169,6 +173,7 @@ def _transpile_function_body(
169
173
  source=src,
170
174
  filename=filename,
171
175
  func_name=fn.__name__,
176
+ source_start_line=source_start_line,
172
177
  ) from None
173
178
  raise
174
179
 
@@ -347,10 +352,10 @@ class JsxFunction(Expr, Generic[P, R]):
347
352
 
348
353
  @override
349
354
  def transpile_call(
350
- self, args: list[ast.expr], kwargs: dict[str, ast.expr], ctx: Transpiler
355
+ self, args: list[ast.expr], keywords: list[ast.keyword], ctx: Transpiler
351
356
  ) -> Expr:
352
357
  # delegate JSX element building to the generic Jsx wrapper
353
- return Jsx(self).transpile_call(args, kwargs, ctx)
358
+ return Jsx(self).transpile_call(args, keywords, ctx)
354
359
 
355
360
  @override
356
361
  def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
@@ -367,18 +372,40 @@ def analyze_code_object(
367
372
  - effective_globals: dict mapping names to their values (includes closure vars)
368
373
  - all_names: set of all names referenced in the code (including nested functions)
369
374
  """
375
+ import dis
376
+
370
377
  code = fn.__code__
371
378
 
372
379
  # Collect all names from code object and nested functions in one pass
373
380
  seen_codes: set[int] = set()
374
381
  all_names: set[str] = set()
375
382
 
383
+ # Opcodes that load names from globals/locals (not attributes)
384
+ GLOBAL_LOAD_OPS = frozenset(
385
+ {
386
+ "LOAD_GLOBAL",
387
+ "LOAD_NAME",
388
+ "STORE_GLOBAL",
389
+ "STORE_NAME",
390
+ "DELETE_GLOBAL",
391
+ "DELETE_NAME",
392
+ }
393
+ )
394
+
376
395
  def walk_code(c: pytypes.CodeType) -> None:
377
396
  if id(c) in seen_codes:
378
397
  return
379
398
  seen_codes.add(id(c))
380
- all_names.update(c.co_names)
399
+
400
+ # Only collect names that are actually loaded as globals, not attributes
401
+ # co_names contains both global names and attribute names, so we need
402
+ # to check the bytecode to distinguish them
403
+ for instr in dis.get_instructions(c):
404
+ if instr.opname in GLOBAL_LOAD_OPS and instr.argval is not None:
405
+ all_names.add(instr.argval)
406
+
381
407
  all_names.update(c.co_freevars) # Include closure variables
408
+
382
409
  for const in c.co_consts:
383
410
  if isinstance(const, pytypes.CodeType):
384
411
  walk_code(const)
@@ -63,10 +63,10 @@ class Class(Expr):
63
63
  def transpile_call(
64
64
  self,
65
65
  args: list[ast.expr],
66
- kwargs: dict[str, ast.expr],
66
+ keywords: list[ast.keyword],
67
67
  ctx: Transpiler,
68
68
  ) -> Expr:
69
- if kwargs:
69
+ if keywords:
70
70
  raise TranspileError("Keyword arguments not supported in constructor call")
71
71
  return New(self.ctor, [ctx.emit_expr(a) for a in args])
72
72
 
@@ -114,7 +114,7 @@ class JsModule(Expr):
114
114
  def transpile_call(
115
115
  self,
116
116
  args: list[ast.expr],
117
- kwargs: dict[str, ast.expr],
117
+ keywords: list[ast.keyword],
118
118
  ctx: Transpiler,
119
119
  ) -> Expr:
120
120
  label = self.py_name or self.name or "JsModule"
@@ -162,9 +162,9 @@ class JsModule(Expr):
162
162
  def get_value(self, name: str) -> Member | Class | Identifier | Import:
163
163
  """Get a member of this module as an expression.
164
164
 
165
- For global-identifier modules (name=None): returns Identifier(name) directly (e.g., Set -> Set)
166
- For builtins: returns Member (e.g., Math.sin), or Identifier if name
167
- matches the module name (e.g., Set -> Set, not Set.Set)
165
+ For global-identifier modules (name=None): returns Identifier directly (e.g., Set -> Set)
166
+ These are "virtual" Python modules exposing JS globals - no actual JS module exists.
167
+ For builtin namespaces (src=None): returns Member (e.g., Math.floor)
168
168
  For external modules with "member" style: returns Member (e.g., React.useState)
169
169
  For external modules with "named_import" style: returns a named Import
170
170
 
@@ -176,14 +176,11 @@ class JsModule(Expr):
176
176
 
177
177
  expr: Member | Identifier | Import
178
178
  if self.name is None:
179
- # No module expression: members are just identifiers, not members of a module
179
+ # Virtual module exposing JS globals - members are just identifiers
180
180
  expr = Identifier(js_name)
181
181
  elif self.src is None:
182
- # Builtins: use identifier when name matches module name (Set.Set -> Set)
183
- if name == self.name:
184
- expr = Identifier(js_name)
185
- else:
186
- expr = Member(Identifier(self.name), js_name)
182
+ # Builtin namespace (Math, console, etc.) - members accessed as properties
183
+ expr = Member(Identifier(self.name), js_name)
187
184
  elif self.values == "named_import":
188
185
  expr = Import(js_name, self.src)
189
186
  else:
@@ -16,7 +16,16 @@ import ast
16
16
  from dataclasses import dataclass
17
17
  from typing import Any, final, override
18
18
 
19
- from pulse.transpiler.nodes import Element, Expr, Literal, Node, Prop
19
+ from pulse.components.for_ import emit_for
20
+ from pulse.transpiler.nodes import (
21
+ Element,
22
+ Expr,
23
+ Literal,
24
+ Node,
25
+ Prop,
26
+ Spread,
27
+ spread_dict,
28
+ )
20
29
  from pulse.transpiler.py_module import PyModule
21
30
  from pulse.transpiler.transpiler import Transpiler
22
31
  from pulse.transpiler.vdom import VDOMNode
@@ -44,30 +53,38 @@ class TagExpr(Expr):
44
53
  def transpile_call(
45
54
  self,
46
55
  args: list[ast.expr],
47
- kwargs: dict[str, ast.expr],
56
+ keywords: list[ast.keyword],
48
57
  ctx: Transpiler,
49
58
  ) -> Expr:
50
- """Handle tag calls: positional args are children, kwargs are props."""
59
+ """Handle tag calls: positional args are children, kwargs are props.
60
+
61
+ Spread (**expr) is supported for prop spreading.
62
+ """
51
63
  # Build children from positional args
52
64
  children: list[Node] = []
53
65
  for a in args:
54
66
  children.append(ctx.emit_expr(a))
55
67
 
56
68
  # Build props from kwargs
57
- props: dict[str, Prop] = {}
69
+ props: list[tuple[str, Prop] | Spread] = []
58
70
  key: str | Expr | None = None
59
- for k, v in kwargs.items():
60
- prop_value = ctx.emit_expr(v)
61
- if k == "key":
62
- # Accept any expression as key for transpilation
63
- if isinstance(prop_value, Literal) and isinstance(
64
- prop_value.value, str
65
- ):
66
- key = prop_value.value # Optimize string literals
67
- else:
68
- key = prop_value # Keep as expression
71
+ for kw in keywords:
72
+ if kw.arg is None:
73
+ # **spread syntax
74
+ props.append(spread_dict(ctx.emit_expr(kw.value)))
69
75
  else:
70
- props[k] = prop_value
76
+ k = kw.arg
77
+ prop_value = ctx.emit_expr(kw.value)
78
+ if k == "key":
79
+ # Accept any expression as key for transpilation
80
+ if isinstance(prop_value, Literal) and isinstance(
81
+ prop_value.value, str
82
+ ):
83
+ key = prop_value.value # Optimize string literals
84
+ else:
85
+ key = prop_value # Keep as expression
86
+ else:
87
+ props.append((k, prop_value))
71
88
 
72
89
  return Element(
73
90
  tag=self.tag,
@@ -229,3 +246,6 @@ class PulseTags(PyModule):
229
246
 
230
247
  # React fragment
231
248
  fragment = TagExpr("")
249
+
250
+ # For component - maps to array.map()
251
+ For = emit_for