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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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
+ ]