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/__init__.py +19 -4
- pulse/app.py +159 -99
- pulse/channel.py +7 -7
- pulse/cli/cmd.py +81 -45
- pulse/cli/models.py +2 -0
- pulse/cli/processes.py +67 -22
- pulse/cli/uvicorn_log_config.py +1 -1
- pulse/codegen/codegen.py +14 -1
- pulse/codegen/templates/layout.py +10 -2
- pulse/context.py +3 -2
- pulse/decorators.py +132 -40
- pulse/form.py +9 -9
- pulse/helpers.py +75 -11
- pulse/hooks/core.py +7 -8
- pulse/hooks/init.py +460 -0
- pulse/hooks/states.py +91 -54
- pulse/messages.py +1 -1
- pulse/middleware.py +170 -119
- pulse/plugin.py +0 -3
- pulse/proxy.py +134 -16
- pulse/queries/__init__.py +0 -0
- pulse/queries/common.py +24 -0
- pulse/queries/mutation.py +142 -0
- pulse/queries/query.py +270 -0
- pulse/queries/query_observer.py +365 -0
- pulse/queries/store.py +60 -0
- pulse/react_component.py +2 -1
- pulse/reactive.py +153 -53
- pulse/render_session.py +5 -2
- pulse/routing.py +68 -10
- pulse/state.py +8 -7
- pulse/types/event_handler.py +2 -3
- pulse/user_session.py +3 -2
- pulse/vdom.py +3 -1
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/RECORD +38 -32
- pulse/query.py +0 -408
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/entry_points.txt +0 -0
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
|
|
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.
|
|
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],
|
|
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],
|
|
187
|
+
fn: Callable[[TState], Awaitable[T]],
|
|
177
188
|
*,
|
|
178
|
-
|
|
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
|
-
|
|
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,
|
|
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],
|
|
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],
|
|
221
|
+
fn: Callable[[TState], Awaitable[T]],
|
|
193
222
|
*,
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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],
|
|
252
|
+
fn: Callable[[TState], Awaitable[T]] | None = None,
|
|
212
253
|
*,
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
|
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(
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
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],
|
|
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],
|
|
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
|
-
|
|
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
|
|
6
|
-
from
|
|
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
|
-
|
|
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
|
|
150
|
+
if not is_pytest():
|
|
113
151
|
raise
|
|
114
152
|
|
|
115
153
|
|
|
116
154
|
def create_task(
|
|
117
|
-
coroutine:
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
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)
|