pulse-framework 0.1.55__py3-none-any.whl → 0.1.57__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.
Files changed (70) hide show
  1. pulse/__init__.py +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/codegen/codegen.py +43 -12
  6. pulse/component.py +104 -0
  7. pulse/components/for_.py +30 -4
  8. pulse/components/if_.py +28 -5
  9. pulse/components/react_router.py +61 -3
  10. pulse/context.py +39 -5
  11. pulse/cookies.py +108 -4
  12. pulse/decorators.py +193 -24
  13. pulse/env.py +56 -2
  14. pulse/form.py +198 -5
  15. pulse/helpers.py +7 -1
  16. pulse/hooks/core.py +135 -5
  17. pulse/hooks/effects.py +61 -77
  18. pulse/hooks/init.py +60 -1
  19. pulse/hooks/runtime.py +241 -0
  20. pulse/hooks/setup.py +77 -0
  21. pulse/hooks/stable.py +58 -1
  22. pulse/hooks/state.py +107 -20
  23. pulse/js/__init__.py +40 -24
  24. pulse/js/array.py +9 -6
  25. pulse/js/console.py +15 -12
  26. pulse/js/date.py +9 -6
  27. pulse/js/document.py +5 -2
  28. pulse/js/error.py +7 -4
  29. pulse/js/json.py +9 -6
  30. pulse/js/map.py +8 -5
  31. pulse/js/math.py +9 -6
  32. pulse/js/navigator.py +5 -2
  33. pulse/js/number.py +9 -6
  34. pulse/js/obj.py +16 -13
  35. pulse/js/object.py +9 -6
  36. pulse/js/promise.py +19 -13
  37. pulse/js/pulse.py +28 -25
  38. pulse/js/react.py +94 -55
  39. pulse/js/regexp.py +7 -4
  40. pulse/js/set.py +8 -5
  41. pulse/js/string.py +9 -6
  42. pulse/js/weakmap.py +8 -5
  43. pulse/js/weakset.py +8 -5
  44. pulse/js/window.py +6 -3
  45. pulse/messages.py +5 -0
  46. pulse/middleware.py +147 -76
  47. pulse/plugin.py +76 -5
  48. pulse/queries/client.py +186 -39
  49. pulse/queries/common.py +52 -3
  50. pulse/queries/infinite_query.py +154 -2
  51. pulse/queries/mutation.py +127 -7
  52. pulse/queries/query.py +112 -11
  53. pulse/react_component.py +66 -3
  54. pulse/reactive.py +314 -30
  55. pulse/reactive_extensions.py +106 -26
  56. pulse/render_session.py +304 -173
  57. pulse/request.py +46 -11
  58. pulse/routing.py +140 -4
  59. pulse/serializer.py +71 -0
  60. pulse/state.py +177 -9
  61. pulse/test_helpers.py +15 -0
  62. pulse/transpiler/__init__.py +0 -3
  63. pulse/transpiler/py_module.py +1 -7
  64. pulse/user_session.py +119 -18
  65. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.dist-info}/METADATA +5 -5
  66. pulse_framework-0.1.57.dist-info/RECORD +127 -0
  67. pulse/transpiler/react_component.py +0 -44
  68. pulse_framework-0.1.55.dist-info/RECORD +0 -127
  69. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.dist-info}/WHEEL +0 -0
  70. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.dist-info}/entry_points.txt +0 -0
pulse/request.py CHANGED
@@ -17,15 +17,32 @@ def _bytes_kv_to_str(headers: list[tuple[bytes, bytes]]) -> dict[str, str]:
17
17
 
18
18
 
19
19
  class PulseRequest:
20
- """Normalized request for both HTTP prerender and Socket.IO connect.
21
-
22
- Provides a minimal, consistent surface:
23
- - headers: dict[str, str] (lowercased)
24
- - cookies: dict[str, str]
25
- - scheme, method, path, query_string, url
26
- - client: tuple[str, int] | None
27
- - auth: Any | None (only set for Socket.IO connect when provided)
28
- - raw: underlying request/scope/environ for advanced users
20
+ """Normalized request object for both HTTP prerender and WebSocket connect.
21
+
22
+ Provides a consistent interface for accessing request data regardless of
23
+ the underlying transport (FastAPI/Starlette HTTP or Socket.IO WebSocket).
24
+
25
+ Attributes:
26
+ headers: Request headers with lowercased keys.
27
+ cookies: Request cookies as name-value pairs.
28
+ scheme: URL scheme (http/https).
29
+ method: HTTP method (GET, POST, etc.).
30
+ path: URL path.
31
+ query_string: Query string (without leading ?).
32
+ client: Client address as (host, port) tuple, or None.
33
+ auth: Auth data (Socket.IO only).
34
+ raw: Underlying request object for advanced use.
35
+
36
+ Args:
37
+ headers: Request headers (keys will be lowercased).
38
+ cookies: Request cookies.
39
+ scheme: URL scheme (http/https).
40
+ method: HTTP method.
41
+ path: URL path.
42
+ query_string: Query string (without ?).
43
+ client: Client address as (host, port) tuple.
44
+ auth: Auth data (for Socket.IO).
45
+ raw: Underlying request object.
29
46
  """
30
47
 
31
48
  headers: dict[str, str]
@@ -63,6 +80,7 @@ class PulseRequest:
63
80
 
64
81
  @property
65
82
  def url(self) -> str:
83
+ """Full URL including scheme, host, path, and query string."""
66
84
  qs = f"?{self.query_string}" if self.query_string else ""
67
85
  host = self.headers.get("host", "")
68
86
  if host:
@@ -70,7 +88,15 @@ class PulseRequest:
70
88
  return f"{self.path}{qs}"
71
89
 
72
90
  @staticmethod
73
- def from_fastapi(request: Any) -> PulseRequest:
91
+ def from_fastapi(request: Any) -> "PulseRequest":
92
+ """Create from a FastAPI/Starlette request.
93
+
94
+ Args:
95
+ request: FastAPI/Starlette Request object.
96
+
97
+ Returns:
98
+ PulseRequest instance with normalized request data.
99
+ """
74
100
  # FastAPI/Starlette Request
75
101
  headers = {k.lower(): v for k, v in request.headers.items()}
76
102
  cookies = dict(request.cookies or {})
@@ -93,7 +119,16 @@ class PulseRequest:
93
119
  @staticmethod
94
120
  def from_socketio_environ(
95
121
  environ: MutableMapping[str, Any], auth: Any | None
96
- ) -> PulseRequest:
122
+ ) -> "PulseRequest":
123
+ """Create from a Socket.IO environ dictionary.
124
+
125
+ Args:
126
+ environ: Socket.IO environ dictionary (WSGI or ASGI-like).
127
+ auth: Auth data passed during Socket.IO connect.
128
+
129
+ Returns:
130
+ PulseRequest instance with normalized request data.
131
+ """
97
132
  # python-socketio passes a WSGI/ASGI-like environ. Try to detect ASGI scope first.
98
133
  scope: MutableMapping[str, Any] = environ.get("asgi.scope") or environ
99
134
 
pulse/routing.py CHANGED
@@ -149,8 +149,45 @@ def route_or_ancestors_have_dynamic(node: "Route | Layout") -> bool:
149
149
 
150
150
 
151
151
  class Route:
152
- """
153
- Represents a route definition with its component dependencies.
152
+ """Defines a route in the application.
153
+
154
+ Routes map URL paths to components that render the page content.
155
+
156
+ Args:
157
+ path: URL path pattern (e.g., "/users/:id"). Supports static segments,
158
+ dynamic parameters (`:id`), optional parameters (`:id?`), and
159
+ catch-all segments (`*`).
160
+ render: Component function to render for this route. Must be a
161
+ zero-argument component.
162
+ children: Nested child routes. Child paths are relative to parent.
163
+ dev: If True, route is only included in dev mode. Defaults to False.
164
+
165
+ Attributes:
166
+ path: Normalized relative path (no leading/trailing slashes).
167
+ segments: Parsed path segments.
168
+ render: Component to render.
169
+ children: Nested routes.
170
+ is_index: True if this is an index route (empty path).
171
+ is_dynamic: True if path contains dynamic or optional segments.
172
+ dev: Whether route is dev-only.
173
+
174
+ Path Syntax:
175
+ - Static: `/users` - Exact match
176
+ - Dynamic: `:id` - Named parameter (available in pathParams)
177
+ - Optional: `:id?` - Optional parameter
178
+ - Catch-all: `*` - Match remaining path (must be last segment)
179
+
180
+ Example:
181
+ ```python
182
+ ps.Route(
183
+ "/users",
184
+ render=users_page,
185
+ children=[
186
+ ps.Route(":id", render=user_detail),
187
+ ps.Route(":id/edit", render=user_edit),
188
+ ],
189
+ )
190
+ ```
154
191
  """
155
192
 
156
193
  path: str
@@ -243,6 +280,43 @@ def replace_layout_indicator(path_list: list[str], value: str):
243
280
 
244
281
 
245
282
  class Layout:
283
+ """Wraps child routes with a shared layout component.
284
+
285
+ Layouts provide persistent UI elements (headers, sidebars, etc.) that
286
+ wrap child routes. The layout component must render an `Outlet` to
287
+ display the matched child route.
288
+
289
+ Args:
290
+ render: Layout component function. Must render `ps.Outlet()` to
291
+ display child content.
292
+ children: Nested routes that will be wrapped by this layout.
293
+ dev: If True, layout is only included in dev mode. Defaults to False.
294
+
295
+ Attributes:
296
+ render: Layout component to render.
297
+ children: Nested routes.
298
+ dev: Whether layout is dev-only.
299
+
300
+ Example:
301
+ ```python
302
+ @ps.component
303
+ def AppLayout():
304
+ return ps.div(
305
+ Header(),
306
+ ps.main(ps.Outlet()),
307
+ Footer(),
308
+ )
309
+
310
+ ps.Layout(
311
+ render=AppLayout,
312
+ children=[
313
+ ps.Route("/", render=home),
314
+ ps.Route("/about", render=about),
315
+ ],
316
+ )
317
+ ```
318
+ """
319
+
246
320
  render: Component[...]
247
321
  children: Sequence["Route | Layout"]
248
322
  dev: bool
@@ -359,7 +433,17 @@ def filter_dev_routes(routes: Sequence[Route | Layout]) -> list[Route | Layout]:
359
433
  return filtered
360
434
 
361
435
 
362
- class InvalidRouteError(Exception): ...
436
+ class InvalidRouteError(Exception):
437
+ """Raised for invalid route configurations.
438
+
439
+ Examples of invalid configurations:
440
+ - Empty path segments
441
+ - Invalid characters in path
442
+ - Catch-all (*) not at end of path
443
+ - Attempting to get default RouteInfo for dynamic routes
444
+ """
445
+
446
+ ...
363
447
 
364
448
 
365
449
  class RouteTree:
@@ -406,6 +490,20 @@ class RouteTree:
406
490
 
407
491
 
408
492
  class RouteInfo(TypedDict):
493
+ """TypedDict containing current route information.
494
+
495
+ Provides access to URL components and parsed parameters for the
496
+ current route. Available via `use_route()` hook in components.
497
+
498
+ Attributes:
499
+ pathname: Current URL path (e.g., "/users/123").
500
+ hash: URL hash fragment after # (e.g., "section1").
501
+ query: Raw query string after ? (e.g., "page=2&sort=name").
502
+ queryParams: Parsed query parameters as dict (e.g., {"page": "2"}).
503
+ pathParams: Dynamic path parameters (e.g., {"id": "123"} for ":id").
504
+ catchall: Catch-all segments as list (e.g., ["a", "b"] for "a/b").
505
+ """
506
+
409
507
  pathname: str
410
508
  hash: str
411
509
  query: str
@@ -415,6 +513,33 @@ class RouteInfo(TypedDict):
415
513
 
416
514
 
417
515
  class RouteContext:
516
+ """Runtime context for the current route.
517
+
518
+ Provides reactive access to the current route's URL components and
519
+ parameters. Accessible via `ps.route()` in components.
520
+
521
+ Attributes:
522
+ info: Current route info (reactive, auto-updates on navigation).
523
+ pulse_route: Route or Layout definition for this context.
524
+
525
+ Properties:
526
+ pathname: Current URL path (e.g., "/users/123").
527
+ hash: URL hash fragment (without #).
528
+ query: Raw query string (without ?).
529
+ queryParams: Parsed query parameters as dict.
530
+ pathParams: Dynamic path parameters (e.g., {"id": "123"}).
531
+ catchall: Catch-all segments as list.
532
+
533
+ Example:
534
+ ```python
535
+ @ps.component
536
+ def UserProfile():
537
+ ctx = ps.route()
538
+ user_id = ctx.pathParams.get("id")
539
+ return ps.div(f"User: {user_id}")
540
+ ```
541
+ """
542
+
418
543
  info: RouteInfo
419
544
  pulse_route: Route | Layout
420
545
 
@@ -422,31 +547,42 @@ class RouteContext:
422
547
  self.info = cast(RouteInfo, cast(object, ReactiveDict(info)))
423
548
  self.pulse_route = pulse_route
424
549
 
425
- def update(self, info: RouteInfo):
550
+ def update(self, info: RouteInfo) -> None:
551
+ """Update the route info with new values.
552
+
553
+ Args:
554
+ info: New route info to apply.
555
+ """
426
556
  self.info.update(info)
427
557
 
428
558
  @property
429
559
  def pathname(self) -> str:
560
+ """Current URL path (e.g., "/users/123")."""
430
561
  return self.info["pathname"]
431
562
 
432
563
  @property
433
564
  def hash(self) -> str:
565
+ """URL hash fragment (without #)."""
434
566
  return self.info["hash"]
435
567
 
436
568
  @property
437
569
  def query(self) -> str:
570
+ """Raw query string (without ?)."""
438
571
  return self.info["query"]
439
572
 
440
573
  @property
441
574
  def queryParams(self) -> dict[str, str]:
575
+ """Parsed query parameters as dict."""
442
576
  return self.info["queryParams"]
443
577
 
444
578
  @property
445
579
  def pathParams(self) -> dict[str, str]:
580
+ """Dynamic path parameters (e.g., {"id": "123"} for ":id")."""
446
581
  return self.info["pathParams"]
447
582
 
448
583
  @property
449
584
  def catchall(self) -> list[str]:
585
+ """Catch-all segments as list."""
450
586
  return self.info["catchall"]
451
587
 
452
588
  @override
pulse/serializer.py CHANGED
@@ -46,6 +46,48 @@ __all__ = [
46
46
 
47
47
 
48
48
  def serialize(data: Any) -> Serialized:
49
+ """Serialize a Python value to wire format.
50
+
51
+ Converts Python values to a JSON-compatible format with metadata for
52
+ preserving types like datetime, set, and shared references.
53
+
54
+ Args:
55
+ data: Value to serialize.
56
+
57
+ Returns:
58
+ Serialized tuple containing metadata and JSON payload.
59
+
60
+ Raises:
61
+ TypeError: For unsupported types (functions, modules, classes).
62
+ ValueError: For Infinity float values.
63
+
64
+ Supported types:
65
+ - Primitives: None, bool, int, float, str
66
+ - Collections: list, tuple, dict, set
67
+ - datetime.datetime (converted to milliseconds since Unix epoch)
68
+ - Dataclasses (serialized as dict of fields)
69
+ - Objects with __dict__ (public attributes only)
70
+
71
+ Notes:
72
+ - NaN floats serialize as None
73
+ - Infinity raises ValueError
74
+ - Dict keys must be strings
75
+ - Private attributes (starting with _) are excluded
76
+ - Shared references and cycles are preserved
77
+
78
+ Example:
79
+ ```python
80
+ from datetime import datetime
81
+ import pulse as ps
82
+
83
+ data = {
84
+ "name": "Alice",
85
+ "created": datetime.now(),
86
+ "tags": {"admin", "user"},
87
+ }
88
+ serialized = ps.serialize(data)
89
+ ```
90
+ """
49
91
  # Map object id -> assigned global index
50
92
  seen: dict[int, int] = {}
51
93
  refs: list[int] = []
@@ -133,6 +175,35 @@ def serialize(data: Any) -> Serialized:
133
175
  def deserialize(
134
176
  payload: Serialized,
135
177
  ) -> Any:
178
+ """Deserialize wire format back to Python values.
179
+
180
+ Reconstructs Python values from the serialized format, restoring
181
+ datetime objects, sets, and shared references.
182
+
183
+ Args:
184
+ payload: Serialized tuple from serialize().
185
+
186
+ Returns:
187
+ Reconstructed Python value.
188
+
189
+ Raises:
190
+ TypeError: For malformed payloads.
191
+
192
+ Notes:
193
+ - datetime values are reconstructed as UTC-aware
194
+ - set values are reconstructed as Python sets
195
+ - Shared references and cycles are restored
196
+
197
+ Example:
198
+ ```python
199
+ from datetime import datetime
200
+ import pulse as ps
201
+
202
+ original = {"items": [1, 2, 3], "timestamp": datetime.now()}
203
+ serialized = ps.serialize(original)
204
+ restored = ps.deserialize(serialized)
205
+ ```
206
+ """
136
207
  (refs, dates, sets, _maps), data = payload
137
208
  refs = set(refs)
138
209
  dates = set(dates)
pulse/state.py CHANGED
@@ -25,6 +25,30 @@ T = TypeVar("T")
25
25
 
26
26
 
27
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
+
28
52
  pass
29
53
 
30
54
 
@@ -35,7 +59,33 @@ class InitializableProperty(ABC):
35
59
 
36
60
  class ComputedProperty(Generic[T]):
37
61
  """
38
- Descriptor for computed properties on State classes.
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
+ ```
39
89
  """
40
90
 
41
91
  name: str
@@ -74,12 +124,56 @@ class ComputedProperty(Generic[T]):
74
124
 
75
125
 
76
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
+
77
170
  fn: "Callable[[State], T]"
78
171
  name: str | None
79
172
  immediate: bool
80
173
  on_error: "Callable[[Exception], None] | None"
81
174
  lazy: bool
82
175
  deps: "list[Signal[Any] | Computed[Any]] | None"
176
+ update_deps: bool | None
83
177
  interval: float | None
84
178
 
85
179
  def __init__(
@@ -90,6 +184,7 @@ class StateEffect(Generic[T], InitializableProperty):
90
184
  lazy: bool = False,
91
185
  on_error: "Callable[[Exception], None] | None" = None,
92
186
  deps: "list[Signal[Any] | Computed[Any]] | None" = None,
187
+ update_deps: bool | None = None,
93
188
  interval: float | None = None,
94
189
  ):
95
190
  self.fn = fn
@@ -98,6 +193,7 @@ class StateEffect(Generic[T], InitializableProperty):
98
193
  self.on_error = on_error
99
194
  self.lazy = lazy
100
195
  self.deps = deps
196
+ self.update_deps = update_deps
101
197
  self.interval = interval
102
198
 
103
199
  @override
@@ -111,6 +207,7 @@ class StateEffect(Generic[T], InitializableProperty):
111
207
  lazy=self.lazy,
112
208
  on_error=self.on_error,
113
209
  deps=self.deps,
210
+ update_deps=self.update_deps,
114
211
  interval=self.interval,
115
212
  )
116
213
  else:
@@ -121,6 +218,7 @@ class StateEffect(Generic[T], InitializableProperty):
121
218
  lazy=self.lazy,
122
219
  on_error=self.on_error,
123
220
  deps=self.deps,
221
+ update_deps=self.update_deps,
124
222
  interval=self.interval,
125
223
  )
126
224
  setattr(state, name, effect)
@@ -129,6 +227,28 @@ class StateEffect(Generic[T], InitializableProperty):
129
227
  class StateMeta(ABCMeta):
130
228
  """
131
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
+ ```
132
252
  """
133
253
 
134
254
  def __new__(
@@ -292,7 +412,19 @@ class State(Disposable, metaclass=StateMeta):
292
412
  setattr(self, STATE_STATUS_FIELD, StateStatus.INITIALIZED)
293
413
 
294
414
  def properties(self) -> Iterator[Signal[Any]]:
295
- """Iterate over the state's `Signal` instances, including base classes."""
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
+ """
296
428
  seen: set[str] = set()
297
429
  for cls in self.__class__.__mro__:
298
430
  if cls in (State, ABC):
@@ -305,7 +437,19 @@ class State(Disposable, metaclass=StateMeta):
305
437
  yield prop.get_signal(self)
306
438
 
307
439
  def computeds(self) -> Iterator[Computed[Any]]:
308
- """Iterate over the state's `Computed` instances, including base classes."""
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
+ """
309
453
  seen: set[str] = set()
310
454
  for cls in self.__class__.__mro__:
311
455
  if cls in (State, ABC):
@@ -317,13 +461,24 @@ class State(Disposable, metaclass=StateMeta):
317
461
  seen.add(name)
318
462
  yield comp_prop.get_computed(self)
319
463
 
320
- def effects(self):
321
- """Iterate over the state's `Effect` instances."""
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
+ """
322
479
  for value in self.__dict__.values():
323
480
  if isinstance(value, Effect):
324
481
  yield value
325
- # if isinstance(value,QueryProperty):
326
- # value.
327
482
 
328
483
  def on_dispose(self) -> None:
329
484
  """
@@ -335,9 +490,22 @@ class State(Disposable, metaclass=StateMeta):
335
490
  pass
336
491
 
337
492
  @override
338
- def dispose(self):
339
- # Call user-defined cleanup hook first
493
+ def dispose(self) -> None:
494
+ """
495
+ Clean up the state, disposing all effects and resources.
340
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
341
509
  self.on_dispose()
342
510
  for value in self.__dict__.values():
343
511
  if isinstance(value, Disposable):
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
@@ -106,9 +106,6 @@ from pulse.transpiler.nodes import While as While
106
106
  # Emit
107
107
  from pulse.transpiler.nodes import emit as emit
108
108
 
109
- # React components (JSX imports with typed call signature)
110
- from pulse.transpiler.react_component import react_component as react_component
111
-
112
109
  # Transpiler
113
110
  from pulse.transpiler.transpiler import Transpiler as Transpiler
114
111
  from pulse.transpiler.transpiler import transpile as transpile
@@ -124,13 +124,7 @@ class PyModule(Expr):
124
124
  elif hasattr(transpilation, "_transpiler"):
125
125
  transpiler_dict = transpilation._transpiler
126
126
  else:
127
- # Legacy: class namespace without PyModule inheritance
128
- items = (
129
- (name, getattr(transpilation, name))
130
- for name in dir(transpilation)
131
- if not name.startswith("_")
132
- )
133
- transpiler_dict = PyModule._build_transpiler(items)
127
+ raise TypeError("PyModule.register expects a PyModule subclass or dict")
134
128
 
135
129
  # Register individual values for lookup by id
136
130
  for attr_name, expr in transpiler_dict.items():