pulse-framework 0.1.54__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 (80) 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/code_analysis.py +38 -0
  6. pulse/codegen/codegen.py +61 -62
  7. pulse/codegen/templates/route.py +100 -56
  8. pulse/component.py +128 -6
  9. pulse/components/for_.py +30 -4
  10. pulse/components/if_.py +28 -5
  11. pulse/components/react_router.py +61 -3
  12. pulse/context.py +39 -5
  13. pulse/cookies.py +108 -4
  14. pulse/decorators.py +193 -24
  15. pulse/env.py +56 -2
  16. pulse/form.py +198 -5
  17. pulse/helpers.py +7 -1
  18. pulse/hooks/core.py +135 -5
  19. pulse/hooks/effects.py +61 -77
  20. pulse/hooks/init.py +60 -1
  21. pulse/hooks/runtime.py +241 -0
  22. pulse/hooks/setup.py +77 -0
  23. pulse/hooks/stable.py +58 -1
  24. pulse/hooks/state.py +107 -20
  25. pulse/js/__init__.py +41 -25
  26. pulse/js/array.py +9 -6
  27. pulse/js/console.py +15 -12
  28. pulse/js/date.py +9 -6
  29. pulse/js/document.py +5 -2
  30. pulse/js/error.py +7 -4
  31. pulse/js/json.py +9 -6
  32. pulse/js/map.py +8 -5
  33. pulse/js/math.py +9 -6
  34. pulse/js/navigator.py +5 -2
  35. pulse/js/number.py +9 -6
  36. pulse/js/obj.py +16 -13
  37. pulse/js/object.py +9 -6
  38. pulse/js/promise.py +19 -13
  39. pulse/js/pulse.py +28 -25
  40. pulse/js/react.py +190 -44
  41. pulse/js/regexp.py +7 -4
  42. pulse/js/set.py +8 -5
  43. pulse/js/string.py +9 -6
  44. pulse/js/weakmap.py +8 -5
  45. pulse/js/weakset.py +8 -5
  46. pulse/js/window.py +6 -3
  47. pulse/messages.py +5 -0
  48. pulse/middleware.py +147 -76
  49. pulse/plugin.py +76 -5
  50. pulse/queries/client.py +186 -39
  51. pulse/queries/common.py +52 -3
  52. pulse/queries/infinite_query.py +154 -2
  53. pulse/queries/mutation.py +127 -7
  54. pulse/queries/query.py +112 -11
  55. pulse/react_component.py +66 -3
  56. pulse/reactive.py +314 -30
  57. pulse/reactive_extensions.py +106 -26
  58. pulse/render_session.py +304 -173
  59. pulse/request.py +46 -11
  60. pulse/routing.py +140 -4
  61. pulse/serializer.py +71 -0
  62. pulse/state.py +177 -9
  63. pulse/test_helpers.py +15 -0
  64. pulse/transpiler/__init__.py +13 -3
  65. pulse/transpiler/assets.py +66 -0
  66. pulse/transpiler/dynamic_import.py +131 -0
  67. pulse/transpiler/emit_context.py +49 -0
  68. pulse/transpiler/function.py +6 -2
  69. pulse/transpiler/imports.py +33 -27
  70. pulse/transpiler/js_module.py +64 -8
  71. pulse/transpiler/py_module.py +1 -7
  72. pulse/transpiler/transpiler.py +4 -0
  73. pulse/user_session.py +119 -18
  74. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  75. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  76. pulse/js/react_dom.py +0 -30
  77. pulse/transpiler/react_component.py +0 -51
  78. pulse_framework-0.1.54.dist-info/RECORD +0 -124
  79. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/components/if_.py CHANGED
@@ -1,3 +1,8 @@
1
+ """Conditional rendering component.
2
+
3
+ Provides a declarative way to conditionally render elements based on a condition.
4
+ """
5
+
1
6
  from collections.abc import Iterable
2
7
  from typing import Any, TypeVar
3
8
 
@@ -37,15 +42,33 @@ def If(
37
42
  then: T1,
38
43
  else_: T2 = None,
39
44
  ) -> T1 | T2:
40
- """Conditional rendering helper that returns either then or else_ based on condition.
45
+ """Conditional rendering helper.
46
+
47
+ Returns `then` if the condition is truthy, otherwise returns `else_`.
48
+ Automatically unwraps reactive values (Signal, Computed) before evaluation.
41
49
 
42
50
  Args:
43
- condition: Value to test truthiness
44
- then: Element to render if condition is truthy
45
- else_: Optional element to render if condition is falsy
51
+ condition: A boolean or reactive value to evaluate. Supports `Signal[bool]`
52
+ and `Computed[bool]` which are automatically unwrapped.
53
+ then: Element to render when condition is truthy.
54
+ else_: Element to render when condition is falsy. Defaults to None.
46
55
 
47
56
  Returns:
48
- The then value if condition is truthy, else_ if provided and condition is falsy, None otherwise
57
+ The `then` value if condition is truthy, otherwise `else_`.
58
+
59
+ Example:
60
+ Basic conditional::
61
+
62
+ ps.If(
63
+ user.is_admin,
64
+ then=AdminPanel(),
65
+ else_=ps.p("Access denied"),
66
+ )
67
+
68
+ With reactive condition::
69
+
70
+ is_visible = ps.Signal(True)
71
+ ps.If(is_visible, then=ps.div("Content"))
49
72
  """
50
73
  # Unwrap reactive condition if needed and coerce to bool explicitly with guards
51
74
  if isinstance(condition, (Signal, Computed)):
@@ -1,12 +1,19 @@
1
+ """React Router components for client-side navigation.
2
+
3
+ Provides Pulse bindings for react-router's Link and Outlet components.
4
+ """
5
+
1
6
  from typing import Literal, TypedDict, Unpack
2
7
 
3
8
  from pulse.dom.props import HTMLAnchorProps
9
+ from pulse.react_component import react_component
4
10
  from pulse.transpiler import Import
5
11
  from pulse.transpiler.nodes import Node
6
- from pulse.transpiler.react_component import react_component
7
12
 
8
13
 
9
14
  class LinkPath(TypedDict):
15
+ """TypedDict for Link's `to` prop when using an object instead of string."""
16
+
10
17
  pathname: str
11
18
  search: str
12
19
  hash: str
@@ -27,12 +34,63 @@ def Link(
27
34
  state: dict[str, object] | None = None,
28
35
  viewTransition: bool | None = None,
29
36
  **props: Unpack[HTMLAnchorProps],
30
- ): ...
37
+ ) -> None:
38
+ """Client-side navigation link using react-router.
39
+
40
+ Renders an anchor tag that performs client-side navigation without a full
41
+ page reload. Supports prefetching and various navigation behaviors.
42
+
43
+ Args:
44
+ *children: Content to render inside the link.
45
+ key: React reconciliation key.
46
+ to: The target URL path (e.g., "/dashboard", "/users/123").
47
+ discover: Route discovery behavior. "render" discovers on render,
48
+ "none" disables discovery.
49
+ prefetch: Prefetch strategy. "intent" (default) prefetches on hover/focus,
50
+ "render" prefetches immediately, "viewport" when visible, "none" disables.
51
+ preventScrollReset: If True, prevents scroll position reset on navigation.
52
+ relative: Path resolution mode. "route" resolves relative to route hierarchy,
53
+ "path" resolves relative to URL path.
54
+ reloadDocument: If True, performs a full page navigation instead of SPA.
55
+ replace: If True, replaces current history entry instead of pushing.
56
+ state: Arbitrary state to pass to the destination location.
57
+ viewTransition: If True, enables View Transitions API for the navigation.
58
+ **props: Additional HTML anchor attributes (className, onClick, etc.).
59
+
60
+ Example:
61
+ Basic navigation::
62
+
63
+ ps.Link(to="/dashboard")["Go to Dashboard"]
64
+
65
+ With prefetching disabled::
66
+
67
+ ps.Link(to="/settings", prefetch="none")["Settings"]
68
+ """
69
+ ...
31
70
 
32
71
 
33
72
  # @react_component(Import("Outlet", "react-router", version="^7"))
34
73
  @react_component(Import("Outlet", "react-router"))
35
- def Outlet(key: str | None = None): ...
74
+ def Outlet(key: str | None = None) -> None:
75
+ """Renders the matched child route's element.
76
+
77
+ Outlet is used in parent route components to render their child routes.
78
+ It acts as a placeholder where nested route content will be displayed.
79
+
80
+ Args:
81
+ key: React reconciliation key.
82
+
83
+ Example:
84
+ Layout with outlet for child routes::
85
+
86
+ @ps.component
87
+ def Layout():
88
+ return ps.div(
89
+ ps.nav("Navigation"),
90
+ ps.Outlet(), # Child route renders here
91
+ )
92
+ """
93
+ ...
36
94
 
37
95
 
38
96
  __all__ = ["Link", "Outlet"]
pulse/context.py CHANGED
@@ -16,9 +16,23 @@ if TYPE_CHECKING:
16
16
  class PulseContext:
17
17
  """Composite context accessible to hooks and internals.
18
18
 
19
- - session: per-user session ReactiveDict
20
- - render: per-connection RenderSession
21
- - route: active RouteContext for this render/effect scope
19
+ Manages per-request state via context variables. Provides access to the
20
+ application instance, user session, render session, and route context.
21
+
22
+ Attributes:
23
+ app: Application instance.
24
+ session: Per-user session (UserSession or None).
25
+ render: Per-connection render session (RenderSession or None).
26
+ route: Active route context (RouteContext or None).
27
+
28
+ Example:
29
+ ```python
30
+ ctx = PulseContext(app=app, session=session)
31
+ with ctx:
32
+ # Context is active here
33
+ current = PulseContext.get()
34
+ # Previous context restored
35
+ ```
22
36
  """
23
37
 
24
38
  app: "App"
@@ -28,7 +42,15 @@ class PulseContext:
28
42
  _token: "Token[PulseContext | None] | None" = None
29
43
 
30
44
  @classmethod
31
- def get(cls):
45
+ def get(cls) -> "PulseContext":
46
+ """Get the current context.
47
+
48
+ Returns:
49
+ Current PulseContext instance.
50
+
51
+ Raises:
52
+ RuntimeError: If no context is active.
53
+ """
32
54
  ctx = PULSE_CONTEXT.get()
33
55
  if ctx is None:
34
56
  raise RuntimeError("Internal error: PULSE_CONTEXT is not set")
@@ -40,7 +62,19 @@ class PulseContext:
40
62
  session: "UserSession | None" = None,
41
63
  render: "RenderSession | None" = None,
42
64
  route: "RouteContext | None" = None,
43
- ):
65
+ ) -> "PulseContext":
66
+ """Create a new context with updated values.
67
+
68
+ Inherits unspecified values from the current context.
69
+
70
+ Args:
71
+ session: New session (optional, inherits if not provided).
72
+ render: New render session (optional, inherits if not provided).
73
+ route: New route context (optional, inherits if not provided).
74
+
75
+ Returns:
76
+ New PulseContext instance with updated values.
77
+ """
44
78
  ctx = cls.get()
45
79
  return PulseContext(
46
80
  app=ctx.app,
pulse/cookies.py CHANGED
@@ -14,6 +14,26 @@ if TYPE_CHECKING:
14
14
 
15
15
  @dataclass
16
16
  class Cookie:
17
+ """Configuration for HTTP cookies used in session management.
18
+
19
+ Attributes:
20
+ name: Cookie name.
21
+ domain: Cookie domain. Set automatically in subdomain mode.
22
+ secure: HTTPS-only flag. Auto-resolved from server address if None.
23
+ samesite: SameSite attribute ("lax", "strict", or "none").
24
+ max_age_seconds: Cookie lifetime in seconds (default 7 days).
25
+
26
+ Example:
27
+ ```python
28
+ cookie = Cookie(
29
+ name="session",
30
+ secure=True,
31
+ samesite="strict",
32
+ max_age_seconds=3600,
33
+ )
34
+ ```
35
+ """
36
+
17
37
  name: str
18
38
  _: KW_ONLY
19
39
  domain: str | None = None
@@ -22,18 +42,45 @@ class Cookie:
22
42
  max_age_seconds: int = 7 * 24 * 3600
23
43
 
24
44
  def get_from_fastapi(self, request: Request) -> str | None:
25
- """Extract sid from a FastAPI Request (by reading Cookie header)."""
45
+ """Extract cookie value from a FastAPI Request.
46
+
47
+ Reads the Cookie header and parses it to find this cookie's value.
48
+
49
+ Args:
50
+ request: FastAPI/Starlette Request object.
51
+
52
+ Returns:
53
+ Cookie value if found, None otherwise.
54
+ """
26
55
  header = request.headers.get("cookie")
27
56
  cookies = parse_cookie_header(header)
28
57
  return cookies.get(self.name)
29
58
 
30
59
  def get_from_socketio(self, environ: dict[str, Any]) -> str | None:
31
- """Extract sid from a socket.io environ mapping."""
60
+ """Extract cookie value from a Socket.IO environ mapping.
61
+
62
+ Args:
63
+ environ: Socket.IO environ dictionary.
64
+
65
+ Returns:
66
+ Cookie value if found, None otherwise.
67
+ """
32
68
  raw = environ.get("HTTP_COOKIE") or environ.get("COOKIE")
33
69
  cookies = parse_cookie_header(raw)
34
70
  return cookies.get(self.name)
35
71
 
36
- async def set_through_api(self, value: str):
72
+ async def set_through_api(self, value: str) -> None:
73
+ """Set the cookie on the client via WebSocket.
74
+
75
+ Must be called during a callback context.
76
+
77
+ Args:
78
+ value: Cookie value to set.
79
+
80
+ Raises:
81
+ RuntimeError: If Cookie.secure is not resolved (ensure App.setup()
82
+ ran first).
83
+ """
37
84
  if self.secure is None:
38
85
  raise RuntimeError(
39
86
  "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
@@ -48,7 +95,17 @@ class Cookie:
48
95
  )
49
96
 
50
97
  def set_on_fastapi(self, response: Response, value: str) -> None:
51
- """Set the session cookie on a FastAPI Response-like object."""
98
+ """Set the cookie on a FastAPI Response object.
99
+
100
+ Configured with httponly=True and path="/".
101
+
102
+ Args:
103
+ response: FastAPI Response object.
104
+ value: Cookie value to set.
105
+
106
+ Raises:
107
+ RuntimeError: If Cookie.secure is not resolved.
108
+ """
52
109
  if self.secure is None:
53
110
  raise RuntimeError(
54
111
  "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
@@ -67,10 +124,31 @@ class Cookie:
67
124
 
68
125
  @dataclass
69
126
  class SetCookie(Cookie):
127
+ """Extended Cookie dataclass that includes the cookie value.
128
+
129
+ Used for setting cookies with a specific value. Inherits all configuration
130
+ from Cookie.
131
+
132
+ Attributes:
133
+ value: The cookie value to set.
134
+ """
135
+
70
136
  value: str
71
137
 
72
138
  @classmethod
73
139
  def from_cookie(cls, cookie: Cookie, value: str) -> "SetCookie":
140
+ """Create a SetCookie from an existing Cookie configuration.
141
+
142
+ Args:
143
+ cookie: Cookie configuration to copy settings from.
144
+ value: Cookie value to set.
145
+
146
+ Returns:
147
+ SetCookie instance with the same configuration and specified value.
148
+
149
+ Raises:
150
+ RuntimeError: If cookie.secure is not resolved.
151
+ """
74
152
  if cookie.secure is None:
75
153
  raise RuntimeError(
76
154
  "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
@@ -111,6 +189,18 @@ def session_cookie(
111
189
 
112
190
 
113
191
  class CORSOptions(TypedDict, total=False):
192
+ """TypedDict for CORS middleware configuration.
193
+
194
+ Attributes:
195
+ allow_origins: List of allowed origins. Use ['*'] for all. Default: ().
196
+ allow_methods: List of allowed HTTP methods. Default: ('GET',).
197
+ allow_headers: List of allowed HTTP headers. Default: ().
198
+ allow_credentials: Whether to allow credentials. Default: False.
199
+ allow_origin_regex: Regex pattern for allowed origins. Default: None.
200
+ expose_headers: List of headers to expose to browser. Default: ().
201
+ max_age: Browser CORS cache duration in seconds. Default: 600.
202
+ """
203
+
114
204
  allow_origins: Sequence[str]
115
205
  "List of allowed origins. Use ['*'] to allow all origins. Default: ()"
116
206
 
@@ -207,6 +297,20 @@ def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
207
297
 
208
298
 
209
299
  def parse_cookie_header(header: str | None) -> dict[str, str]:
300
+ """Parse a raw Cookie header string into a dictionary.
301
+
302
+ Args:
303
+ header: Raw Cookie header string (e.g., "session=abc123; theme=dark").
304
+
305
+ Returns:
306
+ Dictionary of cookie name-value pairs.
307
+
308
+ Example:
309
+ ```python
310
+ cookies = parse_cookie_header("session=abc123; theme=dark")
311
+ # {"session": "abc123", "theme": "dark"}
312
+ ```
313
+ """
210
314
  cookies: dict[str, str] = {}
211
315
  if not header:
212
316
  return cookies
pulse/decorators.py CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  import inspect
4
4
  from collections.abc import Awaitable, Callable
5
- from typing import Any, ParamSpec, Protocol, TypeVar, overload
5
+ from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload
6
6
 
7
+ from pulse.hooks.core import HOOK_CONTEXT
8
+ from pulse.hooks.effects import inline_effect_hook
9
+ from pulse.hooks.state import collect_component_identity
7
10
  from pulse.reactive import (
8
11
  AsyncEffect,
9
12
  AsyncEffectFn,
@@ -20,10 +23,6 @@ TState = TypeVar("TState", bound=State)
20
23
  P = ParamSpec("P")
21
24
 
22
25
 
23
- # -> @ps.computed The chalenge is:
24
- # - We want to turn regular functions with no arguments into a Computed object
25
- # - We want to turn state methods into a ComputedProperty (which wraps a
26
- # Computed, but gives it access to the State object).
27
26
  @overload
28
27
  def computed(fn: Callable[[], T], *, name: str | None = None) -> Computed[T]: ...
29
28
  @overload
@@ -36,7 +35,61 @@ def computed(
36
35
  ) -> Callable[[Callable[[], T]], Computed[T]]: ...
37
36
 
38
37
 
39
- def computed(fn: Callable[..., Any] | None = None, *, name: str | None = None):
38
+ def computed(
39
+ fn: Callable[..., Any] | None = None,
40
+ *,
41
+ name: str | None = None,
42
+ ) -> (
43
+ Computed[T]
44
+ | ComputedProperty[T]
45
+ | Callable[[Callable[..., Any]], Computed[T] | ComputedProperty[T]]
46
+ ):
47
+ """
48
+ Decorator for computed (derived) properties.
49
+
50
+ Creates a cached, reactive value that automatically recalculates when its
51
+ dependencies change. The computed tracks which Signals/Computeds are read
52
+ during execution and subscribes to them.
53
+
54
+ Can be used in two ways:
55
+ 1. On a State method (with single `self` argument) - creates a ComputedProperty
56
+ 2. As a standalone function (with no arguments) - creates a Computed
57
+
58
+ Args:
59
+ fn: The function to compute the value. Must take no arguments (standalone) or only `self` (State method).
60
+ name: Optional debug name for the computed. Defaults to the function name.
61
+
62
+ Returns:
63
+ Computed wrapper or decorator depending on usage.
64
+
65
+ Raises:
66
+ TypeError: If the function takes arguments other than `self`.
67
+
68
+ Example:
69
+ On a State method:
70
+
71
+ class MyState(ps.State):
72
+ count: int = 0
73
+
74
+ @ps.computed
75
+ def doubled(self):
76
+ return self.count * 2
77
+
78
+ As a standalone computed:
79
+
80
+ signal = Signal(5)
81
+
82
+ @ps.computed
83
+ def doubled():
84
+ return signal() * 2
85
+
86
+ With explicit name:
87
+
88
+ @ps.computed(name="my_computed")
89
+ def doubled(self):
90
+ return self.count * 2
91
+ """
92
+
40
93
  # The type checker is not happy if I don't specify the `/` here.
41
94
  def decorator(fn: Callable[..., Any], /):
42
95
  sig = inspect.signature(fn)
@@ -81,7 +134,9 @@ def effect(
81
134
  lazy: bool = False,
82
135
  on_error: Callable[[Exception], None] | None = None,
83
136
  deps: list[Signal[Any] | Computed[Any]] | None = None,
137
+ update_deps: bool | None = None,
84
138
  interval: float | None = None,
139
+ key: str | None = None,
85
140
  ) -> Effect: ...
86
141
 
87
142
 
@@ -94,7 +149,9 @@ def effect(
94
149
  lazy: bool = False,
95
150
  on_error: Callable[[Exception], None] | None = None,
96
151
  deps: list[Signal[Any] | Computed[Any]] | None = None,
152
+ update_deps: bool | None = None,
97
153
  interval: float | None = None,
154
+ key: str | None = None,
98
155
  ) -> AsyncEffect: ...
99
156
  # In practice this overload returns a StateEffect, but it gets converted into an
100
157
  # Effect at state instantiation.
@@ -111,7 +168,9 @@ def effect(
111
168
  lazy: bool = False,
112
169
  on_error: Callable[[Exception], None] | None = None,
113
170
  deps: list[Signal[Any] | Computed[Any]] | None = None,
171
+ update_deps: bool | None = None,
114
172
  interval: float | None = None,
173
+ key: str | None = None,
115
174
  ) -> EffectBuilder: ...
116
175
 
117
176
 
@@ -123,17 +182,86 @@ def effect(
123
182
  lazy: bool = False,
124
183
  on_error: Callable[[Exception], None] | None = None,
125
184
  deps: list[Signal[Any] | Computed[Any]] | None = None,
185
+ update_deps: bool | None = None,
126
186
  interval: float | None = None,
187
+ key: str | None = None,
127
188
  ):
128
- # The type checker is not happy if I don't specify the `/` here.
189
+ """
190
+ Decorator for side effects that run when dependencies change.
191
+
192
+ Creates an effect that automatically re-runs when any of its tracked
193
+ dependencies change. Dependencies are automatically tracked by observing
194
+ which Signals/Computeds are read during execution.
195
+
196
+ Can be used in two ways:
197
+ 1. On a State method (with single `self` argument) - creates a StateEffect
198
+ 2. As a standalone function (with no arguments) - creates an Effect
199
+
200
+ Supports both sync and async functions. Async effects cannot use `immediate=True`.
201
+
202
+ Args:
203
+ fn: The effect function. Must take no arguments (standalone) or only
204
+ `self` (State method). Can return a cleanup function.
205
+ name: Optional debug name. Defaults to "ClassName.method_name" or function name.
206
+ immediate: If True, run synchronously when scheduled instead of batching.
207
+ Only valid for sync effects.
208
+ lazy: If True, don't run on creation; wait for first dependency change.
209
+ on_error: Callback invoked if the effect throws an exception.
210
+ deps: Explicit list of dependencies. If provided, auto-tracking is disabled
211
+ and the effect only re-runs when these specific dependencies change.
212
+ interval: Re-run interval in seconds. Creates a polling effect that runs
213
+ periodically regardless of dependency changes.
214
+
215
+ Returns:
216
+ Effect, AsyncEffect, or StateEffect depending on usage.
217
+
218
+ Raises:
219
+ TypeError: If the function takes arguments other than `self`.
220
+ ValueError: If `immediate=True` is used with an async function.
221
+
222
+ Example:
223
+ State method effect:
224
+
225
+ class MyState(ps.State):
226
+ count: int = 0
227
+
228
+ @ps.effect
229
+ def log_changes(self):
230
+ print(f"Count is {self.count}")
231
+
232
+ Async effect:
233
+
234
+ class MyState(ps.State):
235
+ query: str = ""
236
+
237
+ @ps.effect
238
+ async def fetch_data(self):
239
+ data = await api.fetch(self.query)
240
+ self.data = data
241
+
242
+ Effect with cleanup:
243
+
244
+ @ps.effect
245
+ def subscribe(self):
246
+ unsub = event_bus.subscribe(self.handle)
247
+ return unsub # Called before next run or on dispose
248
+
249
+ Polling effect:
250
+
251
+ @ps.effect(interval=5.0)
252
+ async def poll_status(self):
253
+ self.status = await api.get_status()
254
+ """
255
+
129
256
  def decorator(func: Callable[..., Any], /):
130
257
  sig = inspect.signature(func)
131
258
  params = list(sig.parameters.values())
132
259
 
133
- # Disallow intermediate + async
260
+ # Disallow immediate + async
134
261
  if immediate and inspect.iscoroutinefunction(func):
135
262
  raise ValueError("Async effects cannot have immediate=True")
136
263
 
264
+ # State method - unchanged behavior
137
265
  if len(params) == 1 and params[0].name == "self":
138
266
  return StateEffect(
139
267
  func,
@@ -142,34 +270,75 @@ def effect(
142
270
  lazy=lazy,
143
271
  on_error=on_error,
144
272
  deps=deps,
273
+ update_deps=update_deps,
145
274
  interval=interval,
146
275
  )
147
276
 
148
- if len(params) > 0:
277
+ # Allow params with defaults (used for variable binding in loops)
278
+ # Reject only if there are required params (no default)
279
+ required_params = [p for p in params if p.default is inspect.Parameter.empty]
280
+ if required_params:
149
281
  raise TypeError(
150
- f"@effect: Function '{func.__name__}' must take no arguments or a single 'self' argument"
282
+ f"@effect: Function '{func.__name__}' must take no arguments, a single 'self' argument, "
283
+ + "or only arguments with defaults (for variable binding)"
151
284
  )
152
285
 
153
- # This is a standalone effect function. Choose subclass based on async-ness
154
- if inspect.iscoroutinefunction(func):
155
- return AsyncEffect(
286
+ # Check if we're in a hook context (component render)
287
+ ctx = HOOK_CONTEXT.get()
288
+
289
+ def create_effect() -> Effect | AsyncEffect:
290
+ if inspect.iscoroutinefunction(func):
291
+ return AsyncEffect(
292
+ func, # type: ignore[arg-type]
293
+ name=name or func.__name__,
294
+ lazy=lazy,
295
+ on_error=on_error,
296
+ deps=deps,
297
+ update_deps=update_deps,
298
+ interval=interval,
299
+ )
300
+ return Effect(
156
301
  func, # type: ignore[arg-type]
157
302
  name=name or func.__name__,
303
+ immediate=immediate,
158
304
  lazy=lazy,
159
305
  on_error=on_error,
160
306
  deps=deps,
307
+ update_deps=update_deps,
161
308
  interval=interval,
162
309
  )
163
- return Effect(
164
- func, # type: ignore[arg-type]
165
- name=name or func.__name__,
166
- immediate=immediate,
167
- lazy=lazy,
168
- on_error=on_error,
169
- deps=deps,
170
- interval=interval,
171
- )
172
-
173
- if fn:
310
+
311
+ if ctx is None:
312
+ # Not in component - create standalone effect (current behavior)
313
+ return create_effect()
314
+
315
+ # In component render - use inline caching
316
+
317
+ # Get the frame where the decorator was applied.
318
+ # When called as `@ps.effect` (no parens), the call stack is:
319
+ # decorator -> effect -> component
320
+ # When called as `@ps.effect(...)` (with parens), the stack is:
321
+ # decorator -> component
322
+ # We detect which case by checking if the immediate caller is effect().
323
+ frame = inspect.currentframe()
324
+ assert frame is not None
325
+ caller = frame.f_back
326
+ assert caller is not None
327
+ # If the immediate caller is the effect function itself, go back one more
328
+ if (
329
+ caller.f_code.co_name == "effect"
330
+ and "decorators" in caller.f_code.co_filename
331
+ ):
332
+ caller = caller.f_back
333
+ assert caller is not None
334
+ if key is None:
335
+ identity = collect_component_identity(caller)
336
+ else:
337
+ identity = key
338
+
339
+ state = inline_effect_hook()
340
+ return state.get_or_create(cast(Any, identity), key, create_effect)
341
+
342
+ if fn is not None:
174
343
  return decorator(fn)
175
344
  return decorator