pulse-framework 0.1.62__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 +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/hooks/core.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from contextvars import ContextVar, Token
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Generic, Literal, TypeVar, override
|
|
7
|
+
|
|
8
|
+
from pulse.helpers import Disposable, call_flexible
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HookAlreadyRegisteredError(HookError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HookNotFoundError(HookError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HookRenameCollisionError(HookError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MISSING: Any = object()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
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
|
+
|
|
46
|
+
description: str | None = None
|
|
47
|
+
owner: str | None = None
|
|
48
|
+
version: str | None = None
|
|
49
|
+
extra: Mapping[str, Any] | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HookState(Disposable):
|
|
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
|
+
"""
|
|
80
|
+
|
|
81
|
+
render_cycle: int = 0
|
|
82
|
+
|
|
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
|
+
"""
|
|
89
|
+
self.render_cycle = render_cycle
|
|
90
|
+
|
|
91
|
+
def on_render_end(self, render_cycle: int) -> None:
|
|
92
|
+
"""Called after the component render has completed.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
render_cycle: The current render cycle number.
|
|
96
|
+
"""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
def dispose(self) -> None:
|
|
101
|
+
"""Called when the hook instance is discarded.
|
|
102
|
+
|
|
103
|
+
Override to clean up resources (close connections, cancel tasks, etc.).
|
|
104
|
+
"""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
T = TypeVar("T", bound=HookState)
|
|
109
|
+
|
|
110
|
+
HookFactory = Callable[[], T] | Callable[["HookInit[T]"], T]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _default_factory() -> HookState:
|
|
114
|
+
return HookState()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True)
|
|
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
|
+
|
|
130
|
+
name: str
|
|
131
|
+
factory: HookFactory[T]
|
|
132
|
+
metadata: HookMetadata
|
|
133
|
+
|
|
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
|
+
"""
|
|
146
|
+
ctx = HookContext.require(self.name)
|
|
147
|
+
namespace = ctx.namespace_for(self)
|
|
148
|
+
state = namespace.ensure(ctx, key)
|
|
149
|
+
return state
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(slots=True)
|
|
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
|
+
|
|
165
|
+
key: str | None
|
|
166
|
+
render_cycle: int
|
|
167
|
+
definition: Hook[T]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
DEFAULT_HOOK_KEY = object()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class HookNamespace(Generic[T]):
|
|
174
|
+
__slots__ = ("hook", "states") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
175
|
+
hook: Hook[T]
|
|
176
|
+
|
|
177
|
+
def __init__(self, hook: Hook[T]) -> None:
|
|
178
|
+
self.hook = hook
|
|
179
|
+
self.states: dict[object, T] = {}
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _normalize_key(key: str | None) -> object:
|
|
183
|
+
return key if key is not None else DEFAULT_HOOK_KEY
|
|
184
|
+
|
|
185
|
+
def on_render_start(self, render_cycle: int):
|
|
186
|
+
for state in self.states.values():
|
|
187
|
+
state.on_render_start(render_cycle)
|
|
188
|
+
|
|
189
|
+
def on_render_end(self, render_cycle: int):
|
|
190
|
+
for state in self.states.values():
|
|
191
|
+
state.on_render_end(render_cycle)
|
|
192
|
+
|
|
193
|
+
def ensure(self, ctx: "HookContext", key: str | None) -> T:
|
|
194
|
+
normalized = self._normalize_key(key)
|
|
195
|
+
state = self.states.get(normalized)
|
|
196
|
+
if state is None:
|
|
197
|
+
created = call_flexible(
|
|
198
|
+
self.hook.factory,
|
|
199
|
+
HookInit(definition=self.hook, render_cycle=ctx.render_cycle, key=key),
|
|
200
|
+
)
|
|
201
|
+
if inspect.isawaitable(created):
|
|
202
|
+
raise HookError(
|
|
203
|
+
f"Hook factory '{self.hook.name}' returned an awaitable; "
|
|
204
|
+
+ "async factories are not supported"
|
|
205
|
+
)
|
|
206
|
+
if not isinstance(created, HookState):
|
|
207
|
+
raise HookError(
|
|
208
|
+
f"Hook factory '{self.hook.name}' must return a HookState instance"
|
|
209
|
+
)
|
|
210
|
+
state = created
|
|
211
|
+
self.states[normalized] = state
|
|
212
|
+
state.on_render_start(ctx.render_cycle)
|
|
213
|
+
return state
|
|
214
|
+
|
|
215
|
+
def dispose(self) -> None:
|
|
216
|
+
for key, state in self.states.items():
|
|
217
|
+
try:
|
|
218
|
+
state.dispose()
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.exception(
|
|
221
|
+
"Error disposing hook '%s' (key=%r)",
|
|
222
|
+
self.hook.name,
|
|
223
|
+
key,
|
|
224
|
+
)
|
|
225
|
+
self.states.clear()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class HookContext:
|
|
229
|
+
render_cycle: int
|
|
230
|
+
namespaces: dict[str, HookNamespace[Any]]
|
|
231
|
+
_token: "Token[HookContext | None] | None"
|
|
232
|
+
|
|
233
|
+
def __init__(self) -> None:
|
|
234
|
+
self.render_cycle = 0
|
|
235
|
+
self.namespaces = {}
|
|
236
|
+
self._token = None
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def require(caller: str | None = None):
|
|
240
|
+
ctx = HOOK_CONTEXT.get()
|
|
241
|
+
if ctx is None:
|
|
242
|
+
caller = caller or "this function"
|
|
243
|
+
raise HookError(
|
|
244
|
+
f"Missing hook context, {caller} was likely called outside rendering"
|
|
245
|
+
)
|
|
246
|
+
return ctx
|
|
247
|
+
|
|
248
|
+
def __enter__(self):
|
|
249
|
+
self.render_cycle += 1
|
|
250
|
+
self._token = HOOK_CONTEXT.set(self)
|
|
251
|
+
for namespace in self.namespaces.values():
|
|
252
|
+
namespace.on_render_start(self.render_cycle)
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def __exit__(
|
|
256
|
+
self,
|
|
257
|
+
exc_type: type[BaseException] | None,
|
|
258
|
+
exc_val: BaseException | None,
|
|
259
|
+
exc_tb: Any,
|
|
260
|
+
) -> Literal[False]:
|
|
261
|
+
if self._token is not None:
|
|
262
|
+
HOOK_CONTEXT.reset(self._token)
|
|
263
|
+
self._token = None
|
|
264
|
+
for namespace in self.namespaces.values():
|
|
265
|
+
namespace.on_render_end(self.render_cycle)
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
def namespace_for(self, hook: Hook[T]) -> HookNamespace[T]:
|
|
269
|
+
namespace = self.namespaces.get(hook.name)
|
|
270
|
+
if namespace is None:
|
|
271
|
+
namespace = HookNamespace(hook)
|
|
272
|
+
self.namespaces[hook.name] = namespace
|
|
273
|
+
return namespace
|
|
274
|
+
|
|
275
|
+
def unmount(self) -> None:
|
|
276
|
+
for namespace in self.namespaces.values():
|
|
277
|
+
namespace.dispose()
|
|
278
|
+
self.namespaces.clear()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
HOOK_CONTEXT: ContextVar[HookContext | None] = ContextVar(
|
|
282
|
+
"pulse_hook_context", default=None
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class HookRegistry:
|
|
287
|
+
_locked: bool
|
|
288
|
+
|
|
289
|
+
def __init__(self) -> None:
|
|
290
|
+
self.hooks: dict[str, Hook[Any]] = {}
|
|
291
|
+
self._locked = False
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def get():
|
|
295
|
+
return HOOK_REGISTRY
|
|
296
|
+
|
|
297
|
+
def create(
|
|
298
|
+
self,
|
|
299
|
+
name: str,
|
|
300
|
+
factory: HookFactory[T] = _default_factory,
|
|
301
|
+
metadata: HookMetadata | None = None,
|
|
302
|
+
) -> Hook[T]:
|
|
303
|
+
if not isinstance(name, str) or not name:
|
|
304
|
+
raise ValueError("Hook name must be a non-empty string")
|
|
305
|
+
hook_metadata = metadata or HookMetadata()
|
|
306
|
+
if self._locked:
|
|
307
|
+
raise HookError("Hook registry is locked")
|
|
308
|
+
if name in self.hooks:
|
|
309
|
+
raise HookAlreadyRegisteredError(f"Hook '{name}' is already registered")
|
|
310
|
+
hook = Hook(
|
|
311
|
+
name=name,
|
|
312
|
+
factory=factory,
|
|
313
|
+
metadata=hook_metadata,
|
|
314
|
+
)
|
|
315
|
+
self.hooks[name] = hook
|
|
316
|
+
|
|
317
|
+
return hook
|
|
318
|
+
|
|
319
|
+
def rename(self, current: str, new: str) -> None:
|
|
320
|
+
if current == new:
|
|
321
|
+
return
|
|
322
|
+
if self._locked:
|
|
323
|
+
raise HookError("Hook registry is locked")
|
|
324
|
+
hook = self.hooks.get(current)
|
|
325
|
+
if hook is None:
|
|
326
|
+
raise HookNotFoundError(f"Hook '{current}' is not registered")
|
|
327
|
+
if new in self.hooks:
|
|
328
|
+
raise HookRenameCollisionError(f"Hook '{new}' is already registered")
|
|
329
|
+
del self.hooks[current]
|
|
330
|
+
hook.name = new
|
|
331
|
+
self.hooks[new] = hook
|
|
332
|
+
|
|
333
|
+
def list(self) -> list[str]:
|
|
334
|
+
return sorted(self.hooks.keys())
|
|
335
|
+
|
|
336
|
+
def describe(self, name: str) -> HookMetadata:
|
|
337
|
+
definition = self.hooks.get(name)
|
|
338
|
+
if definition is None:
|
|
339
|
+
raise HookNotFoundError(f"Hook '{name}' is not registered")
|
|
340
|
+
return definition.metadata
|
|
341
|
+
|
|
342
|
+
def lock(self) -> None:
|
|
343
|
+
self._locked = True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
HOOK_REGISTRY: HookRegistry = HookRegistry()
|
|
347
|
+
|
|
348
|
+
|
|
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
|
+
|
|
363
|
+
__slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
|
|
364
|
+
|
|
365
|
+
State: type[HookState] = HookState
|
|
366
|
+
Metadata: type[HookMetadata] = HookMetadata
|
|
367
|
+
AlreadyRegisteredError: type[HookAlreadyRegisteredError] = (
|
|
368
|
+
HookAlreadyRegisteredError
|
|
369
|
+
)
|
|
370
|
+
NotFoundError: type[HookNotFoundError] = HookNotFoundError
|
|
371
|
+
RenameCollisionError: type[HookRenameCollisionError] = HookRenameCollisionError
|
|
372
|
+
|
|
373
|
+
def create(
|
|
374
|
+
self,
|
|
375
|
+
name: str,
|
|
376
|
+
factory: HookFactory[T] = _default_factory,
|
|
377
|
+
*,
|
|
378
|
+
metadata: HookMetadata | None = None,
|
|
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
|
+
"""
|
|
415
|
+
return HOOK_REGISTRY.create(name, factory, metadata)
|
|
416
|
+
|
|
417
|
+
def rename(self, current: str, new: str) -> None:
|
|
418
|
+
HOOK_REGISTRY.rename(current, new)
|
|
419
|
+
|
|
420
|
+
def list(self) -> list[str]:
|
|
421
|
+
return HOOK_REGISTRY.list()
|
|
422
|
+
|
|
423
|
+
def describe(self, name: str) -> HookMetadata:
|
|
424
|
+
return HOOK_REGISTRY.describe(name)
|
|
425
|
+
|
|
426
|
+
def registry(self) -> HookRegistry:
|
|
427
|
+
return HOOK_REGISTRY
|
|
428
|
+
|
|
429
|
+
def lock(self) -> None:
|
|
430
|
+
HOOK_REGISTRY.lock()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
hooks = HooksAPI()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
__all__ = [
|
|
437
|
+
"HooksAPI",
|
|
438
|
+
"HookContext",
|
|
439
|
+
"Hook",
|
|
440
|
+
"HookError",
|
|
441
|
+
"HookInit",
|
|
442
|
+
"HookMetadata",
|
|
443
|
+
"HookNamespace",
|
|
444
|
+
"HookNotFoundError",
|
|
445
|
+
"HookRenameCollisionError",
|
|
446
|
+
"HookState",
|
|
447
|
+
"HookAlreadyRegisteredError",
|
|
448
|
+
"HOOK_CONTEXT",
|
|
449
|
+
"HookRegistry",
|
|
450
|
+
"hooks",
|
|
451
|
+
"MISSING",
|
|
452
|
+
]
|
pulse/hooks/effects.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, override
|
|
3
|
+
|
|
4
|
+
from pulse.hooks.core import HookMetadata, HookState, hooks
|
|
5
|
+
from pulse.reactive import AsyncEffect, Effect
|
|
6
|
+
|
|
7
|
+
|
|
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
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
super().__init__()
|
|
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)
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def on_render_start(self, render_cycle: int) -> None:
|
|
25
|
+
super().on_render_start(render_cycle)
|
|
26
|
+
self._seen_this_render.clear()
|
|
27
|
+
|
|
28
|
+
@override
|
|
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
|
+
)
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"@ps.effect decorator called multiple times with the same key='{key}' during a single render."
|
|
56
|
+
)
|
|
57
|
+
self._seen_this_render.add(full_identity)
|
|
58
|
+
|
|
59
|
+
existing = self.effects.get(full_identity)
|
|
60
|
+
if existing is not None:
|
|
61
|
+
return existing
|
|
62
|
+
|
|
63
|
+
effect = factory()
|
|
64
|
+
self.effects[full_identity] = effect
|
|
65
|
+
return effect
|
|
66
|
+
|
|
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()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
inline_effect_hook = hooks.create(
|
|
76
|
+
"pulse:core.inline_effects",
|
|
77
|
+
lambda: InlineEffectHookState(),
|
|
78
|
+
metadata=HookMetadata(
|
|
79
|
+
owner="pulse.core",
|
|
80
|
+
description="Storage for inline @ps.effect decorators in components",
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"InlineEffectHookState",
|
|
87
|
+
"inline_effect_hook",
|
|
88
|
+
]
|