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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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
+ )
File without changes