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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/helpers.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import linecache
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
ParamSpec,
|
|
12
|
+
Self,
|
|
13
|
+
TypedDict,
|
|
14
|
+
TypeVar,
|
|
15
|
+
overload,
|
|
16
|
+
override,
|
|
17
|
+
)
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from anyio import from_thread
|
|
21
|
+
from fastapi import Request
|
|
22
|
+
|
|
23
|
+
from pulse.env import env
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def values_equal(a: Any, b: Any) -> bool:
|
|
27
|
+
"""Robust equality that avoids ambiguous truth for DataFrames/ndarrays.
|
|
28
|
+
|
|
29
|
+
Strategy:
|
|
30
|
+
- identity check fast-path
|
|
31
|
+
- try a == b / != comparison
|
|
32
|
+
- if comparison raises or returns a non-bool (e.g., array-like), fall back to False
|
|
33
|
+
"""
|
|
34
|
+
if a is b:
|
|
35
|
+
return True
|
|
36
|
+
try:
|
|
37
|
+
result = a == b
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
# Some libs return array-like; only accept plain bools
|
|
41
|
+
if isinstance(result, bool):
|
|
42
|
+
return result
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def getsourcecode(obj: Any) -> str:
|
|
47
|
+
"""Get source code for an object, handling stale cache issues after module renames.
|
|
48
|
+
|
|
49
|
+
This is a wrapper around inspect.getsource() that handles cases where the
|
|
50
|
+
linecache has stale entries after module renames or when source files have moved.
|
|
51
|
+
"""
|
|
52
|
+
# Try to get source first without clearing cache (common case)
|
|
53
|
+
try:
|
|
54
|
+
return inspect.getsource(obj)
|
|
55
|
+
except OSError:
|
|
56
|
+
# If that fails, it might be a stale cache issue after module rename
|
|
57
|
+
# Clear cache and try again
|
|
58
|
+
linecache.clearcache()
|
|
59
|
+
try:
|
|
60
|
+
return inspect.getsource(obj)
|
|
61
|
+
except OSError:
|
|
62
|
+
# Still failing - code object might have a stale filename
|
|
63
|
+
# Get the actual source file from the module and update cache manually
|
|
64
|
+
module = inspect.getmodule(obj)
|
|
65
|
+
if module and hasattr(module, "__file__") and module.__file__:
|
|
66
|
+
module_file = module.__file__
|
|
67
|
+
if module_file.endswith(".pyc"):
|
|
68
|
+
module_file = module_file[:-1]
|
|
69
|
+
if os.path.exists(module_file):
|
|
70
|
+
# Read the file and update cache with code object's filename
|
|
71
|
+
with open(module_file, "r", encoding="utf-8") as f:
|
|
72
|
+
lines = f.readlines()
|
|
73
|
+
code_filename = obj.__code__.co_filename
|
|
74
|
+
linecache.cache[code_filename] = (
|
|
75
|
+
len(lines),
|
|
76
|
+
None,
|
|
77
|
+
lines,
|
|
78
|
+
code_filename,
|
|
79
|
+
)
|
|
80
|
+
# Try again after updating cache
|
|
81
|
+
return inspect.getsource(obj)
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
T = TypeVar("T")
|
|
86
|
+
P = ParamSpec("P")
|
|
87
|
+
|
|
88
|
+
# In case we refine it later
|
|
89
|
+
CSSProperties = dict[str, Any]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
MISSING = object()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class File(TypedDict):
|
|
96
|
+
name: str
|
|
97
|
+
type: str
|
|
98
|
+
"Indicates the MIME type of the data. If the type is unknown, the string is empty."
|
|
99
|
+
size: int
|
|
100
|
+
last_modified: int
|
|
101
|
+
"Last modified time of the file, in millisecond since the UNIX epoch"
|
|
102
|
+
contents: bytes
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Sentinel:
|
|
106
|
+
name: str
|
|
107
|
+
value: Any
|
|
108
|
+
|
|
109
|
+
def __init__(self, name: str, value: Any = MISSING) -> None:
|
|
110
|
+
self.name = name
|
|
111
|
+
self.value = value
|
|
112
|
+
|
|
113
|
+
def __call__(self, value: Any):
|
|
114
|
+
return Sentinel(self.name, value)
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
def __repr__(self) -> str:
|
|
118
|
+
if self.value is not MISSING:
|
|
119
|
+
return f"{self.name}({self.value})"
|
|
120
|
+
else:
|
|
121
|
+
return self.name
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def data(**attrs: Any):
|
|
125
|
+
"""Helper to pass data attributes as keyword arguments to Pulse elements.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
data(foo="bar") -> {"data-foo": "bar"}
|
|
129
|
+
"""
|
|
130
|
+
return {f"data-{k}": v for k, v in attrs.items()}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- Async scheduling helpers (work from loop or sync threads) ---
|
|
134
|
+
class Disposable(ABC):
|
|
135
|
+
__disposed__: bool = False
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def dispose(self) -> None: ...
|
|
139
|
+
|
|
140
|
+
def __init_subclass__(cls, **kwargs: Any):
|
|
141
|
+
super().__init_subclass__(**kwargs)
|
|
142
|
+
|
|
143
|
+
if "dispose" in cls.__dict__:
|
|
144
|
+
original_dispose = cls.dispose
|
|
145
|
+
|
|
146
|
+
@wraps(original_dispose)
|
|
147
|
+
def wrapped_dispose(self: Self, *args: Any, **kwargs: Any):
|
|
148
|
+
if self.__disposed__:
|
|
149
|
+
if env.pulse_env == "dev":
|
|
150
|
+
cls_name = type(self).__name__
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"{self} (type={cls_name}) was disposed twice. This is likely a bug."
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
self.__disposed__ = True
|
|
156
|
+
return original_dispose(self, *args, **kwargs)
|
|
157
|
+
|
|
158
|
+
cls.dispose = wrapped_dispose
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_pytest() -> bool:
|
|
162
|
+
"""Detect if running inside pytest using environment variables."""
|
|
163
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
|
|
164
|
+
"PYTEST_XDIST_TESTRUNUID" in os.environ
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def schedule_on_loop(callback: Callable[[], None]) -> None:
|
|
169
|
+
"""Schedule a callback to run ASAP on the main event loop from any thread."""
|
|
170
|
+
try:
|
|
171
|
+
loop = asyncio.get_running_loop()
|
|
172
|
+
loop.call_soon_threadsafe(callback)
|
|
173
|
+
except RuntimeError:
|
|
174
|
+
|
|
175
|
+
async def _runner():
|
|
176
|
+
loop = asyncio.get_running_loop()
|
|
177
|
+
loop.call_soon(callback)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
from_thread.run(_runner)
|
|
181
|
+
except RuntimeError:
|
|
182
|
+
if not is_pytest():
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def create_task(
|
|
187
|
+
coroutine: Awaitable[T],
|
|
188
|
+
*,
|
|
189
|
+
name: str | None = None,
|
|
190
|
+
on_done: Callable[[asyncio.Task[T]], None] | None = None,
|
|
191
|
+
) -> asyncio.Task[T]:
|
|
192
|
+
"""Create and schedule a coroutine task on the main loop from any thread.
|
|
193
|
+
|
|
194
|
+
- factory should create a fresh coroutine each call
|
|
195
|
+
- optional on_done is attached on the created task within the loop
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
asyncio.get_running_loop()
|
|
200
|
+
# ensure_future accepts Awaitable and returns a Task when given a coroutine
|
|
201
|
+
task = asyncio.ensure_future(coroutine)
|
|
202
|
+
if name is not None:
|
|
203
|
+
task.set_name(name)
|
|
204
|
+
if on_done:
|
|
205
|
+
task.add_done_callback(on_done)
|
|
206
|
+
return task
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
|
|
209
|
+
async def _runner():
|
|
210
|
+
asyncio.get_running_loop()
|
|
211
|
+
# ensure_future accepts Awaitable and returns a Task when given a coroutine
|
|
212
|
+
task = asyncio.ensure_future(coroutine)
|
|
213
|
+
if name is not None:
|
|
214
|
+
task.set_name(name)
|
|
215
|
+
if on_done:
|
|
216
|
+
task.add_done_callback(on_done)
|
|
217
|
+
return task
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
return from_thread.run(_runner)
|
|
221
|
+
except RuntimeError:
|
|
222
|
+
if is_pytest():
|
|
223
|
+
return None # pyright: ignore[reportReturnType]
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def create_future_on_loop() -> asyncio.Future[Any]:
|
|
228
|
+
"""Create an asyncio Future on the main event loop from any thread."""
|
|
229
|
+
try:
|
|
230
|
+
return asyncio.get_running_loop().create_future()
|
|
231
|
+
except RuntimeError:
|
|
232
|
+
from anyio import from_thread
|
|
233
|
+
|
|
234
|
+
async def _create():
|
|
235
|
+
loop = asyncio.get_running_loop()
|
|
236
|
+
return loop.create_future()
|
|
237
|
+
|
|
238
|
+
return from_thread.run(_create)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def later(
|
|
242
|
+
delay: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
|
|
243
|
+
) -> asyncio.TimerHandle:
|
|
244
|
+
"""
|
|
245
|
+
Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
|
|
246
|
+
Works with sync or async functions. Returns a TimerHandle; call .cancel() to cancel.
|
|
247
|
+
|
|
248
|
+
The callback runs with no reactive scope to avoid accidentally capturing
|
|
249
|
+
reactive dependencies from the calling context. Other context vars (like
|
|
250
|
+
PulseContext) are preserved normally.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
from pulse.reactive import Untrack
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
loop = asyncio.get_running_loop()
|
|
257
|
+
except RuntimeError:
|
|
258
|
+
try:
|
|
259
|
+
loop = asyncio.get_event_loop()
|
|
260
|
+
except RuntimeError as exc:
|
|
261
|
+
raise RuntimeError("later() requires an event loop") from exc
|
|
262
|
+
|
|
263
|
+
def _run():
|
|
264
|
+
try:
|
|
265
|
+
with Untrack():
|
|
266
|
+
res = fn(*args, **kwargs)
|
|
267
|
+
if asyncio.iscoroutine(res):
|
|
268
|
+
task = loop.create_task(res)
|
|
269
|
+
|
|
270
|
+
def _log_task_exception(t: asyncio.Task[Any]):
|
|
271
|
+
try:
|
|
272
|
+
t.result()
|
|
273
|
+
except asyncio.CancelledError:
|
|
274
|
+
# Normal cancellation path
|
|
275
|
+
pass
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
loop.call_exception_handler(
|
|
278
|
+
{
|
|
279
|
+
"message": "Unhandled exception in later() task",
|
|
280
|
+
"exception": exc,
|
|
281
|
+
"context": {"callback": fn},
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
task.add_done_callback(_log_task_exception)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
# Surface exceptions via the loop's exception handler and continue
|
|
288
|
+
loop.call_exception_handler(
|
|
289
|
+
{
|
|
290
|
+
"message": "Unhandled exception in later() callback",
|
|
291
|
+
"exception": exc,
|
|
292
|
+
"context": {"callback": fn},
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return loop.call_later(delay, _run)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class RepeatHandle:
|
|
300
|
+
task: asyncio.Task[None] | None
|
|
301
|
+
cancelled: bool
|
|
302
|
+
|
|
303
|
+
def __init__(self) -> None:
|
|
304
|
+
self.task = None
|
|
305
|
+
self.cancelled = False
|
|
306
|
+
|
|
307
|
+
def cancel(self):
|
|
308
|
+
if self.cancelled:
|
|
309
|
+
return
|
|
310
|
+
self.cancelled = True
|
|
311
|
+
if self.task is not None and not self.task.done():
|
|
312
|
+
self.task.cancel()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
|
|
316
|
+
"""
|
|
317
|
+
Repeatedly run `fn(*args, **kwargs)` every `interval` seconds.
|
|
318
|
+
Works with sync or async functions.
|
|
319
|
+
For async functions, waits for completion before starting the next delay.
|
|
320
|
+
Returns a handle with .cancel() to stop future runs.
|
|
321
|
+
|
|
322
|
+
The callback runs with no reactive scope to avoid accidentally capturing
|
|
323
|
+
reactive dependencies from the calling context. Other context vars (like
|
|
324
|
+
PulseContext) are preserved normally.
|
|
325
|
+
|
|
326
|
+
Optional kwargs:
|
|
327
|
+
- immediate: bool = False # run once immediately before the first interval
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
from pulse.reactive import Untrack
|
|
331
|
+
|
|
332
|
+
loop = asyncio.get_running_loop()
|
|
333
|
+
handle = RepeatHandle()
|
|
334
|
+
|
|
335
|
+
async def _runner():
|
|
336
|
+
nonlocal handle
|
|
337
|
+
try:
|
|
338
|
+
while not handle.cancelled:
|
|
339
|
+
# Start counting the next interval AFTER the previous execution completes
|
|
340
|
+
await asyncio.sleep(interval)
|
|
341
|
+
if handle.cancelled:
|
|
342
|
+
break
|
|
343
|
+
try:
|
|
344
|
+
with Untrack():
|
|
345
|
+
result = fn(*args, **kwargs)
|
|
346
|
+
if asyncio.iscoroutine(result):
|
|
347
|
+
await result
|
|
348
|
+
except asyncio.CancelledError:
|
|
349
|
+
# Propagate to outer handler to finish cleanly
|
|
350
|
+
raise
|
|
351
|
+
except Exception as exc:
|
|
352
|
+
# Surface exceptions via the loop's exception handler and continue
|
|
353
|
+
loop.call_exception_handler(
|
|
354
|
+
{
|
|
355
|
+
"message": "Unhandled exception in repeat() callback",
|
|
356
|
+
"exception": exc,
|
|
357
|
+
"context": {"callback": fn},
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
except asyncio.CancelledError:
|
|
361
|
+
# Swallow task cancellation to avoid noisy "exception was never retrieved"
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
handle.task = loop.create_task(_runner())
|
|
365
|
+
|
|
366
|
+
return handle
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_client_address(request: Request) -> str | None:
|
|
370
|
+
"""Best-effort client origin/address from an HTTP request.
|
|
371
|
+
|
|
372
|
+
Preference order:
|
|
373
|
+
1) Origin header (full scheme://host:port)
|
|
374
|
+
1b) Referer header (full URL) when Origin missing
|
|
375
|
+
2) Forwarded header (proto + for)
|
|
376
|
+
3) X-Forwarded-* headers
|
|
377
|
+
4) Host header (server address the client connected to)
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
origin = request.headers.get("origin")
|
|
381
|
+
if origin:
|
|
382
|
+
return origin
|
|
383
|
+
referer = request.headers.get("referer")
|
|
384
|
+
if referer:
|
|
385
|
+
parts = urlsplit(referer)
|
|
386
|
+
if parts.scheme and parts.netloc:
|
|
387
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
388
|
+
|
|
389
|
+
fwd = request.headers.get("forwarded")
|
|
390
|
+
proto = request.headers.get("x-forwarded-proto") or (
|
|
391
|
+
[p.split("proto=")[-1] for p in fwd.split(";") if "proto=" in p][0]
|
|
392
|
+
.strip()
|
|
393
|
+
.strip('"')
|
|
394
|
+
if fwd and "proto=" in fwd
|
|
395
|
+
else request.url.scheme
|
|
396
|
+
)
|
|
397
|
+
if fwd and "for=" in fwd:
|
|
398
|
+
part = [p for p in fwd.split(";") if "for=" in p]
|
|
399
|
+
hostport = part[0].split("for=")[-1].strip().strip('"') if part else ""
|
|
400
|
+
if hostport:
|
|
401
|
+
return f"{proto}://{hostport}"
|
|
402
|
+
|
|
403
|
+
xff = request.headers.get("x-forwarded-for")
|
|
404
|
+
xfp = request.headers.get("x-forwarded-port")
|
|
405
|
+
if xff:
|
|
406
|
+
host = xff.split(",")[0].strip()
|
|
407
|
+
if host in ("127.0.0.1", "::1"):
|
|
408
|
+
host = "localhost"
|
|
409
|
+
return f"{proto}://{host}:{xfp}" if xfp else f"{proto}://{host}"
|
|
410
|
+
|
|
411
|
+
# Fallback: use Host header which contains the server address the client connected to
|
|
412
|
+
host_header = request.headers.get("host")
|
|
413
|
+
if host_header:
|
|
414
|
+
return f"{proto}://{host_header}"
|
|
415
|
+
return None
|
|
416
|
+
except Exception:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def get_client_address_socketio(environ: dict[str, Any]) -> str | None:
|
|
421
|
+
"""Best-effort client origin/address from a WS environ mapping.
|
|
422
|
+
|
|
423
|
+
Preference order mirrors HTTP variant using environ keys.
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
origin = environ.get("HTTP_ORIGIN")
|
|
427
|
+
if origin:
|
|
428
|
+
return origin
|
|
429
|
+
|
|
430
|
+
fwd = environ.get("HTTP_FORWARDED")
|
|
431
|
+
proto = environ.get("HTTP_X_FORWARDED_PROTO") or (
|
|
432
|
+
[p.split("proto=")[-1] for p in str(fwd).split(";") if "proto=" in p][0]
|
|
433
|
+
.strip()
|
|
434
|
+
.strip('"')
|
|
435
|
+
if fwd and "proto=" in str(fwd)
|
|
436
|
+
else environ.get("wsgi.url_scheme", "http")
|
|
437
|
+
)
|
|
438
|
+
if fwd and "for=" in str(fwd):
|
|
439
|
+
part = [p for p in str(fwd).split(";") if "for=" in p]
|
|
440
|
+
hostport = part[0].split("for=")[-1].strip().strip('"') if part else ""
|
|
441
|
+
if hostport:
|
|
442
|
+
return f"{proto}://{hostport}"
|
|
443
|
+
|
|
444
|
+
xff = environ.get("HTTP_X_FORWARDED_FOR")
|
|
445
|
+
xfp = environ.get("HTTP_X_FORWARDED_PORT")
|
|
446
|
+
if xff:
|
|
447
|
+
host = str(xff).split(",")[0].strip()
|
|
448
|
+
if host in ("127.0.0.1", "::1"):
|
|
449
|
+
host = "localhost"
|
|
450
|
+
return f"{proto}://{host}:{xfp}" if xfp else f"{proto}://{host}"
|
|
451
|
+
|
|
452
|
+
# Fallback: use HTTP_HOST which contains the server address the client connected to
|
|
453
|
+
host_header = environ.get("HTTP_HOST")
|
|
454
|
+
if host_header:
|
|
455
|
+
return f"{proto}://{host_header}"
|
|
456
|
+
return None
|
|
457
|
+
except Exception:
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# --- Runtime lock helpers moved to pulse.cli.web_lock ---
|
|
462
|
+
# Use WebLock context manager for idempotent lock management
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@overload
|
|
466
|
+
def call_flexible(
|
|
467
|
+
handler: Callable[..., Awaitable[T]], *payload_args: Any
|
|
468
|
+
) -> Awaitable[T]: ...
|
|
469
|
+
@overload
|
|
470
|
+
def call_flexible(handler: Callable[..., T], *payload_args: Any) -> T: ...
|
|
471
|
+
def call_flexible(handler: Callable[..., Any], *payload_args: Any) -> Any:
|
|
472
|
+
"""
|
|
473
|
+
Call handler with a trimmed list of positional args based on its signature; await if needed.
|
|
474
|
+
|
|
475
|
+
- If the handler accepts *args, pass all payload_args.
|
|
476
|
+
- Otherwise, pass up to N positional args where N is the number of positional params.
|
|
477
|
+
- If inspection fails, pass payload_args as-is.
|
|
478
|
+
- Any exceptions raised by the handler are swallowed (best-effort callback semantics).
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
sig = inspect.signature(handler)
|
|
482
|
+
params = list(sig.parameters.values())
|
|
483
|
+
has_var_pos = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params)
|
|
484
|
+
if has_var_pos:
|
|
485
|
+
args_to_pass = payload_args
|
|
486
|
+
else:
|
|
487
|
+
nb_positional = 0
|
|
488
|
+
for p in params:
|
|
489
|
+
if p.kind in (
|
|
490
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
491
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
492
|
+
):
|
|
493
|
+
nb_positional += 1
|
|
494
|
+
args_to_pass = payload_args[:nb_positional]
|
|
495
|
+
except Exception:
|
|
496
|
+
# If inspection fails, default to passing the payload as-is
|
|
497
|
+
args_to_pass = payload_args
|
|
498
|
+
|
|
499
|
+
return handler(*args_to_pass)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def maybe_await(value: T | Awaitable[T]) -> T:
|
|
503
|
+
if inspect.isawaitable(value):
|
|
504
|
+
return await value
|
|
505
|
+
return value
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
|
|
509
|
+
"""Find an available port starting from start_port."""
|
|
510
|
+
for port in range(start_port, start_port + max_attempts):
|
|
511
|
+
# First check if something is actively listening on the port
|
|
512
|
+
# by trying to connect to it (check both IPv4 and IPv6)
|
|
513
|
+
port_in_use = False
|
|
514
|
+
for family, addr in [(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")]:
|
|
515
|
+
try:
|
|
516
|
+
with socket.socket(family, socket.SOCK_STREAM) as test_socket:
|
|
517
|
+
test_socket.settimeout(0.1)
|
|
518
|
+
result = test_socket.connect_ex((addr, port))
|
|
519
|
+
# If connection succeeds (result == 0), something is listening
|
|
520
|
+
if result == 0:
|
|
521
|
+
port_in_use = True
|
|
522
|
+
break
|
|
523
|
+
except OSError:
|
|
524
|
+
# Connection failed, continue checking
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
if port_in_use:
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
# Port appears free, try to bind to it
|
|
531
|
+
# Allow reuse of addresses in TIME_WAIT state (matches uvicorn behavior)
|
|
532
|
+
try:
|
|
533
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
534
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
535
|
+
s.bind(("localhost", port))
|
|
536
|
+
return port
|
|
537
|
+
except OSError:
|
|
538
|
+
continue
|
|
539
|
+
raise RuntimeError(
|
|
540
|
+
f"Could not find an available port after {max_attempts} attempts starting from {start_port}"
|
|
541
|
+
)
|
pulse/hooks/__init__.py
ADDED
|
File without changes
|