pulse-framework 0.1.40__py3-none-any.whl → 0.1.42__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/decorators.py CHANGED
@@ -1,10 +1,17 @@
1
1
  # Separate file from reactive.py due to needing to import from state too
2
2
 
3
3
  import inspect
4
- from collections.abc import Callable, Coroutine
5
- from typing import Any, Protocol, TypeVar, overload
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, overload
6
6
 
7
- from pulse.query import QueryProperty, QueryPropertyWithInitial
7
+ from pulse.helpers import MISSING
8
+ from pulse.queries.common import OnErrorFn, OnSuccessFn
9
+ from pulse.queries.mutation import MutationProperty
10
+ from pulse.queries.query import RETRY_DELAY_DEFAULT
11
+ from pulse.queries.query_observer import (
12
+ QueryProperty,
13
+ QueryPropertyWithInitial,
14
+ )
8
15
  from pulse.reactive import (
9
16
  AsyncEffect,
10
17
  AsyncEffectFn,
@@ -18,6 +25,7 @@ from pulse.state import ComputedProperty, State, StateEffect
18
25
 
19
26
  T = TypeVar("T")
20
27
  TState = TypeVar("TState", bound=State)
28
+ P = ParamSpec("P")
21
29
 
22
30
 
23
31
  # -> @ps.computed The chalenge is:
@@ -58,7 +66,7 @@ def computed(fn: Callable[..., Any] | None = None, *, name: str | None = None):
58
66
 
59
67
 
60
68
  StateEffectFn = Callable[[TState], EffectCleanup | None]
61
- AsyncStateEffectFn = Callable[[TState], Coroutine[Any, Any, EffectCleanup | None]]
69
+ AsyncStateEffectFn = Callable[[TState], Awaitable[EffectCleanup | None]]
62
70
 
63
71
 
64
72
  class EffectBuilder(Protocol):
@@ -171,75 +179,159 @@ def effect(
171
179
  # -----------------
172
180
  # Query decorator
173
181
  # -----------------
182
+
183
+
184
+ # With initial (narrowed return type) - more specific overloads first
174
185
  @overload
175
186
  def query(
176
- fn: Callable[[TState], Coroutine[Any, Any, T]],
187
+ fn: Callable[[TState], Awaitable[T]],
177
188
  *,
178
- keep_alive: bool = False,
189
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
190
+ stale_time: float = 0.0,
191
+ gc_time: float | None = 300.0,
179
192
  keep_previous_data: bool = False,
180
- ) -> QueryProperty[T, TState]: ...
193
+ retries: int = 3,
194
+ retry_delay: float | None = None,
195
+ initial: T | Callable[[TState], T],
196
+ on_success: OnSuccessFn[TState, T] | None = None,
197
+ on_error: OnErrorFn[TState] | None = None,
198
+ ) -> QueryPropertyWithInitial[T, TState]: ...
199
+
200
+
181
201
  @overload
182
202
  def query(
183
- fn: None = None, *, keep_alive: bool = False, keep_previous_data: bool = False
203
+ fn: None = None,
204
+ *,
205
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
206
+ stale_time: float = 0.0,
207
+ gc_time: float | None = 300.0,
208
+ keep_previous_data: bool = False,
209
+ retries: int = 3,
210
+ retry_delay: float | None = None,
211
+ initial: T | Callable[[TState], T],
212
+ on_success: OnSuccessFn[TState, T] | None = None,
213
+ on_error: OnErrorFn[TState] | None = None,
184
214
  ) -> Callable[
185
- [Callable[[TState], Coroutine[Any, Any, T]]], QueryProperty[T, TState]
215
+ [Callable[[TState], Awaitable[T]]], QueryPropertyWithInitial[T, TState]
186
216
  ]: ...
187
217
 
188
218
 
189
- # When an initial value is provided, the resulting property narrows data to non-None
190
219
  @overload
191
220
  def query(
192
- fn: Callable[[TState], Coroutine[Any, Any, T]],
221
+ fn: Callable[[TState], Awaitable[T]],
193
222
  *,
194
- keep_alive: bool = False,
223
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
224
+ stale_time: float = 0.0,
225
+ gc_time: float | None = 300.0,
195
226
  keep_previous_data: bool = False,
196
- initial: T,
197
- ) -> QueryPropertyWithInitial[T, TState]: ...
227
+ retries: int = 3,
228
+ retry_delay: float | None = None,
229
+ initial: T | Callable[[TState], T] | None = ...,
230
+ on_success: OnSuccessFn[TState, T] | None = None,
231
+ on_error: OnErrorFn[TState] | None = None,
232
+ ) -> QueryProperty[T, TState]: ...
233
+
234
+
198
235
  @overload
199
236
  def query(
200
237
  fn: None = None,
201
238
  *,
202
- keep_alive: bool = False,
239
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
240
+ stale_time: float = 0.0,
241
+ gc_time: float | None = 300.0,
203
242
  keep_previous_data: bool = False,
204
- initial: T,
205
- ) -> Callable[
206
- [Callable[[TState], Coroutine[Any, Any, T]]], QueryPropertyWithInitial[T, TState]
207
- ]: ...
243
+ retries: int = 3,
244
+ retry_delay: float | None = None,
245
+ initial: T | Callable[[TState], T] | None = ...,
246
+ on_success: OnSuccessFn[TState, T] | None = None,
247
+ on_error: OnErrorFn[TState] | None = None,
248
+ ) -> Callable[[Callable[[TState], Awaitable[T]]], QueryProperty[T, TState]]: ...
208
249
 
209
250
 
210
251
  def query(
211
- fn: Callable[[TState], Any] | None = None,
252
+ fn: Callable[[TState], Awaitable[T]] | None = None,
212
253
  *,
213
- keep_alive: bool = False,
254
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
255
+ stale_time: float = 0.0,
256
+ gc_time: float | None = 300.0,
214
257
  keep_previous_data: bool = False,
215
- initial: Any = None,
216
- ) -> (
217
- QueryProperty[T, TState]
218
- | QueryPropertyWithInitial[T, TState]
219
- | Callable[
220
- [Callable[[TState], Coroutine[Any, Any, T]]],
221
- QueryProperty[T, TState] | QueryPropertyWithInitial[T, TState],
222
- ]
258
+ retries: int = 3,
259
+ retry_delay: float | None = None,
260
+ initial: T | Callable[[TState], T] | None = MISSING,
261
+ on_success: OnSuccessFn[TState, T] | None = None,
262
+ on_error: OnErrorFn[TState] | None = None,
223
263
  ):
224
- def decorator(func: Callable[[TState], Coroutine[Any, Any, T]], /):
264
+ def decorator(
265
+ func: Callable[[TState], Awaitable[T]], /
266
+ ) -> QueryProperty[T, TState] | QueryPropertyWithInitial[T, TState]:
225
267
  sig = inspect.signature(func)
226
268
  params = list(sig.parameters.values())
227
269
  # Only state-method form supported for now (single 'self')
228
270
  if not (len(params) == 1 and params[0].name == "self"):
229
271
  raise TypeError("@query currently only supports state methods (self)")
230
- if initial is not None:
231
- return QueryPropertyWithInitial(
232
- func.__name__,
233
- func,
234
- keep_alive=keep_alive,
235
- keep_previous_data=keep_previous_data,
236
- initial=initial,
237
- )
238
- return QueryProperty(
272
+
273
+ prop_cls = QueryPropertyWithInitial if initial is not None else QueryProperty
274
+
275
+ return prop_cls(
239
276
  func.__name__,
240
277
  func,
241
- keep_alive=keep_alive,
278
+ key=key,
279
+ stale_time=stale_time,
280
+ gc_time=gc_time if gc_time is not None else 300.0,
242
281
  keep_previous_data=keep_previous_data,
282
+ retries=retries,
283
+ retry_delay=RETRY_DELAY_DEFAULT if retry_delay is None else retry_delay,
284
+ initial=initial,
285
+ on_success=on_success,
286
+ on_error=on_error,
287
+ )
288
+
289
+ if fn:
290
+ return decorator(fn)
291
+ return decorator
292
+
293
+
294
+ # -----------------
295
+ # Mutation decorator
296
+ # -----------------
297
+ @overload
298
+ def mutation(
299
+ fn: Callable[Concatenate[TState, P], Awaitable[T]],
300
+ *,
301
+ on_success: OnSuccessFn[TState, T] | None = None,
302
+ on_error: OnErrorFn[TState] | None = None,
303
+ ) -> MutationProperty[T, TState, P]: ...
304
+
305
+
306
+ @overload
307
+ def mutation(
308
+ fn: None = None,
309
+ *,
310
+ on_success: OnSuccessFn[TState, T] | None = None,
311
+ on_error: OnErrorFn[TState] | None = None,
312
+ ) -> Callable[
313
+ [Callable[Concatenate[TState, P], Awaitable[T]]], MutationProperty[T, TState, P]
314
+ ]: ...
315
+
316
+
317
+ def mutation(
318
+ fn: Callable[Concatenate[TState, P], Awaitable[T]] | None = None,
319
+ *,
320
+ on_success: OnSuccessFn[TState, T] | None = None,
321
+ on_error: OnErrorFn[TState] | None = None,
322
+ ):
323
+ def decorator(func: Callable[Concatenate[TState, P], Awaitable[T]], /):
324
+ sig = inspect.signature(func)
325
+ params = list(sig.parameters.values())
326
+
327
+ if len(params) == 0 or params[0].name != "self":
328
+ raise TypeError("@mutation method must have 'self' as first argument")
329
+
330
+ return MutationProperty(
331
+ func.__name__,
332
+ func,
333
+ on_success=on_success,
334
+ on_error=on_error,
243
335
  )
244
336
 
245
337
  if fn:
pulse/form.py CHANGED
@@ -1,10 +1,9 @@
1
1
  import json
2
2
  import uuid
3
- from collections.abc import Callable, Coroutine
3
+ from collections.abc import Awaitable, Callable
4
4
  from dataclasses import dataclass
5
5
  from typing import (
6
6
  TYPE_CHECKING,
7
- Any,
8
7
  Never,
9
8
  TypedDict,
10
9
  Unpack,
@@ -16,7 +15,7 @@ from starlette.datastructures import FormData as StarletteFormData
16
15
  from starlette.datastructures import UploadFile
17
16
 
18
17
  from pulse.context import PulseContext
19
- from pulse.helpers import call_flexible, maybe_await
18
+ from pulse.helpers import Disposable, call_flexible, maybe_await
20
19
  from pulse.hooks.core import HOOK_CONTEXT, HookMetadata, HookState, hooks
21
20
  from pulse.hooks.runtime import server_address
22
21
  from pulse.hooks.stable import stable
@@ -60,10 +59,10 @@ class FormRegistration:
60
59
  render_id: str
61
60
  route_path: str
62
61
  session_id: str
63
- on_submit: Callable[[FormData], Coroutine[Any, Any, None]]
62
+ on_submit: Callable[[FormData], Awaitable[None]]
64
63
 
65
64
 
66
- class FormRegistry:
65
+ class FormRegistry(Disposable):
67
66
  def __init__(self, render: "RenderSession") -> None:
68
67
  self._render: "RenderSession" = render
69
68
  self._handlers: dict[str, FormRegistration] = {}
@@ -73,7 +72,7 @@ class FormRegistry:
73
72
  render_id: str,
74
73
  route_id: str,
75
74
  session_id: str,
76
- on_submit: Callable[[FormData], Coroutine[Any, Any, None]],
75
+ on_submit: Callable[[FormData], Awaitable[None]],
77
76
  ) -> FormRegistration:
78
77
  registration = FormRegistration(
79
78
  uuid.uuid4().hex,
@@ -88,9 +87,9 @@ class FormRegistry:
88
87
  def unregister(self, form_id: str) -> None:
89
88
  self._handlers.pop(form_id, None)
90
89
 
90
+ @override
91
91
  def dispose(self) -> None:
92
- for form_id in list(self._handlers.keys()):
93
- self.unregister(form_id)
92
+ self._handlers.clear()
94
93
 
95
94
  async def handle_submit(
96
95
  self,
@@ -203,7 +202,7 @@ class GeneratedFormProps(TypedDict):
203
202
  onSubmit: Callable[[], None]
204
203
 
205
204
 
206
- class ManualForm:
205
+ class ManualForm(Disposable):
207
206
  _submit_signal: Signal[bool]
208
207
  _render: "RenderSession"
209
208
  _registration: FormRegistration | None
@@ -267,6 +266,7 @@ class ManualForm:
267
266
  props.update(self.props()) # pyright: ignore[reportCallIssue, reportArgumentType]
268
267
  return client_form_component(*children, key=key, **props)
269
268
 
269
+ @override
270
270
  def dispose(self) -> None:
271
271
  if self._registration is None:
272
272
  return
pulse/helpers.py CHANGED
@@ -2,13 +2,26 @@ import asyncio
2
2
  import inspect
3
3
  import os
4
4
  import socket
5
- from collections.abc import Awaitable, Callable, Coroutine
6
- from typing import Any, ParamSpec, Protocol, TypedDict, TypeVar, overload, override
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Awaitable, Callable
7
+ from functools import wraps
8
+ from typing import (
9
+ Any,
10
+ ParamSpec,
11
+ Protocol,
12
+ Self,
13
+ TypedDict,
14
+ TypeVar,
15
+ overload,
16
+ override,
17
+ )
7
18
  from urllib.parse import urlsplit
8
19
 
9
20
  from anyio import from_thread
10
21
  from fastapi import Request
11
22
 
23
+ from pulse.env import env
24
+
12
25
 
13
26
  def values_equal(a: Any, b: Any) -> bool:
14
27
  """Robust equality that avoids ambiguous truth for DataFrames/ndarrays.
@@ -86,9 +99,34 @@ def data(**attrs: Any):
86
99
 
87
100
 
88
101
  # --- Async scheduling helpers (work from loop or sync threads) ---
102
+ class Disposable(ABC):
103
+ __disposed__: bool = False
104
+
105
+ @abstractmethod
106
+ def dispose(self) -> None: ...
107
+
108
+ def __init_subclass__(cls, **kwargs: Any):
109
+ super().__init_subclass__(**kwargs)
110
+
111
+ if "dispose" in cls.__dict__:
112
+ original_dispose = cls.dispose
113
+
114
+ @wraps(original_dispose)
115
+ def wrapped_dispose(self: Self, *args: Any, **kwargs: Any):
116
+ if self.__disposed__:
117
+ if env.pulse_env == "dev":
118
+ cls_name = type(self).__name__
119
+ raise RuntimeError(
120
+ f"{self} (type={cls_name}) was disposed twice. This is likely a bug."
121
+ )
122
+ return
123
+ self.__disposed__ = True
124
+ return original_dispose(self, *args, **kwargs)
89
125
 
126
+ cls.dispose = wrapped_dispose
90
127
 
91
- def _running_under_pytest() -> bool:
128
+
129
+ def is_pytest() -> bool:
92
130
  """Detect if running inside pytest using environment variables."""
93
131
  return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
94
132
  "PYTEST_XDIST_TESTRUNUID" in os.environ
@@ -109,12 +147,12 @@ def schedule_on_loop(callback: Callable[[], None]) -> None:
109
147
  try:
110
148
  from_thread.run(_runner)
111
149
  except RuntimeError:
112
- if not _running_under_pytest():
150
+ if not is_pytest():
113
151
  raise
114
152
 
115
153
 
116
154
  def create_task(
117
- coroutine: Coroutine[Any, Any, T],
155
+ coroutine: Awaitable[T],
118
156
  *,
119
157
  name: str | None = None,
120
158
  on_done: Callable[[asyncio.Task[T]], None] | None = None,
@@ -126,16 +164,22 @@ def create_task(
126
164
  """
127
165
 
128
166
  try:
129
- loop = asyncio.get_running_loop()
130
- task = loop.create_task(coroutine, name=name)
167
+ asyncio.get_running_loop()
168
+ # ensure_future accepts Awaitable and returns a Task when given a coroutine
169
+ task = asyncio.ensure_future(coroutine)
170
+ if name is not None:
171
+ task.set_name(name)
131
172
  if on_done:
132
173
  task.add_done_callback(on_done)
133
174
  return task
134
175
  except RuntimeError:
135
176
 
136
177
  async def _runner():
137
- loop = asyncio.get_running_loop()
138
- task = loop.create_task(coroutine, name=name)
178
+ asyncio.get_running_loop()
179
+ # ensure_future accepts Awaitable and returns a Task when given a coroutine
180
+ task = asyncio.ensure_future(coroutine)
181
+ if name is not None:
182
+ task.set_name(name)
139
183
  if on_done:
140
184
  task.add_done_callback(on_done)
141
185
  return task
@@ -143,7 +187,7 @@ def create_task(
143
187
  try:
144
188
  return from_thread.run(_runner)
145
189
  except RuntimeError:
146
- if _running_under_pytest():
190
+ if is_pytest():
147
191
  return None # pyright: ignore[reportReturnType]
148
192
  raise
149
193
 
@@ -418,9 +462,29 @@ async def maybe_await(value: T | Awaitable[T]) -> T:
418
462
  def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
419
463
  """Find an available port starting from start_port."""
420
464
  for port in range(start_port, start_port + max_attempts):
465
+ # First check if something is actively listening on the port
466
+ # by trying to connect to it (check both IPv4 and IPv6)
467
+ port_in_use = False
468
+ for family, addr in [(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")]:
469
+ try:
470
+ with socket.socket(family, socket.SOCK_STREAM) as test_socket:
471
+ test_socket.settimeout(0.1)
472
+ result = test_socket.connect_ex((addr, port))
473
+ # If connection succeeds (result == 0), something is listening
474
+ if result == 0:
475
+ port_in_use = True
476
+ break
477
+ except OSError:
478
+ # Connection failed, continue checking
479
+ pass
480
+
481
+ if port_in_use:
482
+ continue
483
+
484
+ # Port appears free, try to bind to it
485
+ # Allow reuse of addresses in TIME_WAIT state (matches uvicorn behavior)
421
486
  try:
422
487
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
423
- # Allow reuse of addresses in TIME_WAIT state (matches uvicorn behavior)
424
488
  s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
425
489
  s.bind(("localhost", port))
426
490
  return port
pulse/hooks/core.py CHANGED
@@ -3,9 +3,9 @@ import logging
3
3
  from collections.abc import Callable, Mapping
4
4
  from contextvars import ContextVar, Token
5
5
  from dataclasses import dataclass
6
- from typing import Any, Generic, TypeVar
6
+ from typing import Any, Generic, Literal, TypeVar, override
7
7
 
8
- from pulse.helpers import call_flexible
8
+ from pulse.helpers import Disposable, call_flexible
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
@@ -37,13 +37,10 @@ class HookMetadata:
37
37
  extra: Mapping[str, Any] | None = None
38
38
 
39
39
 
40
- class HookState:
40
+ class HookState(Disposable):
41
41
  """Base class returned by hook factories."""
42
42
 
43
- render_cycle: int
44
-
45
- def __init__(self) -> None:
46
- self.render_cycle = 0
43
+ render_cycle: int = 0
47
44
 
48
45
  def on_render_start(self, render_cycle: int) -> None:
49
46
  self.render_cycle = render_cycle
@@ -52,6 +49,7 @@ class HookState:
52
49
  """Called after the component render has completed."""
53
50
  ...
54
51
 
52
+ @override
55
53
  def dispose(self) -> None:
56
54
  """Called when the hook instance is discarded."""
57
55
  ...
@@ -176,13 +174,14 @@ class HookContext:
176
174
  exc_type: type[BaseException] | None,
177
175
  exc_val: BaseException | None,
178
176
  exc_tb: Any,
179
- ):
177
+ ) -> Literal[False]:
180
178
  if self._token is not None:
181
179
  HOOK_CONTEXT.reset(self._token)
182
180
  self._token = None
183
181
 
184
182
  for namespace in self.namespaces.values():
185
183
  namespace.on_render_end(self.render_cycle)
184
+ return False
186
185
 
187
186
  def namespace_for(self, hook: Hook[T]) -> HookNamespace[T]:
188
187
  namespace = self.namespaces.get(hook.name)