pulse-framework 0.1.54__py3-none-any.whl → 0.1.56__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 +5 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/code_analysis.py +38 -0
- pulse/codegen/codegen.py +61 -62
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +128 -6
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +41 -25
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +190 -44
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +13 -3
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/function.py +6 -2
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +64 -8
- pulse/transpiler/py_module.py +1 -7
- pulse/transpiler/transpiler.py +4 -0
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/js/react_dom.py +0 -30
- pulse/transpiler/react_component.py +0 -51
- pulse_framework-0.1.54.dist-info/RECORD +0 -124
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.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
|
|
21
|
-
|
|
22
|
-
Provides a
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
pulse/transpiler/__init__.py
CHANGED
|
@@ -3,10 +3,23 @@
|
|
|
3
3
|
# Ensure built-in Python modules (e.g., math) are registered on import.
|
|
4
4
|
from pulse.transpiler import modules as _modules # noqa: F401
|
|
5
5
|
|
|
6
|
+
# Asset registry (unified for Import and DynamicImport)
|
|
7
|
+
from pulse.transpiler.assets import LocalAsset as LocalAsset
|
|
8
|
+
from pulse.transpiler.assets import clear_asset_registry as clear_asset_registry
|
|
9
|
+
from pulse.transpiler.assets import get_registered_assets as get_registered_assets
|
|
10
|
+
from pulse.transpiler.assets import register_local_asset as register_local_asset
|
|
11
|
+
|
|
6
12
|
# Builtins
|
|
7
13
|
from pulse.transpiler.builtins import BUILTINS as BUILTINS
|
|
8
14
|
from pulse.transpiler.builtins import emit_method as emit_method
|
|
9
15
|
|
|
16
|
+
# Dynamic import primitive
|
|
17
|
+
from pulse.transpiler.dynamic_import import DynamicImport as DynamicImport
|
|
18
|
+
from pulse.transpiler.dynamic_import import import_ as import_
|
|
19
|
+
|
|
20
|
+
# Emit context
|
|
21
|
+
from pulse.transpiler.emit_context import EmitContext as EmitContext
|
|
22
|
+
|
|
10
23
|
# Errors
|
|
11
24
|
from pulse.transpiler.errors import TranspileError as TranspileError
|
|
12
25
|
|
|
@@ -93,9 +106,6 @@ from pulse.transpiler.nodes import While as While
|
|
|
93
106
|
# Emit
|
|
94
107
|
from pulse.transpiler.nodes import emit as emit
|
|
95
108
|
|
|
96
|
-
# React components (JSX imports with typed call signature)
|
|
97
|
-
from pulse.transpiler.react_component import react_component as react_component
|
|
98
|
-
|
|
99
109
|
# Transpiler
|
|
100
110
|
from pulse.transpiler.transpiler import Transpiler as Transpiler
|
|
101
111
|
from pulse.transpiler.transpiler import transpile as transpile
|