pulse-framework 0.1.73__py3-none-any.whl → 0.1.74__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.
@@ -106,6 +106,11 @@ from pulse.transpiler.nodes import While as While
106
106
  # Emit
107
107
  from pulse.transpiler.nodes import emit as emit
108
108
 
109
+ # Parse helpers
110
+ from pulse.transpiler.parse import ParsedSource as ParsedSource
111
+ from pulse.transpiler.parse import get_ast as get_ast
112
+ from pulse.transpiler.parse import get_source as get_source
113
+
109
114
  # Transpiler
110
115
  from pulse.transpiler.transpiler import Transpiler as Transpiler
111
116
  from pulse.transpiler.transpiler import transpile as transpile
@@ -7,8 +7,8 @@ and JsFunction which wraps transpiled functions with their dependencies.
7
7
  from __future__ import annotations
8
8
 
9
9
  import ast
10
+ import dis
10
11
  import inspect
11
- import textwrap
12
12
  import types as pytypes
13
13
  from collections.abc import Callable
14
14
  from dataclasses import dataclass, field
@@ -25,7 +25,6 @@ from typing import (
25
25
  override,
26
26
  )
27
27
 
28
- from pulse.helpers import getsourcecode
29
28
  from pulse.transpiler.errors import TranspileError
30
29
  from pulse.transpiler.id import next_id, reset_id_counter
31
30
  from pulse.transpiler.imports import Import
@@ -38,6 +37,7 @@ from pulse.transpiler.nodes import (
38
37
  Return,
39
38
  to_js_identifier,
40
39
  )
40
+ from pulse.transpiler.parse import clear_parse_cache, get_ast, get_source
41
41
  from pulse.transpiler.transpiler import Transpiler
42
42
  from pulse.transpiler.vdom import VDOMExpr
43
43
 
@@ -63,6 +63,7 @@ def clear_function_cache() -> None:
63
63
 
64
64
  FUNCTION_CACHE.clear()
65
65
  CONSTANT_REGISTRY.clear()
66
+ clear_parse_cache()
66
67
  clear_import_registry()
67
68
  clear_asset_registry()
68
69
  reset_id_counter()
@@ -137,33 +138,17 @@ def _transpile_function_body(
137
138
  deps: dict[str, Expr],
138
139
  *,
139
140
  jsx: bool = False,
140
- ) -> tuple[Function | Arrow, str]:
141
+ ) -> Function | Arrow:
141
142
  """Shared transpilation logic for JsFunction and JsxFunction.
142
143
 
143
- Returns the transpiled Function/Arrow node and the source code.
144
+ Returns the transpiled Function/Arrow node.
144
145
  """
145
146
  # Get and parse source
146
- src = getsourcecode(fn)
147
- src = textwrap.dedent(src)
148
- try:
149
- source_start_line = inspect.getsourcelines(fn)[1]
150
- except (OSError, TypeError):
151
- source_start_line = None
152
- module = ast.parse(src)
153
-
154
- # Find the function definition
155
- fndefs = [
156
- n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
157
- ]
158
- if not fndefs:
159
- raise TranspileError("No function definition found in source")
160
- fndef = fndefs[-1]
161
-
162
- # Get filename for error messages and source file resolution
163
- try:
164
- filename = inspect.getfile(fn)
165
- except (TypeError, OSError):
166
- filename = None
147
+ parsed = get_source(fn)
148
+ src = parsed.source
149
+ fndef = get_ast(fn)
150
+ filename = parsed.filename
151
+ source_start_line = parsed.source_start_line
167
152
 
168
153
  # Transpile with source context for errors
169
154
  try:
@@ -181,7 +166,7 @@ def _transpile_function_body(
181
166
  ) from None
182
167
  raise
183
168
 
184
- return result, src
169
+ return result
185
170
 
186
171
 
187
172
  @dataclass(slots=True, init=False)
@@ -238,7 +223,7 @@ class JsFunction(Expr, Generic[*Args, R]):
238
223
  if self._transpiled is not None:
239
224
  return self._transpiled
240
225
 
241
- result, _ = _transpile_function_body(self.fn, self.deps)
226
+ result = _transpile_function_body(self.fn, self.deps)
242
227
 
243
228
  # Convert Arrow to Function if needed, and set the name
244
229
  if isinstance(result, Function):
@@ -326,7 +311,7 @@ class JsxFunction(Expr, Generic[P, R]):
326
311
  if self._transpiled is not None:
327
312
  return self._transpiled
328
313
 
329
- result, _ = _transpile_function_body(self.fn, self.deps, jsx=True)
314
+ result = _transpile_function_body(self.fn, self.deps, jsx=True)
330
315
 
331
316
  # JSX transpilation always returns Function (never Arrow)
332
317
  assert isinstance(result, Function), (
@@ -376,7 +361,6 @@ def analyze_code_object(
376
361
  - effective_globals: dict mapping names to their values (includes closure vars)
377
362
  - all_names: set of all names referenced in the code (including nested functions)
378
363
  """
379
- import dis
380
364
 
381
365
  code = fn.__code__
382
366
 
@@ -443,14 +427,54 @@ def analyze_deps(fn: Callable[..., Any]) -> dict[str, Expr]:
443
427
  """
444
428
  # Analyze code object and resolve globals + closure vars
445
429
  effective_globals, all_names = analyze_code_object(fn)
430
+ code_names = set(all_names)
431
+ default_names: set[str] = set()
432
+ default_name_values: dict[str, Any] = {}
433
+
434
+ # Include names referenced only in default expressions (not in bytecode)
435
+ try:
436
+ args = get_ast(fn).args
437
+ pos_defaults = list(args.defaults)
438
+ py_defaults = fn.__defaults__ or ()
439
+ num_args = len(args.args)
440
+ num_defaults = len(pos_defaults)
441
+ for i, _arg in enumerate(args.args):
442
+ default_idx = i - (num_args - num_defaults)
443
+ if default_idx < 0 or default_idx >= len(pos_defaults):
444
+ continue
445
+ default_node = pos_defaults[default_idx]
446
+ if isinstance(default_node, ast.Name) and default_idx < len(py_defaults):
447
+ default_name_values[default_node.id] = py_defaults[default_idx]
448
+ for node in ast.walk(default_node):
449
+ if isinstance(node, ast.Name):
450
+ default_names.add(node.id)
451
+
452
+ py_kwdefaults = fn.__kwdefaults__ or {}
453
+ for i, kwarg in enumerate(args.kwonlyargs):
454
+ default_node = args.kw_defaults[i]
455
+ if default_node is None:
456
+ continue
457
+ if isinstance(default_node, ast.Name) and kwarg.arg in py_kwdefaults:
458
+ default_name_values[default_node.id] = py_kwdefaults[kwarg.arg]
459
+ for node in ast.walk(default_node):
460
+ if isinstance(node, ast.Name):
461
+ default_names.add(node.id)
462
+ except (OSError, TypeError, SyntaxError, TranspileError):
463
+ pass
464
+
465
+ all_names.update(default_names)
466
+ default_only_names = default_names - code_names
446
467
 
447
468
  # Build dependencies dictionary - all values are Expr
448
469
  deps: dict[str, Expr] = {}
449
470
 
471
+ missing = object()
450
472
  for name in all_names:
451
- value = effective_globals.get(name)
452
-
453
- if value is None:
473
+ if name in default_only_names and name in default_name_values:
474
+ value = default_name_values[name]
475
+ else:
476
+ value = effective_globals.get(name, missing)
477
+ if value is missing:
454
478
  # Not in globals - could be a builtin or unresolved
455
479
  # For now, skip - builtins will be handled by the transpiler
456
480
  # TODO: Add builtin support
pulse/transpiler/nodes.py CHANGED
@@ -1517,7 +1517,7 @@ class If(Stmt):
1517
1517
 
1518
1518
  @dataclass(slots=True)
1519
1519
  class ForOf(Stmt):
1520
- """JS for-of loop: for (const x of iter) { ... }
1520
+ """JS for-of loop: for (x of iter) { ... }
1521
1521
 
1522
1522
  target can be a single name or array pattern for destructuring: [a, b]
1523
1523
  """
@@ -1528,7 +1528,7 @@ class ForOf(Stmt):
1528
1528
 
1529
1529
  @override
1530
1530
  def emit(self, out: list[str]) -> None:
1531
- out.append("for (const ")
1531
+ out.append("for (")
1532
1532
  out.append(self.target)
1533
1533
  out.append(" of ")
1534
1534
  self.iter.emit(out)
@@ -1619,6 +1619,21 @@ class Assign(Stmt):
1619
1619
  out.append(";")
1620
1620
 
1621
1621
 
1622
+ @dataclass(slots=True)
1623
+ class LetDecl(Stmt):
1624
+ """JS let declaration: let a, b;"""
1625
+
1626
+ names: Sequence[str]
1627
+
1628
+ @override
1629
+ def emit(self, out: list[str]) -> None:
1630
+ if not self.names:
1631
+ return
1632
+ out.append("let ")
1633
+ out.append(", ".join(self.names))
1634
+ out.append(";")
1635
+
1636
+
1622
1637
  @dataclass(slots=True)
1623
1638
  class ExprStmt(Stmt):
1624
1639
  """JS expression statement: expr;"""
@@ -0,0 +1,70 @@
1
+ """Cached parsing helpers for transpiler source inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import inspect
7
+ import textwrap
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from pulse.helpers import getsourcecode
13
+ from pulse.transpiler.errors import TranspileError
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class ParsedSource:
18
+ source: str
19
+ filename: str | None
20
+ source_start_line: int | None
21
+
22
+
23
+ _SOURCE_CACHE: dict[Callable[..., Any], ParsedSource] = {}
24
+ _AST_CACHE: dict[Callable[..., Any], ast.FunctionDef | ast.AsyncFunctionDef] = {}
25
+
26
+
27
+ def clear_parse_cache() -> None:
28
+ _SOURCE_CACHE.clear()
29
+ _AST_CACHE.clear()
30
+
31
+
32
+ def get_source(fn: Callable[..., Any]) -> ParsedSource:
33
+ cached = _SOURCE_CACHE.get(fn)
34
+ if cached is not None:
35
+ return cached
36
+
37
+ src = getsourcecode(fn)
38
+ src = textwrap.dedent(src)
39
+ try:
40
+ source_start_line = inspect.getsourcelines(fn)[1]
41
+ except (OSError, TypeError):
42
+ source_start_line = None
43
+ try:
44
+ filename = inspect.getfile(fn)
45
+ except (TypeError, OSError):
46
+ filename = None
47
+
48
+ parsed = ParsedSource(
49
+ source=src,
50
+ filename=filename,
51
+ source_start_line=source_start_line,
52
+ )
53
+ _SOURCE_CACHE[fn] = parsed
54
+ return parsed
55
+
56
+
57
+ def get_ast(fn: Callable[..., Any]) -> ast.FunctionDef | ast.AsyncFunctionDef:
58
+ cached = _AST_CACHE.get(fn)
59
+ if cached is not None:
60
+ return cached
61
+
62
+ module = ast.parse(get_source(fn).source)
63
+ fndefs = [
64
+ n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
65
+ ]
66
+ if not fndefs:
67
+ raise TranspileError("No function definition found in source")
68
+ fndef = fndefs[-1]
69
+ _AST_CACHE[fn] = fndef
70
+ return fndef
@@ -10,8 +10,9 @@ from __future__ import annotations
10
10
  import ast
11
11
  import re
12
12
  from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass
13
14
  from pathlib import Path
14
- from typing import Any, cast
15
+ from typing import Any, cast, override
15
16
 
16
17
  from pulse.transpiler.builtins import BUILTINS, emit_method
17
18
  from pulse.transpiler.errors import TranspileError
@@ -30,6 +31,7 @@ from pulse.transpiler.nodes import (
30
31
  Function,
31
32
  Identifier,
32
33
  If,
34
+ LetDecl,
33
35
  Literal,
34
36
  Member,
35
37
  New,
@@ -78,6 +80,228 @@ ALLOWED_CMPOPS: dict[type[ast.cmpop], str] = {
78
80
  }
79
81
 
80
82
 
83
+ def _collect_param_names(args: ast.arguments) -> list[str]:
84
+ """Collect argument names (regular, vararg, kwonly, kwarg)."""
85
+ names: list[str] = [arg.arg for arg in args.args]
86
+ if args.vararg:
87
+ names.append(args.vararg.arg)
88
+ names.extend(arg.arg for arg in args.kwonlyargs)
89
+ if args.kwarg:
90
+ names.append(args.kwarg.arg)
91
+ return names
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class Scope:
96
+ locals: set[str]
97
+ params: set[str]
98
+ parent: "Scope | None" = None
99
+
100
+
101
+ class ScopeAnalyzer(ast.NodeVisitor):
102
+ """Collect locals per scope (function/lambda/comprehension)."""
103
+
104
+ _scopes: dict[ast.AST, Scope]
105
+ _stack: list[Scope]
106
+ _global_scope: Scope
107
+
108
+ def __init__(self) -> None:
109
+ self._scopes = {}
110
+ self._stack = []
111
+ self._global_scope = Scope(locals=set(), params=set(), parent=None)
112
+
113
+ def analyze(
114
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
115
+ ) -> dict[ast.AST, Scope]:
116
+ self._stack.append(self._global_scope)
117
+ self._visit_defaults(node.args)
118
+ scope = self._new_scope(
119
+ node, _collect_param_names(node.args), parent=self._global_scope
120
+ )
121
+ self._stack.append(scope)
122
+ for stmt in node.body:
123
+ self.visit(stmt)
124
+ self._stack.pop()
125
+ self._stack.pop()
126
+ return self._scopes
127
+
128
+ def _new_scope(
129
+ self,
130
+ node: ast.AST,
131
+ params: list[str],
132
+ *,
133
+ parent: Scope | None,
134
+ ) -> Scope:
135
+ param_set = set(params)
136
+ scope = Scope(locals=set(param_set), params=param_set, parent=parent)
137
+ self._scopes[node] = scope
138
+ return scope
139
+
140
+ def _current_scope(self) -> Scope:
141
+ return self._stack[-1]
142
+
143
+ def _add_local(self, name: str) -> None:
144
+ self._current_scope().locals.add(name)
145
+
146
+ def _add_targets(self, target: ast.expr) -> None:
147
+ if isinstance(target, ast.Name):
148
+ self._add_local(target.id)
149
+ return
150
+ if isinstance(target, (ast.Tuple, ast.List)):
151
+ for elt in target.elts:
152
+ if isinstance(elt, ast.Name):
153
+ self._add_local(elt.id)
154
+
155
+ def _visit_target_expr(self, target: ast.expr) -> None:
156
+ if isinstance(target, (ast.Tuple, ast.List)):
157
+ for elt in target.elts:
158
+ self._visit_target_expr(elt)
159
+ return
160
+ self.visit(target)
161
+
162
+ def _analyze_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
163
+ self._visit_defaults(node.args)
164
+ scope = self._new_scope(
165
+ node,
166
+ _collect_param_names(node.args),
167
+ parent=self._current_scope(),
168
+ )
169
+ self._stack.append(scope)
170
+ for stmt in node.body:
171
+ self.visit(stmt)
172
+ self._stack.pop()
173
+
174
+ @property
175
+ def global_scope(self) -> Scope:
176
+ return self._global_scope
177
+
178
+ def _visit_defaults(self, args: ast.arguments) -> None:
179
+ for default in args.defaults:
180
+ self.visit(default)
181
+ for default in args.kw_defaults:
182
+ if default is not None:
183
+ self.visit(default)
184
+
185
+ @override
186
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
187
+ self._add_local(node.name)
188
+ self._analyze_function(node)
189
+
190
+ @override
191
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
192
+ self._add_local(node.name)
193
+ self._analyze_function(node)
194
+
195
+ @override
196
+ def visit_Lambda(self, node: ast.Lambda) -> None:
197
+ scope = self._new_scope(
198
+ node,
199
+ _collect_param_names(node.args),
200
+ parent=self._current_scope(),
201
+ )
202
+ self._stack.append(scope)
203
+ self.visit(node.body)
204
+ self._stack.pop()
205
+
206
+ def _visit_comprehension(
207
+ self,
208
+ node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
209
+ *,
210
+ elt: ast.expr,
211
+ key: ast.expr | None = None,
212
+ ) -> None:
213
+ scope = self._new_scope(node, [], parent=self._current_scope())
214
+ self._stack.append(scope)
215
+ if key is not None:
216
+ self.visit(key)
217
+ self.visit(elt)
218
+ for gen in node.generators:
219
+ self.visit(gen.iter)
220
+ for if_node in gen.ifs:
221
+ self.visit(if_node)
222
+ self._stack.pop()
223
+
224
+ @override
225
+ def visit_ListComp(self, node: ast.ListComp) -> None:
226
+ self._visit_comprehension(node, elt=node.elt)
227
+
228
+ @override
229
+ def visit_SetComp(self, node: ast.SetComp) -> None:
230
+ self._visit_comprehension(node, elt=node.elt)
231
+
232
+ @override
233
+ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
234
+ self._visit_comprehension(node, elt=node.elt)
235
+
236
+ @override
237
+ def visit_DictComp(self, node: ast.DictComp) -> None:
238
+ self._visit_comprehension(node, elt=node.value, key=node.key)
239
+
240
+ @override
241
+ def visit_Assign(self, node: ast.Assign) -> None:
242
+ for target in node.targets:
243
+ if isinstance(target, (ast.Name, ast.Tuple, ast.List)):
244
+ self._add_targets(target)
245
+ self._visit_target_expr(target)
246
+ self.visit(node.value)
247
+
248
+ @override
249
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
250
+ if isinstance(node.target, (ast.Name, ast.Tuple, ast.List)):
251
+ self._add_targets(node.target)
252
+ self._visit_target_expr(node.target)
253
+ if node.value is not None:
254
+ self.visit(node.value)
255
+
256
+ @override
257
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
258
+ if isinstance(node.target, (ast.Name, ast.Tuple, ast.List)):
259
+ self._add_targets(node.target)
260
+ self._visit_target_expr(node.target)
261
+ self.visit(node.value)
262
+
263
+ @override
264
+ def visit_For(self, node: ast.For) -> None:
265
+ if isinstance(node.target, (ast.Name, ast.Tuple, ast.List)):
266
+ self._add_targets(node.target)
267
+ self._visit_target_expr(node.target)
268
+ self.visit(node.iter)
269
+ for stmt in node.body:
270
+ self.visit(stmt)
271
+ for stmt in node.orelse:
272
+ self.visit(stmt)
273
+
274
+ @override
275
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
276
+ if node.name:
277
+ self._add_local(node.name)
278
+ if node.type is not None:
279
+ self.visit(node.type)
280
+ for stmt in node.body:
281
+ self.visit(stmt)
282
+
283
+ @override
284
+ def visit_With(self, node: ast.With) -> None:
285
+ for item in node.items:
286
+ if item.optional_vars and isinstance(
287
+ item.optional_vars, (ast.Name, ast.Tuple, ast.List)
288
+ ):
289
+ self._add_targets(item.optional_vars)
290
+ if item.optional_vars is not None:
291
+ self._visit_target_expr(item.optional_vars)
292
+ self.visit(item.context_expr)
293
+ for stmt in node.body:
294
+ self.visit(stmt)
295
+
296
+ @override
297
+ def visit_Global(self, node: ast.Global) -> None:
298
+ raise TranspileError("global is not supported", node=node)
299
+
300
+ @override
301
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
302
+ raise TranspileError("nonlocal is not supported", node=node)
303
+
304
+
81
305
  class Transpiler:
82
306
  """Transpile Python AST to v2 Expr/Stmt AST nodes.
83
307
 
@@ -93,10 +317,12 @@ class Transpiler:
93
317
  fndef: ast.FunctionDef | ast.AsyncFunctionDef
94
318
  args: list[str]
95
319
  deps: Mapping[str, Expr]
96
- locals: set[str]
97
320
  jsx: bool
98
321
  source_file: Path | None
99
322
  _temp_counter: int
323
+ _scope_map: dict[ast.AST, Scope]
324
+ _scope_stack: list[Scope]
325
+ _global_scope: Scope
100
326
 
101
327
  def __init__(
102
328
  self,
@@ -109,22 +335,21 @@ class Transpiler:
109
335
  self.fndef = fndef
110
336
  self.source_file = source_file
111
337
  # 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
338
+ self.args = _collect_param_names(fndef.args)
119
339
  self.deps = deps
120
340
  self.jsx = jsx
121
- self.locals = set(self.args)
122
341
  self._temp_counter = 0
342
+ analyzer = ScopeAnalyzer()
343
+ self._scope_map = analyzer.analyze(fndef)
344
+ self._global_scope = analyzer.global_scope
345
+ self._scope_stack = [self._scope_map[fndef]]
123
346
  self.init_temp_counter()
124
347
 
125
348
  def init_temp_counter(self) -> None:
126
349
  """Initialize temp counter to avoid collisions with args or globals."""
127
- all_names = set(self.args) | set(self.deps.keys())
350
+ all_names = set(self.deps.keys())
351
+ for scope in self._scope_map.values():
352
+ all_names.update(scope.locals)
128
353
  counter = 0
129
354
  while f"$tmp{counter}" in all_names:
130
355
  counter += 1
@@ -136,6 +361,38 @@ class Transpiler:
136
361
  self._temp_counter += 1
137
362
  return name
138
363
 
364
+ def _current_scope(self) -> Scope:
365
+ return self._scope_stack[-1]
366
+
367
+ def _push_scope(self, node: ast.AST) -> None:
368
+ self._scope_stack.append(self._scope_map[node])
369
+
370
+ def _pop_scope(self) -> None:
371
+ self._scope_stack.pop()
372
+
373
+ def _is_local_here(self, name: str) -> bool:
374
+ return name in self._current_scope().locals
375
+
376
+ def _is_local(self, name: str) -> bool:
377
+ scope: Scope | None = self._current_scope()
378
+ while scope is not None:
379
+ if name in scope.locals:
380
+ return True
381
+ scope = scope.parent
382
+ return False
383
+
384
+ def _require_local(
385
+ self, name: str, *, node: ast.expr | ast.stmt | ast.excepthandler
386
+ ) -> None:
387
+ if not self._is_local_here(name):
388
+ raise TranspileError(f"Assignment to unknown local: {name}", node=node)
389
+
390
+ def _function_prelude(self, scope: Scope) -> list[Stmt]:
391
+ names = sorted(scope.locals - scope.params)
392
+ if not names:
393
+ return []
394
+ return [LetDecl(names)]
395
+
139
396
  # --- Entrypoint ---------------------------------------------------------
140
397
 
141
398
  def transpile(self) -> Function | Arrow:
@@ -176,7 +433,8 @@ class Transpiler:
176
433
  return Arrow(self.args, expr)
177
434
 
178
435
  # General case: Function (for JSX or multi-statement)
179
- stmts = [self.emit_stmt(s) for s in body]
436
+ prelude = self._function_prelude(self._current_scope())
437
+ stmts = prelude + [self.emit_stmt(s) for s in body]
180
438
  is_async = isinstance(self.fndef, ast.AsyncFunctionDef)
181
439
  args = [self._jsx_args()] if self.jsx else self.args
182
440
  return Function(args, stmts, is_async=is_async)
@@ -202,7 +460,7 @@ class Transpiler:
202
460
  if default_idx >= 0:
203
461
  # Has a default value
204
462
  default_node = args.defaults[default_idx]
205
- default_expr = self.emit_expr(default_node)
463
+ default_expr = self._emit_default_expr(default_node)
206
464
  default_out.clear()
207
465
  default_expr.emit(default_out)
208
466
  destructure_parts.append(f"{param_name} = {''.join(default_out)}")
@@ -220,7 +478,7 @@ class Transpiler:
220
478
  default_node = args.kw_defaults[i]
221
479
  if default_node is not None:
222
480
  # Has a default value
223
- default_expr = self.emit_expr(default_node)
481
+ default_expr = self._emit_default_expr(default_node)
224
482
  default_out.clear()
225
483
  default_expr.emit(default_out)
226
484
  destructure_parts.append(f"{param_name} = {''.join(default_out)}")
@@ -234,6 +492,14 @@ class Transpiler:
234
492
 
235
493
  return "{" + ", ".join(destructure_parts) + "}"
236
494
 
495
+ def _emit_default_expr(self, node: ast.expr) -> Expr:
496
+ """Emit defaults in defining-scope context."""
497
+ self._scope_stack.append(self._global_scope)
498
+ try:
499
+ return self.emit_expr(node)
500
+ finally:
501
+ self._scope_stack.pop()
502
+
237
503
  # --- Statements ----------------------------------------------------------
238
504
 
239
505
  def emit_stmt(self, node: ast.stmt) -> Stmt:
@@ -262,6 +528,7 @@ class Transpiler:
262
528
  "Only simple augmented assignments supported", node=node
263
529
  )
264
530
  target = node.target.id
531
+ self._require_local(target, node=node)
265
532
  op_type = type(node.op)
266
533
  if op_type not in ALLOWED_BINOPS:
267
534
  raise TranspileError(
@@ -296,22 +563,16 @@ class Transpiler:
296
563
  target = target_node.id
297
564
  value_expr = self.emit_expr(node.value)
298
565
 
299
- if target in self.locals:
300
- return Assign(target, value_expr)
301
- else:
302
- self.locals.add(target)
303
- return Assign(target, value_expr, declare="let")
566
+ self._require_local(target, node=node)
567
+ return Assign(target, value_expr)
304
568
 
305
569
  if isinstance(node, ast.AnnAssign):
306
570
  if not isinstance(node.target, ast.Name):
307
571
  raise TranspileError("Only simple annotated assignments supported")
308
572
  target = node.target.id
309
573
  value = Literal(None) if node.value is None else self.emit_expr(node.value)
310
- if target in self.locals:
311
- return Assign(target, value)
312
- else:
313
- self.locals.add(target)
314
- return Assign(target, value, declare="let")
574
+ self._require_local(target, node=node)
575
+ return Assign(target, value)
315
576
 
316
577
  if isinstance(node, ast.If):
317
578
  cond = self.emit_expr(node.test)
@@ -358,11 +619,8 @@ class Transpiler:
358
619
  assert isinstance(e, ast.Name)
359
620
  name = e.id
360
621
  sub = Subscript(Identifier(tmp_name), Literal(idx))
361
- if name in self.locals:
362
- stmts.append(Assign(name, sub))
363
- else:
364
- self.locals.add(name)
365
- stmts.append(Assign(name, sub, declare="let"))
622
+ self._require_local(name, node=target)
623
+ stmts.append(Assign(name, sub))
366
624
 
367
625
  return StmtSequence(stmts)
368
626
 
@@ -453,7 +711,7 @@ class Transpiler:
453
711
  "Only simple name targets supported in for-loop unpacking"
454
712
  )
455
713
  names.append(e.id)
456
- self.locals.add(e.id)
714
+ self._require_local(e.id, node=node)
457
715
  iter_expr = self.emit_expr(node.iter)
458
716
  body = [self.emit_stmt(s) for s in node.body]
459
717
  # Use array pattern for destructuring
@@ -464,7 +722,7 @@ class Transpiler:
464
722
  raise TranspileError("Only simple name targets supported in for-loops")
465
723
 
466
724
  target = node.target.id
467
- self.locals.add(target)
725
+ self._require_local(target, node=node)
468
726
  iter_expr = self.emit_expr(node.iter)
469
727
  body = [self.emit_stmt(s) for s in node.body]
470
728
  return ForOf(target, iter_expr, body)
@@ -474,31 +732,29 @@ class Transpiler:
474
732
  ) -> Stmt:
475
733
  """Emit a nested function definition."""
476
734
  name = node.name
477
- params = [arg.arg for arg in node.args.args]
478
-
479
- # Save current locals and extend with params
480
- saved_locals = set(self.locals)
481
- self.locals.update(params)
482
-
483
- # Skip docstrings and emit body
484
- body_stmts = node.body
485
- if (
486
- body_stmts
487
- and isinstance(body_stmts[0], ast.Expr)
488
- and isinstance(body_stmts[0].value, ast.Constant)
489
- and isinstance(body_stmts[0].value.value, str)
490
- ):
491
- body_stmts = body_stmts[1:]
735
+ params = _collect_param_names(node.args)
736
+ self._require_local(name, node=node)
492
737
 
493
- stmts: list[Stmt] = [self.emit_stmt(s) for s in body_stmts]
738
+ self._push_scope(node)
739
+ try:
740
+ # Skip docstrings and emit body
741
+ body_stmts = node.body
742
+ if (
743
+ body_stmts
744
+ and isinstance(body_stmts[0], ast.Expr)
745
+ and isinstance(body_stmts[0].value, ast.Constant)
746
+ and isinstance(body_stmts[0].value.value, str)
747
+ ):
748
+ body_stmts = body_stmts[1:]
494
749
 
495
- # Restore outer locals and add function name
496
- self.locals = saved_locals
497
- self.locals.add(name)
750
+ prelude = self._function_prelude(self._current_scope())
751
+ stmts: list[Stmt] = prelude + [self.emit_stmt(s) for s in body_stmts]
752
+ finally:
753
+ self._pop_scope()
498
754
 
499
755
  is_async = isinstance(node, ast.AsyncFunctionDef)
500
756
  fn = Function(params, stmts, is_async=is_async)
501
- return Assign(name, fn, declare="const")
757
+ return Assign(name, fn)
502
758
 
503
759
  def _emit_try(self, node: ast.Try) -> Stmt:
504
760
  """Emit a try/except/finally statement."""
@@ -517,7 +773,6 @@ class Transpiler:
517
773
  handler = node.handlers[0]
518
774
  if handler.name:
519
775
  catch_param = handler.name
520
- self.locals.add(catch_param)
521
776
  catch_body = [self.emit_stmt(s) for s in handler.body]
522
777
 
523
778
  # Handle finally
@@ -594,23 +849,21 @@ class Transpiler:
594
849
 
595
850
  if isinstance(node, ast.ListComp):
596
851
  return self._emit_comprehension_chain(
597
- node.generators, lambda: self.emit_expr(node.elt)
852
+ node, lambda: self.emit_expr(node.elt)
598
853
  )
599
854
 
600
855
  if isinstance(node, ast.GeneratorExp):
601
856
  return self._emit_comprehension_chain(
602
- node.generators, lambda: self.emit_expr(node.elt)
857
+ node, lambda: self.emit_expr(node.elt)
603
858
  )
604
859
 
605
860
  if isinstance(node, ast.SetComp):
606
- arr = self._emit_comprehension_chain(
607
- node.generators, lambda: self.emit_expr(node.elt)
608
- )
861
+ arr = self._emit_comprehension_chain(node, lambda: self.emit_expr(node.elt))
609
862
  return New(Identifier("Set"), [arr])
610
863
 
611
864
  if isinstance(node, ast.DictComp):
612
865
  pairs = self._emit_comprehension_chain(
613
- node.generators,
866
+ node,
614
867
  lambda: Array([self.emit_expr(node.key), self.emit_expr(node.value)]),
615
868
  )
616
869
  return New(Identifier("Map"), [pairs])
@@ -648,14 +901,14 @@ class Transpiler:
648
901
  """Emit a name reference."""
649
902
  name = node.id
650
903
 
651
- # Check deps first
904
+ # Local variable (current or enclosing scope)
905
+ if self._is_local(name):
906
+ return Identifier(name)
907
+
908
+ # Check deps
652
909
  if name in self.deps:
653
910
  return self.deps[name]
654
911
 
655
- # Local variable
656
- if name in self.locals:
657
- return Identifier(name)
658
-
659
912
  # Check builtins
660
913
  if name in BUILTINS:
661
914
  return BUILTINS[name]
@@ -1093,29 +1346,26 @@ class Transpiler:
1093
1346
 
1094
1347
  def _emit_lambda(self, node: ast.Lambda) -> Expr:
1095
1348
  """Emit a lambda expression as an arrow function."""
1096
- params = [arg.arg for arg in node.args.args]
1097
-
1098
- # Add params to locals temporarily
1099
- saved_locals = set(self.locals)
1100
- self.locals.update(params)
1101
-
1102
- body = self.emit_expr(node.body)
1349
+ params = _collect_param_names(node.args)
1103
1350
 
1104
- self.locals = saved_locals
1351
+ self._push_scope(node)
1352
+ try:
1353
+ body = self.emit_expr(node.body)
1354
+ finally:
1355
+ self._pop_scope()
1105
1356
 
1106
1357
  return Arrow(params, body)
1107
1358
 
1108
1359
  def _emit_comprehension_chain(
1109
1360
  self,
1110
- generators: list[ast.comprehension],
1361
+ node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
1111
1362
  build_last: Callable[[], Expr],
1112
1363
  ) -> Expr:
1113
1364
  """Build a flatMap/map chain for comprehensions."""
1365
+ generators = node.generators
1114
1366
  if len(generators) == 0:
1115
1367
  raise TranspileError("Empty comprehension")
1116
1368
 
1117
- saved_locals = set(self.locals)
1118
-
1119
1369
  def build_chain(gen_index: int) -> Expr:
1120
1370
  gen = generators[gen_index]
1121
1371
  if gen.is_async:
@@ -1139,7 +1389,7 @@ class Transpiler:
1139
1389
  )
1140
1390
 
1141
1391
  for nm in names:
1142
- self.locals.add(nm)
1392
+ self._current_scope().locals.add(nm)
1143
1393
 
1144
1394
  base = iter_expr
1145
1395
 
@@ -1159,10 +1409,11 @@ class Transpiler:
1159
1409
  inner = build_chain(gen_index + 1)
1160
1410
  return Call(Member(base, "flatMap"), [Arrow(params, inner)])
1161
1411
 
1412
+ self._push_scope(node)
1162
1413
  try:
1163
1414
  return build_chain(0)
1164
1415
  finally:
1165
- self.locals = saved_locals
1416
+ self._pop_scope()
1166
1417
 
1167
1418
 
1168
1419
  def transpile(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.73
3
+ Version: 0.1.74
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: fastapi>=0.128.0
6
6
  Requires-Dist: uvicorn>=0.24.0
@@ -101,13 +101,13 @@ pulse/state/property.py,sha256=pitudvCGBNqK6lePdPrs4Inyy7szo8qcR_0BI30veek,6274
101
101
  pulse/state/query_param.py,sha256=7faf244_KJ3yrsBimKTBlNLg6wP96FUuWoZl40oxpyY,14478
102
102
  pulse/state/state.py,sha256=_4DcB-og2s5Ui01kDx9HzpNwmAVD_pdCLB0TfYbcnzM,11603
103
103
  pulse/test_helpers.py,sha256=4iO5Ymy3SMvSjh-UaAaSdqm1I_SAJMNjdY2iYVro5f8,436
104
- pulse/transpiler/__init__.py,sha256=wDDnzqxgHpp_OLtcgyrJEg2jVoTnFIe3SSSTOsMDW8w,4700
104
+ pulse/transpiler/__init__.py,sha256=0UqeEQ2dMD2tDDkzTASv-StQfKZkPuSvbYTqjkNk-3A,4895
105
105
  pulse/transpiler/assets.py,sha256=digd5hKYPEgLOzMtDBHULX3Adj1sfngdvnx3quQmgPY,2299
106
106
  pulse/transpiler/builtins.py,sha256=QZrow7XJ2wxGMAE-mgZmaUD03egOnXCbikOg8yMx9vQ,30807
107
107
  pulse/transpiler/dynamic_import.py,sha256=1AmBl6agGSoTZBp_94seXH733fewLOULUix9BOBPtKI,3372
108
108
  pulse/transpiler/emit_context.py,sha256=GyK6VdsBSTVIewQRhBagaV0hlqLTlPZ1i8EAZGi8SaY,1321
109
109
  pulse/transpiler/errors.py,sha256=LSBjLBnMglbl2D94p9JR4y-3jDefk6iHSlUVBaBOTu4,2823
110
- pulse/transpiler/function.py,sha256=a871LZFergCmjs1vr-XlOx4eU1FQKAuYxSLJej-LHHc,17036
110
+ pulse/transpiler/function.py,sha256=8-WZC2VcPTGfFeWWQpCltOVPLyr4f1H8pWoCvSnI654,18105
111
111
  pulse/transpiler/id.py,sha256=CdgA1NndBpZjv0Hp4XiYbKn7wi-x4zWsFSjEiViKxVk,434
112
112
  pulse/transpiler/imports.py,sha256=gWLjRr9jakbUzBGDEepE2RI5Xn_UZwOD4TmlqjNIapM,10302
113
113
  pulse/transpiler/js_module.py,sha256=OcIgmrfiA6Hh6aukzgkyX63KsVSHdLzx5ezdKiJFUaQ,11093
@@ -118,15 +118,16 @@ pulse/transpiler/modules/math.py,sha256=8gjvdYTMqtuOnXrvX_Lwuo0ywAdSl7cpss4TMk6m
118
118
  pulse/transpiler/modules/pulse/__init__.py,sha256=TfMsiiB53ZFlxdNl7jfCAiMZs-vSRUTxUmqzkLTj-po,91
119
119
  pulse/transpiler/modules/pulse/tags.py,sha256=FMN1mWMlnsXa2qO6VmXxUAhFn1uOfGoKPQOjH4ZPlRE,6218
120
120
  pulse/transpiler/modules/typing.py,sha256=J9QCkXE6zzwMjiprX2q1BtK-iKLIiS21sQ78JH4RSMc,1716
121
- pulse/transpiler/nodes.py,sha256=vebA81QXkWoJMygX2CHH_94azKPRATaB6eBs5wdX4rE,52420
121
+ pulse/transpiler/nodes.py,sha256=ObdCFIEvtKMVRO8iy1hIN4L-vC4yPqRvhPS6E344-bE,52673
122
+ pulse/transpiler/parse.py,sha256=uz_KDnjmjzFSjGtVKRznWg95P0NHM8CafWgvqrqJcOs,1622
122
123
  pulse/transpiler/py_module.py,sha256=um4BYLrbs01bpgv2LEBHTbhXXh8Bs174c3ygv5tHHOg,4410
123
- pulse/transpiler/transpiler.py,sha256=pxNFVnJB_zpUnp12cfzeOqUINAb1ZKhlU2E1gYUE6mk,35752
124
+ pulse/transpiler/transpiler.py,sha256=bu33-wGNqHGheT_ZqMnQgEARyPG6xyOvuLuixjxIZnI,42761
124
125
  pulse/transpiler/vdom.py,sha256=Bf1yw10hQl8BXa6rhr5byRa5ua3qgRsVGNgEtQneA2A,6460
125
126
  pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
127
  pulse/types/event_handler.py,sha256=psQCydj-WEtBcFU5JU4mDwvyzkW8V2O0g_VFRU2EOHI,1618
127
128
  pulse/user_session.py,sha256=nsnsMgqq2xGJZLpbHRMHUHcLrElMP8WcA4gjGMrcoBk,10208
128
129
  pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
129
- pulse_framework-0.1.73.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
130
- pulse_framework-0.1.73.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
131
- pulse_framework-0.1.73.dist-info/METADATA,sha256=PIqxEHW98zMJlT1C_cLNisDzX73GDvGKFW8e4UtVTfk,8299
132
- pulse_framework-0.1.73.dist-info/RECORD,,
130
+ pulse_framework-0.1.74.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
131
+ pulse_framework-0.1.74.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
132
+ pulse_framework-0.1.74.dist-info/METADATA,sha256=m4TuEvBPlRud4ARyJi26t3nDDk2gFPvFd4xWi8k2w3k,8299
133
+ pulse_framework-0.1.74.dist-info/RECORD,,