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/state.py ADDED
@@ -0,0 +1,556 @@
1
+ """
2
+ Reactive state system for Pulse UI.
3
+
4
+ This module provides the base State class and reactive property system
5
+ that enables automatic re-rendering when state changes.
6
+ """
7
+
8
+ import inspect
9
+ from abc import ABC, ABCMeta, abstractmethod
10
+ from collections.abc import Callable, Iterator
11
+ from enum import IntEnum
12
+ from typing import Any, Generic, Never, TypeVar, override
13
+
14
+ from pulse.helpers import Disposable
15
+ from pulse.reactive import (
16
+ AsyncEffect,
17
+ Computed,
18
+ Effect,
19
+ Scope,
20
+ Signal,
21
+ )
22
+ from pulse.reactive_extensions import ReactiveProperty
23
+
24
+ T = TypeVar("T")
25
+
26
+
27
+ class StateProperty(ReactiveProperty[Any]):
28
+ """
29
+ Descriptor for reactive properties on State classes.
30
+
31
+ StateProperty wraps a Signal and provides automatic reactivity for
32
+ class attributes. When a property is read, it subscribes to the underlying
33
+ Signal. When written, it updates the Signal and triggers re-renders.
34
+
35
+ This class is typically not used directly. Instead, declare typed attributes
36
+ on a State subclass, and the StateMeta metaclass will automatically convert
37
+ them into StateProperty instances.
38
+
39
+ Example:
40
+
41
+ ```python
42
+ class MyState(ps.State):
43
+ count: int = 0 # Automatically becomes a StateProperty
44
+ name: str = "default"
45
+
46
+ state = MyState()
47
+ state.count = 5 # Updates the underlying Signal
48
+ print(state.count) # Reads from the Signal, subscribes to changes
49
+ ```
50
+ """
51
+
52
+ pass
53
+
54
+
55
+ class InitializableProperty(ABC):
56
+ @abstractmethod
57
+ def initialize(self, state: "State", name: str) -> Any: ...
58
+
59
+
60
+ class ComputedProperty(Generic[T]):
61
+ """
62
+ Descriptor for computed (derived) properties on State classes.
63
+
64
+ ComputedProperty wraps a method that derives its value from other reactive
65
+ properties. The computed value is cached and only recalculated when its
66
+ dependencies change. Reading a computed property subscribes to it.
67
+
68
+ Created automatically when using the @ps.computed decorator on a State method.
69
+
70
+ Args:
71
+ name: The property name (used for debugging and the private storage key).
72
+ fn: The method that computes the value. Must take only `self` as argument.
73
+
74
+ Example:
75
+
76
+ ```python
77
+ class MyState(ps.State):
78
+ count: int = 0
79
+
80
+ @ps.computed
81
+ def doubled(self):
82
+ return self.count * 2
83
+
84
+ state = MyState()
85
+ print(state.doubled) # 0
86
+ state.count = 5
87
+ print(state.doubled) # 10 (automatically recomputed)
88
+ ```
89
+ """
90
+
91
+ name: str
92
+ private_name: str
93
+ fn: "Callable[[State], T]"
94
+
95
+ def __init__(self, name: str, fn: "Callable[[State], T]"):
96
+ self.name = name
97
+ self.private_name = f"__computed_{name}"
98
+ # The computed_template holds the original method
99
+ self.fn = fn
100
+
101
+ def get_computed(self, obj: Any) -> Computed[T]:
102
+ if not isinstance(obj, State):
103
+ raise ValueError(
104
+ f"Computed property {self.name} defined on a non-State class"
105
+ )
106
+ if not hasattr(obj, self.private_name):
107
+ # Create the computed on first access for this instance
108
+ bound_method = self.fn.__get__(obj, obj.__class__)
109
+ new_computed = Computed(
110
+ bound_method,
111
+ name=f"{obj.__class__.__name__}.{self.name}",
112
+ )
113
+ setattr(obj, self.private_name, new_computed)
114
+ return getattr(obj, self.private_name)
115
+
116
+ def __get__(self, obj: Any, objtype: Any = None) -> T:
117
+ if obj is None:
118
+ return self # pyright: ignore[reportReturnType]
119
+
120
+ return self.get_computed(obj).read()
121
+
122
+ def __set__(self, obj: Any, value: Any) -> Never:
123
+ raise AttributeError(f"Cannot set computed property '{self.name}'")
124
+
125
+
126
+ class StateEffect(Generic[T], InitializableProperty):
127
+ """
128
+ Descriptor for side effects on State classes.
129
+
130
+ StateEffect wraps a method that performs side effects when its dependencies
131
+ change. The effect is initialized when the State instance is created and
132
+ disposed when the State is disposed.
133
+
134
+ Created automatically when using the @ps.effect decorator on a State method.
135
+ Supports both sync and async methods.
136
+
137
+ Args:
138
+ fn: The effect function. Must take only `self` as argument.
139
+ Can return a cleanup function that runs before the next execution
140
+ or when the effect is disposed.
141
+ name: Debug name for the effect. Defaults to "ClassName.method_name".
142
+ immediate: If True, run synchronously when scheduled (sync effects only).
143
+ lazy: If True, don't run on creation; wait for first dependency change.
144
+ on_error: Callback for handling errors during effect execution.
145
+ deps: Explicit dependencies. If provided, auto-tracking is disabled.
146
+ interval: Re-run interval in seconds for polling effects.
147
+
148
+ Example:
149
+
150
+ ```python
151
+ class MyState(ps.State):
152
+ count: int = 0
153
+
154
+ @ps.effect
155
+ def log_count(self):
156
+ print(f"Count changed to: {self.count}")
157
+
158
+ @ps.effect
159
+ async def fetch_data(self):
160
+ data = await api.fetch(self.query)
161
+ self.data = data
162
+
163
+ @ps.effect
164
+ def subscribe(self):
165
+ unsub = event_bus.subscribe(self.handle_event)
166
+ return unsub # Cleanup function
167
+ ```
168
+ """
169
+
170
+ fn: "Callable[[State], T]"
171
+ name: str | None
172
+ immediate: bool
173
+ on_error: "Callable[[Exception], None] | None"
174
+ lazy: bool
175
+ deps: "list[Signal[Any] | Computed[Any]] | None"
176
+ update_deps: bool | None
177
+ interval: float | None
178
+
179
+ def __init__(
180
+ self,
181
+ fn: "Callable[[State], T]",
182
+ name: str | None = None,
183
+ immediate: bool = False,
184
+ lazy: bool = False,
185
+ on_error: "Callable[[Exception], None] | None" = None,
186
+ deps: "list[Signal[Any] | Computed[Any]] | None" = None,
187
+ update_deps: bool | None = None,
188
+ interval: float | None = None,
189
+ ):
190
+ self.fn = fn
191
+ self.name = name
192
+ self.immediate = immediate
193
+ self.on_error = on_error
194
+ self.lazy = lazy
195
+ self.deps = deps
196
+ self.update_deps = update_deps
197
+ self.interval = interval
198
+
199
+ @override
200
+ def initialize(self, state: "State", name: str):
201
+ bound_method = self.fn.__get__(state, state.__class__)
202
+ # Select sync/async effect type based on bound method
203
+ if inspect.iscoroutinefunction(bound_method):
204
+ effect: Effect = AsyncEffect(
205
+ bound_method, # type: ignore[arg-type]
206
+ name=self.name or f"{state.__class__.__name__}.{name}",
207
+ lazy=self.lazy,
208
+ on_error=self.on_error,
209
+ deps=self.deps,
210
+ update_deps=self.update_deps,
211
+ interval=self.interval,
212
+ )
213
+ else:
214
+ effect = Effect(
215
+ bound_method, # type: ignore[arg-type]
216
+ name=self.name or f"{state.__class__.__name__}.{name}",
217
+ immediate=self.immediate,
218
+ lazy=self.lazy,
219
+ on_error=self.on_error,
220
+ deps=self.deps,
221
+ update_deps=self.update_deps,
222
+ interval=self.interval,
223
+ )
224
+ setattr(state, name, effect)
225
+
226
+
227
+ class StateMeta(ABCMeta):
228
+ """
229
+ Metaclass that automatically converts annotated attributes into reactive properties.
230
+
231
+ When a class uses StateMeta (via inheriting from State), the metaclass:
232
+
233
+ 1. Converts all public type-annotated attributes into StateProperty descriptors
234
+ 2. Converts all public non-callable values into StateProperty descriptors
235
+ 3. Skips private attributes (starting with '_')
236
+ 4. Preserves existing descriptors (StateProperty, ComputedProperty, StateEffect)
237
+
238
+ This enables the declarative state definition pattern:
239
+
240
+ Example:
241
+
242
+ ```python
243
+ class MyState(ps.State):
244
+ count: int = 0 # Becomes StateProperty
245
+ name: str = "test" # Becomes StateProperty
246
+ _private: int = 0 # Stays as regular attribute (not reactive)
247
+
248
+ @ps.computed
249
+ def doubled(self): # Becomes ComputedProperty
250
+ return self.count * 2
251
+ ```
252
+ """
253
+
254
+ def __new__(
255
+ mcs,
256
+ name: str,
257
+ bases: tuple[type, ...],
258
+ namespace: dict[str, Any],
259
+ **kwargs: Any,
260
+ ):
261
+ annotations = namespace.get("__annotations__", {})
262
+
263
+ # 1) Turn annotated fields into StateProperty descriptors
264
+ for attr_name in annotations:
265
+ # Do not wrap private/dunder attributes as reactive
266
+ if attr_name.startswith("_"):
267
+ continue
268
+ default_value = namespace.get(attr_name)
269
+ namespace[attr_name] = StateProperty(attr_name, default_value)
270
+
271
+ # 2) Turn non-annotated plain values into StateProperty descriptors
272
+ for attr_name, value in list(namespace.items()):
273
+ # Do not wrap private/dunder attributes as reactive
274
+ if attr_name.startswith("_"):
275
+ continue
276
+ # Skip if already set as a descriptor we care about
277
+ if isinstance(
278
+ value,
279
+ (StateProperty, ComputedProperty, StateEffect, InitializableProperty),
280
+ ):
281
+ continue
282
+ # Skip common callables and descriptors
283
+ if callable(value) or isinstance(
284
+ value, (staticmethod, classmethod, property)
285
+ ):
286
+ continue
287
+ # Convert plain class var into a StateProperty
288
+ namespace[attr_name] = StateProperty(attr_name, value)
289
+
290
+ return super().__new__(mcs, name, bases, namespace)
291
+
292
+ @override
293
+ def __call__(cls, *args: Any, **kwargs: Any):
294
+ # Create the instance (runs __new__ and the class' __init__)
295
+ instance = super().__call__(*args, **kwargs)
296
+ # Ensure state effects are initialized even if user __init__ skipped super().__init__
297
+ try:
298
+ initializer = instance._initialize
299
+ except AttributeError:
300
+ return instance
301
+ initializer()
302
+ return instance
303
+
304
+
305
+ class StateStatus(IntEnum):
306
+ UNINITIALIZED = 0
307
+ INITIALIZING = 1
308
+ INITIALIZED = 2
309
+
310
+
311
+ STATE_STATUS_FIELD = "__pulse_status__"
312
+
313
+
314
+ class State(Disposable, metaclass=StateMeta):
315
+ """
316
+ Base class for reactive state objects.
317
+
318
+ Define state properties using type annotations:
319
+
320
+ ```python
321
+ class CounterState(ps.State):
322
+ count: int = 0
323
+ name: str = "Counter"
324
+
325
+ @ps.computed
326
+ def double_count(self):
327
+ return self.count * 2
328
+
329
+ @ps.effect
330
+ def print_count(self):
331
+ print(f"Count is now: {self.count}")
332
+ ```
333
+
334
+ Properties will automatically trigger re-renders when changed.
335
+
336
+ Override `on_dispose()` to run cleanup code when the state is disposed:
337
+ ```python
338
+ class MyState(ps.State):
339
+ def on_dispose(self):
340
+ # Clean up timers, connections, etc.
341
+ self.timer.cancel()
342
+ self.connection.close()
343
+ ```
344
+ """
345
+
346
+ @override
347
+ def __setattr__(self, name: str, value: Any) -> None:
348
+ if (
349
+ # Allow writing private/internal attributes
350
+ name.startswith("_")
351
+ # Allow writing during initialization
352
+ or getattr(self, STATE_STATUS_FIELD, StateStatus.UNINITIALIZED)
353
+ == StateStatus.INITIALIZING
354
+ ):
355
+ super().__setattr__(name, value)
356
+ return
357
+
358
+ # Route reactive properties through their descriptor
359
+ cls_attr = getattr(self.__class__, name, None)
360
+ if isinstance(cls_attr, ReactiveProperty):
361
+ cls_attr.__set__(self, value)
362
+ return
363
+
364
+ if isinstance(cls_attr, ComputedProperty):
365
+ raise AttributeError(f"Cannot set computed property '{name}'")
366
+
367
+ # Reject all other public writes
368
+ raise AttributeError(
369
+ "Cannot set non-reactive property '"
370
+ + name
371
+ + "' on "
372
+ + self.__class__.__name__
373
+ + ". "
374
+ + "To make '"
375
+ + name
376
+ + "' reactive, declare it with a type annotation at the class level: "
377
+ + "'"
378
+ + name
379
+ + ": <type> = <default_value>'"
380
+ + "Otherwise, make it private with an underscore: 'self._"
381
+ + name
382
+ + " = <value>'"
383
+ )
384
+
385
+ _scope: Scope
386
+
387
+ def _initialize(self):
388
+ # Idempotent: avoid double-initialization when subclass calls super().__init__
389
+ status = getattr(self, STATE_STATUS_FIELD, StateStatus.UNINITIALIZED)
390
+ if status == StateStatus.INITIALIZED:
391
+ return
392
+ if status == StateStatus.INITIALIZING:
393
+ raise RuntimeError(
394
+ "Circular state initialization, this is a Pulse internal error"
395
+ )
396
+ setattr(self, STATE_STATUS_FIELD, StateStatus.INITIALIZING)
397
+
398
+ self._scope = Scope()
399
+ with self._scope:
400
+ # Traverse MRO so effects declared on base classes are also initialized
401
+ for cls in self.__class__.__mro__:
402
+ if cls is State or cls is ABC:
403
+ continue
404
+ for name, attr in cls.__dict__.items():
405
+ # If the attribute is shadowed in a subclass with a non-StateEffect, skip
406
+ if getattr(self.__class__, name, attr) is not attr:
407
+ continue
408
+ if isinstance(attr, InitializableProperty):
409
+ # Initialize properties like state effects or queries
410
+ attr.initialize(self, name)
411
+
412
+ setattr(self, STATE_STATUS_FIELD, StateStatus.INITIALIZED)
413
+
414
+ def properties(self) -> Iterator[Signal[Any]]:
415
+ """
416
+ Iterate over the state's reactive Signal instances.
417
+
418
+ Traverses the class hierarchy (MRO) to include properties from base classes.
419
+ Each Signal is yielded only once, even if shadowed in subclasses.
420
+
421
+ Yields:
422
+ Signal[Any]: Each reactive property's underlying Signal instance.
423
+
424
+ Example:
425
+ for signal in state.properties():
426
+ print(signal.name, signal.value)
427
+ """
428
+ seen: set[str] = set()
429
+ for cls in self.__class__.__mro__:
430
+ if cls in (State, ABC):
431
+ continue
432
+ for name, prop in cls.__dict__.items():
433
+ if name in seen:
434
+ continue
435
+ if isinstance(prop, ReactiveProperty):
436
+ seen.add(name)
437
+ yield prop.get_signal(self)
438
+
439
+ def computeds(self) -> Iterator[Computed[Any]]:
440
+ """
441
+ Iterate over the state's Computed instances.
442
+
443
+ Traverses the class hierarchy (MRO) to include computed properties from
444
+ base classes. Each Computed is yielded only once.
445
+
446
+ Yields:
447
+ Computed[Any]: Each computed property's underlying Computed instance.
448
+
449
+ Example:
450
+ for computed in state.computeds():
451
+ print(computed.name, computed.read())
452
+ """
453
+ seen: set[str] = set()
454
+ for cls in self.__class__.__mro__:
455
+ if cls in (State, ABC):
456
+ continue
457
+ for name, comp_prop in cls.__dict__.items():
458
+ if name in seen:
459
+ continue
460
+ if isinstance(comp_prop, ComputedProperty):
461
+ seen.add(name)
462
+ yield comp_prop.get_computed(self)
463
+
464
+ def effects(self) -> Iterator[Effect]:
465
+ """
466
+ Iterate over the state's Effect instances.
467
+
468
+ Returns effects that have been initialized on this state instance.
469
+ Effects are created from @ps.effect decorated methods when the
470
+ state is instantiated.
471
+
472
+ Yields:
473
+ Effect: Each effect instance attached to this state.
474
+
475
+ Example:
476
+ for effect in state.effects():
477
+ print(effect.name)
478
+ """
479
+ for value in self.__dict__.values():
480
+ if isinstance(value, Effect):
481
+ yield value
482
+
483
+ def on_dispose(self) -> None:
484
+ """
485
+ Override this method to run cleanup code when the state is disposed.
486
+
487
+ This is called automatically when `dispose()` is called, before effects are disposed.
488
+ Use this to clean up timers, connections, or other resources.
489
+ """
490
+ pass
491
+
492
+ @override
493
+ def dispose(self) -> None:
494
+ """
495
+ Clean up the state, disposing all effects and resources.
496
+
497
+ Calls on_dispose() first for user-defined cleanup, then disposes all
498
+ Disposable instances attached to this state (including effects).
499
+
500
+ This method is called automatically when the state goes out of scope
501
+ or when explicitly cleaning up. After disposal, the state should not
502
+ be used.
503
+
504
+ Raises:
505
+ RuntimeError: If any effects defined on the state's scope were not
506
+ properly disposed.
507
+ """
508
+ # Call user-defined cleanup hook first
509
+ self.on_dispose()
510
+ for value in self.__dict__.values():
511
+ if isinstance(value, Disposable):
512
+ value.dispose()
513
+
514
+ undisposed_effects = [e for e in self._scope.effects if not e.__disposed__]
515
+ if len(undisposed_effects) > 0:
516
+ raise RuntimeError(
517
+ f"State.dispose() missed effects defined on its Scope: {[e.name for e in undisposed_effects]}"
518
+ )
519
+
520
+ @override
521
+ def __repr__(self) -> str:
522
+ """Return a developer-friendly representation of the state."""
523
+ props: list[str] = []
524
+
525
+ # Include StateProperty values from MRO
526
+ seen: set[str] = set()
527
+ for cls in self.__class__.__mro__:
528
+ if cls in (State, ABC):
529
+ continue
530
+ for name, value in cls.__dict__.items():
531
+ if name in seen:
532
+ continue
533
+ if isinstance(value, ReactiveProperty):
534
+ seen.add(name)
535
+ prop_value = getattr(self, name)
536
+ props.append(f"{name}={prop_value!r}")
537
+
538
+ # Include ComputedProperty values from MRO
539
+ seen.clear()
540
+ for cls in self.__class__.__mro__:
541
+ if cls in (State, ABC):
542
+ continue
543
+ for name, value in cls.__dict__.items():
544
+ if name in seen:
545
+ continue
546
+ if isinstance(value, ComputedProperty):
547
+ seen.add(name)
548
+ prop_value = getattr(self, name)
549
+ props.append(f"{name}={prop_value!r} (computed)")
550
+
551
+ return f"<{self.__class__.__name__} {' '.join(props)}>"
552
+
553
+ @override
554
+ def __str__(self) -> str:
555
+ """Return a user-friendly representation of the state."""
556
+ return self.__repr__()
pulse/test_helpers.py ADDED
@@ -0,0 +1,15 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+
4
+
5
+ async def wait_for(
6
+ condition: Callable[[], bool], *, timeout: float = 1.0, poll_interval: float = 0.005
7
+ ) -> bool:
8
+ """Poll until condition() is truthy or timeout. Returns True if condition met."""
9
+ loop = asyncio.get_event_loop()
10
+ deadline = loop.time() + timeout
11
+ while loop.time() < deadline:
12
+ if condition():
13
+ return True
14
+ await asyncio.sleep(poll_interval)
15
+ return False
@@ -0,0 +1,111 @@
1
+ """v2 transpiler with pure data node AST."""
2
+
3
+ # Ensure built-in Python modules (e.g., math) are registered on import.
4
+ from pulse.transpiler import modules as _modules # noqa: F401
5
+
6
+ # Asset registry (unified for Import and DynamicImport)
7
+ from pulse.transpiler.assets import LocalAsset as LocalAsset
8
+ from pulse.transpiler.assets import clear_asset_registry as clear_asset_registry
9
+ from pulse.transpiler.assets import get_registered_assets as get_registered_assets
10
+ from pulse.transpiler.assets import register_local_asset as register_local_asset
11
+
12
+ # Builtins
13
+ from pulse.transpiler.builtins import BUILTINS as BUILTINS
14
+ from pulse.transpiler.builtins import emit_method as emit_method
15
+
16
+ # Dynamic import primitive
17
+ from pulse.transpiler.dynamic_import import DynamicImport as DynamicImport
18
+ from pulse.transpiler.dynamic_import import import_ as import_
19
+
20
+ # Emit context
21
+ from pulse.transpiler.emit_context import EmitContext as EmitContext
22
+
23
+ # Errors
24
+ from pulse.transpiler.errors import TranspileError as TranspileError
25
+
26
+ # Function system
27
+ from pulse.transpiler.function import FUNCTION_CACHE as FUNCTION_CACHE
28
+
29
+ # Constant hoisting
30
+ from pulse.transpiler.function import Constant as Constant
31
+ from pulse.transpiler.function import JsFunction as JsFunction
32
+ from pulse.transpiler.function import analyze_deps as analyze_deps
33
+ from pulse.transpiler.function import clear_function_cache as clear_function_cache
34
+ from pulse.transpiler.function import (
35
+ collect_function_graph as collect_function_graph,
36
+ )
37
+ from pulse.transpiler.function import javascript as javascript
38
+ from pulse.transpiler.function import registered_constants as registered_constants
39
+ from pulse.transpiler.function import registered_functions as registered_functions
40
+
41
+ # ID generator
42
+ from pulse.transpiler.id import next_id as next_id
43
+ from pulse.transpiler.id import reset_id_counter as reset_id_counter
44
+
45
+ # Import utilities
46
+ from pulse.transpiler.imports import Import as Import
47
+ from pulse.transpiler.imports import ImportKind as ImportKind
48
+ from pulse.transpiler.imports import caller_file as caller_file
49
+ from pulse.transpiler.imports import clear_import_registry as clear_import_registry
50
+ from pulse.transpiler.imports import get_registered_imports as get_registered_imports
51
+ from pulse.transpiler.imports import is_absolute_path as is_absolute_path
52
+ from pulse.transpiler.imports import is_local_path as is_local_path
53
+ from pulse.transpiler.imports import is_relative_path as is_relative_path
54
+
55
+ # JS module system
56
+ from pulse.transpiler.js_module import JsModule as JsModule
57
+
58
+ # Global registry
59
+ from pulse.transpiler.nodes import EXPR_REGISTRY as EXPR_REGISTRY
60
+ from pulse.transpiler.nodes import UNDEFINED as UNDEFINED
61
+
62
+ # Expression nodes
63
+ from pulse.transpiler.nodes import Array as Array
64
+ from pulse.transpiler.nodes import Arrow as Arrow
65
+
66
+ # Statement nodes
67
+ from pulse.transpiler.nodes import Assign as Assign
68
+ from pulse.transpiler.nodes import Binary as Binary
69
+ from pulse.transpiler.nodes import Block as Block
70
+ from pulse.transpiler.nodes import Break as Break
71
+ from pulse.transpiler.nodes import Call as Call
72
+
73
+ # Type aliases
74
+ from pulse.transpiler.nodes import Continue as Continue
75
+
76
+ # Data nodes
77
+ from pulse.transpiler.nodes import Element as Element
78
+ from pulse.transpiler.nodes import Expr as Expr
79
+ from pulse.transpiler.nodes import ExprStmt as ExprStmt
80
+ from pulse.transpiler.nodes import ForOf as ForOf
81
+ from pulse.transpiler.nodes import Function as Function
82
+ from pulse.transpiler.nodes import Identifier as Identifier
83
+ from pulse.transpiler.nodes import If as If
84
+
85
+ # JSX wrapper
86
+ from pulse.transpiler.nodes import Jsx as Jsx
87
+ from pulse.transpiler.nodes import Literal as Literal
88
+ from pulse.transpiler.nodes import Member as Member
89
+ from pulse.transpiler.nodes import New as New
90
+ from pulse.transpiler.nodes import Node as Node
91
+ from pulse.transpiler.nodes import Object as Object
92
+ from pulse.transpiler.nodes import Prop as Prop
93
+ from pulse.transpiler.nodes import PulseNode as PulseNode
94
+ from pulse.transpiler.nodes import Return as Return
95
+ from pulse.transpiler.nodes import Spread as Spread
96
+ from pulse.transpiler.nodes import Stmt as Stmt
97
+ from pulse.transpiler.nodes import Subscript as Subscript
98
+ from pulse.transpiler.nodes import Template as Template
99
+ from pulse.transpiler.nodes import Ternary as Ternary
100
+ from pulse.transpiler.nodes import Throw as Throw
101
+ from pulse.transpiler.nodes import Unary as Unary
102
+ from pulse.transpiler.nodes import Undefined as Undefined
103
+ from pulse.transpiler.nodes import Value as Value
104
+ from pulse.transpiler.nodes import While as While
105
+
106
+ # Emit
107
+ from pulse.transpiler.nodes import emit as emit
108
+
109
+ # Transpiler
110
+ from pulse.transpiler.transpiler import Transpiler as Transpiler
111
+ from pulse.transpiler.transpiler import transpile as transpile