pulse-framework 0.1.55__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/codegen/codegen.py +43 -12
- pulse/component.py +104 -0
- 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 +40 -24
- 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 +94 -55
- 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 +0 -3
- pulse/transpiler/py_module.py +1 -7
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/transpiler/react_component.py +0 -44
- pulse_framework-0.1.55.dist-info/RECORD +0 -127
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/hooks/core.py
CHANGED
|
@@ -31,6 +31,18 @@ MISSING: Any = object()
|
|
|
31
31
|
|
|
32
32
|
@dataclass(slots=True)
|
|
33
33
|
class HookMetadata:
|
|
34
|
+
"""Metadata for a registered hook.
|
|
35
|
+
|
|
36
|
+
Contains optional descriptive information about a hook, useful for
|
|
37
|
+
debugging and documentation.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
description: Human-readable description of the hook's purpose.
|
|
41
|
+
owner: Module or package that owns this hook (e.g., "pulse.core").
|
|
42
|
+
version: Version string for the hook implementation.
|
|
43
|
+
extra: Additional metadata as a mapping.
|
|
44
|
+
"""
|
|
45
|
+
|
|
34
46
|
description: str | None = None
|
|
35
47
|
owner: str | None = None
|
|
36
48
|
version: str | None = None
|
|
@@ -38,20 +50,58 @@ class HookMetadata:
|
|
|
38
50
|
|
|
39
51
|
|
|
40
52
|
class HookState(Disposable):
|
|
41
|
-
"""Base class returned by hook factories.
|
|
53
|
+
"""Base class for hook state returned by hook factories.
|
|
54
|
+
|
|
55
|
+
Subclass this to create custom hook state that persists across renders.
|
|
56
|
+
Override lifecycle methods to respond to render events.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
render_cycle: The current render cycle number.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
class TimerHookState(ps.hooks.State):
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self.start_time = time.time()
|
|
67
|
+
|
|
68
|
+
def elapsed(self) -> float:
|
|
69
|
+
return time.time() - self.start_time
|
|
70
|
+
|
|
71
|
+
def dispose(self) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
_timer_hook = ps.hooks.create("my_app:timer", lambda: TimerHookState())
|
|
75
|
+
|
|
76
|
+
def use_timer() -> TimerHookState:
|
|
77
|
+
return _timer_hook()
|
|
78
|
+
```
|
|
79
|
+
"""
|
|
42
80
|
|
|
43
81
|
render_cycle: int = 0
|
|
44
82
|
|
|
45
83
|
def on_render_start(self, render_cycle: int) -> None:
|
|
84
|
+
"""Called before each component render.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
render_cycle: The current render cycle number.
|
|
88
|
+
"""
|
|
46
89
|
self.render_cycle = render_cycle
|
|
47
90
|
|
|
48
91
|
def on_render_end(self, render_cycle: int) -> None:
|
|
49
|
-
"""Called after the component render has completed.
|
|
92
|
+
"""Called after the component render has completed.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
render_cycle: The current render cycle number.
|
|
96
|
+
"""
|
|
50
97
|
...
|
|
51
98
|
|
|
52
99
|
@override
|
|
53
100
|
def dispose(self) -> None:
|
|
54
|
-
"""Called when the hook instance is discarded.
|
|
101
|
+
"""Called when the hook instance is discarded.
|
|
102
|
+
|
|
103
|
+
Override to clean up resources (close connections, cancel tasks, etc.).
|
|
104
|
+
"""
|
|
55
105
|
...
|
|
56
106
|
|
|
57
107
|
|
|
@@ -66,11 +116,33 @@ def _default_factory() -> HookState:
|
|
|
66
116
|
|
|
67
117
|
@dataclass(slots=True)
|
|
68
118
|
class Hook(Generic[T]):
|
|
119
|
+
"""A registered hook definition.
|
|
120
|
+
|
|
121
|
+
Hooks are created via ``ps.hooks.create()`` and can be called during
|
|
122
|
+
component render to access their associated state.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
name: Unique name identifying this hook.
|
|
126
|
+
factory: Function that creates new HookState instances.
|
|
127
|
+
metadata: Optional metadata about the hook.
|
|
128
|
+
"""
|
|
129
|
+
|
|
69
130
|
name: str
|
|
70
131
|
factory: HookFactory[T]
|
|
71
132
|
metadata: HookMetadata
|
|
72
133
|
|
|
73
134
|
def __call__(self, key: str | None = None) -> T:
|
|
135
|
+
"""Get or create hook state for the current component.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
key: Optional key for multiple instances of the same hook.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The hook state instance.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
HookError: If called outside of a render context.
|
|
145
|
+
"""
|
|
74
146
|
ctx = HookContext.require(self.name)
|
|
75
147
|
namespace = ctx.namespace_for(self)
|
|
76
148
|
state = namespace.ensure(ctx, key)
|
|
@@ -79,6 +151,17 @@ class Hook(Generic[T]):
|
|
|
79
151
|
|
|
80
152
|
@dataclass(slots=True)
|
|
81
153
|
class HookInit(Generic[T]):
|
|
154
|
+
"""Initialization context passed to hook factories.
|
|
155
|
+
|
|
156
|
+
When a hook factory accepts a single argument, it receives this object
|
|
157
|
+
containing context about the initialization.
|
|
158
|
+
|
|
159
|
+
Attributes:
|
|
160
|
+
key: Optional key if the hook was called with a specific key.
|
|
161
|
+
render_cycle: The current render cycle number.
|
|
162
|
+
definition: Reference to the Hook definition being initialized.
|
|
163
|
+
"""
|
|
164
|
+
|
|
82
165
|
key: str | None
|
|
83
166
|
render_cycle: int
|
|
84
167
|
definition: Hook[T]
|
|
@@ -178,7 +261,6 @@ class HookContext:
|
|
|
178
261
|
if self._token is not None:
|
|
179
262
|
HOOK_CONTEXT.reset(self._token)
|
|
180
263
|
self._token = None
|
|
181
|
-
|
|
182
264
|
for namespace in self.namespaces.values():
|
|
183
265
|
namespace.on_render_end(self.render_cycle)
|
|
184
266
|
return False
|
|
@@ -265,6 +347,19 @@ HOOK_REGISTRY: HookRegistry = HookRegistry()
|
|
|
265
347
|
|
|
266
348
|
|
|
267
349
|
class HooksAPI:
|
|
350
|
+
"""Low-level API for creating custom hooks.
|
|
351
|
+
|
|
352
|
+
Access via ``ps.hooks``. Provides methods for registering, listing,
|
|
353
|
+
and managing hooks.
|
|
354
|
+
|
|
355
|
+
Attributes:
|
|
356
|
+
State: Alias for HookState base class.
|
|
357
|
+
Metadata: Alias for HookMetadata dataclass.
|
|
358
|
+
AlreadyRegisteredError: Exception for duplicate hook registration.
|
|
359
|
+
NotFoundError: Exception for missing hook lookup.
|
|
360
|
+
RenameCollisionError: Exception for hook rename conflicts.
|
|
361
|
+
"""
|
|
362
|
+
|
|
268
363
|
__slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
|
|
269
364
|
|
|
270
365
|
State: type[HookState] = HookState
|
|
@@ -281,7 +376,42 @@ class HooksAPI:
|
|
|
281
376
|
factory: HookFactory[T] = _default_factory,
|
|
282
377
|
*,
|
|
283
378
|
metadata: HookMetadata | None = None,
|
|
284
|
-
):
|
|
379
|
+
) -> "Hook[T]":
|
|
380
|
+
"""Register a new hook.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
name: Unique name for the hook (e.g., "my_app:timer").
|
|
384
|
+
factory: Function that creates HookState instances. Can be a
|
|
385
|
+
zero-argument callable or accept a HookInit object.
|
|
386
|
+
metadata: Optional metadata describing the hook.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Hook[T]: The registered hook, callable during component render.
|
|
390
|
+
|
|
391
|
+
Raises:
|
|
392
|
+
ValueError: If name is empty.
|
|
393
|
+
HookAlreadyRegisteredError: If a hook with this name already exists.
|
|
394
|
+
HookError: If the registry is locked.
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
class TimerHookState(ps.hooks.State):
|
|
400
|
+
def __init__(self):
|
|
401
|
+
self.start_time = time.time()
|
|
402
|
+
|
|
403
|
+
def elapsed(self) -> float:
|
|
404
|
+
return time.time() - self.start_time
|
|
405
|
+
|
|
406
|
+
def dispose(self) -> None:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
_timer_hook = ps.hooks.create("my_app:timer", lambda: TimerHookState())
|
|
410
|
+
|
|
411
|
+
def use_timer() -> TimerHookState:
|
|
412
|
+
return _timer_hook()
|
|
413
|
+
```
|
|
414
|
+
"""
|
|
285
415
|
return HOOK_REGISTRY.create(name, factory, metadata)
|
|
286
416
|
|
|
287
417
|
def rename(self, current: str, new: str) -> None:
|
pulse/hooks/effects.py
CHANGED
|
@@ -1,104 +1,88 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any, override
|
|
3
3
|
|
|
4
4
|
from pulse.hooks.core import HookMetadata, HookState, hooks
|
|
5
|
-
from pulse.reactive import
|
|
5
|
+
from pulse.reactive import AsyncEffect, Effect
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
class InlineEffectHookState(HookState):
|
|
9
|
+
"""Stores inline effects keyed by function identity or explicit key."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("effects", "_seen_this_render") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
12
12
|
|
|
13
13
|
def __init__(self) -> None:
|
|
14
14
|
super().__init__()
|
|
15
|
-
self.
|
|
16
|
-
self.
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
self.effects: dict[tuple[str, Any], Effect | AsyncEffect] = {}
|
|
16
|
+
self._seen_this_render: set[tuple[str, Any]] = set()
|
|
17
|
+
|
|
18
|
+
def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
|
|
19
|
+
if key is None:
|
|
20
|
+
return ("code", identity)
|
|
21
|
+
return ("key", key)
|
|
19
22
|
|
|
20
23
|
@override
|
|
21
24
|
def on_render_start(self, render_cycle: int) -> None:
|
|
22
25
|
super().on_render_start(render_cycle)
|
|
23
|
-
self.
|
|
24
|
-
|
|
25
|
-
def replace(self, effects: list[Effect], key: str | None) -> None:
|
|
26
|
-
self.dispose_effects()
|
|
27
|
-
self.effects = tuple(effects)
|
|
28
|
-
self.key = key
|
|
29
|
-
self.initialized = True
|
|
30
|
-
|
|
31
|
-
def dispose_effects(self) -> None:
|
|
32
|
-
for effect in self.effects:
|
|
33
|
-
effect.dispose()
|
|
34
|
-
self.effects = ()
|
|
35
|
-
self.initialized = False
|
|
36
|
-
self.key = None
|
|
26
|
+
self._seen_this_render.clear()
|
|
37
27
|
|
|
38
28
|
@override
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
def on_render_end(self, render_cycle: int) -> None:
|
|
30
|
+
super().on_render_end(render_cycle)
|
|
31
|
+
# Dispose effects that weren't seen this render (e.g., inside conditionals that became false)
|
|
32
|
+
for key in list(self.effects.keys()):
|
|
33
|
+
if key not in self._seen_this_render:
|
|
34
|
+
self.effects[key].dispose()
|
|
35
|
+
del self.effects[key]
|
|
36
|
+
|
|
37
|
+
def get_or_create(
|
|
38
|
+
self,
|
|
39
|
+
identity: Any,
|
|
40
|
+
key: str | None,
|
|
41
|
+
factory: Callable[[], Effect | AsyncEffect],
|
|
42
|
+
) -> Effect | AsyncEffect:
|
|
43
|
+
"""Return cached effect or create a new one."""
|
|
44
|
+
# Effects with explicit keys fully bypass identity matching.
|
|
45
|
+
full_identity = self._make_key(identity, key)
|
|
46
|
+
|
|
47
|
+
if full_identity in self._seen_this_render:
|
|
48
|
+
if key is None:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"@ps.effect decorator called multiple times at the same location during a single render. "
|
|
51
|
+
+ "This usually happens when using @ps.effect inside a loop. "
|
|
52
|
+
+ "Use the `key` parameter to disambiguate: @ps.effect(key=unique_value)"
|
|
53
|
+
)
|
|
44
54
|
raise RuntimeError(
|
|
45
|
-
"
|
|
55
|
+
f"@ps.effect decorator called multiple times with the same key='{key}' during a single render."
|
|
46
56
|
)
|
|
57
|
+
self._seen_this_render.add(full_identity)
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _build_effects(
|
|
53
|
-
fns: tuple[EffectFn, ...],
|
|
54
|
-
on_error: Callable[[Exception], None] | None,
|
|
55
|
-
) -> list[Effect]:
|
|
56
|
-
effects: list[Effect] = []
|
|
57
|
-
with Untrack():
|
|
58
|
-
for fn in fns:
|
|
59
|
-
if not callable(fn):
|
|
60
|
-
raise ValueError(
|
|
61
|
-
"Only pass functions or callable objects to `ps.effects`"
|
|
62
|
-
)
|
|
63
|
-
effects.append(
|
|
64
|
-
Effect(fn, name=getattr(fn, "__name__", "effect"), on_error=on_error)
|
|
65
|
-
)
|
|
66
|
-
return effects
|
|
59
|
+
existing = self.effects.get(full_identity)
|
|
60
|
+
if existing is not None:
|
|
61
|
+
return existing
|
|
67
62
|
|
|
63
|
+
effect = factory()
|
|
64
|
+
self.effects[full_identity] = effect
|
|
65
|
+
return effect
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
@override
|
|
68
|
+
def dispose(self) -> None:
|
|
69
|
+
for eff in self.effects.values():
|
|
70
|
+
eff.dispose()
|
|
71
|
+
self.effects.clear()
|
|
72
|
+
self._seen_this_render.clear()
|
|
71
73
|
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
"pulse:core.
|
|
75
|
-
|
|
75
|
+
inline_effect_hook = hooks.create(
|
|
76
|
+
"pulse:core.inline_effects",
|
|
77
|
+
lambda: InlineEffectHookState(),
|
|
76
78
|
metadata=HookMetadata(
|
|
77
79
|
owner="pulse.core",
|
|
78
|
-
description="
|
|
80
|
+
description="Storage for inline @ps.effect decorators in components",
|
|
79
81
|
),
|
|
80
82
|
)
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
) -> None:
|
|
88
|
-
state = cast(EffectsHookState, _effects_hook())
|
|
89
|
-
state.ensure_not_called()
|
|
90
|
-
|
|
91
|
-
if not state.initialized:
|
|
92
|
-
state.replace(_build_effects(fns, on_error), key)
|
|
93
|
-
state.mark_called()
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
if key is not None and key != state.key:
|
|
97
|
-
state.replace(_build_effects(fns, on_error), key)
|
|
98
|
-
state.mark_called()
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
state.mark_called()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
__all__ = ["effects", "EffectsHookState"]
|
|
85
|
+
__all__ = [
|
|
86
|
+
"InlineEffectHookState",
|
|
87
|
+
"inline_effect_hook",
|
|
88
|
+
]
|
pulse/hooks/init.py
CHANGED
|
@@ -41,7 +41,33 @@ def previous_frame() -> types.FrameType:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class InitContext:
|
|
44
|
-
"""Context
|
|
44
|
+
"""Context manager for one-time initialization in components.
|
|
45
|
+
|
|
46
|
+
Variables assigned inside the block persist across re-renders. On first render,
|
|
47
|
+
the code inside runs normally and variables are captured. On subsequent renders,
|
|
48
|
+
the block is skipped and variables are restored from storage.
|
|
49
|
+
|
|
50
|
+
This class is returned by ``ps.init()`` and should be used as a context manager.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
callsite: Tuple of (code object, line number) identifying the call site.
|
|
54
|
+
frame: The stack frame where init was called.
|
|
55
|
+
first_render: True if this is the first render cycle.
|
|
56
|
+
pre_keys: Set of variable names that existed before entering the block.
|
|
57
|
+
saved: Dictionary of captured variable values.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
def my_component():
|
|
63
|
+
with ps.init():
|
|
64
|
+
counter = 0
|
|
65
|
+
api = ApiClient()
|
|
66
|
+
data = fetch_initial_data()
|
|
67
|
+
# counter, api, data retain their values across renders
|
|
68
|
+
return m.Text(f"Counter: {counter}")
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
45
71
|
|
|
46
72
|
callsite: tuple[Any, int] | None
|
|
47
73
|
frame: types.FrameType | None
|
|
@@ -113,6 +139,39 @@ class InitContext:
|
|
|
113
139
|
|
|
114
140
|
|
|
115
141
|
def init() -> InitContext:
|
|
142
|
+
"""Context manager for one-time initialization in components.
|
|
143
|
+
|
|
144
|
+
Variables assigned inside the block persist across re-renders. Uses AST
|
|
145
|
+
rewriting to transform the code at decoration time.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
InitContext: Context manager that captures and restores variables.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
def my_component():
|
|
154
|
+
with ps.init():
|
|
155
|
+
counter = 0
|
|
156
|
+
api = ApiClient()
|
|
157
|
+
data = fetch_initial_data()
|
|
158
|
+
# counter, api, data retain their values across renders
|
|
159
|
+
return m.Text(f"Counter: {counter}")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rules:
|
|
163
|
+
- Can only be used once per component
|
|
164
|
+
- Must be at the top level of the component function (not inside
|
|
165
|
+
conditionals, loops, or nested functions)
|
|
166
|
+
- Cannot contain control flow (if, for, while, try, with, match)
|
|
167
|
+
- Cannot use ``as`` binding (``with ps.init() as ctx:`` not allowed)
|
|
168
|
+
- Variables are restored from first render on subsequent renders
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
If you encounter issues with ``ps.init()`` (e.g., source code not
|
|
172
|
+
available in some deployment environments), use ``ps.setup()`` instead.
|
|
173
|
+
It provides the same functionality without AST rewriting.
|
|
174
|
+
"""
|
|
116
175
|
return InitContext()
|
|
117
176
|
|
|
118
177
|
|