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.
Files changed (70) hide show
  1. pulse/__init__.py +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/codegen/codegen.py +43 -12
  6. pulse/component.py +104 -0
  7. pulse/components/for_.py +30 -4
  8. pulse/components/if_.py +28 -5
  9. pulse/components/react_router.py +61 -3
  10. pulse/context.py +39 -5
  11. pulse/cookies.py +108 -4
  12. pulse/decorators.py +193 -24
  13. pulse/env.py +56 -2
  14. pulse/form.py +198 -5
  15. pulse/helpers.py +7 -1
  16. pulse/hooks/core.py +135 -5
  17. pulse/hooks/effects.py +61 -77
  18. pulse/hooks/init.py +60 -1
  19. pulse/hooks/runtime.py +241 -0
  20. pulse/hooks/setup.py +77 -0
  21. pulse/hooks/stable.py +58 -1
  22. pulse/hooks/state.py +107 -20
  23. pulse/js/__init__.py +40 -24
  24. pulse/js/array.py +9 -6
  25. pulse/js/console.py +15 -12
  26. pulse/js/date.py +9 -6
  27. pulse/js/document.py +5 -2
  28. pulse/js/error.py +7 -4
  29. pulse/js/json.py +9 -6
  30. pulse/js/map.py +8 -5
  31. pulse/js/math.py +9 -6
  32. pulse/js/navigator.py +5 -2
  33. pulse/js/number.py +9 -6
  34. pulse/js/obj.py +16 -13
  35. pulse/js/object.py +9 -6
  36. pulse/js/promise.py +19 -13
  37. pulse/js/pulse.py +28 -25
  38. pulse/js/react.py +94 -55
  39. pulse/js/regexp.py +7 -4
  40. pulse/js/set.py +8 -5
  41. pulse/js/string.py +9 -6
  42. pulse/js/weakmap.py +8 -5
  43. pulse/js/weakset.py +8 -5
  44. pulse/js/window.py +6 -3
  45. pulse/messages.py +5 -0
  46. pulse/middleware.py +147 -76
  47. pulse/plugin.py +76 -5
  48. pulse/queries/client.py +186 -39
  49. pulse/queries/common.py +52 -3
  50. pulse/queries/infinite_query.py +154 -2
  51. pulse/queries/mutation.py +127 -7
  52. pulse/queries/query.py +112 -11
  53. pulse/react_component.py +66 -3
  54. pulse/reactive.py +314 -30
  55. pulse/reactive_extensions.py +106 -26
  56. pulse/render_session.py +304 -173
  57. pulse/request.py +46 -11
  58. pulse/routing.py +140 -4
  59. pulse/serializer.py +71 -0
  60. pulse/state.py +177 -9
  61. pulse/test_helpers.py +15 -0
  62. pulse/transpiler/__init__.py +0 -3
  63. pulse/transpiler/py_module.py +1 -7
  64. pulse/user_session.py +119 -18
  65. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  66. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  67. pulse/transpiler/react_component.py +0 -44
  68. pulse_framework-0.1.55.dist-info/RECORD +0 -127
  69. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  70. {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 cast, override
2
+ from typing import Any, override
3
3
 
4
4
  from pulse.hooks.core import HookMetadata, HookState, hooks
5
- from pulse.reactive import Effect, EffectFn, Untrack
5
+ from pulse.reactive import AsyncEffect, Effect
6
6
 
7
7
 
8
- class EffectsHookState(HookState):
9
- __slots__ = ("initialized", "effects", "key", "_called") # pyright: ignore[reportUnannotatedClassAttribute]
10
- initialized: bool
11
- _called: bool
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.initialized = False
16
- self.effects: tuple[Effect, ...] = ()
17
- self.key: str | None = None
18
- self._called = False
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._called = False
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 dispose(self) -> None:
40
- self.dispose_effects()
41
-
42
- def ensure_not_called(self) -> None:
43
- if self._called:
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
- "`pulse.effects` can only be called once per component render"
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
- def mark_called(self) -> None:
49
- self._called = True
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
- def _effects_factory(*_: object) -> HookState:
70
- return EffectsHookState()
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
- _effects_hook = hooks.create(
74
- "pulse:core.effects",
75
- _effects_factory,
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="Internal storage for pulse.effects hook",
80
+ description="Storage for inline @ps.effect decorators in components",
79
81
  ),
80
82
  )
81
83
 
82
84
 
83
- def effects(
84
- *fns: EffectFn,
85
- on_error: Callable[[Exception], None] | None = None,
86
- key: str | None = None,
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 that captures locals on first render and restores thereafter."""
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