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/serializer.py CHANGED
@@ -1,4 +1,4 @@
1
- """Pulse serializer v3 implementation (Python).
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 ``datetime`` objects; the
17
- payload entry is the millisecond timestamp since the Unix epoch (UTC).
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`` and
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
- preserving types like datetime, set, and shared references.
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 milliseconds since Unix epoch)
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 _datetime_to_millis(value)
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, (int, float)), (
232
- "Date payload must be a numeric timestamp"
233
- )
234
- dt_value = _datetime_from_millis(value)
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 _datetime_to_millis(value: dt.datetime) -> int:
280
+ def _datetime_to_iso(value: dt.datetime) -> str:
271
281
  if value.tzinfo is None:
272
- ts = value.replace(tzinfo=dt.UTC).timestamp()
282
+ value = value.replace(tzinfo=dt.UTC)
273
283
  else:
274
- ts = value.astimezone(dt.UTC).timestamp()
275
- return int(round(ts * 1000))
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 _datetime_from_millis(value: int | float) -> dt.datetime:
279
- return dt.datetime.fromtimestamp(value / 1000.0, tz=dt.UTC)
297
+ def _is_date_literal(value: str) -> bool:
298
+ return len(value) == 10 and value[4] == "-" and value[7] == "-"
@@ -0,0 +1 @@
1
+ """State package modules."""
@@ -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)