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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- 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
|