pulse-framework 0.1.46__py3-none-any.whl → 0.1.48__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 (73) hide show
  1. pulse/__init__.py +9 -23
  2. pulse/app.py +6 -25
  3. pulse/cli/processes.py +1 -0
  4. pulse/codegen/codegen.py +43 -88
  5. pulse/codegen/js.py +35 -5
  6. pulse/codegen/templates/route.py +341 -254
  7. pulse/form.py +1 -1
  8. pulse/helpers.py +51 -27
  9. pulse/hooks/core.py +2 -2
  10. pulse/hooks/effects.py +1 -1
  11. pulse/hooks/init.py +2 -1
  12. pulse/hooks/setup.py +1 -1
  13. pulse/hooks/stable.py +2 -2
  14. pulse/hooks/states.py +2 -2
  15. pulse/html/props.py +3 -2
  16. pulse/html/tags.py +135 -0
  17. pulse/html/tags.pyi +4 -0
  18. pulse/js/__init__.py +110 -0
  19. pulse/js/__init__.pyi +95 -0
  20. pulse/js/_types.py +297 -0
  21. pulse/js/array.py +253 -0
  22. pulse/js/console.py +47 -0
  23. pulse/js/date.py +113 -0
  24. pulse/js/document.py +138 -0
  25. pulse/js/error.py +139 -0
  26. pulse/js/json.py +62 -0
  27. pulse/js/map.py +84 -0
  28. pulse/js/math.py +66 -0
  29. pulse/js/navigator.py +76 -0
  30. pulse/js/number.py +54 -0
  31. pulse/js/object.py +173 -0
  32. pulse/js/promise.py +150 -0
  33. pulse/js/regexp.py +54 -0
  34. pulse/js/set.py +109 -0
  35. pulse/js/string.py +35 -0
  36. pulse/js/weakmap.py +50 -0
  37. pulse/js/weakset.py +45 -0
  38. pulse/js/window.py +199 -0
  39. pulse/messages.py +22 -3
  40. pulse/proxy.py +21 -8
  41. pulse/react_component.py +167 -14
  42. pulse/reactive_extensions.py +5 -5
  43. pulse/render_session.py +144 -34
  44. pulse/renderer.py +80 -115
  45. pulse/routing.py +1 -18
  46. pulse/transpiler/__init__.py +131 -0
  47. pulse/transpiler/builtins.py +731 -0
  48. pulse/transpiler/constants.py +110 -0
  49. pulse/transpiler/context.py +26 -0
  50. pulse/transpiler/errors.py +2 -0
  51. pulse/transpiler/function.py +250 -0
  52. pulse/transpiler/ids.py +16 -0
  53. pulse/transpiler/imports.py +409 -0
  54. pulse/transpiler/js_module.py +274 -0
  55. pulse/transpiler/modules/__init__.py +30 -0
  56. pulse/transpiler/modules/asyncio.py +38 -0
  57. pulse/transpiler/modules/json.py +20 -0
  58. pulse/transpiler/modules/math.py +320 -0
  59. pulse/transpiler/modules/re.py +466 -0
  60. pulse/transpiler/modules/tags.py +268 -0
  61. pulse/transpiler/modules/typing.py +59 -0
  62. pulse/transpiler/nodes.py +1216 -0
  63. pulse/transpiler/py_module.py +119 -0
  64. pulse/transpiler/transpiler.py +938 -0
  65. pulse/transpiler/utils.py +4 -0
  66. pulse/vdom.py +112 -6
  67. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/METADATA +1 -1
  68. pulse_framework-0.1.48.dist-info/RECORD +119 -0
  69. pulse/codegen/imports.py +0 -204
  70. pulse/css.py +0 -155
  71. pulse_framework-0.1.46.dist-info/RECORD +0 -80
  72. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/WHEEL +0 -0
  73. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,938 @@
1
+ """
2
+ Python -> JavaScript transpiler for pure functions.
3
+
4
+ Transpiles a restricted subset of Python into JavaScript. Handles:
5
+ - Pure functions (no global state mutation)
6
+ - Python syntax -> JS syntax conversion
7
+ - Python builtin functions -> JS equivalents
8
+ - Python builtin methods (str, list, dict, set) -> JS equivalents
9
+
10
+ This transpiler is designed for use with @javascript decorated functions.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re
17
+ from collections.abc import Callable
18
+ from typing import Any
19
+
20
+ from pulse.transpiler.errors import JSCompilationError
21
+ from pulse.transpiler.nodes import (
22
+ ALLOWED_BINOPS,
23
+ ALLOWED_CMPOPS,
24
+ ALLOWED_UNOPS,
25
+ JSArray,
26
+ JSArrowFunction,
27
+ JSAssign,
28
+ JSAugAssign,
29
+ JSAwait,
30
+ JSBinary,
31
+ JSBoolean,
32
+ JSBreak,
33
+ JSCall,
34
+ JSConstAssign,
35
+ JSContinue,
36
+ JSExpr,
37
+ JSForOf,
38
+ JSFunctionDef,
39
+ JSIdentifier,
40
+ JSIf,
41
+ JSLogicalChain,
42
+ JSMember,
43
+ JSMemberCall,
44
+ JSMultiStmt,
45
+ JSNew,
46
+ JSNull,
47
+ JSNumber,
48
+ JSReturn,
49
+ JSSingleStmt,
50
+ JSSpread,
51
+ JSStmt,
52
+ JSStmtExpr,
53
+ JSString,
54
+ JSSubscript,
55
+ JSTemplate,
56
+ JSTertiary,
57
+ JSUnary,
58
+ JSUndefined,
59
+ JSWhile,
60
+ )
61
+
62
+
63
+ class JsTranspiler(ast.NodeVisitor):
64
+ """AST visitor that builds a JS AST from a restricted Python subset.
65
+
66
+ The visitor receives a deps dictionary mapping names to JSExpr values.
67
+ Behavior is encoded in JSExpr subclass hooks (emit_call, emit_getattr, emit_subscript).
68
+ """
69
+
70
+ fndef: ast.FunctionDef | ast.AsyncFunctionDef
71
+ args: list[str]
72
+ deps: dict[str, JSExpr]
73
+ locals: set[str]
74
+ _temp_counter: int
75
+ _is_async: bool
76
+
77
+ def __init__(
78
+ self,
79
+ fndef: ast.FunctionDef | ast.AsyncFunctionDef,
80
+ args: list[str],
81
+ deps: dict[str, JSExpr],
82
+ ) -> None:
83
+ self.fndef = fndef
84
+ self.args = args
85
+ self.deps = deps
86
+ # Track locals for declaration decisions (args are predeclared)
87
+ self.locals = set(args)
88
+ self._temp_counter = 0
89
+ # Track async status during transpilation (starts True if source is async def)
90
+ self._is_async = isinstance(fndef, ast.AsyncFunctionDef)
91
+
92
+ def _fresh_temp(self) -> str:
93
+ """Generate a fresh temporary variable name."""
94
+ name = f"$tmp{self._temp_counter}"
95
+ self._temp_counter += 1
96
+ return name
97
+
98
+ def _is_string_expr(self, expr: JSExpr) -> bool:
99
+ """Check if an expression is known to produce a string."""
100
+ # Check common patterns that produce strings
101
+ if isinstance(expr, JSString):
102
+ return True
103
+ if isinstance(expr, JSCall) and isinstance(expr.callee, JSIdentifier):
104
+ return expr.callee.name == "String"
105
+ if isinstance(expr, JSMemberCall):
106
+ # Methods that return strings
107
+ return expr.method in (
108
+ "toFixed",
109
+ "toExponential",
110
+ "toString",
111
+ "toUpperCase",
112
+ "toLowerCase",
113
+ "trim",
114
+ "padStart",
115
+ "padEnd",
116
+ )
117
+ return False
118
+
119
+ # --- Entrypoint ---------------------------------------------------------
120
+ def transpile(self, name: str | None = None) -> JSFunctionDef:
121
+ """Transpile the function definition to a JS function.
122
+
123
+ Args:
124
+ name: Optional function name to emit. If None, emits anonymous function.
125
+ """
126
+ stmts: list[JSStmt] = []
127
+ self._temp_counter = 0
128
+ for i, stmt in enumerate(self.fndef.body):
129
+ # Skip docstrings (first statement that's a string constant expression)
130
+ if (
131
+ i == 0
132
+ and isinstance(stmt, ast.Expr)
133
+ and isinstance(stmt.value, ast.Constant)
134
+ and isinstance(stmt.value.value, str)
135
+ ):
136
+ continue
137
+ s = self.emit_stmt(stmt)
138
+ stmts.append(s)
139
+ # Use the flag we tracked during transpilation
140
+ return JSFunctionDef(self.args, stmts, name=name, is_async=self._is_async)
141
+
142
+ # --- Statements ----------------------------------------------------------
143
+ def emit_stmt(self, node: ast.stmt) -> JSStmt:
144
+ """Emit a statement."""
145
+ if isinstance(node, ast.Return):
146
+ return JSReturn(self.emit_expr(node.value))
147
+
148
+ if isinstance(node, ast.Break):
149
+ return JSBreak()
150
+
151
+ if isinstance(node, ast.Continue):
152
+ return JSContinue()
153
+
154
+ if isinstance(node, ast.Pass):
155
+ # Pass is a no-op, emit empty statement
156
+ return JSMultiStmt([])
157
+
158
+ if isinstance(node, ast.AugAssign):
159
+ if not isinstance(node.target, ast.Name):
160
+ raise JSCompilationError("Only simple augmented assignments supported")
161
+ target = node.target.id
162
+ op_type = type(node.op)
163
+ if op_type not in ALLOWED_BINOPS:
164
+ raise JSCompilationError(
165
+ f"Unsupported augmented assignment operator: {op_type.__name__}"
166
+ )
167
+ value_expr = self.emit_expr(node.value)
168
+ return JSAugAssign(target, ALLOWED_BINOPS[op_type], value_expr)
169
+
170
+ if isinstance(node, ast.Assign):
171
+ if len(node.targets) != 1:
172
+ raise JSCompilationError(
173
+ "Multiple assignment targets are not supported"
174
+ )
175
+ target_node = node.targets[0]
176
+
177
+ # Tuple/list unpacking
178
+ if isinstance(target_node, (ast.Tuple, ast.List)):
179
+ return self._emit_unpacking_assign(target_node, node.value)
180
+
181
+ if not isinstance(target_node, ast.Name):
182
+ raise JSCompilationError(
183
+ "Only simple assignments to local names are supported"
184
+ )
185
+
186
+ target = target_node.id
187
+ value_expr = self.emit_expr(node.value)
188
+
189
+ if target in self.locals:
190
+ return JSAssign(target, value_expr, declare=False)
191
+ else:
192
+ self.locals.add(target)
193
+ return JSAssign(target, value_expr, declare=True)
194
+
195
+ if isinstance(node, ast.AnnAssign):
196
+ if not isinstance(node.target, ast.Name):
197
+ raise JSCompilationError("Only simple annotated assignments supported")
198
+ target = node.target.id
199
+ value = JSUndefined() if node.value is None else self.emit_expr(node.value)
200
+ if target in self.locals:
201
+ return JSAssign(target, value, declare=False)
202
+ else:
203
+ self.locals.add(target)
204
+ return JSAssign(target, value, declare=True)
205
+
206
+ if isinstance(node, ast.If):
207
+ test = self.emit_expr(node.test)
208
+ body = [self.emit_stmt(s) for s in node.body]
209
+ orelse = [self.emit_stmt(s) for s in node.orelse]
210
+ return JSIf(test, body, orelse)
211
+
212
+ if isinstance(node, ast.Expr):
213
+ expr = self.emit_expr(node.value)
214
+ # Unwrap statement-expressions (e.g., throw)
215
+ if isinstance(expr, JSStmtExpr):
216
+ return expr.stmt
217
+ return JSSingleStmt(expr)
218
+
219
+ if isinstance(node, ast.While):
220
+ test = self.emit_expr(node.test)
221
+ body = [self.emit_stmt(s) for s in node.body]
222
+ return JSWhile(test, body)
223
+
224
+ if isinstance(node, ast.For):
225
+ return self._emit_for_loop(node)
226
+
227
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
228
+ return self._emit_nested_function(node)
229
+
230
+ raise JSCompilationError(f"Unsupported statement: {type(node).__name__}")
231
+
232
+ def _emit_unpacking_assign(
233
+ self, target: ast.Tuple | ast.List, value: ast.expr
234
+ ) -> JSStmt:
235
+ """Emit unpacking assignment: a, b, c = expr"""
236
+ elements = target.elts
237
+ if not elements or not all(isinstance(e, ast.Name) for e in elements):
238
+ raise JSCompilationError("Unpacking is only supported for simple variables")
239
+
240
+ tmp_name = self._fresh_temp()
241
+ value_expr = self.emit_expr(value)
242
+ stmts: list[JSStmt] = [JSConstAssign(tmp_name, value_expr)]
243
+
244
+ for idx, e in enumerate(elements):
245
+ assert isinstance(e, ast.Name)
246
+ name = e.id
247
+ sub = JSSubscript(JSIdentifier(tmp_name), JSNumber(idx))
248
+ if name in self.locals:
249
+ stmts.append(JSAssign(name, sub, declare=False))
250
+ else:
251
+ self.locals.add(name)
252
+ stmts.append(JSAssign(name, sub, declare=True))
253
+
254
+ return JSMultiStmt(stmts)
255
+
256
+ def _emit_for_loop(self, node: ast.For) -> JSStmt:
257
+ """Emit a for loop."""
258
+ # Handle tuple unpacking in for target
259
+ if isinstance(node.target, (ast.Tuple, ast.List)):
260
+ names: list[str] = []
261
+ for e in node.target.elts:
262
+ if not isinstance(e, ast.Name):
263
+ raise JSCompilationError(
264
+ "Only simple name targets supported in for-loop unpacking"
265
+ )
266
+ names.append(e.id)
267
+ self.locals.add(e.id)
268
+ iter_expr = self.emit_expr(node.iter)
269
+ body = [self.emit_stmt(s) for s in node.body]
270
+ return JSForOf(names, iter_expr, body)
271
+
272
+ if not isinstance(node.target, ast.Name):
273
+ raise JSCompilationError("Only simple name targets supported in for-loops")
274
+
275
+ target = node.target.id
276
+ self.locals.add(target)
277
+ iter_expr = self.emit_expr(node.iter)
278
+ body = [self.emit_stmt(s) for s in node.body]
279
+ return JSForOf(target, iter_expr, body)
280
+
281
+ def _emit_nested_function(
282
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
283
+ ) -> JSStmt:
284
+ """Emit a nested function definition."""
285
+ name = node.name
286
+ params = [arg.arg for arg in node.args.args]
287
+
288
+ # Save current locals and extend with params (closure captures outer scope)
289
+ saved_locals = set(self.locals)
290
+ self.locals.update(params)
291
+
292
+ # Skip docstrings and emit body
293
+ stmts: list[JSStmt] = []
294
+ for i, stmt in enumerate(node.body):
295
+ if (
296
+ i == 0
297
+ and isinstance(stmt, ast.Expr)
298
+ and isinstance(stmt.value, ast.Constant)
299
+ and isinstance(stmt.value.value, str)
300
+ ):
301
+ continue
302
+ stmts.append(self.emit_stmt(stmt))
303
+
304
+ # Restore outer locals and add function name
305
+ self.locals = saved_locals
306
+ self.locals.add(name)
307
+
308
+ is_async = isinstance(node, ast.AsyncFunctionDef)
309
+ fn = JSFunctionDef(params, stmts, name=None, is_async=is_async)
310
+ return JSConstAssign(name, fn)
311
+
312
+ # --- Expressions ---------------------------------------------------------
313
+ def emit_expr(self, node: ast.expr | None) -> JSExpr:
314
+ """Emit an expression."""
315
+ if node is None:
316
+ return JSNull()
317
+
318
+ if isinstance(node, ast.Constant):
319
+ return self._emit_constant(node)
320
+
321
+ if isinstance(node, ast.Name):
322
+ return self._emit_name(node)
323
+
324
+ if isinstance(node, (ast.List, ast.Tuple)):
325
+ return self._emit_list_or_tuple(node)
326
+
327
+ if isinstance(node, ast.Dict):
328
+ return self._emit_dict(node)
329
+
330
+ if isinstance(node, ast.Set):
331
+ return JSNew(
332
+ JSIdentifier("Set"), [JSArray([self.emit_expr(e) for e in node.elts])]
333
+ )
334
+
335
+ if isinstance(node, ast.BinOp):
336
+ return self._emit_binop(node)
337
+
338
+ if isinstance(node, ast.UnaryOp):
339
+ return self._emit_unaryop(node)
340
+
341
+ if isinstance(node, ast.BoolOp):
342
+ op = "&&" if isinstance(node.op, ast.And) else "||"
343
+ return JSLogicalChain(op, [self.emit_expr(v) for v in node.values])
344
+
345
+ if isinstance(node, ast.Compare):
346
+ return self._emit_compare(node)
347
+
348
+ if isinstance(node, ast.IfExp):
349
+ test = self.emit_expr(node.test)
350
+ body = self.emit_expr(node.body)
351
+ orelse = self.emit_expr(node.orelse)
352
+ return JSTertiary(test, body, orelse)
353
+
354
+ if isinstance(node, ast.Call):
355
+ return self._emit_call(node)
356
+
357
+ if isinstance(node, ast.Attribute):
358
+ return self._emit_attribute(node)
359
+
360
+ if isinstance(node, ast.Subscript):
361
+ return self._emit_subscript(node)
362
+
363
+ if isinstance(node, ast.JoinedStr):
364
+ return self._emit_fstring(node)
365
+
366
+ if isinstance(node, ast.ListComp):
367
+ return self._emit_comprehension_chain(
368
+ node.generators, lambda: self.emit_expr(node.elt)
369
+ )
370
+
371
+ if isinstance(node, ast.GeneratorExp):
372
+ return self._emit_comprehension_chain(
373
+ node.generators, lambda: self.emit_expr(node.elt)
374
+ )
375
+
376
+ if isinstance(node, ast.SetComp):
377
+ arr = self._emit_comprehension_chain(
378
+ node.generators, lambda: self.emit_expr(node.elt)
379
+ )
380
+ return JSNew(JSIdentifier("Set"), [arr])
381
+
382
+ if isinstance(node, ast.DictComp):
383
+ pairs = self._emit_comprehension_chain(
384
+ node.generators,
385
+ lambda: JSArray([self.emit_expr(node.key), self.emit_expr(node.value)]),
386
+ )
387
+ return JSNew(JSIdentifier("Map"), [pairs])
388
+
389
+ if isinstance(node, ast.Lambda):
390
+ return self._emit_lambda(node)
391
+
392
+ if isinstance(node, ast.Starred):
393
+ return JSSpread(self.emit_expr(node.value))
394
+
395
+ if isinstance(node, ast.Await):
396
+ # Mark function as async when we encounter await
397
+ self._is_async = True
398
+ return JSAwait(self.emit_expr(node.value))
399
+
400
+ raise JSCompilationError(f"Unsupported expression: {type(node).__name__}")
401
+
402
+ def _emit_constant(self, node: ast.Constant) -> JSExpr:
403
+ """Emit a constant value."""
404
+ v = node.value
405
+ if isinstance(v, str):
406
+ # Use template literals for strings with Unicode line separators
407
+ if "\u2028" in v or "\u2029" in v:
408
+ return JSTemplate([v])
409
+ return JSString(v)
410
+ if v is None:
411
+ return JSNull()
412
+ if v is True:
413
+ return JSBoolean(True)
414
+ if v is False:
415
+ return JSBoolean(False)
416
+ if isinstance(v, (int, float)):
417
+ return JSNumber(v)
418
+ raise JSCompilationError(f"Unsupported constant type: {type(v).__name__}")
419
+
420
+ def _emit_name(self, node: ast.Name) -> JSExpr:
421
+ """Emit a name reference.
422
+
423
+ All dependencies are JSExpr subclasses. Behavior is encoded in hooks.
424
+ """
425
+ name = node.id
426
+
427
+ # Check deps first - all are JSExpr
428
+ if name in self.deps:
429
+ return self.deps[name]
430
+
431
+ # Local variable
432
+ if name in self.locals:
433
+ return JSIdentifier(name)
434
+
435
+ raise JSCompilationError(f"Unbound name referenced: {name}")
436
+
437
+ def _emit_list_or_tuple(self, node: ast.List | ast.Tuple) -> JSExpr:
438
+ """Emit a list or tuple literal."""
439
+ parts: list[JSExpr] = []
440
+ for e in node.elts:
441
+ if isinstance(e, ast.Starred):
442
+ parts.append(JSSpread(self.emit_expr(e.value)))
443
+ else:
444
+ parts.append(self.emit_expr(e))
445
+ return JSArray(parts)
446
+
447
+ def _emit_dict(self, node: ast.Dict) -> JSExpr:
448
+ """Emit a dict literal as new Map([...])."""
449
+ entries: list[JSExpr] = []
450
+ for k, v in zip(node.keys, node.values, strict=False):
451
+ if k is None:
452
+ # Spread merge
453
+ vexpr = self.emit_expr(v)
454
+ is_map = JSBinary(vexpr, "instanceof", JSIdentifier("Map"))
455
+ map_entries = JSMemberCall(vexpr, "entries", [])
456
+ obj_entries = JSCall(
457
+ JSMember(JSIdentifier("Object"), "entries"), [vexpr]
458
+ )
459
+ entries.append(JSSpread(JSTertiary(is_map, map_entries, obj_entries)))
460
+ continue
461
+ key_expr = self.emit_expr(k)
462
+ val_expr = self.emit_expr(v)
463
+ entries.append(JSArray([key_expr, val_expr]))
464
+ return JSNew(JSIdentifier("Map"), [JSArray(entries)])
465
+
466
+ def _emit_binop(self, node: ast.BinOp) -> JSExpr:
467
+ """Emit a binary operation."""
468
+ op = type(node.op)
469
+ if op not in ALLOWED_BINOPS:
470
+ raise JSCompilationError(f"Unsupported binary operator: {op.__name__}")
471
+ left = self.emit_expr(node.left)
472
+ right = self.emit_expr(node.right)
473
+ return JSBinary(left, ALLOWED_BINOPS[op], right)
474
+
475
+ def _emit_unaryop(self, node: ast.UnaryOp) -> JSExpr:
476
+ """Emit a unary operation."""
477
+ op = type(node.op)
478
+ if op not in ALLOWED_UNOPS:
479
+ raise JSCompilationError(f"Unsupported unary operator: {op.__name__}")
480
+ return JSUnary(ALLOWED_UNOPS[op], self.emit_expr(node.operand))
481
+
482
+ def _emit_compare(self, node: ast.Compare) -> JSExpr:
483
+ """Emit a comparison expression."""
484
+ operands: list[ast.expr] = [node.left, *node.comparators]
485
+ exprs: list[JSExpr] = [self.emit_expr(e) for e in operands]
486
+ cmp_parts: list[JSExpr] = []
487
+
488
+ for i, op in enumerate(node.ops):
489
+ left_node = operands[i]
490
+ right_node = operands[i + 1]
491
+ left_expr = exprs[i]
492
+ right_expr = exprs[i + 1]
493
+ cmp_parts.append(
494
+ self._build_comparison(left_expr, left_node, op, right_expr, right_node)
495
+ )
496
+
497
+ if len(cmp_parts) == 1:
498
+ return cmp_parts[0]
499
+ return JSLogicalChain("&&", cmp_parts)
500
+
501
+ def _build_comparison(
502
+ self,
503
+ left_expr: JSExpr,
504
+ left_node: ast.expr,
505
+ op: ast.cmpop,
506
+ right_expr: JSExpr,
507
+ right_node: ast.expr,
508
+ ) -> JSExpr:
509
+ """Build a single comparison."""
510
+ # Identity comparisons
511
+ if isinstance(op, (ast.Is, ast.IsNot)):
512
+ is_not = isinstance(op, ast.IsNot)
513
+ # Special case for None identity
514
+ if (isinstance(right_node, ast.Constant) and right_node.value is None) or (
515
+ isinstance(left_node, ast.Constant) and left_node.value is None
516
+ ):
517
+ expr = right_expr if isinstance(left_node, ast.Constant) else left_expr
518
+ return JSBinary(expr, "!=" if is_not else "==", JSNull())
519
+ return JSBinary(left_expr, "!==" if is_not else "===", right_expr)
520
+
521
+ # Membership tests
522
+ if isinstance(op, (ast.In, ast.NotIn)):
523
+ return self._build_membership_test(
524
+ left_expr, right_expr, isinstance(op, ast.NotIn)
525
+ )
526
+
527
+ # Standard comparisons
528
+ op_type = type(op)
529
+ if op_type not in ALLOWED_CMPOPS:
530
+ raise JSCompilationError(
531
+ f"Unsupported comparison operator: {op_type.__name__}"
532
+ )
533
+ return JSBinary(left_expr, ALLOWED_CMPOPS[op_type], right_expr)
534
+
535
+ def _build_membership_test(
536
+ self, item: JSExpr, container: JSExpr, negate: bool
537
+ ) -> JSExpr:
538
+ """Build a membership test (in / not in)."""
539
+ is_string = JSBinary(JSUnary("typeof", container), "===", JSString("string"))
540
+ is_array = JSMemberCall(JSIdentifier("Array"), "isArray", [container])
541
+ is_set = JSBinary(container, "instanceof", JSIdentifier("Set"))
542
+ is_map = JSBinary(container, "instanceof", JSIdentifier("Map"))
543
+
544
+ is_array_or_string = JSLogicalChain("||", [is_array, is_string])
545
+ is_set_or_map = JSLogicalChain("||", [is_set, is_map])
546
+
547
+ has_array_or_string = JSMemberCall(container, "includes", [item])
548
+ has_set_or_map = JSMemberCall(container, "has", [item])
549
+ has_obj = JSBinary(item, "in", container)
550
+
551
+ membership_expr = JSTertiary(
552
+ is_array_or_string,
553
+ has_array_or_string,
554
+ JSTertiary(is_set_or_map, has_set_or_map, has_obj),
555
+ )
556
+
557
+ if negate:
558
+ return JSUnary("!", membership_expr)
559
+ return membership_expr
560
+
561
+ def _emit_call(self, node: ast.Call) -> JSExpr:
562
+ """Emit a function call.
563
+
564
+ All behavior is encoded in JSExpr.emit_call hooks.
565
+ emit_call receives raw Python values (JSExpr instances from emit_expr),
566
+ and decides what to convert using JSExpr.of().
567
+ """
568
+ # Handle typing.cast: ignore type argument, return value unchanged
569
+ # Must short-circuit before evaluating args to avoid transpiling type annotations
570
+ if isinstance(node.func, ast.Name) and node.func.id == "cast":
571
+ if len(node.args) >= 2:
572
+ return self.emit_expr(node.args[1])
573
+ raise JSCompilationError("typing.cast requires two arguments")
574
+
575
+ # Emit args as JSExpr (they're already the transpiled form)
576
+ args: list[Any] = [self.emit_expr(a) for a in node.args]
577
+ kwargs = self._build_kwargs(node)
578
+
579
+ # Method call: obj.method(args) -> obj.emit_getattr(method).emit_call(args)
580
+ if isinstance(node.func, ast.Attribute):
581
+ obj = self.emit_expr(node.func.value)
582
+ method_expr = obj.emit_getattr(node.func.attr)
583
+ return method_expr.emit_call(args, kwargs)
584
+
585
+ # Function call
586
+ callee = self.emit_expr(node.func)
587
+ return callee.emit_call(args, kwargs)
588
+
589
+ def _build_kwargs(self, node: ast.Call) -> dict[str, Any]:
590
+ """Build kwargs dict from AST Call node.
591
+
592
+ Returns a dict mapping:
593
+ - "propName" -> JSExpr for named kwargs (as raw values)
594
+ - "$spread{N}" -> JSSpread(expr) for **spread kwargs
595
+
596
+ Dict order is preserved (Python 3.7+), so iteration order matches source order.
597
+ Uses $ prefix for spreads since it's not a valid Python identifier.
598
+ """
599
+ kwargs: dict[str, Any] = {}
600
+ spread_count = 0
601
+
602
+ for kw in node.keywords:
603
+ if kw.arg is None:
604
+ # **kwargs spread - use invalid Python identifier to avoid conflicts
605
+ kwargs[f"$spread{spread_count}"] = JSSpread(self.emit_expr(kw.value))
606
+ spread_count += 1
607
+ else:
608
+ kwargs[kw.arg] = self.emit_expr(kw.value)
609
+ return kwargs
610
+
611
+ def _emit_attribute(self, node: ast.Attribute) -> JSExpr:
612
+ """Emit an attribute access.
613
+
614
+ All behavior is encoded in JSExpr.emit_getattr hooks.
615
+ """
616
+ value = self.emit_expr(node.value)
617
+ return value.emit_getattr(node.attr)
618
+
619
+ def _emit_subscript(self, node: ast.Subscript) -> JSExpr:
620
+ """Emit a subscript expression."""
621
+ value = self.emit_expr(node.value)
622
+
623
+ # Slice handling (not passed through emit_subscript hook)
624
+ if isinstance(node.slice, ast.Slice):
625
+ return self._emit_slice(value, node.slice)
626
+
627
+ # Negative index: use .at() (not passed through emit_subscript hook)
628
+ if isinstance(node.slice, ast.UnaryOp) and isinstance(node.slice.op, ast.USub):
629
+ idx_expr = self.emit_expr(node.slice.operand)
630
+ return JSMemberCall(value, "at", [JSUnary("-", idx_expr)])
631
+
632
+ # Collect indices - tuple means multiple indices like x[a, b, c]
633
+ # Pass as raw values (JSExpr instances) to emit_subscript
634
+ if isinstance(node.slice, ast.Tuple):
635
+ indices: list[Any] = [self.emit_expr(e) for e in node.slice.elts]
636
+ else:
637
+ indices = [self.emit_expr(node.slice)]
638
+
639
+ # Use emit_subscript hook for extensibility
640
+ return value.emit_subscript(indices)
641
+
642
+ def _emit_slice(self, value: JSExpr, slice_node: ast.Slice) -> JSExpr:
643
+ """Emit a slice operation."""
644
+ if slice_node.step is not None:
645
+ raise JSCompilationError("Slice steps are not supported")
646
+
647
+ lower = slice_node.lower
648
+ upper = slice_node.upper
649
+
650
+ if lower is None and upper is None:
651
+ return JSMemberCall(value, "slice", [])
652
+ elif lower is None:
653
+ return JSMemberCall(value, "slice", [JSNumber(0), self.emit_expr(upper)])
654
+ elif upper is None:
655
+ return JSMemberCall(value, "slice", [self.emit_expr(lower)])
656
+ else:
657
+ return JSMemberCall(
658
+ value, "slice", [self.emit_expr(lower), self.emit_expr(upper)]
659
+ )
660
+
661
+ def _emit_fstring(self, node: ast.JoinedStr) -> JSExpr:
662
+ """Emit an f-string as a template literal."""
663
+ parts: list[str | JSExpr] = []
664
+ for part in node.values:
665
+ if isinstance(part, ast.Constant) and isinstance(part.value, str):
666
+ parts.append(part.value)
667
+ elif isinstance(part, ast.FormattedValue):
668
+ expr = self.emit_expr(part.value)
669
+ # Handle conversion flags: !s, !r, !a
670
+ if part.conversion == ord("s"):
671
+ expr = JSCall(JSIdentifier("String"), [expr])
672
+ elif part.conversion == ord("r"):
673
+ expr = JSCall(JSMember(JSIdentifier("JSON"), "stringify"), [expr])
674
+ elif part.conversion == ord("a"):
675
+ # !a is ASCII repr - approximate with JSON.stringify
676
+ expr = JSCall(JSMember(JSIdentifier("JSON"), "stringify"), [expr])
677
+ # Handle format_spec (it's always a JoinedStr in practice)
678
+ if part.format_spec is not None:
679
+ if not isinstance(part.format_spec, ast.JoinedStr):
680
+ raise JSCompilationError("Format spec must be a JoinedStr")
681
+ expr = self._apply_format_spec(expr, part.format_spec)
682
+ parts.append(expr)
683
+ else:
684
+ raise JSCompilationError(
685
+ f"Unsupported f-string component: {type(part).__name__}"
686
+ )
687
+ return JSTemplate(parts)
688
+
689
+ def _apply_format_spec(self, expr: JSExpr, format_spec: ast.JoinedStr) -> JSExpr:
690
+ """Apply a Python format spec to an expression.
691
+
692
+ Supports common format specs:
693
+ - .Nf: N decimal places (float) -> .toFixed(N)
694
+ - 0Nd: zero-padded integer, width N -> String(...).padStart(N, '0')
695
+ - >N: right-align, width N -> String(...).padStart(N)
696
+ - <N: left-align, width N -> String(...).padEnd(N)
697
+ - ^N: center, width N -> custom centering
698
+ - #x, #o, #b: hex/octal/binary with prefix
699
+ - +.Nf: with sign prefix
700
+ """
701
+ # Extract the format spec string (it's a JoinedStr but usually just one constant)
702
+ if len(format_spec.values) != 1:
703
+ raise JSCompilationError("Dynamic format specs not supported")
704
+ spec_part = format_spec.values[0]
705
+ if not isinstance(spec_part, ast.Constant) or not isinstance(
706
+ spec_part.value, str
707
+ ):
708
+ raise JSCompilationError("Dynamic format specs not supported")
709
+
710
+ spec = spec_part.value
711
+ return self._parse_and_apply_format(expr, spec)
712
+
713
+ def _parse_and_apply_format(self, expr: JSExpr, spec: str) -> JSExpr:
714
+ """Parse a format spec string and apply it to expr."""
715
+ if not spec:
716
+ return expr
717
+
718
+ # Parse Python format spec: [[fill]align][sign][#][0][width][,][.precision][type]
719
+ pattern = r"^([^<>=^]?[<>=^])?([+\- ])?([#])?(0)?(\d+)?([,_])?(\.(\d+))?([bcdeEfFgGnosxX%])?$"
720
+ match = re.match(pattern, spec)
721
+ if not match:
722
+ raise JSCompilationError(f"Unsupported format spec: {spec!r}")
723
+
724
+ align_part = match.group(1) or ""
725
+ sign = match.group(2) or ""
726
+ alt_form = match.group(3) # '#'
727
+ zero_pad = match.group(4) # '0'
728
+ width_str = match.group(5)
729
+ # thousands_sep = match.group(6) # ',' or '_' - not commonly needed
730
+ precision_str = match.group(8)
731
+ type_char = match.group(9) or ""
732
+
733
+ width = int(width_str) if width_str else None
734
+ precision = int(precision_str) if precision_str else None
735
+
736
+ # Determine fill and alignment
737
+ if len(align_part) == 2:
738
+ fill = align_part[0]
739
+ align = align_part[1]
740
+ elif len(align_part) == 1:
741
+ fill = " "
742
+ align = align_part[0]
743
+ else:
744
+ fill = " "
745
+ align = ""
746
+
747
+ # Handle type conversions first
748
+ if type_char in ("f", "F"):
749
+ # Float with precision
750
+ prec = precision if precision is not None else 6
751
+ expr = JSMemberCall(expr, "toFixed", [JSNumber(prec)])
752
+ if sign == "+":
753
+ # Add sign prefix for positive numbers
754
+ expr = JSTertiary(
755
+ JSBinary(expr, ">=", JSNumber(0)),
756
+ JSBinary(JSString("+"), "+", expr),
757
+ expr,
758
+ )
759
+ elif type_char == "d":
760
+ # Integer - convert to string for padding (only if we need padding later)
761
+ if width is not None:
762
+ expr = JSCall(JSIdentifier("String"), [expr])
763
+ elif type_char == "x":
764
+ # Hex lowercase
765
+ base_expr = JSMemberCall(expr, "toString", [JSNumber(16)])
766
+ if alt_form:
767
+ expr = JSBinary(JSString("0x"), "+", base_expr)
768
+ else:
769
+ expr = base_expr
770
+ elif type_char == "X":
771
+ # Hex uppercase
772
+ base_expr = JSMemberCall(
773
+ JSMemberCall(expr, "toString", [JSNumber(16)]), "toUpperCase", []
774
+ )
775
+ if alt_form:
776
+ expr = JSBinary(JSString("0x"), "+", base_expr)
777
+ else:
778
+ expr = base_expr
779
+ elif type_char == "o":
780
+ # Octal
781
+ base_expr = JSMemberCall(expr, "toString", [JSNumber(8)])
782
+ if alt_form:
783
+ expr = JSBinary(JSString("0o"), "+", base_expr)
784
+ else:
785
+ expr = base_expr
786
+ elif type_char == "b":
787
+ # Binary
788
+ base_expr = JSMemberCall(expr, "toString", [JSNumber(2)])
789
+ if alt_form:
790
+ expr = JSBinary(JSString("0b"), "+", base_expr)
791
+ else:
792
+ expr = base_expr
793
+ elif type_char == "e":
794
+ # Exponential notation lowercase
795
+ prec = precision if precision is not None else 6
796
+ expr = JSMemberCall(expr, "toExponential", [JSNumber(prec)])
797
+ elif type_char == "E":
798
+ # Exponential notation uppercase
799
+ prec = precision if precision is not None else 6
800
+ expr = JSMemberCall(
801
+ JSMemberCall(expr, "toExponential", [JSNumber(prec)]), "toUpperCase", []
802
+ )
803
+ elif type_char == "s" or type_char == "":
804
+ # String - convert to string if not already
805
+ if type_char == "s" or (width is not None and align):
806
+ expr = JSCall(JSIdentifier("String"), [expr])
807
+
808
+ # Apply width/padding
809
+ if width is not None:
810
+ fill_str = JSString(fill)
811
+ width_num = JSNumber(width)
812
+
813
+ if zero_pad and not align:
814
+ # Zero padding (e.g., 05d) - pad start with zeros
815
+ # If expr is not already a string, wrap it
816
+ if not self._is_string_expr(expr):
817
+ expr = JSCall(JSIdentifier("String"), [expr])
818
+ expr = JSMemberCall(
819
+ expr,
820
+ "padStart",
821
+ [width_num, JSString("0")],
822
+ )
823
+ elif align == "<":
824
+ # Left align -> padEnd
825
+ expr = JSMemberCall(expr, "padEnd", [width_num, fill_str])
826
+ elif align == ">":
827
+ # Right align -> padStart
828
+ expr = JSMemberCall(expr, "padStart", [width_num, fill_str])
829
+ elif align == "^":
830
+ # Center align - needs custom logic
831
+ # JS: s.padStart((width + s.length) / 2).padEnd(width)
832
+ expr = JSMemberCall(
833
+ JSMemberCall(
834
+ expr,
835
+ "padStart",
836
+ [
837
+ JSBinary(
838
+ JSBinary(
839
+ JSBinary(width_num, "+", JSMember(expr, "length")),
840
+ "/",
841
+ JSNumber(2),
842
+ ),
843
+ "|",
844
+ JSNumber(0),
845
+ ),
846
+ fill_str,
847
+ ],
848
+ ),
849
+ "padEnd",
850
+ [width_num, fill_str],
851
+ )
852
+ elif align == "=":
853
+ # Pad after sign - not commonly used, treat as right align
854
+ expr = JSMemberCall(expr, "padStart", [width_num, fill_str])
855
+ elif zero_pad:
856
+ # Just 0N without explicit align means zero-pad from start
857
+ expr = JSMemberCall(
858
+ JSCall(JSIdentifier("String"), [expr]),
859
+ "padStart",
860
+ [width_num, JSString("0")],
861
+ )
862
+
863
+ return expr
864
+
865
+ def _emit_lambda(self, node: ast.Lambda) -> JSExpr:
866
+ """Emit a lambda expression as an arrow function."""
867
+ # Get parameter names
868
+ params = [arg.arg for arg in node.args.args]
869
+ # Add params to locals temporarily
870
+ saved_locals = set(self.locals)
871
+ self.locals.update(params)
872
+
873
+ body = self.emit_expr(node.body)
874
+
875
+ self.locals = saved_locals
876
+
877
+ if len(params) == 0:
878
+ return JSArrowFunction("()", body)
879
+ elif len(params) == 1:
880
+ return JSArrowFunction(params[0], body)
881
+ else:
882
+ return JSArrowFunction(f"({', '.join(params)})", body)
883
+
884
+ def _emit_comprehension_chain(
885
+ self,
886
+ generators: list[ast.comprehension],
887
+ build_last: Callable[[], JSExpr],
888
+ ) -> JSExpr:
889
+ """Build a flatMap/map chain for comprehensions."""
890
+ if len(generators) == 0:
891
+ raise JSCompilationError("Empty comprehension")
892
+
893
+ saved_locals = set(self.locals)
894
+
895
+ def build_chain(gen_index: int) -> JSExpr:
896
+ gen = generators[gen_index]
897
+ if gen.is_async:
898
+ raise JSCompilationError("Async comprehensions are not supported")
899
+
900
+ iter_expr = self.emit_expr(gen.iter)
901
+ # Get arrow function parameter code and variable names from a target
902
+ if isinstance(gen.target, ast.Name):
903
+ param_code = gen.target.id
904
+ names = [gen.target.id]
905
+ elif isinstance(gen.target, ast.Tuple) and all(
906
+ isinstance(e, ast.Name) for e in gen.target.elts
907
+ ):
908
+ names = [e.id for e in gen.target.elts if isinstance(e, ast.Name)]
909
+ param_code = f"([{', '.join(names)}])"
910
+ else:
911
+ raise JSCompilationError(
912
+ "Only name or tuple targets supported in comprehensions"
913
+ )
914
+ for nm in names:
915
+ self.locals.add(nm)
916
+
917
+ base = iter_expr
918
+
919
+ # Apply filters
920
+ if gen.ifs:
921
+ conds = [self.emit_expr(test) for test in gen.ifs]
922
+ cond = JSLogicalChain("&&", conds) if len(conds) > 1 else conds[0]
923
+ base = JSMemberCall(base, "filter", [JSArrowFunction(param_code, cond)])
924
+
925
+ is_last = gen_index == len(generators) - 1
926
+ if is_last:
927
+ elt_expr = build_last()
928
+ return JSMemberCall(
929
+ base, "map", [JSArrowFunction(param_code, elt_expr)]
930
+ )
931
+
932
+ inner = build_chain(gen_index + 1)
933
+ return JSMemberCall(base, "flatMap", [JSArrowFunction(param_code, inner)])
934
+
935
+ try:
936
+ return build_chain(0)
937
+ finally:
938
+ self.locals = saved_locals