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/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("QueryKey values must be hashable") from None
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("QueryKey must be a list or tuple of hashable values")
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.
@@ -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, State
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.schedule_gc()
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"key is required for infinite query '{self.name}'. Provide a key via @infinite_query(key=...) or @{self.name}.key decorator."
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, State
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, State
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.schedule_gc()
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. Accessible via `ps.route()` in components.
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
- ctx = ps.route()
538
- user_id = ctx.pathParams.get("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__(self, info: RouteInfo, pulse_route: Route | Layout):
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 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)