pulse-framework 0.1.0__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 +175 -0
- pulse/app.py +349 -0
- pulse/cmd.py +324 -0
- pulse/codegen.py +147 -0
- pulse/components/__init__.py +1 -0
- pulse/components/react_router.py +43 -0
- pulse/context.py +15 -0
- pulse/decorators.py +187 -0
- pulse/diff.py +252 -0
- pulse/flags.py +5 -0
- pulse/flatted.py +159 -0
- pulse/helpers.py +27 -0
- pulse/hooks.py +441 -0
- pulse/html/__init__.py +304 -0
- pulse/html/attributes.py +930 -0
- pulse/html/elements.py +1024 -0
- pulse/html/events.py +419 -0
- pulse/html/tags.py +171 -0
- pulse/html/tags.pyi +390 -0
- pulse/messages.py +109 -0
- pulse/middleware.py +158 -0
- pulse/query.py +286 -0
- pulse/react_component.py +803 -0
- pulse/reactive.py +514 -0
- pulse/reactive_extensions.py +626 -0
- pulse/reconciler.py +575 -0
- pulse/request.py +162 -0
- pulse/routing.py +350 -0
- pulse/session.py +310 -0
- pulse/state.py +309 -0
- pulse/templates.py +171 -0
- pulse/tests/__init__.py +0 -0
- pulse/tests/old_test_diff.py +174 -0
- pulse/tests/test_codegen.py +224 -0
- pulse/tests/test_flatted.py +297 -0
- pulse/tests/test_nodes.py +439 -0
- pulse/tests/test_query.py +391 -0
- pulse/tests/test_react.py +797 -0
- pulse/tests/test_reactive.py +1203 -0
- pulse/tests/test_reconciler.py +1759 -0
- pulse/tests/test_routing.py +167 -0
- pulse/tests/test_session.py +267 -0
- pulse/tests/test_state.py +569 -0
- pulse/tests/test_utils.py +101 -0
- pulse/vdom.py +381 -0
- pulse_framework-0.1.0.dist-info/METADATA +38 -0
- pulse_framework-0.1.0.dist-info/RECORD +50 -0
- pulse_framework-0.1.0.dist-info/WHEEL +4 -0
- pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
- pulse_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
pulse/hooks.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Callable,
|
|
5
|
+
Generic,
|
|
6
|
+
Mapping,
|
|
7
|
+
ParamSpec,
|
|
8
|
+
Protocol,
|
|
9
|
+
TypeVar,
|
|
10
|
+
TypeVarTuple,
|
|
11
|
+
Unpack,
|
|
12
|
+
overload,
|
|
13
|
+
cast,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from pulse.flags import IS_PRERENDERING
|
|
17
|
+
from pulse.reactive import Effect, EffectFn, Scope, Signal, Untrack
|
|
18
|
+
from pulse.routing import ROUTE_CONTEXT, RouteContext
|
|
19
|
+
from pulse.state import State
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SetupState:
|
|
23
|
+
value: Any
|
|
24
|
+
initialized: bool
|
|
25
|
+
args: list[Signal]
|
|
26
|
+
kwargs: dict[str, Signal]
|
|
27
|
+
effects: list[Effect]
|
|
28
|
+
|
|
29
|
+
def __init__(self, value: Any = None, initialized: bool = False):
|
|
30
|
+
self.value = value
|
|
31
|
+
self.initialized = initialized
|
|
32
|
+
self.args = []
|
|
33
|
+
self.kwargs = {}
|
|
34
|
+
self.effects = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HookCalled:
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.reset()
|
|
40
|
+
|
|
41
|
+
def reset(self):
|
|
42
|
+
self.setup = False
|
|
43
|
+
self.states = False
|
|
44
|
+
self.effects = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MountHookState:
|
|
48
|
+
def __init__(self, hooks: "HookState") -> None:
|
|
49
|
+
self.hooks = hooks
|
|
50
|
+
self._token = None
|
|
51
|
+
|
|
52
|
+
def __enter__(self):
|
|
53
|
+
self._token = HOOK_CONTEXT.set(self.hooks)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
57
|
+
if self._token is not None:
|
|
58
|
+
HOOK_CONTEXT.reset(self._token)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HookState:
|
|
62
|
+
setup: SetupState
|
|
63
|
+
states: tuple[State, ...]
|
|
64
|
+
effects: tuple[Effect, ...]
|
|
65
|
+
called: HookCalled
|
|
66
|
+
render_count: int
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
self.setup = SetupState()
|
|
70
|
+
self.effects = ()
|
|
71
|
+
self.states = ()
|
|
72
|
+
self.called = HookCalled()
|
|
73
|
+
self.render_count = 0
|
|
74
|
+
|
|
75
|
+
def ctx(self):
|
|
76
|
+
self.called.reset()
|
|
77
|
+
self.render_count += 1
|
|
78
|
+
return MountHookState(self)
|
|
79
|
+
|
|
80
|
+
def unmount(self):
|
|
81
|
+
for effect in self.setup.effects:
|
|
82
|
+
effect.dispose()
|
|
83
|
+
for effect in self.effects:
|
|
84
|
+
effect.dispose()
|
|
85
|
+
for state in self.states:
|
|
86
|
+
for effect in state.effects():
|
|
87
|
+
effect.dispose()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
HOOK_CONTEXT: ContextVar[HookState | None] = ContextVar(
|
|
91
|
+
"pulse_hook_context", default=None
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
P = ParamSpec("P")
|
|
96
|
+
T = TypeVar("T")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
|
|
100
|
+
ctx = HOOK_CONTEXT.get()
|
|
101
|
+
if ctx is None:
|
|
102
|
+
raise RuntimeError("Cannot call `pulse.init` hook without a hook context.")
|
|
103
|
+
if ctx.called.setup:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"Cannot call `pulse.init` can only be called once per component render"
|
|
106
|
+
)
|
|
107
|
+
state = ctx.setup
|
|
108
|
+
if not state.initialized:
|
|
109
|
+
with Scope() as scope:
|
|
110
|
+
state.value = init_func(*args, **kwargs)
|
|
111
|
+
state.initialized = True
|
|
112
|
+
state.effects = list(scope.effects)
|
|
113
|
+
state.args = [Signal(x) for x in args]
|
|
114
|
+
state.kwargs = {k: Signal(v) for k, v in kwargs.items()}
|
|
115
|
+
else:
|
|
116
|
+
if len(args) != len(state.args):
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
"Number of positional arguments passed to `pulse.setup` changed. Make sure you always call `pulse.setup` with the same number of positional arguments and the same keyword arguments."
|
|
119
|
+
)
|
|
120
|
+
if kwargs.keys() != state.kwargs.keys():
|
|
121
|
+
new_keys = kwargs.keys() - state.kwargs.keys()
|
|
122
|
+
missing_keys = state.kwargs.keys() - kwargs.keys()
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
f"Keyword arguments passed to `pulse.setup` changed. New arguments: {list(new_keys)}. Missing arguments: {list(missing_keys)}. Make sure you always call `pulse.setup` with the same number of positional arguments and the same keyword arguments."
|
|
125
|
+
)
|
|
126
|
+
for i, arg in enumerate(args):
|
|
127
|
+
state.args[i].write(arg)
|
|
128
|
+
for k, v in kwargs.items():
|
|
129
|
+
state.kwargs[k].write(v)
|
|
130
|
+
return state.value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# -----------------------------------------------------
|
|
134
|
+
# Ugly types, sorry, no other way to do this in Python
|
|
135
|
+
# -----------------------------------------------------
|
|
136
|
+
S1 = TypeVar("S1", bound=State)
|
|
137
|
+
S2 = TypeVar("S2", bound=State)
|
|
138
|
+
S3 = TypeVar("S3", bound=State)
|
|
139
|
+
S4 = TypeVar("S4", bound=State)
|
|
140
|
+
S5 = TypeVar("S5", bound=State)
|
|
141
|
+
S6 = TypeVar("S6", bound=State)
|
|
142
|
+
S7 = TypeVar("S7", bound=State)
|
|
143
|
+
S8 = TypeVar("S8", bound=State)
|
|
144
|
+
S9 = TypeVar("S9", bound=State)
|
|
145
|
+
S10 = TypeVar("S10", bound=State)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
Ts = TypeVarTuple("Ts")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@overload
|
|
152
|
+
def states(*args: Unpack[tuple[S1 | Callable[[], S1]]]) -> S1: ...
|
|
153
|
+
@overload
|
|
154
|
+
def states(
|
|
155
|
+
*args: Unpack[tuple[S1 | Callable[[], S1], S2 | Callable[[], S2]]],
|
|
156
|
+
) -> tuple[S1, S2]: ...
|
|
157
|
+
@overload
|
|
158
|
+
def states(
|
|
159
|
+
*args: Unpack[
|
|
160
|
+
tuple[S1 | Callable[[], S1], S2 | Callable[[], S2], S3 | Callable[[], S3]]
|
|
161
|
+
],
|
|
162
|
+
) -> tuple[S1, S2, S3]: ...
|
|
163
|
+
@overload
|
|
164
|
+
def states(
|
|
165
|
+
*args: Unpack[
|
|
166
|
+
tuple[
|
|
167
|
+
S1 | Callable[[], S1],
|
|
168
|
+
S2 | Callable[[], S2],
|
|
169
|
+
S3 | Callable[[], S3],
|
|
170
|
+
S4 | Callable[[], S4],
|
|
171
|
+
]
|
|
172
|
+
],
|
|
173
|
+
) -> tuple[S1, S2, S3, S4]: ...
|
|
174
|
+
@overload
|
|
175
|
+
def states(
|
|
176
|
+
*args: Unpack[
|
|
177
|
+
tuple[
|
|
178
|
+
S1 | Callable[[], S1],
|
|
179
|
+
S2 | Callable[[], S2],
|
|
180
|
+
S3 | Callable[[], S3],
|
|
181
|
+
S4 | Callable[[], S4],
|
|
182
|
+
S5 | Callable[[], S5],
|
|
183
|
+
]
|
|
184
|
+
],
|
|
185
|
+
) -> tuple[S1, S2, S3, S4, S5]: ...
|
|
186
|
+
@overload
|
|
187
|
+
def states(
|
|
188
|
+
*args: Unpack[
|
|
189
|
+
tuple[
|
|
190
|
+
S1 | Callable[[], S1],
|
|
191
|
+
S2 | Callable[[], S2],
|
|
192
|
+
S3 | Callable[[], S3],
|
|
193
|
+
S4 | Callable[[], S4],
|
|
194
|
+
S5 | Callable[[], S5],
|
|
195
|
+
S6 | Callable[[], S6],
|
|
196
|
+
]
|
|
197
|
+
],
|
|
198
|
+
) -> tuple[S1, S2, S3, S4, S5, S6]: ...
|
|
199
|
+
@overload
|
|
200
|
+
def states(
|
|
201
|
+
*args: Unpack[
|
|
202
|
+
tuple[
|
|
203
|
+
S1 | Callable[[], S1],
|
|
204
|
+
S2 | Callable[[], S2],
|
|
205
|
+
S3 | Callable[[], S3],
|
|
206
|
+
S4 | Callable[[], S4],
|
|
207
|
+
S5 | Callable[[], S5],
|
|
208
|
+
S6 | Callable[[], S6],
|
|
209
|
+
S7 | Callable[[], S7],
|
|
210
|
+
]
|
|
211
|
+
],
|
|
212
|
+
) -> tuple[S1, S2, S3, S4, S5, S6, S7]: ...
|
|
213
|
+
@overload
|
|
214
|
+
def states(
|
|
215
|
+
*args: Unpack[
|
|
216
|
+
tuple[
|
|
217
|
+
S1 | Callable[[], S1],
|
|
218
|
+
S2 | Callable[[], S2],
|
|
219
|
+
S3 | Callable[[], S3],
|
|
220
|
+
S4 | Callable[[], S4],
|
|
221
|
+
S5 | Callable[[], S5],
|
|
222
|
+
S6 | Callable[[], S6],
|
|
223
|
+
S7 | Callable[[], S7],
|
|
224
|
+
S8 | Callable[[], S8],
|
|
225
|
+
]
|
|
226
|
+
],
|
|
227
|
+
) -> tuple[S1, S2, S3, S4, S5, S6, S7, S8]: ...
|
|
228
|
+
@overload
|
|
229
|
+
def states(
|
|
230
|
+
*args: Unpack[
|
|
231
|
+
tuple[
|
|
232
|
+
S1 | Callable[[], S1],
|
|
233
|
+
S2 | Callable[[], S2],
|
|
234
|
+
S3 | Callable[[], S3],
|
|
235
|
+
S4 | Callable[[], S4],
|
|
236
|
+
S5 | Callable[[], S5],
|
|
237
|
+
S6 | Callable[[], S6],
|
|
238
|
+
S7 | Callable[[], S7],
|
|
239
|
+
S8 | Callable[[], S8],
|
|
240
|
+
S9 | Callable[[], S9],
|
|
241
|
+
]
|
|
242
|
+
],
|
|
243
|
+
) -> tuple[S1, S2, S3, S4, S5, S6, S7, S8, S9]: ...
|
|
244
|
+
@overload
|
|
245
|
+
def states(
|
|
246
|
+
*args: Unpack[
|
|
247
|
+
tuple[
|
|
248
|
+
S1 | Callable[[], S1],
|
|
249
|
+
S2 | Callable[[], S2],
|
|
250
|
+
S3 | Callable[[], S3],
|
|
251
|
+
S4 | Callable[[], S4],
|
|
252
|
+
S5 | Callable[[], S5],
|
|
253
|
+
S6 | Callable[[], S6],
|
|
254
|
+
S7 | Callable[[], S7],
|
|
255
|
+
S8 | Callable[[], S8],
|
|
256
|
+
S9 | Callable[[], S9],
|
|
257
|
+
S10 | Callable[[], S10],
|
|
258
|
+
]
|
|
259
|
+
],
|
|
260
|
+
) -> tuple[S1, S2, S3, S4, S5, S6, S7, S8, S9, S10]: ...
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@overload
|
|
264
|
+
def states(*args: S1 | Callable[[], S1]) -> tuple[S1, ...]: ...
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def states(*args: State | Callable[[], State]):
|
|
268
|
+
ctx = HOOK_CONTEXT.get()
|
|
269
|
+
if not ctx:
|
|
270
|
+
raise RuntimeError(
|
|
271
|
+
"`pulse.states` can only be called within a component, during rendering."
|
|
272
|
+
)
|
|
273
|
+
# Enforce single call per component render
|
|
274
|
+
if ctx.called.states:
|
|
275
|
+
raise RuntimeError(
|
|
276
|
+
"`pulse.states` can only be called once per component render"
|
|
277
|
+
)
|
|
278
|
+
ctx.called.states = True
|
|
279
|
+
|
|
280
|
+
if ctx.render_count == 1:
|
|
281
|
+
states: list[State] = []
|
|
282
|
+
for arg in args:
|
|
283
|
+
state_instance = arg() if callable(arg) else arg
|
|
284
|
+
states.append(state_instance)
|
|
285
|
+
ctx.states = tuple(states)
|
|
286
|
+
else:
|
|
287
|
+
for arg in args:
|
|
288
|
+
if isinstance(arg, State):
|
|
289
|
+
arg.dispose()
|
|
290
|
+
|
|
291
|
+
if len(ctx.states) == 1:
|
|
292
|
+
return ctx.states[0]
|
|
293
|
+
else:
|
|
294
|
+
return ctx.states
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def effects(
|
|
298
|
+
*fns: EffectFn, on_error: Callable[[Exception], None] | None = None
|
|
299
|
+
) -> None:
|
|
300
|
+
# Assumption: RenderContext will set up a render context and a batch before
|
|
301
|
+
# rendering. The batch ensures the effects run *after* rendering.
|
|
302
|
+
ctx = HOOK_CONTEXT.get()
|
|
303
|
+
if not ctx:
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
"`pulse.effects` can only be called within a component, during rendering."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Enforce single call per component render
|
|
309
|
+
if ctx.called.effects:
|
|
310
|
+
raise RuntimeError(
|
|
311
|
+
"`pulse.effects` can only be called once per component render"
|
|
312
|
+
)
|
|
313
|
+
ctx.called.effects = True
|
|
314
|
+
|
|
315
|
+
# Remove the effects passed here from the batch, ensuring they only run on mount
|
|
316
|
+
if ctx.render_count == 1:
|
|
317
|
+
with Untrack():
|
|
318
|
+
effects = []
|
|
319
|
+
for fn in fns:
|
|
320
|
+
if not callable(fn):
|
|
321
|
+
raise ValueError(
|
|
322
|
+
"Only pass functions or callabGle objects to `ps.effects`"
|
|
323
|
+
)
|
|
324
|
+
effects.append(Effect(fn, name=fn.__name__, on_error=on_error))
|
|
325
|
+
ctx.effects = tuple(effects)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def route_info() -> RouteContext:
|
|
329
|
+
ctx = ROUTE_CONTEXT.get()
|
|
330
|
+
if not ctx:
|
|
331
|
+
raise RuntimeError(
|
|
332
|
+
"`pulse.router` can only be called within a component during rendering."
|
|
333
|
+
)
|
|
334
|
+
return ctx
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def session_context() -> dict[str, Any]:
|
|
338
|
+
from pulse.session import SESSION_CONTEXT
|
|
339
|
+
|
|
340
|
+
session = SESSION_CONTEXT.get()
|
|
341
|
+
if not session:
|
|
342
|
+
raise RuntimeError(
|
|
343
|
+
"`pulse.session_context` can only be called within a component during rendering."
|
|
344
|
+
)
|
|
345
|
+
return session.context
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def call_api(
|
|
349
|
+
url: str,
|
|
350
|
+
*,
|
|
351
|
+
method: str = "POST",
|
|
352
|
+
headers: Mapping[str, str] | None = None,
|
|
353
|
+
body: Any | None = None,
|
|
354
|
+
credentials: str = "include",
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
"""Ask the client to perform an HTTP request and await the result.
|
|
357
|
+
|
|
358
|
+
This hides session plumbing; safe to call inside Pulse callbacks.
|
|
359
|
+
"""
|
|
360
|
+
from pulse.session import SESSION_CONTEXT
|
|
361
|
+
|
|
362
|
+
session = SESSION_CONTEXT.get()
|
|
363
|
+
if session is None:
|
|
364
|
+
raise RuntimeError("call_api() must be invoked inside a Pulse callback context")
|
|
365
|
+
return await session.call_api(
|
|
366
|
+
url,
|
|
367
|
+
method=method,
|
|
368
|
+
headers=dict(headers or {}),
|
|
369
|
+
body=body,
|
|
370
|
+
credentials=credentials,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def navigate(path: str) -> None:
|
|
375
|
+
"""Instruct the client to navigate to a new path for the current route tree.
|
|
376
|
+
|
|
377
|
+
Non-async; sends a server message to the client to perform SPA navigation.
|
|
378
|
+
"""
|
|
379
|
+
from pulse.session import SESSION_CONTEXT
|
|
380
|
+
|
|
381
|
+
session = SESSION_CONTEXT.get()
|
|
382
|
+
if session is None:
|
|
383
|
+
raise RuntimeError("navigate() must be invoked inside a Pulse callback context")
|
|
384
|
+
# Emit navigate_to once; client will handle redirect at app-level
|
|
385
|
+
session.notify({"type": "navigate_to", "path": path})
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def is_prerendering():
|
|
389
|
+
return IS_PRERENDERING.get()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# -----------------------------------------------------
|
|
393
|
+
# Session-local global singletons (ps.global_state)
|
|
394
|
+
# -----------------------------------------------------
|
|
395
|
+
|
|
396
|
+
S = TypeVar("S", covariant=True)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class GlobalStateAccessor(Protocol, Generic[S]):
|
|
400
|
+
def __call__(self) -> S: ...
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def global_state(
|
|
404
|
+
factory: Callable[[], S] | type[S], key: str | None = None
|
|
405
|
+
) -> GlobalStateAccessor[S]:
|
|
406
|
+
"""Provider for per-session singletons.
|
|
407
|
+
|
|
408
|
+
Usage:
|
|
409
|
+
class Auth(ps.State): ...
|
|
410
|
+
auth = ps.global_state(Auth)
|
|
411
|
+
a = auth() # same instance within the session
|
|
412
|
+
|
|
413
|
+
- key None: derive a stable key from factory's module+qualname
|
|
414
|
+
- future: allow passing an id in the accessor call to support cross-session sharing
|
|
415
|
+
"""
|
|
416
|
+
from pulse.session import SESSION_CONTEXT
|
|
417
|
+
|
|
418
|
+
if isinstance(factory, type):
|
|
419
|
+
cls = factory
|
|
420
|
+
|
|
421
|
+
def _mk() -> S: # type: ignore[misc]
|
|
422
|
+
return cast(S, cls())
|
|
423
|
+
|
|
424
|
+
default_key = f"{cls.__module__}:{cls.__qualname__}"
|
|
425
|
+
mk = _mk
|
|
426
|
+
else:
|
|
427
|
+
default_key = f"{factory.__module__}:{factory.__qualname__}"
|
|
428
|
+
mk = factory
|
|
429
|
+
|
|
430
|
+
base_key = key or default_key
|
|
431
|
+
|
|
432
|
+
def accessor() -> S:
|
|
433
|
+
# Default: session-local when no id provided
|
|
434
|
+
session = SESSION_CONTEXT.get()
|
|
435
|
+
if session is None:
|
|
436
|
+
raise RuntimeError(
|
|
437
|
+
"ps.global_state must be used inside a Pulse render/callback context"
|
|
438
|
+
)
|
|
439
|
+
return cast(S, session.get_global_state(base_key, mk))
|
|
440
|
+
|
|
441
|
+
return accessor
|