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
pulse/hooks/init.py ADDED
@@ -0,0 +1,668 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import ctypes
5
+ import functools
6
+ import inspect
7
+ import textwrap
8
+ import types
9
+ from collections.abc import Callable, Sequence
10
+ from typing import Any, Literal, cast, override
11
+
12
+ from pulse.helpers import getsourcecode
13
+ from pulse.hooks.core import HookState, hooks
14
+ from pulse.transpiler.errors import TranspileError
15
+
16
+ # Storage keyed by (code object, lineno) of the `with ps.init()` call site.
17
+ _init_hook = hooks.create("init_storage", lambda: InitState())
18
+
19
+ _CAN_USE_CPYTHON = hasattr(ctypes.pythonapi, "PyFrame_LocalsToFast")
20
+ if _CAN_USE_CPYTHON:
21
+ PyFrame_LocalsToFast = ctypes.pythonapi.PyFrame_LocalsToFast
22
+ PyFrame_LocalsToFast.argtypes = [ctypes.py_object, ctypes.c_int]
23
+ PyFrame_LocalsToFast.restype = None
24
+
25
+
26
+ def previous_frame() -> types.FrameType:
27
+ """Get the previous frame (caller's frame) with assertions.
28
+
29
+ This skips the frame of this helper function and its immediate caller
30
+ to return the actual previous frame.
31
+ """
32
+ current = inspect.currentframe()
33
+ assert current is not None, "currentframe() returned None"
34
+ # Skip this helper function's frame
35
+ caller = current.f_back
36
+ assert caller is not None, "f_back is None"
37
+ # Skip the caller's frame (e.g., __enter__) to get the actual previous frame
38
+ frame = caller.f_back
39
+ assert frame is not None, "f_back.f_back is None"
40
+ return frame
41
+
42
+
43
+ class InitContext:
44
+ """Context manager for one-time initialization in components.
45
+
46
+ Variables assigned inside the block persist across re-renders. On first render,
47
+ the code inside runs normally and variables are captured. On subsequent renders,
48
+ the block is skipped and variables are restored from storage.
49
+
50
+ This class is returned by ``ps.init()`` and should be used as a context manager.
51
+
52
+ Attributes:
53
+ callsite: Tuple of (code object, line number) identifying the call site.
54
+ frame: The stack frame where init was called.
55
+ first_render: True if this is the first render cycle.
56
+ pre_keys: Set of variable names that existed before entering the block.
57
+ saved: Dictionary of captured variable values.
58
+
59
+ Example:
60
+
61
+ ```python
62
+ def my_component():
63
+ with ps.init():
64
+ counter = 0
65
+ api = ApiClient()
66
+ data = fetch_initial_data()
67
+ # counter, api, data retain their values across renders
68
+ return m.Text(f"Counter: {counter}")
69
+ ```
70
+ """
71
+
72
+ callsite: tuple[Any, int] | None
73
+ frame: types.FrameType | None
74
+ first_render: bool
75
+ pre_keys: set[str]
76
+ saved: dict[str, Any]
77
+
78
+ def __init__(self):
79
+ self.callsite = None
80
+ self.frame = None
81
+ self.first_render = False
82
+ self.pre_keys = set()
83
+ self.saved = {}
84
+
85
+ def __enter__(self):
86
+ self.frame = previous_frame()
87
+ self.pre_keys = set(self.frame.f_locals.keys())
88
+ # Use code object to disambiguate identical line numbers in different fns.
89
+ self.callsite = (self.frame.f_code, self.frame.f_lineno)
90
+
91
+ storage = _init_hook().storage
92
+ entry = storage.get(self.callsite)
93
+ if entry is None:
94
+ self.first_render = True
95
+ self.saved = {}
96
+ else:
97
+ self.first_render = False
98
+ self.saved = entry["vars"]
99
+ return self
100
+
101
+ def restore_variables(self):
102
+ if self.first_render:
103
+ return
104
+ frame = self.frame if self.frame is not None else previous_frame()
105
+ frame.f_locals.update(self.saved)
106
+ PyFrame_LocalsToFast(frame, 1)
107
+
108
+ def save(self, values: dict[str, Any]):
109
+ self.saved = values
110
+ assert self.callsite is not None, "callsite is None"
111
+ storage = _init_hook().storage
112
+ storage[self.callsite] = {"vars": values}
113
+
114
+ def _capture_new_locals(self) -> dict[str, Any]:
115
+ frame = self.frame
116
+ assert frame is not None, "frame is None"
117
+ captured = {}
118
+ for name, value in frame.f_locals.items():
119
+ if name in self.pre_keys:
120
+ continue
121
+ if value is self:
122
+ continue
123
+ captured[name] = value
124
+ return captured
125
+
126
+ def __exit__(
127
+ self,
128
+ exc_type: type[BaseException] | None,
129
+ exc_value: BaseException | None,
130
+ exc_tb: Any,
131
+ ) -> Literal[False]:
132
+ if exc_type is None:
133
+ captured = self._capture_new_locals()
134
+ assert self.callsite is not None, "callsite None"
135
+ storage = _init_hook().storage
136
+ storage[self.callsite] = {"vars": captured}
137
+ self.frame = None
138
+ return False
139
+
140
+
141
+ def init() -> InitContext:
142
+ """Context manager for one-time initialization in components.
143
+
144
+ Variables assigned inside the block persist across re-renders. Uses AST
145
+ rewriting to transform the code at decoration time.
146
+
147
+ Returns:
148
+ InitContext: Context manager that captures and restores variables.
149
+
150
+ Example:
151
+
152
+ ```python
153
+ def my_component():
154
+ with ps.init():
155
+ counter = 0
156
+ api = ApiClient()
157
+ data = fetch_initial_data()
158
+ # counter, api, data retain their values across renders
159
+ return m.Text(f"Counter: {counter}")
160
+ ```
161
+
162
+ Rules:
163
+ - Can only be used once per component
164
+ - Must be at the top level of the component function (not inside
165
+ conditionals, loops, or nested functions)
166
+ - Cannot contain control flow (if, for, while, try, with, match)
167
+ - Cannot use ``as`` binding (``with ps.init() as ctx:`` not allowed)
168
+ - Variables are restored from first render on subsequent renders
169
+
170
+ Notes:
171
+ If you encounter issues with ``ps.init()`` (e.g., source code not
172
+ available in some deployment environments), use ``ps.setup()`` instead.
173
+ It provides the same functionality without AST rewriting.
174
+ """
175
+ return InitContext()
176
+
177
+
178
+ # ---------------------------- AST rewriting -------------------------------
179
+
180
+
181
+ class InitCPythonRewriter(ast.NodeTransformer):
182
+ counter: int
183
+ _init_names: set[str]
184
+ _init_modules: set[str]
185
+
186
+ def __init__(self, init_names: set[str], init_modules: set[str]):
187
+ super().__init__()
188
+ self.counter = 0
189
+ self._init_names = init_names
190
+ self._init_modules = init_modules
191
+
192
+ @override
193
+ def visit_With(self, node: ast.With):
194
+ node = cast(ast.With, self.generic_visit(node))
195
+ if not node.items:
196
+ return node
197
+
198
+ item = node.items[0]
199
+ if self.is_init_call(item.context_expr):
200
+ ctx_name = f"_init_ctx_{self.counter}"
201
+ self.counter += 1
202
+ new_item = ast.withitem(
203
+ context_expr=item.context_expr,
204
+ optional_vars=ast.Name(id=ctx_name, ctx=ast.Store()),
205
+ )
206
+
207
+ restore_call = ast.Expr(
208
+ value=ast.Call(
209
+ func=ast.Attribute(
210
+ value=ast.Name(id=ctx_name, ctx=ast.Load()),
211
+ attr="restore_variables",
212
+ ctx=ast.Load(),
213
+ ),
214
+ args=[],
215
+ keywords=[],
216
+ )
217
+ )
218
+
219
+ new_if = ast.If(
220
+ test=ast.Attribute(
221
+ value=ast.Name(id=ctx_name, ctx=ast.Load()),
222
+ attr="first_render",
223
+ ctx=ast.Load(),
224
+ ),
225
+ body=node.body,
226
+ orelse=[restore_call],
227
+ )
228
+
229
+ return ast.With(
230
+ items=[new_item],
231
+ body=[new_if],
232
+ type_comment=getattr(node, "type_comment", None),
233
+ )
234
+
235
+ return node
236
+
237
+ def is_init_call(self, expr: ast.AST) -> bool:
238
+ if not isinstance(expr, ast.Call):
239
+ return False
240
+ func = expr.func
241
+ if isinstance(func, ast.Name) and func.id in self._init_names:
242
+ return True
243
+ if (
244
+ isinstance(func, ast.Attribute)
245
+ and isinstance(func.value, ast.Name)
246
+ and func.value.id in self._init_modules
247
+ and func.attr == "init"
248
+ ):
249
+ return True
250
+ return False
251
+
252
+
253
+ class InitFallbackRewriter(ast.NodeTransformer):
254
+ """Rewrite using explicit rebinding (portable, no LocalsToFast)."""
255
+
256
+ counter: int
257
+ _init_names: set[str]
258
+ _init_modules: set[str]
259
+
260
+ def __init__(self, init_names: set[str], init_modules: set[str]):
261
+ super().__init__()
262
+ self.counter = 0
263
+ self._init_names = init_names
264
+ self._init_modules = init_modules
265
+
266
+ @override
267
+ def visit_With(self, node: ast.With):
268
+ node = cast(ast.With, self.generic_visit(node))
269
+ if not node.items:
270
+ return node
271
+
272
+ item = node.items[0]
273
+ if not self.is_init_call(item.context_expr):
274
+ return node
275
+
276
+ ctx_name = f"_init_ctx_{self.counter}"
277
+ self.counter += 1
278
+ new_item = ast.withitem(
279
+ context_expr=item.context_expr,
280
+ optional_vars=ast.Name(id=ctx_name, ctx=ast.Store()),
281
+ )
282
+
283
+ assigned = _collect_assigned_names(node.body)
284
+
285
+ save_call = ast.Expr(
286
+ value=ast.Call(
287
+ func=ast.Attribute(
288
+ value=ast.Name(id=ctx_name, ctx=ast.Load()),
289
+ attr="save",
290
+ ctx=ast.Load(),
291
+ ),
292
+ args=[
293
+ ast.Dict(
294
+ keys=[ast.Constant(n) for n in assigned],
295
+ values=[ast.Name(id=n, ctx=ast.Load()) for n in assigned],
296
+ )
297
+ ],
298
+ keywords=[],
299
+ )
300
+ )
301
+
302
+ restore_assigns: Sequence[ast.stmt] = [
303
+ ast.Assign(
304
+ targets=[ast.Name(id=name, ctx=ast.Store())],
305
+ value=ast.Subscript(
306
+ value=ast.Attribute(
307
+ value=ast.Name(id=ctx_name, ctx=ast.Load()),
308
+ attr="saved",
309
+ ctx=ast.Load(),
310
+ ),
311
+ slice=ast.Constant(name),
312
+ ctx=ast.Load(),
313
+ ),
314
+ )
315
+ for name in assigned
316
+ ]
317
+
318
+ new_if = ast.If(
319
+ test=ast.Attribute(
320
+ value=ast.Name(id=ctx_name, ctx=ast.Load()),
321
+ attr="first_render",
322
+ ctx=ast.Load(),
323
+ ),
324
+ body=node.body + [save_call],
325
+ orelse=list(restore_assigns),
326
+ )
327
+
328
+ return ast.With(
329
+ items=[new_item],
330
+ body=[new_if],
331
+ type_comment=getattr(node, "type_comment", None),
332
+ )
333
+
334
+ def is_init_call(self, expr: ast.AST) -> bool:
335
+ if not isinstance(expr, ast.Call):
336
+ return False
337
+ func = expr.func
338
+ if isinstance(func, ast.Name) and func.id in self._init_names:
339
+ return True
340
+ if (
341
+ isinstance(func, ast.Attribute)
342
+ and isinstance(func.value, ast.Name)
343
+ and func.value.id in self._init_modules
344
+ and func.attr == "init"
345
+ ):
346
+ return True
347
+ return False
348
+
349
+
350
+ def _collect_assigned_names(body: list[ast.stmt]) -> list[str]:
351
+ names: set[str] = set()
352
+
353
+ def add_target(target: ast.AST):
354
+ if isinstance(target, ast.Name):
355
+ names.add(target.id)
356
+ elif isinstance(target, (ast.Tuple, ast.List)):
357
+ for elt in target.elts:
358
+ add_target(elt)
359
+
360
+ for stmt in body:
361
+ if isinstance(stmt, ast.Assign):
362
+ for target in stmt.targets:
363
+ add_target(target)
364
+ elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
365
+ names.add(stmt.target.id)
366
+ elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
367
+ names.add(stmt.name)
368
+ return list(names)
369
+
370
+
371
+ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
372
+ """Rewrite `with ps.init()` blocks in the provided function, if present."""
373
+
374
+ source = textwrap.dedent(getsourcecode(func)) # raises immediately if missing
375
+ try:
376
+ source_start_line = inspect.getsourcelines(func)[1]
377
+ except (OSError, TypeError):
378
+ source_start_line = None
379
+
380
+ if "init" not in source: # quick prefilter, allow alias detection later
381
+ return func
382
+
383
+ tree = ast.parse(source)
384
+
385
+ init_names, init_modules = _resolve_init_bindings(func)
386
+
387
+ target_def: ast.FunctionDef | ast.AsyncFunctionDef | None = None
388
+ # Remove decorators so the re-exec'd function isn't double-wrapped.
389
+ for node in ast.walk(tree):
390
+ if (
391
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
392
+ and node.name == func.__name__
393
+ ):
394
+ node.decorator_list = []
395
+ target_def = node
396
+
397
+ if target_def is None:
398
+ return func
399
+
400
+ if not _contains_ps_init(tree, init_names, init_modules):
401
+ return func
402
+
403
+ init_items = _find_init_items(target_def.body, init_names, init_modules)
404
+ if len(init_items) > 1:
405
+ try:
406
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
407
+ except (TypeError, OSError):
408
+ filename = None
409
+ raise TranspileError(
410
+ "ps.init may only be used once per component render",
411
+ node=init_items[1].context_expr,
412
+ source=source,
413
+ filename=filename,
414
+ func_name=func.__name__,
415
+ source_start_line=source_start_line,
416
+ ) from None
417
+
418
+ if init_items and init_items[0].optional_vars is not None:
419
+ try:
420
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
421
+ except (TypeError, OSError):
422
+ filename = None
423
+ raise TranspileError(
424
+ "ps.init does not support 'as' bindings",
425
+ node=init_items[0].optional_vars,
426
+ source=source,
427
+ filename=filename,
428
+ func_name=func.__name__,
429
+ source_start_line=source_start_line,
430
+ ) from None
431
+
432
+ disallowed = _find_disallowed_control_flow(
433
+ target_def.body, init_names, init_modules
434
+ )
435
+ if disallowed is not None:
436
+ try:
437
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
438
+ except (TypeError, OSError):
439
+ filename = None
440
+ raise TranspileError(
441
+ "ps.init blocks cannot contain control flow (if/for/while/try/with/match)",
442
+ node=disallowed,
443
+ source=source,
444
+ filename=filename,
445
+ func_name=func.__name__,
446
+ source_start_line=source_start_line,
447
+ ) from None
448
+
449
+ rewriter: ast.NodeTransformer
450
+ if _CAN_USE_CPYTHON:
451
+ rewriter = InitCPythonRewriter(init_names, init_modules)
452
+ else:
453
+ rewriter = InitFallbackRewriter(init_names, init_modules)
454
+
455
+ tree = rewriter.visit(tree)
456
+ ast.fix_missing_locations(tree)
457
+
458
+ filename = inspect.getsourcefile(func) or "<rewrite>"
459
+ compiled = compile(tree, filename=filename, mode="exec")
460
+
461
+ global_ns = dict(func.__globals__)
462
+ closure_vars = inspect.getclosurevars(func)
463
+ global_ns.update(closure_vars.nonlocals)
464
+ # Ensure `ps` resolves during exec.
465
+ if "ps" not in global_ns:
466
+ try:
467
+ import pulse as ps
468
+
469
+ global_ns["ps"] = ps
470
+ except Exception:
471
+ pass
472
+ local_ns: dict[str, Any] = {}
473
+ exec(compiled, global_ns, local_ns)
474
+ rewritten = local_ns.get(func.__name__) or global_ns[func.__name__]
475
+ functools.update_wrapper(rewritten, func)
476
+ return rewritten
477
+
478
+
479
+ def _contains_ps_init(
480
+ tree: ast.AST, init_names: set[str], init_modules: set[str]
481
+ ) -> bool:
482
+ checker = _InitCallChecker(init_names, init_modules)
483
+ return checker.contains_init(tree)
484
+
485
+
486
+ def _find_disallowed_control_flow(
487
+ body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
488
+ ) -> ast.stmt | None:
489
+ disallowed: tuple[type[ast.AST], ...] = (
490
+ ast.If,
491
+ ast.For,
492
+ ast.AsyncFor,
493
+ ast.While,
494
+ ast.Try,
495
+ ast.With,
496
+ ast.AsyncWith,
497
+ ast.Match,
498
+ )
499
+ checker = _InitCallChecker(init_names, init_modules)
500
+
501
+ class _Finder(ast.NodeVisitor):
502
+ found: ast.stmt | None
503
+
504
+ def __init__(self) -> None:
505
+ self.found = None
506
+
507
+ @override
508
+ def visit(self, node: ast.AST) -> Any: # type: ignore[override]
509
+ if self.found is not None:
510
+ return None
511
+ if isinstance(node, disallowed):
512
+ self.found = cast(ast.stmt, node)
513
+ return None
514
+ return super().visit(node)
515
+
516
+ @override
517
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
518
+ return None
519
+
520
+ @override
521
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
522
+ return None
523
+
524
+ @override
525
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
526
+ return None
527
+
528
+ finder = _Finder()
529
+
530
+ class _WithFinder(ast.NodeVisitor):
531
+ @override
532
+ def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
533
+ first = node.items[0] if node.items else None
534
+ if first and checker.is_init_call(first.context_expr):
535
+ for stmt in node.body:
536
+ finder.visit(stmt)
537
+ if finder.found is not None:
538
+ return None
539
+ self.generic_visit(node)
540
+
541
+ @override
542
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
543
+ first = node.items[0] if node.items else None
544
+ if first and checker.is_init_call(first.context_expr):
545
+ for stmt in node.body:
546
+ finder.visit(stmt)
547
+ if finder.found is not None:
548
+ return None
549
+ self.generic_visit(node)
550
+
551
+ @override
552
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
553
+ return None
554
+
555
+ @override
556
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
557
+ return None
558
+
559
+ @override
560
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
561
+ return None
562
+
563
+ with_finder = _WithFinder()
564
+ for stmt in body:
565
+ with_finder.visit(stmt)
566
+ if finder.found is not None:
567
+ return finder.found
568
+ return None
569
+
570
+
571
+ def _find_init_items(
572
+ body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
573
+ ) -> list[ast.withitem]:
574
+ checker = _InitCallChecker(init_names, init_modules)
575
+ items: list[ast.withitem] = []
576
+
577
+ class _Finder(ast.NodeVisitor):
578
+ @override
579
+ def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
580
+ first = node.items[0] if node.items else None
581
+ if first and checker.is_init_call(first.context_expr):
582
+ items.append(first)
583
+ self.generic_visit(node)
584
+
585
+ @override
586
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
587
+ first = node.items[0] if node.items else None
588
+ if first and checker.is_init_call(first.context_expr):
589
+ items.append(first)
590
+ self.generic_visit(node)
591
+
592
+ @override
593
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
594
+ return None
595
+
596
+ @override
597
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
598
+ return None
599
+
600
+ @override
601
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
602
+ return None
603
+
604
+ finder = _Finder()
605
+ for stmt in body:
606
+ finder.visit(stmt)
607
+ return items
608
+
609
+
610
+ class _InitCallChecker:
611
+ init_names: set[str]
612
+ init_modules: set[str]
613
+
614
+ def __init__(self, init_names: set[str], init_modules: set[str]):
615
+ self.init_names = init_names
616
+ self.init_modules = init_modules
617
+
618
+ def is_init_call(self, expr: ast.AST) -> bool:
619
+ if not isinstance(expr, ast.Call):
620
+ return False
621
+ func = expr.func
622
+ if isinstance(func, ast.Name) and func.id in self.init_names:
623
+ return True
624
+ if (
625
+ isinstance(func, ast.Attribute)
626
+ and isinstance(func.value, ast.Name)
627
+ and func.value.id in self.init_modules
628
+ and func.attr == "init"
629
+ ):
630
+ return True
631
+ return False
632
+
633
+ def contains_init(self, tree: ast.AST) -> bool:
634
+ for node in ast.walk(tree):
635
+ if self.is_init_call(node):
636
+ return True
637
+ return False
638
+
639
+
640
+ def _resolve_init_bindings(func: Callable[..., Any]) -> tuple[set[str], set[str]]:
641
+ """Find names/modules that resolve to pulse.init in the function scope."""
642
+
643
+ init_names: set[str] = set()
644
+ init_modules: set[str] = set()
645
+
646
+ closure = inspect.getclosurevars(func)
647
+ scopes = [func.__globals__, closure.nonlocals, closure.globals]
648
+
649
+ for scope in scopes:
650
+ for name, val in scope.items():
651
+ if val is init:
652
+ init_names.add(name)
653
+ try:
654
+ if getattr(val, "init", None) is init:
655
+ init_modules.add(name)
656
+ except Exception:
657
+ continue
658
+
659
+ return init_names, init_modules
660
+
661
+
662
+ class InitState(HookState):
663
+ def __init__(self) -> None:
664
+ self.storage: dict[tuple[Any, int], dict[str, Any]] = {}
665
+
666
+ @override
667
+ def dispose(self) -> None:
668
+ self.storage.clear()