pulse-framework 0.1.62__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,1100 @@
1
+ """
2
+ Python -> JavaScript transpiler using v2 nodes.
3
+
4
+ Transpiles a restricted subset of Python into v2 Expr/Stmt AST nodes.
5
+ Dependencies are resolved through a dict[str, Expr] mapping.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from collections.abc import Callable, Mapping
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ from pulse.transpiler.builtins import BUILTINS, emit_method
17
+ from pulse.transpiler.errors import TranspileError
18
+ from pulse.transpiler.nodes import (
19
+ Array,
20
+ Arrow,
21
+ Assign,
22
+ Binary,
23
+ Block,
24
+ Break,
25
+ Call,
26
+ Continue,
27
+ Expr,
28
+ ExprStmt,
29
+ ForOf,
30
+ Function,
31
+ Identifier,
32
+ If,
33
+ Literal,
34
+ Member,
35
+ New,
36
+ Return,
37
+ Spread,
38
+ Stmt,
39
+ StmtSequence,
40
+ Subscript,
41
+ Template,
42
+ Ternary,
43
+ Throw,
44
+ TryStmt,
45
+ Unary,
46
+ While,
47
+ )
48
+
49
+ ALLOWED_BINOPS: dict[type[ast.operator], str] = {
50
+ ast.Add: "+",
51
+ ast.Sub: "-",
52
+ ast.Mult: "*",
53
+ ast.Div: "/",
54
+ ast.Mod: "%",
55
+ ast.Pow: "**",
56
+ # Bitwise operators
57
+ ast.BitAnd: "&",
58
+ ast.BitOr: "|",
59
+ ast.BitXor: "^",
60
+ ast.LShift: "<<",
61
+ ast.RShift: ">>",
62
+ }
63
+
64
+ ALLOWED_UNOPS: dict[type[ast.unaryop], str] = {
65
+ ast.UAdd: "+",
66
+ ast.USub: "-",
67
+ ast.Not: "!",
68
+ ast.Invert: "~", # Bitwise NOT
69
+ }
70
+
71
+ ALLOWED_CMPOPS: dict[type[ast.cmpop], str] = {
72
+ ast.Eq: "===",
73
+ ast.NotEq: "!==",
74
+ ast.Lt: "<",
75
+ ast.LtE: "<=",
76
+ ast.Gt: ">",
77
+ ast.GtE: ">=",
78
+ }
79
+
80
+
81
+ class Transpiler:
82
+ """Transpile Python AST to v2 Expr/Stmt AST nodes.
83
+
84
+ Takes a function definition and a dictionary of dependencies.
85
+ Dependencies are substituted when their names are referenced.
86
+
87
+ Dependencies are Expr instances. Expr subclasses can override:
88
+ - transpile_call: custom call behavior (e.g., JSX components)
89
+ - transpile_getattr: custom attribute access
90
+ - transpile_subscript: custom subscript behavior
91
+ """
92
+
93
+ fndef: ast.FunctionDef | ast.AsyncFunctionDef
94
+ args: list[str]
95
+ deps: Mapping[str, Expr]
96
+ locals: set[str]
97
+ jsx: bool
98
+ source_file: Path | None
99
+ _temp_counter: int
100
+
101
+ def __init__(
102
+ self,
103
+ fndef: ast.FunctionDef | ast.AsyncFunctionDef,
104
+ deps: Mapping[str, Expr],
105
+ *,
106
+ jsx: bool = False,
107
+ source_file: Path | None = None,
108
+ ) -> None:
109
+ self.fndef = fndef
110
+ self.source_file = source_file
111
+ # Collect all argument names (regular, vararg, kwonly, kwarg)
112
+ args: list[str] = [arg.arg for arg in fndef.args.args]
113
+ if fndef.args.vararg:
114
+ args.append(fndef.args.vararg.arg)
115
+ args.extend(arg.arg for arg in fndef.args.kwonlyargs)
116
+ if fndef.args.kwarg:
117
+ args.append(fndef.args.kwarg.arg)
118
+ self.args = args
119
+ self.deps = deps
120
+ self.jsx = jsx
121
+ self.locals = set(self.args)
122
+ self._temp_counter = 0
123
+ self.init_temp_counter()
124
+
125
+ def init_temp_counter(self) -> None:
126
+ """Initialize temp counter to avoid collisions with args or globals."""
127
+ all_names = set(self.args) | set(self.deps.keys())
128
+ counter = 0
129
+ while f"$tmp{counter}" in all_names:
130
+ counter += 1
131
+ self._temp_counter = counter
132
+
133
+ def _fresh_temp(self) -> str:
134
+ """Generate a fresh temporary variable name."""
135
+ name = f"$tmp{self._temp_counter}"
136
+ self._temp_counter += 1
137
+ return name
138
+
139
+ # --- Entrypoint ---------------------------------------------------------
140
+
141
+ def transpile(self) -> Function | Arrow:
142
+ """Transpile the function to a Function or Arrow node.
143
+
144
+ For single-expression functions (or single return), produces Arrow:
145
+ (params) => expr
146
+
147
+ For multi-statement functions, produces Function:
148
+ function(params) { ... }
149
+
150
+ For JSX functions, produces Function with destructured props parameter:
151
+ function({param1, param2 = default}) { ... }
152
+ """
153
+ body = self.fndef.body
154
+
155
+ # Skip docstrings
156
+ if (
157
+ body
158
+ and isinstance(body[0], ast.Expr)
159
+ and isinstance(body[0].value, ast.Constant)
160
+ and isinstance(body[0].value.value, str)
161
+ ):
162
+ body = body[1:]
163
+
164
+ # Arrow optimizations (only for non-JSX)
165
+ if not self.jsx:
166
+ if not body:
167
+ return Arrow(self.args, Literal(None))
168
+
169
+ if len(body) == 1:
170
+ stmt = body[0]
171
+ if isinstance(stmt, ast.Return):
172
+ expr = self.emit_expr(stmt.value)
173
+ return Arrow(self.args, expr)
174
+ if isinstance(stmt, ast.Expr):
175
+ expr = self.emit_expr(stmt.value)
176
+ return Arrow(self.args, expr)
177
+
178
+ # General case: Function (for JSX or multi-statement)
179
+ stmts = [self.emit_stmt(s) for s in body]
180
+ is_async = isinstance(self.fndef, ast.AsyncFunctionDef)
181
+ args = [self._jsx_args()] if self.jsx else self.args
182
+ return Function(args, stmts, is_async=is_async)
183
+
184
+ def _jsx_args(self) -> str:
185
+ """Build a destructured props parameter for JSX functions.
186
+
187
+ React components receive a single props object, so parameters
188
+ are emitted as a destructuring pattern: {param1, param2 = default, ...}
189
+ """
190
+ args = self.fndef.args
191
+ destructure_parts: list[str] = []
192
+ default_out: list[str] = []
193
+
194
+ # Regular arguments (may have defaults at the end)
195
+ num_defaults = len(args.defaults)
196
+ num_args = len(args.args)
197
+ for i, arg in enumerate(args.args):
198
+ param_name = arg.arg
199
+ # Defaults align to the right: if we have 3 args and 1 default,
200
+ # the default is for args[2], not args[0]
201
+ default_idx = i - (num_args - num_defaults)
202
+ if default_idx >= 0:
203
+ # Has a default value
204
+ default_node = args.defaults[default_idx]
205
+ default_expr = self.emit_expr(default_node)
206
+ default_out.clear()
207
+ default_expr.emit(default_out)
208
+ destructure_parts.append(f"{param_name} = {''.join(default_out)}")
209
+ else:
210
+ # No default
211
+ destructure_parts.append(param_name)
212
+
213
+ # *args (VAR_POSITIONAL)
214
+ if args.vararg:
215
+ destructure_parts.append(args.vararg.arg)
216
+
217
+ # Keyword-only arguments
218
+ for i, arg in enumerate(args.kwonlyargs):
219
+ param_name = arg.arg
220
+ default_node = args.kw_defaults[i]
221
+ if default_node is not None:
222
+ # Has a default value
223
+ default_expr = self.emit_expr(default_node)
224
+ default_out.clear()
225
+ default_expr.emit(default_out)
226
+ destructure_parts.append(f"{param_name} = {''.join(default_out)}")
227
+ else:
228
+ # No default
229
+ destructure_parts.append(param_name)
230
+
231
+ # **kwargs (VAR_KEYWORD)
232
+ if args.kwarg:
233
+ destructure_parts.append(f"...{args.kwarg.arg}")
234
+
235
+ return "{" + ", ".join(destructure_parts) + "}"
236
+
237
+ # --- Statements ----------------------------------------------------------
238
+
239
+ def emit_stmt(self, node: ast.stmt) -> Stmt:
240
+ """Emit a statement."""
241
+ if isinstance(node, ast.Return):
242
+ value = self.emit_expr(node.value) if node.value else None
243
+ return Return(value)
244
+
245
+ if isinstance(node, ast.Break):
246
+ return Break()
247
+
248
+ if isinstance(node, ast.Continue):
249
+ return Continue()
250
+
251
+ if isinstance(node, ast.Pass):
252
+ # Pass is a no-op, emit empty block
253
+ return Block([])
254
+
255
+ if isinstance(node, ast.AugAssign):
256
+ if not isinstance(node.target, ast.Name):
257
+ raise TranspileError(
258
+ "Only simple augmented assignments supported", node=node
259
+ )
260
+ target = node.target.id
261
+ op_type = type(node.op)
262
+ if op_type not in ALLOWED_BINOPS:
263
+ raise TranspileError(
264
+ f"Unsupported augmented assignment operator: {op_type.__name__}",
265
+ node=node,
266
+ )
267
+ value_expr = self.emit_expr(node.value)
268
+ return Assign(target, value_expr, op=ALLOWED_BINOPS[op_type])
269
+
270
+ if isinstance(node, ast.Assign):
271
+ if len(node.targets) != 1:
272
+ raise TranspileError(
273
+ "Multiple assignment targets not supported", node=node
274
+ )
275
+ target_node = node.targets[0]
276
+
277
+ # Tuple/list unpacking
278
+ if isinstance(target_node, (ast.Tuple, ast.List)):
279
+ return self._emit_unpacking_assign(target_node, node.value)
280
+
281
+ if not isinstance(target_node, ast.Name):
282
+ raise TranspileError(
283
+ "Only simple assignments to local names supported", node=node
284
+ )
285
+
286
+ target = target_node.id
287
+ value_expr = self.emit_expr(node.value)
288
+
289
+ if target in self.locals:
290
+ return Assign(target, value_expr)
291
+ else:
292
+ self.locals.add(target)
293
+ return Assign(target, value_expr, declare="let")
294
+
295
+ if isinstance(node, ast.AnnAssign):
296
+ if not isinstance(node.target, ast.Name):
297
+ raise TranspileError("Only simple annotated assignments supported")
298
+ target = node.target.id
299
+ value = Literal(None) if node.value is None else self.emit_expr(node.value)
300
+ if target in self.locals:
301
+ return Assign(target, value)
302
+ else:
303
+ self.locals.add(target)
304
+ return Assign(target, value, declare="let")
305
+
306
+ if isinstance(node, ast.If):
307
+ cond = self.emit_expr(node.test)
308
+ then = [self.emit_stmt(s) for s in node.body]
309
+ else_ = [self.emit_stmt(s) for s in node.orelse]
310
+ return If(cond, then, else_)
311
+
312
+ if isinstance(node, ast.Expr):
313
+ expr = self.emit_expr(node.value)
314
+ return ExprStmt(expr)
315
+
316
+ if isinstance(node, ast.While):
317
+ cond = self.emit_expr(node.test)
318
+ body = [self.emit_stmt(s) for s in node.body]
319
+ return While(cond, body)
320
+
321
+ if isinstance(node, ast.For):
322
+ return self._emit_for_loop(node)
323
+
324
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
325
+ return self._emit_nested_function(node)
326
+
327
+ if isinstance(node, ast.Try):
328
+ return self._emit_try(node)
329
+
330
+ if isinstance(node, ast.Raise):
331
+ return self._emit_raise(node)
332
+
333
+ raise TranspileError(f"Unsupported statement: {type(node).__name__}", node=node)
334
+
335
+ def _emit_unpacking_assign(
336
+ self, target: ast.Tuple | ast.List, value: ast.expr
337
+ ) -> Stmt:
338
+ """Emit unpacking assignment: a, b, c = expr"""
339
+ elements = target.elts
340
+ if not elements or not all(isinstance(e, ast.Name) for e in elements):
341
+ raise TranspileError("Unpacking only supported for simple variables")
342
+
343
+ tmp_name = self._fresh_temp()
344
+ value_expr = self.emit_expr(value)
345
+ stmts: list[Stmt] = [Assign(tmp_name, value_expr, declare="const")]
346
+
347
+ for idx, e in enumerate(elements):
348
+ assert isinstance(e, ast.Name)
349
+ name = e.id
350
+ sub = Subscript(Identifier(tmp_name), Literal(idx))
351
+ if name in self.locals:
352
+ stmts.append(Assign(name, sub))
353
+ else:
354
+ self.locals.add(name)
355
+ stmts.append(Assign(name, sub, declare="let"))
356
+
357
+ return StmtSequence(stmts)
358
+
359
+ def _emit_for_loop(self, node: ast.For) -> Stmt:
360
+ """Emit a for loop."""
361
+ # Handle tuple unpacking in for target
362
+ if isinstance(node.target, (ast.Tuple, ast.List)):
363
+ names: list[str] = []
364
+ for e in node.target.elts:
365
+ if not isinstance(e, ast.Name):
366
+ raise TranspileError(
367
+ "Only simple name targets supported in for-loop unpacking"
368
+ )
369
+ names.append(e.id)
370
+ self.locals.add(e.id)
371
+ iter_expr = self.emit_expr(node.iter)
372
+ body = [self.emit_stmt(s) for s in node.body]
373
+ # Use array pattern for destructuring
374
+ target = f"[{', '.join(names)}]"
375
+ return ForOf(target, iter_expr, body)
376
+
377
+ if not isinstance(node.target, ast.Name):
378
+ raise TranspileError("Only simple name targets supported in for-loops")
379
+
380
+ target = node.target.id
381
+ self.locals.add(target)
382
+ iter_expr = self.emit_expr(node.iter)
383
+ body = [self.emit_stmt(s) for s in node.body]
384
+ return ForOf(target, iter_expr, body)
385
+
386
+ def _emit_nested_function(
387
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
388
+ ) -> Stmt:
389
+ """Emit a nested function definition."""
390
+ name = node.name
391
+ params = [arg.arg for arg in node.args.args]
392
+
393
+ # Save current locals and extend with params
394
+ saved_locals = set(self.locals)
395
+ self.locals.update(params)
396
+
397
+ # Skip docstrings and emit body
398
+ body_stmts = node.body
399
+ if (
400
+ body_stmts
401
+ and isinstance(body_stmts[0], ast.Expr)
402
+ and isinstance(body_stmts[0].value, ast.Constant)
403
+ and isinstance(body_stmts[0].value.value, str)
404
+ ):
405
+ body_stmts = body_stmts[1:]
406
+
407
+ stmts: list[Stmt] = [self.emit_stmt(s) for s in body_stmts]
408
+
409
+ # Restore outer locals and add function name
410
+ self.locals = saved_locals
411
+ self.locals.add(name)
412
+
413
+ is_async = isinstance(node, ast.AsyncFunctionDef)
414
+ fn = Function(params, stmts, is_async=is_async)
415
+ return Assign(name, fn, declare="const")
416
+
417
+ def _emit_try(self, node: ast.Try) -> Stmt:
418
+ """Emit a try/except/finally statement."""
419
+ body = [self.emit_stmt(s) for s in node.body]
420
+
421
+ # Handle except handlers - JS only supports single catch
422
+ catch_param: str | None = None
423
+ catch_body: list[Stmt] | None = None
424
+
425
+ if node.handlers:
426
+ if len(node.handlers) > 1:
427
+ raise TranspileError(
428
+ "Multiple except clauses not supported; JS only has one catch block",
429
+ node=node.handlers[1],
430
+ )
431
+ handler = node.handlers[0]
432
+ if handler.name:
433
+ catch_param = handler.name
434
+ self.locals.add(catch_param)
435
+ catch_body = [self.emit_stmt(s) for s in handler.body]
436
+
437
+ # Handle finally
438
+ finally_body: list[Stmt] | None = None
439
+ if node.finalbody:
440
+ finally_body = [self.emit_stmt(s) for s in node.finalbody]
441
+
442
+ return TryStmt(body, catch_param, catch_body, finally_body)
443
+
444
+ def _emit_raise(self, node: ast.Raise) -> Stmt:
445
+ """Emit a raise statement as throw."""
446
+ if node.exc is None:
447
+ raise TranspileError(
448
+ "Bare raise not supported; use explicit 'raise e' instead", node=node
449
+ )
450
+
451
+ return Throw(self.emit_expr(node.exc))
452
+
453
+ # --- Expressions ---------------------------------------------------------
454
+
455
+ def emit_expr(self, node: ast.expr | None) -> Expr:
456
+ """Emit an expression."""
457
+ if node is None:
458
+ return Literal(None)
459
+
460
+ if isinstance(node, ast.Constant):
461
+ return self._emit_constant(node)
462
+
463
+ if isinstance(node, ast.Name):
464
+ return self._emit_name(node)
465
+
466
+ if isinstance(node, (ast.List, ast.Tuple)):
467
+ return self._emit_list_or_tuple(node)
468
+
469
+ if isinstance(node, ast.Dict):
470
+ return self._emit_dict(node)
471
+
472
+ if isinstance(node, ast.Set):
473
+ return New(
474
+ Identifier("Set"),
475
+ [Array([self.emit_expr(e) for e in node.elts])],
476
+ )
477
+
478
+ if isinstance(node, ast.BinOp):
479
+ return self._emit_binop(node)
480
+
481
+ if isinstance(node, ast.UnaryOp):
482
+ return self._emit_unaryop(node)
483
+
484
+ if isinstance(node, ast.BoolOp):
485
+ return self._emit_boolop(node)
486
+
487
+ if isinstance(node, ast.Compare):
488
+ return self._emit_compare(node)
489
+
490
+ if isinstance(node, ast.IfExp):
491
+ return Ternary(
492
+ self.emit_expr(node.test),
493
+ self.emit_expr(node.body),
494
+ self.emit_expr(node.orelse),
495
+ )
496
+
497
+ if isinstance(node, ast.Call):
498
+ return self._emit_call(node)
499
+
500
+ if isinstance(node, ast.Attribute):
501
+ return self._emit_attribute(node)
502
+
503
+ if isinstance(node, ast.Subscript):
504
+ return self._emit_subscript(node)
505
+
506
+ if isinstance(node, ast.JoinedStr):
507
+ return self._emit_fstring(node)
508
+
509
+ if isinstance(node, ast.ListComp):
510
+ return self._emit_comprehension_chain(
511
+ node.generators, lambda: self.emit_expr(node.elt)
512
+ )
513
+
514
+ if isinstance(node, ast.GeneratorExp):
515
+ return self._emit_comprehension_chain(
516
+ node.generators, lambda: self.emit_expr(node.elt)
517
+ )
518
+
519
+ if isinstance(node, ast.SetComp):
520
+ arr = self._emit_comprehension_chain(
521
+ node.generators, lambda: self.emit_expr(node.elt)
522
+ )
523
+ return New(Identifier("Set"), [arr])
524
+
525
+ if isinstance(node, ast.DictComp):
526
+ pairs = self._emit_comprehension_chain(
527
+ node.generators,
528
+ lambda: Array([self.emit_expr(node.key), self.emit_expr(node.value)]),
529
+ )
530
+ return New(Identifier("Map"), [pairs])
531
+
532
+ if isinstance(node, ast.Lambda):
533
+ return self._emit_lambda(node)
534
+
535
+ if isinstance(node, ast.Starred):
536
+ return Spread(self.emit_expr(node.value))
537
+
538
+ if isinstance(node, ast.Await):
539
+ return Unary("await", self.emit_expr(node.value))
540
+
541
+ raise TranspileError(
542
+ f"Unsupported expression: {type(node).__name__}", node=node
543
+ )
544
+
545
+ def _emit_constant(self, node: ast.Constant) -> Expr:
546
+ """Emit a constant value."""
547
+ v = node.value
548
+ if isinstance(v, str):
549
+ # Use template literals for strings with Unicode line separators
550
+ if "\u2028" in v or "\u2029" in v:
551
+ return Template([v])
552
+ return Literal(v)
553
+ if v is None:
554
+ return Literal(None)
555
+ if isinstance(v, bool):
556
+ return Literal(v)
557
+ if isinstance(v, (int, float)):
558
+ return Literal(v)
559
+ raise TranspileError(f"Unsupported constant type: {type(v).__name__}")
560
+
561
+ def _emit_name(self, node: ast.Name) -> Expr:
562
+ """Emit a name reference."""
563
+ name = node.id
564
+
565
+ # Check deps first
566
+ if name in self.deps:
567
+ return self.deps[name]
568
+
569
+ # Local variable
570
+ if name in self.locals:
571
+ return Identifier(name)
572
+
573
+ # Check builtins
574
+ if name in BUILTINS:
575
+ return BUILTINS[name]
576
+
577
+ raise TranspileError(f"Unbound name referenced: {name}", node=node)
578
+
579
+ def _emit_list_or_tuple(self, node: ast.List | ast.Tuple) -> Expr:
580
+ """Emit a list or tuple literal."""
581
+ parts: list[Expr] = []
582
+ for e in node.elts:
583
+ if isinstance(e, ast.Starred):
584
+ parts.append(Spread(self.emit_expr(e.value)))
585
+ else:
586
+ parts.append(self.emit_expr(e))
587
+ return Array(parts)
588
+
589
+ def _emit_dict(self, node: ast.Dict) -> Expr:
590
+ """Emit a dict literal as new Map([...])."""
591
+ entries: list[Expr] = []
592
+ for k, v in zip(node.keys, node.values, strict=False):
593
+ if k is None:
594
+ # Spread merge
595
+ vexpr = self.emit_expr(v)
596
+ is_map = Binary(vexpr, "instanceof", Identifier("Map"))
597
+ map_entries = Call(Member(vexpr, "entries"), [])
598
+ obj_entries = Call(Member(Identifier("Object"), "entries"), [vexpr])
599
+ entries.append(Spread(Ternary(is_map, map_entries, obj_entries)))
600
+ continue
601
+ key_expr = self.emit_expr(k)
602
+ val_expr = self.emit_expr(v)
603
+ entries.append(Array([key_expr, val_expr]))
604
+ return New(Identifier("Map"), [Array(entries)])
605
+
606
+ def _emit_binop(self, node: ast.BinOp) -> Expr:
607
+ """Emit a binary operation."""
608
+ op = type(node.op)
609
+
610
+ # Special case: floor division -> Math.floor(x / y)
611
+ if op is ast.FloorDiv:
612
+ left = self.emit_expr(node.left)
613
+ right = self.emit_expr(node.right)
614
+ return Call(
615
+ Member(Identifier("Math"), "floor"),
616
+ [Binary(left, "/", right)],
617
+ )
618
+
619
+ if op not in ALLOWED_BINOPS:
620
+ raise TranspileError(
621
+ f"Unsupported binary operator: {op.__name__}", node=node
622
+ )
623
+ left = self.emit_expr(node.left)
624
+ right = self.emit_expr(node.right)
625
+ return Binary(left, ALLOWED_BINOPS[op], right)
626
+
627
+ def _emit_unaryop(self, node: ast.UnaryOp) -> Expr:
628
+ """Emit a unary operation."""
629
+ op = type(node.op)
630
+ if op not in ALLOWED_UNOPS:
631
+ raise TranspileError(
632
+ f"Unsupported unary operator: {op.__name__}", node=node
633
+ )
634
+ return Unary(ALLOWED_UNOPS[op], self.emit_expr(node.operand))
635
+
636
+ def _emit_boolop(self, node: ast.BoolOp) -> Expr:
637
+ """Emit a boolean operation (and/or chain)."""
638
+ op = "&&" if isinstance(node.op, ast.And) else "||"
639
+ values = [self.emit_expr(v) for v in node.values]
640
+ # Build binary chain: a && b && c -> Binary(Binary(a, &&, b), &&, c)
641
+ result = values[0]
642
+ for v in values[1:]:
643
+ result = Binary(result, op, v)
644
+ return result
645
+
646
+ def _emit_compare(self, node: ast.Compare) -> Expr:
647
+ """Emit a comparison expression."""
648
+ operands: list[ast.expr] = [node.left, *node.comparators]
649
+ exprs: list[Expr] = [self.emit_expr(e) for e in operands]
650
+ cmp_parts: list[Expr] = []
651
+
652
+ for i, op in enumerate(node.ops):
653
+ left_node = operands[i]
654
+ right_node = operands[i + 1]
655
+ left_expr = exprs[i]
656
+ right_expr = exprs[i + 1]
657
+ cmp_parts.append(
658
+ self._build_comparison(left_expr, left_node, op, right_expr, right_node)
659
+ )
660
+
661
+ if len(cmp_parts) == 1:
662
+ return cmp_parts[0]
663
+
664
+ # Chain with &&
665
+ result = cmp_parts[0]
666
+ for v in cmp_parts[1:]:
667
+ result = Binary(result, "&&", v)
668
+ return result
669
+
670
+ def _build_comparison(
671
+ self,
672
+ left_expr: Expr,
673
+ left_node: ast.expr,
674
+ op: ast.cmpop,
675
+ right_expr: Expr,
676
+ right_node: ast.expr,
677
+ ) -> Expr:
678
+ """Build a single comparison."""
679
+ # Identity comparisons
680
+ if isinstance(op, (ast.Is, ast.IsNot)):
681
+ is_not = isinstance(op, ast.IsNot)
682
+ # Special case for None identity
683
+ if (isinstance(right_node, ast.Constant) and right_node.value is None) or (
684
+ isinstance(left_node, ast.Constant) and left_node.value is None
685
+ ):
686
+ expr = right_expr if isinstance(left_node, ast.Constant) else left_expr
687
+ return Binary(expr, "!=" if is_not else "==", Literal(None))
688
+ return Binary(left_expr, "!==" if is_not else "===", right_expr)
689
+
690
+ # Membership tests
691
+ if isinstance(op, (ast.In, ast.NotIn)):
692
+ return self._build_membership_test(
693
+ left_expr, right_expr, isinstance(op, ast.NotIn)
694
+ )
695
+
696
+ # Standard comparisons
697
+ op_type = type(op)
698
+ if op_type not in ALLOWED_CMPOPS:
699
+ raise TranspileError(f"Unsupported comparison operator: {op_type.__name__}")
700
+ return Binary(left_expr, ALLOWED_CMPOPS[op_type], right_expr)
701
+
702
+ def _build_membership_test(self, item: Expr, container: Expr, negate: bool) -> Expr:
703
+ """Build a membership test (in / not in)."""
704
+ is_string = Binary(Unary("typeof", container), "===", Literal("string"))
705
+ is_array = Call(Member(Identifier("Array"), "isArray"), [container])
706
+ is_set = Binary(container, "instanceof", Identifier("Set"))
707
+ is_map = Binary(container, "instanceof", Identifier("Map"))
708
+
709
+ is_array_or_string = Binary(is_array, "||", is_string)
710
+ is_set_or_map = Binary(is_set, "||", is_map)
711
+
712
+ has_array_or_string = Call(Member(container, "includes"), [item])
713
+ has_set_or_map = Call(Member(container, "has"), [item])
714
+ has_obj = Binary(item, "in", container)
715
+
716
+ membership_expr = Ternary(
717
+ is_array_or_string,
718
+ has_array_or_string,
719
+ Ternary(is_set_or_map, has_set_or_map, has_obj),
720
+ )
721
+
722
+ if negate:
723
+ return Unary("!", membership_expr)
724
+ return membership_expr
725
+
726
+ def _emit_call(self, node: ast.Call) -> Expr:
727
+ """Emit a function call."""
728
+ # Method call: obj.method(args) - try builtin method dispatch
729
+ if isinstance(node.func, ast.Attribute):
730
+ # Check for spreads - if present, skip builtin method handling
731
+ # (let transpile_call decide on spread support)
732
+ has_spread = any(kw.arg is None for kw in node.keywords)
733
+
734
+ obj = self.emit_expr(node.func.value)
735
+ method = node.func.attr
736
+
737
+ # Try builtin method handling only if no spreads
738
+ if not has_spread:
739
+ # Safe to cast: has_spread=False means all kw.arg are str (not None)
740
+ kwargs_raw: dict[str, Any] = {
741
+ cast(str, kw.arg): kw.value for kw in node.keywords
742
+ }
743
+ args: list[Expr] = [self.emit_expr(a) for a in node.args]
744
+ kwargs: dict[str, Expr] = {
745
+ k: self.emit_expr(v) for k, v in kwargs_raw.items()
746
+ }
747
+ result = emit_method(obj, method, args, kwargs)
748
+ if result is not None:
749
+ return result
750
+
751
+ # IMPORTANT: derive method expr via transpile_getattr
752
+ method_expr = obj.transpile_getattr(method, self)
753
+ return method_expr.transpile_call(
754
+ list(node.args), list(node.keywords), self
755
+ )
756
+
757
+ # Function call - pass raw keywords (let callee decide on spread support)
758
+ callee = self.emit_expr(node.func)
759
+ return callee.transpile_call(list(node.args), list(node.keywords), self)
760
+
761
+ def _emit_attribute(self, node: ast.Attribute) -> Expr:
762
+ """Emit an attribute access."""
763
+ value = self.emit_expr(node.value)
764
+ # Delegate to Expr.transpile_getattr (default returns Member)
765
+ return value.transpile_getattr(node.attr, self)
766
+
767
+ def _emit_subscript(self, node: ast.Subscript) -> Expr:
768
+ """Emit a subscript expression."""
769
+ value = self.emit_expr(node.value)
770
+
771
+ # Slice handling
772
+ if isinstance(node.slice, ast.Slice):
773
+ return self._emit_slice(value, node.slice)
774
+
775
+ # Negative index: use .at()
776
+ if isinstance(node.slice, ast.UnaryOp) and isinstance(node.slice.op, ast.USub):
777
+ idx_expr = self.emit_expr(node.slice.operand)
778
+ return Call(Member(value, "at"), [Unary("-", idx_expr)])
779
+
780
+ # Delegate to Expr.transpile_subscript (default returns Subscript)
781
+ return value.transpile_subscript(node.slice, self)
782
+
783
+ def _emit_slice(self, value: Expr, slice_node: ast.Slice) -> Expr:
784
+ """Emit a slice operation."""
785
+ if slice_node.step is not None:
786
+ raise TranspileError("Slice steps are not supported")
787
+
788
+ lower = slice_node.lower
789
+ upper = slice_node.upper
790
+
791
+ if lower is None and upper is None:
792
+ return Call(Member(value, "slice"), [])
793
+ elif lower is None:
794
+ return Call(Member(value, "slice"), [Literal(0), self.emit_expr(upper)])
795
+ elif upper is None:
796
+ return Call(Member(value, "slice"), [self.emit_expr(lower)])
797
+ else:
798
+ return Call(
799
+ Member(value, "slice"), [self.emit_expr(lower), self.emit_expr(upper)]
800
+ )
801
+
802
+ def _emit_fstring(self, node: ast.JoinedStr) -> Expr:
803
+ """Emit an f-string as a template literal."""
804
+ parts: list[str | Expr] = []
805
+ for part in node.values:
806
+ if isinstance(part, ast.Constant) and isinstance(part.value, str):
807
+ parts.append(part.value)
808
+ elif isinstance(part, ast.FormattedValue):
809
+ expr = self.emit_expr(part.value)
810
+ # Handle conversion flags: !s, !r, !a
811
+ if part.conversion == ord("s"):
812
+ expr = Call(Identifier("String"), [expr])
813
+ elif part.conversion == ord("r"):
814
+ expr = Call(Member(Identifier("JSON"), "stringify"), [expr])
815
+ elif part.conversion == ord("a"):
816
+ expr = Call(Member(Identifier("JSON"), "stringify"), [expr])
817
+ # Handle format_spec
818
+ if part.format_spec is not None:
819
+ if not isinstance(part.format_spec, ast.JoinedStr):
820
+ raise TranspileError("Format spec must be a JoinedStr")
821
+ expr = self._apply_format_spec(expr, part.format_spec)
822
+ parts.append(expr)
823
+ else:
824
+ raise TranspileError(
825
+ f"Unsupported f-string component: {type(part).__name__}"
826
+ )
827
+ return Template(parts)
828
+
829
+ def _apply_format_spec(self, expr: Expr, format_spec: ast.JoinedStr) -> Expr:
830
+ """Apply a Python format spec to an expression."""
831
+ if len(format_spec.values) != 1:
832
+ raise TranspileError("Dynamic format specs not supported")
833
+ spec_part = format_spec.values[0]
834
+ if not isinstance(spec_part, ast.Constant) or not isinstance(
835
+ spec_part.value, str
836
+ ):
837
+ raise TranspileError("Dynamic format specs not supported")
838
+
839
+ spec = spec_part.value
840
+ return self._parse_and_apply_format(expr, spec)
841
+
842
+ def _parse_and_apply_format(self, expr: Expr, spec: str) -> Expr:
843
+ """Parse a format spec string and apply it to expr."""
844
+ if not spec:
845
+ return expr
846
+
847
+ # Parse Python format spec
848
+ pattern = r"^([^<>=^]?[<>=^])?([+\- ])?([#])?(0)?(\d+)?([,_])?(\.(\d+))?([bcdeEfFgGnosxX%])?$"
849
+ match = re.match(pattern, spec)
850
+ if not match:
851
+ raise TranspileError(f"Unsupported format spec: {spec!r}")
852
+
853
+ align_part = match.group(1) or ""
854
+ sign = match.group(2) or ""
855
+ alt_form = match.group(3)
856
+ zero_pad = match.group(4)
857
+ width_str = match.group(5)
858
+ grouping = match.group(6) or ""
859
+ precision_str = match.group(8)
860
+ type_char = match.group(9) or ""
861
+
862
+ width = int(width_str) if width_str else None
863
+ precision = int(precision_str) if precision_str else None
864
+
865
+ # Determine fill and alignment
866
+ if len(align_part) == 2:
867
+ fill = align_part[0]
868
+ align = align_part[1]
869
+ elif len(align_part) == 1:
870
+ fill = " "
871
+ align = align_part[0]
872
+ else:
873
+ fill = " "
874
+ align = ""
875
+
876
+ # Handle type conversions first
877
+ if type_char in ("f", "F"):
878
+ prec = precision if precision is not None else 6
879
+ expr = Call(Member(expr, "toFixed"), [Literal(prec)])
880
+ if sign == "+":
881
+ expr = Ternary(
882
+ Binary(expr, ">=", Literal(0)),
883
+ Binary(Literal("+"), "+", expr),
884
+ expr,
885
+ )
886
+ elif type_char == "d":
887
+ if width is not None:
888
+ expr = Call(Identifier("String"), [expr])
889
+ elif type_char == "x":
890
+ base_expr = Call(Member(expr, "toString"), [Literal(16)])
891
+ if alt_form:
892
+ expr = Binary(Literal("0x"), "+", base_expr)
893
+ else:
894
+ expr = base_expr
895
+ elif type_char == "X":
896
+ base_expr = Call(
897
+ Member(Call(Member(expr, "toString"), [Literal(16)]), "toUpperCase"), []
898
+ )
899
+ if alt_form:
900
+ expr = Binary(Literal("0x"), "+", base_expr)
901
+ else:
902
+ expr = base_expr
903
+ elif type_char == "o":
904
+ base_expr = Call(Member(expr, "toString"), [Literal(8)])
905
+ if alt_form:
906
+ expr = Binary(Literal("0o"), "+", base_expr)
907
+ else:
908
+ expr = base_expr
909
+ elif type_char == "b":
910
+ base_expr = Call(Member(expr, "toString"), [Literal(2)])
911
+ if alt_form:
912
+ expr = Binary(Literal("0b"), "+", base_expr)
913
+ else:
914
+ expr = base_expr
915
+ elif type_char == "e":
916
+ prec = precision if precision is not None else 6
917
+ expr = Call(Member(expr, "toExponential"), [Literal(prec)])
918
+ elif type_char == "E":
919
+ prec = precision if precision is not None else 6
920
+ expr = Call(
921
+ Member(
922
+ Call(Member(expr, "toExponential"), [Literal(prec)]), "toUpperCase"
923
+ ),
924
+ [],
925
+ )
926
+ elif type_char == "g":
927
+ # General format: uses toPrecision
928
+ prec = precision if precision is not None else 6
929
+ expr = Call(Member(expr, "toPrecision"), [Literal(prec)])
930
+ elif type_char == "G":
931
+ # General format uppercase
932
+ prec = precision if precision is not None else 6
933
+ expr = Call(
934
+ Member(
935
+ Call(Member(expr, "toPrecision"), [Literal(prec)]), "toUpperCase"
936
+ ),
937
+ [],
938
+ )
939
+ elif type_char == "%":
940
+ # Percentage: multiply by 100, format as fixed, append %
941
+ prec = precision if precision is not None else 6
942
+ multiplied = Binary(expr, "*", Literal(100))
943
+ fixed = Call(Member(multiplied, "toFixed"), [Literal(prec)])
944
+ expr = Binary(fixed, "+", Literal("%"))
945
+ elif type_char == "c":
946
+ # Character: convert code point to character
947
+ expr = Call(Member(Identifier("String"), "fromCharCode"), [expr])
948
+ elif type_char == "n":
949
+ # Locale-aware number format
950
+ expr = Call(Member(expr, "toLocaleString"), [])
951
+ elif type_char == "s" or type_char == "":
952
+ if type_char == "s" or (width is not None and align):
953
+ expr = Call(Identifier("String"), [expr])
954
+
955
+ # Apply thousand separator grouping
956
+ if grouping == ",":
957
+ # Use toLocaleString with en-US to get comma separators
958
+ expr = Call(Member(expr, "toLocaleString"), [Literal("en-US")])
959
+ elif grouping == "_":
960
+ # Use toLocaleString then replace commas with underscores
961
+ locale_expr = Call(Member(expr, "toLocaleString"), [Literal("en-US")])
962
+ expr = Call(
963
+ Member(locale_expr, "replace"), [Identifier(r"/,/g"), Literal("_")]
964
+ )
965
+
966
+ # Apply width/padding
967
+ if width is not None:
968
+ fill_str = Literal(fill)
969
+ width_num = Literal(width)
970
+
971
+ if zero_pad and not align:
972
+ expr = Call(
973
+ Member(Call(Identifier("String"), [expr]), "padStart"),
974
+ [width_num, Literal("0")],
975
+ )
976
+ elif align == "<":
977
+ expr = Call(Member(expr, "padEnd"), [width_num, fill_str])
978
+ elif align == ">":
979
+ expr = Call(Member(expr, "padStart"), [width_num, fill_str])
980
+ elif align == "^":
981
+ # Center align
982
+ expr = Call(
983
+ Member(
984
+ Call(
985
+ Member(expr, "padStart"),
986
+ [
987
+ Binary(
988
+ Binary(
989
+ Binary(width_num, "+", Member(expr, "length")),
990
+ "/",
991
+ Literal(2),
992
+ ),
993
+ "|",
994
+ Literal(0),
995
+ ),
996
+ fill_str,
997
+ ],
998
+ ),
999
+ "padEnd",
1000
+ ),
1001
+ [width_num, fill_str],
1002
+ )
1003
+ elif align == "=":
1004
+ expr = Call(Member(expr, "padStart"), [width_num, fill_str])
1005
+ elif zero_pad:
1006
+ expr = Call(
1007
+ Member(Call(Identifier("String"), [expr]), "padStart"),
1008
+ [width_num, Literal("0")],
1009
+ )
1010
+
1011
+ return expr
1012
+
1013
+ def _emit_lambda(self, node: ast.Lambda) -> Expr:
1014
+ """Emit a lambda expression as an arrow function."""
1015
+ params = [arg.arg for arg in node.args.args]
1016
+
1017
+ # Add params to locals temporarily
1018
+ saved_locals = set(self.locals)
1019
+ self.locals.update(params)
1020
+
1021
+ body = self.emit_expr(node.body)
1022
+
1023
+ self.locals = saved_locals
1024
+
1025
+ return Arrow(params, body)
1026
+
1027
+ def _emit_comprehension_chain(
1028
+ self,
1029
+ generators: list[ast.comprehension],
1030
+ build_last: Callable[[], Expr],
1031
+ ) -> Expr:
1032
+ """Build a flatMap/map chain for comprehensions."""
1033
+ if len(generators) == 0:
1034
+ raise TranspileError("Empty comprehension")
1035
+
1036
+ saved_locals = set(self.locals)
1037
+
1038
+ def build_chain(gen_index: int) -> Expr:
1039
+ gen = generators[gen_index]
1040
+ if gen.is_async:
1041
+ raise TranspileError("Async comprehensions are not supported")
1042
+
1043
+ iter_expr = self.emit_expr(gen.iter)
1044
+
1045
+ # Get parameter and variable names from target
1046
+ if isinstance(gen.target, ast.Name):
1047
+ params = [gen.target.id]
1048
+ names = [gen.target.id]
1049
+ elif isinstance(gen.target, ast.Tuple) and all(
1050
+ isinstance(e, ast.Name) for e in gen.target.elts
1051
+ ):
1052
+ names = [e.id for e in gen.target.elts if isinstance(e, ast.Name)]
1053
+ # For destructuring, use array pattern as single param: [a, b]
1054
+ params = [f"([{', '.join(names)}])"]
1055
+ else:
1056
+ raise TranspileError(
1057
+ "Only name or tuple targets supported in comprehensions"
1058
+ )
1059
+
1060
+ for nm in names:
1061
+ self.locals.add(nm)
1062
+
1063
+ base = iter_expr
1064
+
1065
+ # Apply filters
1066
+ if gen.ifs:
1067
+ conds = [self.emit_expr(test) for test in gen.ifs]
1068
+ cond = conds[0]
1069
+ for c in conds[1:]:
1070
+ cond = Binary(cond, "&&", c)
1071
+ base = Call(Member(base, "filter"), [Arrow(params, cond)])
1072
+
1073
+ is_last = gen_index == len(generators) - 1
1074
+ if is_last:
1075
+ elt_expr = build_last()
1076
+ return Call(Member(base, "map"), [Arrow(params, elt_expr)])
1077
+
1078
+ inner = build_chain(gen_index + 1)
1079
+ return Call(Member(base, "flatMap"), [Arrow(params, inner)])
1080
+
1081
+ try:
1082
+ return build_chain(0)
1083
+ finally:
1084
+ self.locals = saved_locals
1085
+
1086
+
1087
+ def transpile(
1088
+ fndef: ast.FunctionDef | ast.AsyncFunctionDef,
1089
+ deps: Mapping[str, Expr] | None = None,
1090
+ ) -> Function | Arrow:
1091
+ """Transpile a Python function to a v2 Function or Arrow node.
1092
+
1093
+ Args:
1094
+ fndef: The function definition AST node
1095
+ deps: Dictionary mapping global names to Expr instances
1096
+
1097
+ Returns:
1098
+ Arrow for single-expression functions, Function for multi-statement
1099
+ """
1100
+ return Transpiler(fndef, deps or {}).transpile()