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.
Files changed (50) hide show
  1. pulse/__init__.py +175 -0
  2. pulse/app.py +349 -0
  3. pulse/cmd.py +324 -0
  4. pulse/codegen.py +147 -0
  5. pulse/components/__init__.py +1 -0
  6. pulse/components/react_router.py +43 -0
  7. pulse/context.py +15 -0
  8. pulse/decorators.py +187 -0
  9. pulse/diff.py +252 -0
  10. pulse/flags.py +5 -0
  11. pulse/flatted.py +159 -0
  12. pulse/helpers.py +27 -0
  13. pulse/hooks.py +441 -0
  14. pulse/html/__init__.py +304 -0
  15. pulse/html/attributes.py +930 -0
  16. pulse/html/elements.py +1024 -0
  17. pulse/html/events.py +419 -0
  18. pulse/html/tags.py +171 -0
  19. pulse/html/tags.pyi +390 -0
  20. pulse/messages.py +109 -0
  21. pulse/middleware.py +158 -0
  22. pulse/query.py +286 -0
  23. pulse/react_component.py +803 -0
  24. pulse/reactive.py +514 -0
  25. pulse/reactive_extensions.py +626 -0
  26. pulse/reconciler.py +575 -0
  27. pulse/request.py +162 -0
  28. pulse/routing.py +350 -0
  29. pulse/session.py +310 -0
  30. pulse/state.py +309 -0
  31. pulse/templates.py +171 -0
  32. pulse/tests/__init__.py +0 -0
  33. pulse/tests/old_test_diff.py +174 -0
  34. pulse/tests/test_codegen.py +224 -0
  35. pulse/tests/test_flatted.py +297 -0
  36. pulse/tests/test_nodes.py +439 -0
  37. pulse/tests/test_query.py +391 -0
  38. pulse/tests/test_react.py +797 -0
  39. pulse/tests/test_reactive.py +1203 -0
  40. pulse/tests/test_reconciler.py +1759 -0
  41. pulse/tests/test_routing.py +167 -0
  42. pulse/tests/test_session.py +267 -0
  43. pulse/tests/test_state.py +569 -0
  44. pulse/tests/test_utils.py +101 -0
  45. pulse/vdom.py +381 -0
  46. pulse_framework-0.1.0.dist-info/METADATA +38 -0
  47. pulse_framework-0.1.0.dist-info/RECORD +50 -0
  48. pulse_framework-0.1.0.dist-info/WHEEL +4 -0
  49. pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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