pulse-framework 0.1.72__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.
- pulse/__init__.py +16 -4
- pulse/cli/processes.py +2 -0
- pulse/debounce.py +79 -0
- pulse/decorators.py +4 -3
- pulse/hooks/effects.py +5 -5
- pulse/hooks/runtime.py +25 -8
- pulse/hooks/setup.py +6 -10
- pulse/hooks/stable.py +5 -9
- pulse/hooks/state.py +4 -8
- pulse/queries/common.py +1 -1
- pulse/queries/infinite_query.py +2 -1
- pulse/queries/mutation.py +2 -1
- pulse/queries/query.py +2 -1
- pulse/render_session.py +2 -2
- pulse/renderer.py +30 -2
- pulse/routing.py +19 -5
- pulse/serializer.py +38 -19
- pulse/state/__init__.py +1 -0
- pulse/state/property.py +218 -0
- pulse/state/query_param.py +538 -0
- pulse/{state.py → state/state.py} +66 -220
- pulse/transpiler/__init__.py +5 -0
- pulse/transpiler/function.py +56 -32
- pulse/transpiler/nodes.py +43 -4
- pulse/transpiler/parse.py +70 -0
- pulse/transpiler/transpiler.py +413 -81
- pulse/transpiler/vdom.py +1 -1
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/METADATA +2 -2
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/RECORD +31 -26
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.72.dist-info → pulse_framework-0.1.74.dist-info}/entry_points.txt +0 -0
pulse/serializer.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Pulse serializer
|
|
1
|
+
"""Pulse serializer v4 implementation (Python).
|
|
2
2
|
|
|
3
3
|
The format mirrors the TypeScript implementation in ``packages/pulse/js``.
|
|
4
4
|
|
|
@@ -13,8 +13,10 @@ Serialized payload structure::
|
|
|
13
13
|
``refs``, ``dates``, ``sets``, ``maps``.
|
|
14
14
|
- ``refs`` – indices where the payload entry is an integer pointing to a
|
|
15
15
|
previously visited node's index (shared refs/cycles).
|
|
16
|
-
- ``dates`` – indices that should be materialised as
|
|
17
|
-
payload entry is
|
|
16
|
+
- ``dates`` – indices that should be materialised as temporal objects; the
|
|
17
|
+
payload entry is an ISO 8601 string:
|
|
18
|
+
- ``YYYY-MM-DD`` → ``datetime.date``
|
|
19
|
+
- ``YYYY-MM-DDTHH:MM:SS.SSSZ`` → ``datetime.datetime`` (UTC)
|
|
18
20
|
- ``sets`` – indices that are ``set`` instances; payload is an array of their
|
|
19
21
|
items.
|
|
20
22
|
- ``maps`` – indices that are ``Map`` instances; payload is an object mapping
|
|
@@ -22,8 +24,8 @@ Serialized payload structure::
|
|
|
22
24
|
|
|
23
25
|
Nodes are assigned a single global index as they are visited (non-primitives
|
|
24
26
|
only). This preserves shared references and cycles across nested structures
|
|
25
|
-
containing primitives, lists/tuples, ``dict``/plain objects, ``set``
|
|
26
|
-
``datetime`` objects.
|
|
27
|
+
containing primitives, lists/tuples, ``dict``/plain objects, ``set``, ``date``
|
|
28
|
+
and ``datetime`` objects.
|
|
27
29
|
"""
|
|
28
30
|
|
|
29
31
|
from __future__ import annotations
|
|
@@ -49,7 +51,7 @@ def serialize(data: Any) -> Serialized:
|
|
|
49
51
|
"""Serialize a Python value to wire format.
|
|
50
52
|
|
|
51
53
|
Converts Python values to a JSON-compatible format with metadata for
|
|
52
|
-
|
|
54
|
+
preserving types like datetime, date, set, and shared references.
|
|
53
55
|
|
|
54
56
|
Args:
|
|
55
57
|
data: Value to serialize.
|
|
@@ -64,7 +66,8 @@ def serialize(data: Any) -> Serialized:
|
|
|
64
66
|
Supported types:
|
|
65
67
|
- Primitives: None, bool, int, float, str
|
|
66
68
|
- Collections: list, tuple, dict, set
|
|
67
|
-
- datetime.datetime (converted to
|
|
69
|
+
- datetime.datetime (converted to ISO 8601 UTC)
|
|
70
|
+
- datetime.date (converted to ISO 8601 date string)
|
|
68
71
|
- Dataclasses (serialized as dict of fields)
|
|
69
72
|
- Objects with __dict__ (public attributes only)
|
|
70
73
|
|
|
@@ -123,7 +126,11 @@ def serialize(data: Any) -> Serialized:
|
|
|
123
126
|
|
|
124
127
|
if isinstance(value, dt.datetime):
|
|
125
128
|
dates.append(idx)
|
|
126
|
-
return
|
|
129
|
+
return _datetime_to_iso(value)
|
|
130
|
+
|
|
131
|
+
if isinstance(value, dt.date):
|
|
132
|
+
dates.append(idx)
|
|
133
|
+
return value.isoformat()
|
|
127
134
|
|
|
128
135
|
if isinstance(value, dict):
|
|
129
136
|
result_dict: dict[str, PlainJSON] = {}
|
|
@@ -178,7 +185,7 @@ def deserialize(
|
|
|
178
185
|
"""Deserialize wire format back to Python values.
|
|
179
186
|
|
|
180
187
|
Reconstructs Python values from the serialized format, restoring
|
|
181
|
-
datetime objects, sets, and shared references.
|
|
188
|
+
date/datetime objects, sets, and shared references.
|
|
182
189
|
|
|
183
190
|
Args:
|
|
184
191
|
payload: Serialized tuple from serialize().
|
|
@@ -191,6 +198,7 @@ def deserialize(
|
|
|
191
198
|
|
|
192
199
|
Notes:
|
|
193
200
|
- datetime values are reconstructed as UTC-aware
|
|
201
|
+
- date values are reconstructed as ``datetime.date``
|
|
194
202
|
- set values are reconstructed as Python sets
|
|
195
203
|
- Shared references and cycles are restored
|
|
196
204
|
|
|
@@ -228,10 +236,12 @@ def deserialize(
|
|
|
228
236
|
return objects[target_index]
|
|
229
237
|
|
|
230
238
|
if idx in dates:
|
|
231
|
-
assert isinstance(value,
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
assert isinstance(value, str), "Date payload must be an ISO string"
|
|
240
|
+
if _is_date_literal(value):
|
|
241
|
+
date_value = dt.date.fromisoformat(value)
|
|
242
|
+
objects.append(date_value)
|
|
243
|
+
return date_value
|
|
244
|
+
dt_value = _datetime_from_iso(value)
|
|
235
245
|
objects.append(dt_value)
|
|
236
246
|
return dt_value
|
|
237
247
|
|
|
@@ -267,13 +277,22 @@ def deserialize(
|
|
|
267
277
|
return reconstruct(data)
|
|
268
278
|
|
|
269
279
|
|
|
270
|
-
def
|
|
280
|
+
def _datetime_to_iso(value: dt.datetime) -> str:
|
|
271
281
|
if value.tzinfo is None:
|
|
272
|
-
|
|
282
|
+
value = value.replace(tzinfo=dt.UTC)
|
|
273
283
|
else:
|
|
274
|
-
|
|
275
|
-
return
|
|
284
|
+
value = value.astimezone(dt.UTC)
|
|
285
|
+
return value.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _datetime_from_iso(value: str) -> dt.datetime:
|
|
289
|
+
if value.endswith("Z"):
|
|
290
|
+
value = value[:-1] + "+00:00"
|
|
291
|
+
parsed = dt.datetime.fromisoformat(value)
|
|
292
|
+
if parsed.tzinfo is None:
|
|
293
|
+
return parsed.replace(tzinfo=dt.UTC)
|
|
294
|
+
return parsed
|
|
276
295
|
|
|
277
296
|
|
|
278
|
-
def
|
|
279
|
-
return
|
|
297
|
+
def _is_date_literal(value: str) -> bool:
|
|
298
|
+
return len(value) == 10 and value[4] == "-" and value[7] == "-"
|
pulse/state/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""State package modules."""
|
pulse/state/property.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Descriptors for reactive state classes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Generic, Never, TypeVar, override
|
|
9
|
+
|
|
10
|
+
from pulse.reactive import AsyncEffect, Computed, Effect, Signal
|
|
11
|
+
from pulse.reactive_extensions import ReactiveProperty
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pulse.state.state import State
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StateProperty(ReactiveProperty[Any]):
|
|
20
|
+
"""
|
|
21
|
+
Descriptor for reactive properties on State classes.
|
|
22
|
+
|
|
23
|
+
StateProperty wraps a Signal and provides automatic reactivity for
|
|
24
|
+
class attributes. When a property is read, it subscribes to the underlying
|
|
25
|
+
Signal. When written, it updates the Signal and triggers re-renders.
|
|
26
|
+
|
|
27
|
+
This class is typically not used directly. Instead, declare typed attributes
|
|
28
|
+
on a State subclass, and the StateMeta metaclass will automatically convert
|
|
29
|
+
them into StateProperty instances.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
class MyState(ps.State):
|
|
35
|
+
count: int = 0 # Automatically becomes a StateProperty
|
|
36
|
+
name: str = "default"
|
|
37
|
+
|
|
38
|
+
state = MyState()
|
|
39
|
+
state.count = 5 # Updates the underlying Signal
|
|
40
|
+
print(state.count) # Reads from the Signal, subscribes to changes
|
|
41
|
+
```
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InitializableProperty(ABC):
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def initialize(self, state: "State", name: str) -> Any: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ComputedProperty(Generic[T]):
|
|
53
|
+
"""
|
|
54
|
+
Descriptor for computed (derived) properties on State classes.
|
|
55
|
+
|
|
56
|
+
ComputedProperty wraps a method that derives its value from other reactive
|
|
57
|
+
properties. The computed value is cached and only recalculated when its
|
|
58
|
+
dependencies change. Reading a computed property subscribes to it.
|
|
59
|
+
|
|
60
|
+
Created automatically when using the @ps.computed decorator on a State method.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
name: The property name (used for debugging and the private storage key).
|
|
64
|
+
fn: The method that computes the value. Must take only `self` as argument.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
class MyState(ps.State):
|
|
70
|
+
count: int = 0
|
|
71
|
+
|
|
72
|
+
@ps.computed
|
|
73
|
+
def doubled(self):
|
|
74
|
+
return self.count * 2
|
|
75
|
+
|
|
76
|
+
state = MyState()
|
|
77
|
+
print(state.doubled) # 0
|
|
78
|
+
state.count = 5
|
|
79
|
+
print(state.doubled) # 10 (automatically recomputed)
|
|
80
|
+
```
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
private_name: str
|
|
85
|
+
fn: "Callable[[State], T]"
|
|
86
|
+
|
|
87
|
+
def __init__(self, name: str, fn: "Callable[[State], T]"):
|
|
88
|
+
self.name = name
|
|
89
|
+
self.private_name = f"__computed_{name}"
|
|
90
|
+
# The computed_template holds the original method
|
|
91
|
+
self.fn = fn
|
|
92
|
+
|
|
93
|
+
def get_computed(self, obj: Any) -> Computed[T]:
|
|
94
|
+
from pulse.state.state import State
|
|
95
|
+
|
|
96
|
+
if not isinstance(obj, State):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Computed property {self.name} defined on a non-State class"
|
|
99
|
+
)
|
|
100
|
+
if not hasattr(obj, self.private_name):
|
|
101
|
+
# Create the computed on first access for this instance
|
|
102
|
+
bound_method = self.fn.__get__(obj, obj.__class__)
|
|
103
|
+
new_computed = Computed(
|
|
104
|
+
bound_method,
|
|
105
|
+
name=f"{obj.__class__.__name__}.{self.name}",
|
|
106
|
+
)
|
|
107
|
+
setattr(obj, self.private_name, new_computed)
|
|
108
|
+
return getattr(obj, self.private_name)
|
|
109
|
+
|
|
110
|
+
def __get__(self, obj: Any, objtype: Any = None) -> T:
|
|
111
|
+
if obj is None:
|
|
112
|
+
return self # pyright: ignore[reportReturnType]
|
|
113
|
+
|
|
114
|
+
return self.get_computed(obj).read()
|
|
115
|
+
|
|
116
|
+
def __set__(self, obj: Any, value: Any) -> Never:
|
|
117
|
+
raise AttributeError(f"Cannot set computed property '{self.name}'")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class StateEffect(Generic[T], InitializableProperty):
|
|
121
|
+
"""
|
|
122
|
+
Descriptor for side effects on State classes.
|
|
123
|
+
|
|
124
|
+
StateEffect wraps a method that performs side effects when its dependencies
|
|
125
|
+
change. The effect is initialized when the State instance is created and
|
|
126
|
+
disposed when the State is disposed.
|
|
127
|
+
|
|
128
|
+
Created automatically when using the @ps.effect decorator on a State method.
|
|
129
|
+
Supports both sync and async methods.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
fn: The effect function. Must take only `self` as argument.
|
|
133
|
+
Can return a cleanup function that runs before the next execution
|
|
134
|
+
or when the effect is disposed.
|
|
135
|
+
name: Debug name for the effect. Defaults to "ClassName.method_name".
|
|
136
|
+
immediate: If True, run synchronously when scheduled (sync effects only).
|
|
137
|
+
lazy: If True, don't run on creation; wait for first dependency change.
|
|
138
|
+
on_error: Callback for handling errors during effect execution.
|
|
139
|
+
deps: Explicit dependencies. If provided, auto-tracking is disabled.
|
|
140
|
+
interval: Re-run interval in seconds for polling effects.
|
|
141
|
+
|
|
142
|
+
Example:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
class MyState(ps.State):
|
|
146
|
+
count: int = 0
|
|
147
|
+
|
|
148
|
+
@ps.effect
|
|
149
|
+
def log_count(self):
|
|
150
|
+
print(f"Count changed to: {self.count}")
|
|
151
|
+
|
|
152
|
+
@ps.effect
|
|
153
|
+
async def fetch_data(self):
|
|
154
|
+
data = await api.fetch(self.query)
|
|
155
|
+
self.data = data
|
|
156
|
+
|
|
157
|
+
@ps.effect
|
|
158
|
+
def subscribe(self):
|
|
159
|
+
unsub = event_bus.subscribe(self.handle_event)
|
|
160
|
+
return unsub # Cleanup function
|
|
161
|
+
```
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
fn: "Callable[[State], T]"
|
|
165
|
+
name: str | None
|
|
166
|
+
immediate: bool
|
|
167
|
+
on_error: "Callable[[Exception], None] | None"
|
|
168
|
+
lazy: bool
|
|
169
|
+
deps: "list[Signal[Any] | Computed[Any]] | None"
|
|
170
|
+
update_deps: bool | None
|
|
171
|
+
interval: float | None
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
fn: "Callable[[State], T]",
|
|
176
|
+
name: str | None = None,
|
|
177
|
+
immediate: bool = False,
|
|
178
|
+
lazy: bool = False,
|
|
179
|
+
on_error: "Callable[[Exception], None] | None" = None,
|
|
180
|
+
deps: "list[Signal[Any] | Computed[Any]] | None" = None,
|
|
181
|
+
update_deps: bool | None = None,
|
|
182
|
+
interval: float | None = None,
|
|
183
|
+
):
|
|
184
|
+
self.fn = fn
|
|
185
|
+
self.name = name
|
|
186
|
+
self.immediate = immediate
|
|
187
|
+
self.on_error = on_error
|
|
188
|
+
self.lazy = lazy
|
|
189
|
+
self.deps = deps
|
|
190
|
+
self.update_deps = update_deps
|
|
191
|
+
self.interval = interval
|
|
192
|
+
|
|
193
|
+
@override
|
|
194
|
+
def initialize(self, state: "State", name: str):
|
|
195
|
+
bound_method = self.fn.__get__(state, state.__class__)
|
|
196
|
+
# Select sync/async effect type based on bound method
|
|
197
|
+
if inspect.iscoroutinefunction(bound_method):
|
|
198
|
+
effect: Effect = AsyncEffect(
|
|
199
|
+
bound_method, # type: ignore[arg-type]
|
|
200
|
+
name=self.name or f"{state.__class__.__name__}.{name}",
|
|
201
|
+
lazy=self.lazy,
|
|
202
|
+
on_error=self.on_error,
|
|
203
|
+
deps=self.deps,
|
|
204
|
+
update_deps=self.update_deps,
|
|
205
|
+
interval=self.interval,
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
effect = Effect(
|
|
209
|
+
bound_method, # type: ignore[arg-type]
|
|
210
|
+
name=self.name or f"{state.__class__.__name__}.{name}",
|
|
211
|
+
immediate=self.immediate,
|
|
212
|
+
lazy=self.lazy,
|
|
213
|
+
on_error=self.on_error,
|
|
214
|
+
deps=self.deps,
|
|
215
|
+
update_deps=self.update_deps,
|
|
216
|
+
interval=self.interval,
|
|
217
|
+
)
|
|
218
|
+
setattr(state, name, effect)
|