pulse-framework 0.1.71__py3-none-any.whl → 0.1.73__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 +19 -4
- pulse/app.py +27 -24
- pulse/cli/cmd.py +1 -1
- pulse/cli/folder_lock.py +25 -6
- pulse/cli/processes.py +2 -0
- pulse/codegen/templates/layout.py +3 -1
- pulse/debounce.py +79 -0
- pulse/decorators.py +4 -3
- pulse/hooks/effects.py +20 -6
- 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/proxy.py +719 -185
- pulse/queries/common.py +17 -5
- pulse/queries/infinite_query.py +14 -3
- pulse/queries/mutation.py +2 -1
- pulse/queries/query.py +4 -2
- pulse/render_session.py +7 -4
- 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/nodes.py +26 -2
- pulse/transpiler/transpiler.py +86 -5
- pulse/transpiler/vdom.py +1 -1
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/METADATA +4 -4
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/RECORD +33 -29
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/entry_points.txt +0 -0
pulse/queries/common.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import (
|
|
|
13
13
|
override,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
from pulse.state import State
|
|
16
|
+
from pulse.state.state import State
|
|
17
17
|
|
|
18
18
|
T = TypeVar("T")
|
|
19
19
|
TState = TypeVar("TState", bound="State")
|
|
@@ -34,19 +34,31 @@ class Key(tuple[Hashable, ...]):
|
|
|
34
34
|
parts = tuple(key)
|
|
35
35
|
try:
|
|
36
36
|
key_hash = hash(parts)
|
|
37
|
-
except TypeError:
|
|
38
|
-
raise TypeError(
|
|
37
|
+
except TypeError as e:
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"Query key contains unhashable value: {e}.\n\n"
|
|
40
|
+
+ "Keys must contain only hashable values (strings, numbers, tuples).\n"
|
|
41
|
+
+ f"Got: {key!r}\n\n"
|
|
42
|
+
+ "If using a dict or list inside the key, convert it to a tuple:\n"
|
|
43
|
+
+ " key=('users', tuple(user_ids)) # instead of list"
|
|
44
|
+
) from None
|
|
39
45
|
obj = super().__new__(cls, parts)
|
|
40
46
|
obj._hash = key_hash
|
|
41
47
|
return obj
|
|
42
|
-
raise TypeError(
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"Query key must be a tuple or list, got {type(key).__name__}: {key!r}\n\n"
|
|
50
|
+
+ "Examples of valid keys:\n"
|
|
51
|
+
+ " key=('users',) # single-element tuple\n"
|
|
52
|
+
+ " key=('user', user_id) # tuple with dynamic value\n"
|
|
53
|
+
+ " key=['posts', 'feed'] # list form also works"
|
|
54
|
+
)
|
|
43
55
|
|
|
44
56
|
@override
|
|
45
57
|
def __hash__(self) -> int:
|
|
46
58
|
return self._hash
|
|
47
59
|
|
|
48
60
|
|
|
49
|
-
QueryKey: TypeAlias = tuple[Hashable, ...] | list[Hashable] | Key
|
|
61
|
+
QueryKey: TypeAlias = tuple[Hashable, ...] | list[Hashable] | Key # pyright: ignore[reportImplicitStringConcatenation]
|
|
50
62
|
"""List/tuple of hashable values identifying a query in the store.
|
|
51
63
|
|
|
52
64
|
Used to uniquely identify queries for caching, deduplication, and invalidation.
|
pulse/queries/infinite_query.py
CHANGED
|
@@ -39,7 +39,8 @@ from pulse.queries.query import RETRY_DELAY_DEFAULT, QueryConfig
|
|
|
39
39
|
from pulse.reactive import Computed, Effect, Signal, Untrack
|
|
40
40
|
from pulse.reactive_extensions import ReactiveList, unwrap
|
|
41
41
|
from pulse.scheduling import TimerHandleLike, create_task, later
|
|
42
|
-
from pulse.state import InitializableProperty
|
|
42
|
+
from pulse.state.property import InitializableProperty
|
|
43
|
+
from pulse.state.state import State
|
|
43
44
|
|
|
44
45
|
T = TypeVar("T")
|
|
45
46
|
TParam = TypeVar("TParam")
|
|
@@ -432,7 +433,8 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
|
|
|
432
433
|
self._cancel_observer_actions(observer)
|
|
433
434
|
|
|
434
435
|
if len(self._observers) == 0:
|
|
435
|
-
self.
|
|
436
|
+
if not self.__disposed__:
|
|
437
|
+
self.schedule_gc()
|
|
436
438
|
|
|
437
439
|
def invalidate(
|
|
438
440
|
self,
|
|
@@ -1308,8 +1310,17 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
|
|
|
1308
1310
|
)
|
|
1309
1311
|
|
|
1310
1312
|
if self._key is None:
|
|
1313
|
+
# pyright: ignore[reportImplicitStringConcatenation]
|
|
1311
1314
|
raise RuntimeError(
|
|
1312
|
-
f"
|
|
1315
|
+
f"Missing query key for @infinite_query '{self.name}'. "
|
|
1316
|
+
+ "A key is required to cache and share query results.\n\n"
|
|
1317
|
+
+ f"Fix: Add key=(...) to the decorator or use the @{self.name}.key decorator:\n\n"
|
|
1318
|
+
+ " @ps.infinite_query(initial_page_param=..., key=('my_query',))\n"
|
|
1319
|
+
+ f" async def {self.name}(self, param): ...\n\n"
|
|
1320
|
+
+ "Or with a dynamic key:\n\n"
|
|
1321
|
+
+ f" @{self.name}.key\n"
|
|
1322
|
+
+ f" def _{self.name}_key(self):\n"
|
|
1323
|
+
+ " return ('my_query', self.some_param)"
|
|
1313
1324
|
)
|
|
1314
1325
|
raw_initial = (
|
|
1315
1326
|
call_flexible(self._initial_data, state)
|
pulse/queries/mutation.py
CHANGED
|
@@ -13,7 +13,8 @@ from typing import (
|
|
|
13
13
|
from pulse.helpers import call_flexible, maybe_await
|
|
14
14
|
from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
|
|
15
15
|
from pulse.reactive import Signal
|
|
16
|
-
from pulse.state import InitializableProperty
|
|
16
|
+
from pulse.state.property import InitializableProperty
|
|
17
|
+
from pulse.state.state import State
|
|
17
18
|
|
|
18
19
|
T = TypeVar("T")
|
|
19
20
|
TState = TypeVar("TState", bound=State)
|
pulse/queries/query.py
CHANGED
|
@@ -37,7 +37,8 @@ from pulse.queries.common import (
|
|
|
37
37
|
from pulse.queries.effect import AsyncQueryEffect
|
|
38
38
|
from pulse.reactive import Computed, Effect, Signal, Untrack
|
|
39
39
|
from pulse.scheduling import TimerHandleLike, create_task, is_pytest, later
|
|
40
|
-
from pulse.state import InitializableProperty
|
|
40
|
+
from pulse.state.property import InitializableProperty
|
|
41
|
+
from pulse.state.state import State
|
|
41
42
|
|
|
42
43
|
if TYPE_CHECKING:
|
|
43
44
|
from pulse.queries.protocol import QueryResult
|
|
@@ -563,7 +564,8 @@ class KeyedQuery(Generic[T], Disposable):
|
|
|
563
564
|
)
|
|
564
565
|
|
|
565
566
|
if len(self.observers) == 0:
|
|
566
|
-
self.
|
|
567
|
+
if not self.__disposed__:
|
|
568
|
+
self.schedule_gc()
|
|
567
569
|
|
|
568
570
|
def schedule_gc(self):
|
|
569
571
|
self.cancel_gc()
|
pulse/render_session.py
CHANGED
|
@@ -34,7 +34,7 @@ from pulse.scheduling import (
|
|
|
34
34
|
TimerRegistry,
|
|
35
35
|
create_future,
|
|
36
36
|
)
|
|
37
|
-
from pulse.state import State
|
|
37
|
+
from pulse.state.state import State
|
|
38
38
|
from pulse.transpiler.id import next_id
|
|
39
39
|
from pulse.transpiler.nodes import Expr
|
|
40
40
|
|
|
@@ -111,7 +111,7 @@ class RouteMount:
|
|
|
111
111
|
) -> None:
|
|
112
112
|
self.render = render
|
|
113
113
|
self.path = ensure_absolute_path(path)
|
|
114
|
-
self.route = RouteContext(route_info, route)
|
|
114
|
+
self.route = RouteContext(route_info, route, render)
|
|
115
115
|
self.effect = None
|
|
116
116
|
self._pulse_ctx = None
|
|
117
117
|
self.tree = RenderTree(route.render())
|
|
@@ -285,7 +285,6 @@ class RenderSession:
|
|
|
285
285
|
self._send_message = None
|
|
286
286
|
self._global_states = {}
|
|
287
287
|
self._global_queue = []
|
|
288
|
-
self.query_store = QueryStore()
|
|
289
288
|
self.connected = False
|
|
290
289
|
self.channels = ChannelsManager(self)
|
|
291
290
|
self.forms = FormRegistry(self)
|
|
@@ -293,6 +292,7 @@ class RenderSession:
|
|
|
293
292
|
self._pending_js_results = {}
|
|
294
293
|
self._tasks = TaskRegistry(name=f"render:{id}")
|
|
295
294
|
self._timers = TimerRegistry(tasks=self._tasks, name=f"render:{id}")
|
|
295
|
+
self.query_store = QueryStore()
|
|
296
296
|
self.prerender_queue_timeout = prerender_queue_timeout
|
|
297
297
|
self.detach_queue_timeout = detach_queue_timeout
|
|
298
298
|
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
@@ -574,9 +574,10 @@ class RenderSession:
|
|
|
574
574
|
# ---- Helpers ----
|
|
575
575
|
|
|
576
576
|
def close(self):
|
|
577
|
+
# Close all pending timers at the start, to avoid anything firing while we clean up
|
|
578
|
+
self._timers.cancel_all()
|
|
577
579
|
self.forms.dispose()
|
|
578
580
|
self._tasks.cancel_all()
|
|
579
|
-
self._timers.cancel_all()
|
|
580
581
|
for path in list(self.route_mounts.keys()):
|
|
581
582
|
self.detach(path, timeout=0)
|
|
582
583
|
self.route_mounts.clear()
|
|
@@ -597,6 +598,8 @@ class RenderSession:
|
|
|
597
598
|
if not fut.done():
|
|
598
599
|
fut.cancel()
|
|
599
600
|
self._pending_js_results.clear()
|
|
601
|
+
# Close any timer that may have been scheduled during cleanup (ex: query GC)
|
|
602
|
+
self._timers.cancel_all()
|
|
600
603
|
self._global_queue = []
|
|
601
604
|
self._send_message = None
|
|
602
605
|
self.connected = False
|
pulse/renderer.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from types import NoneType
|
|
7
7
|
from typing import Any, NamedTuple, TypeAlias, cast
|
|
8
8
|
|
|
9
|
+
from pulse.debounce import Debounced
|
|
9
10
|
from pulse.helpers import values_equal
|
|
10
11
|
from pulse.hooks.core import HookContext
|
|
11
12
|
from pulse.transpiler import Import
|
|
@@ -33,7 +34,7 @@ from pulse.transpiler.vdom import (
|
|
|
33
34
|
VDOMPropValue,
|
|
34
35
|
)
|
|
35
36
|
|
|
36
|
-
PropValue: TypeAlias = Node | Callable[..., Any]
|
|
37
|
+
PropValue: TypeAlias = Node | Callable[..., Any] | Debounced[Any, Any]
|
|
37
38
|
|
|
38
39
|
FRAGMENT_TAG = ""
|
|
39
40
|
MOUNT_PREFIX = "$$"
|
|
@@ -404,6 +405,21 @@ class Renderer:
|
|
|
404
405
|
updated[key] = value.render()
|
|
405
406
|
continue
|
|
406
407
|
|
|
408
|
+
if isinstance(value, Debounced):
|
|
409
|
+
eval_keys.add(key)
|
|
410
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
411
|
+
unmount_element(old_value)
|
|
412
|
+
if normalized is None:
|
|
413
|
+
normalized = current.copy()
|
|
414
|
+
normalized[key] = value
|
|
415
|
+
register_callback(self.callbacks, prop_path, value.fn)
|
|
416
|
+
prev_delay = (
|
|
417
|
+
old_value.delay_ms if isinstance(old_value, Debounced) else None
|
|
418
|
+
)
|
|
419
|
+
if prev_delay != value.delay_ms:
|
|
420
|
+
updated[key] = format_callback_placeholder(value.delay_ms)
|
|
421
|
+
continue
|
|
422
|
+
|
|
407
423
|
if callable(value):
|
|
408
424
|
eval_keys.add(key)
|
|
409
425
|
if isinstance(old_value, (Element, PulseNode)):
|
|
@@ -412,7 +428,7 @@ class Renderer:
|
|
|
412
428
|
normalized = current.copy()
|
|
413
429
|
normalized[key] = value
|
|
414
430
|
register_callback(self.callbacks, prop_path, value)
|
|
415
|
-
if not callable(old_value):
|
|
431
|
+
if not callable(old_value) or isinstance(old_value, Debounced):
|
|
416
432
|
updated[key] = CALLBACK_PLACEHOLDER
|
|
417
433
|
continue
|
|
418
434
|
|
|
@@ -483,6 +499,8 @@ def prop_requires_eval(value: PropValue) -> bool:
|
|
|
483
499
|
return True
|
|
484
500
|
if isinstance(value, Expr):
|
|
485
501
|
return True
|
|
502
|
+
if isinstance(value, Debounced):
|
|
503
|
+
return True
|
|
486
504
|
return callable(value)
|
|
487
505
|
|
|
488
506
|
|
|
@@ -530,6 +548,16 @@ def normalize_children(children: Children | None) -> list[Node]:
|
|
|
530
548
|
return out
|
|
531
549
|
|
|
532
550
|
|
|
551
|
+
def format_callback_placeholder(delay_ms: float | None) -> str:
|
|
552
|
+
if delay_ms is None:
|
|
553
|
+
return CALLBACK_PLACEHOLDER
|
|
554
|
+
if delay_ms.is_integer():
|
|
555
|
+
suffix = str(int(delay_ms))
|
|
556
|
+
else:
|
|
557
|
+
suffix = format(delay_ms, "g")
|
|
558
|
+
return f"{CALLBACK_PLACEHOLDER}:{suffix}"
|
|
559
|
+
|
|
560
|
+
|
|
533
561
|
def register_callback(
|
|
534
562
|
callbacks: Callbacks,
|
|
535
563
|
path: str,
|
pulse/routing.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from collections.abc import Sequence
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from typing import TypedDict, cast, override
|
|
4
|
+
from typing import TYPE_CHECKING, TypedDict, cast, override
|
|
5
5
|
|
|
6
6
|
from pulse.component import Component
|
|
7
7
|
from pulse.env import env
|
|
8
8
|
from pulse.reactive_extensions import ReactiveDict
|
|
9
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pulse.render_session import RenderSession
|
|
12
|
+
from pulse.state.query_param import QueryParamSync
|
|
13
|
+
|
|
10
14
|
# angle brackets cannot appear in a regular URL path, this ensures no name conflicts
|
|
11
15
|
LAYOUT_INDICATOR = "<layout>"
|
|
12
16
|
|
|
@@ -516,7 +520,8 @@ class RouteContext:
|
|
|
516
520
|
"""Runtime context for the current route.
|
|
517
521
|
|
|
518
522
|
Provides reactive access to the current route's URL components and
|
|
519
|
-
parameters.
|
|
523
|
+
parameters. Available via `ps.route()` (route info) and `ps.pulse_route()`
|
|
524
|
+
(route definition) in components.
|
|
520
525
|
|
|
521
526
|
Attributes:
|
|
522
527
|
info: Current route info (reactive, auto-updates on navigation).
|
|
@@ -534,18 +539,27 @@ class RouteContext:
|
|
|
534
539
|
```python
|
|
535
540
|
@ps.component
|
|
536
541
|
def UserProfile():
|
|
537
|
-
|
|
538
|
-
user_id =
|
|
542
|
+
info = ps.route()
|
|
543
|
+
user_id = info["pathParams"].get("id")
|
|
539
544
|
return ps.div(f"User: {user_id}")
|
|
540
545
|
```
|
|
541
546
|
"""
|
|
542
547
|
|
|
543
548
|
info: RouteInfo
|
|
544
549
|
pulse_route: Route | Layout
|
|
550
|
+
query_param_sync: "QueryParamSync"
|
|
545
551
|
|
|
546
|
-
def __init__(
|
|
552
|
+
def __init__(
|
|
553
|
+
self,
|
|
554
|
+
info: RouteInfo,
|
|
555
|
+
pulse_route: Route | Layout,
|
|
556
|
+
render: "RenderSession",
|
|
557
|
+
):
|
|
547
558
|
self.info = cast(RouteInfo, cast(object, ReactiveDict(info)))
|
|
548
559
|
self.pulse_route = pulse_route
|
|
560
|
+
from pulse.state.query_param import QueryParamSync
|
|
561
|
+
|
|
562
|
+
self.query_param_sync = QueryParamSync(render, self)
|
|
549
563
|
|
|
550
564
|
def update(self, info: RouteInfo) -> None:
|
|
551
565
|
"""Update the route info with new values.
|
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)
|