zndraw-socketio 0.1.0__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.
- zndraw_socketio/__init__.py +13 -0
- zndraw_socketio/params.py +48 -0
- zndraw_socketio/py.typed +0 -0
- zndraw_socketio/wrapper.py +1639 -0
- zndraw_socketio-0.1.0.dist-info/METADATA +212 -0
- zndraw_socketio-0.1.0.dist-info/RECORD +8 -0
- zndraw_socketio-0.1.0.dist-info/WHEEL +4 -0
- zndraw_socketio-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
"""Typed wrapper for python-socketio with Pydantic validation.
|
|
2
|
+
|
|
3
|
+
This module provides a thin wrapper around socketio instances that adds:
|
|
4
|
+
- Typed emit/call methods with automatic event name derivation
|
|
5
|
+
- Handler registration with Pydantic validation from type hints
|
|
6
|
+
- Support for union and discriminated union response types
|
|
7
|
+
- FastAPI integration via Depends()
|
|
8
|
+
- Exception handlers for Socket.IO events
|
|
9
|
+
|
|
10
|
+
Note on Union Types (PEP 747):
|
|
11
|
+
The response_model parameter uses TypeForm[T] from PEP 747 for type inference.
|
|
12
|
+
See: https://peps.python.org/pep-0747/
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import inspect
|
|
19
|
+
import re
|
|
20
|
+
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from functools import wraps
|
|
23
|
+
from typing import (
|
|
24
|
+
TYPE_CHECKING,
|
|
25
|
+
Annotated,
|
|
26
|
+
Any,
|
|
27
|
+
Callable,
|
|
28
|
+
Type,
|
|
29
|
+
TypeVar,
|
|
30
|
+
get_args,
|
|
31
|
+
get_origin,
|
|
32
|
+
get_type_hints,
|
|
33
|
+
overload,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, TypeAdapter, validate_call
|
|
37
|
+
from pydantic_core import to_jsonable_python
|
|
38
|
+
from socketio import (
|
|
39
|
+
AsyncClient,
|
|
40
|
+
AsyncServer,
|
|
41
|
+
AsyncSimpleClient,
|
|
42
|
+
Client,
|
|
43
|
+
Server,
|
|
44
|
+
SimpleClient,
|
|
45
|
+
)
|
|
46
|
+
from typing_extensions import TypeForm
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from fastapi.params import Depends as _DependsClass
|
|
50
|
+
except ImportError:
|
|
51
|
+
from zndraw_socketio.params import _DependsBase as _DependsClass
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from fastapi import Depends
|
|
55
|
+
except ImportError:
|
|
56
|
+
from zndraw_socketio.params import Depends
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from fastapi import Request
|
|
60
|
+
except ImportError:
|
|
61
|
+
Request = None # type: ignore[assignment, misc]
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from fastapi import FastAPI
|
|
65
|
+
|
|
66
|
+
T = TypeVar("T")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# =============================================================================
|
|
70
|
+
# Event Context
|
|
71
|
+
# =============================================================================
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class EventContext:
|
|
76
|
+
"""Context for Socket.IO event handler exceptions.
|
|
77
|
+
|
|
78
|
+
Provides full context about the event that raised the exception,
|
|
79
|
+
similar to FastAPI's Request object for exception handlers.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
sid: Session ID of the client that triggered the event.
|
|
83
|
+
event: Event name that raised the exception.
|
|
84
|
+
namespace: Namespace the event was sent to (e.g., "/" or "/chat").
|
|
85
|
+
data: Original data sent by the client.
|
|
86
|
+
sio: Wrapper instance for emitting events, accessing rooms, etc.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
sid: str
|
|
90
|
+
event: str
|
|
91
|
+
namespace: str
|
|
92
|
+
data: Any
|
|
93
|
+
sio: "AsyncServerWrapper"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class SioRequest:
|
|
98
|
+
"""Minimal Request-compatible shim for Socket.IO dependency injection.
|
|
99
|
+
|
|
100
|
+
Provides ``request.app`` access so that FastAPI-style dependencies
|
|
101
|
+
like ``def get_db(request: Request)`` can be reused in Socket.IO
|
|
102
|
+
handlers. Unsupported Request attributes (url, headers, etc.)
|
|
103
|
+
raise ``AttributeError`` naturally.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
app: Any
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# =============================================================================
|
|
110
|
+
# Event Name Helper
|
|
111
|
+
# =============================================================================
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_event_name(model: Type[BaseModel] | BaseModel) -> str:
|
|
115
|
+
"""Get event name from a Pydantic model class or instance.
|
|
116
|
+
|
|
117
|
+
Checks for an `event_name` class attribute first, then falls back to
|
|
118
|
+
converting the class name from PascalCase to snake_case.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
model: A Pydantic model class or instance.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The event name string.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
>>> class Ping(BaseModel):
|
|
128
|
+
... message: str
|
|
129
|
+
>>> get_event_name(Ping)
|
|
130
|
+
'ping'
|
|
131
|
+
|
|
132
|
+
>>> from typing import ClassVar
|
|
133
|
+
>>> class CustomEvent(BaseModel):
|
|
134
|
+
... event_name: ClassVar[str] = "my_custom_event"
|
|
135
|
+
... data: str
|
|
136
|
+
>>> get_event_name(CustomEvent)
|
|
137
|
+
'my_custom_event'
|
|
138
|
+
"""
|
|
139
|
+
cls = model if isinstance(model, type) else type(model)
|
|
140
|
+
|
|
141
|
+
if hasattr(cls, "event_name"):
|
|
142
|
+
return cls.event_name # type: ignore[return-value]
|
|
143
|
+
|
|
144
|
+
# Convert PascalCase to snake_case
|
|
145
|
+
name = cls.__name__
|
|
146
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# =============================================================================
|
|
150
|
+
# Shared Helper Functions
|
|
151
|
+
# =============================================================================
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_emit_args(event: str | BaseModel, data: Any = None) -> tuple[str, Any]:
|
|
155
|
+
"""Resolve event name and payload from emit arguments.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
event: Either a string event name or a BaseModel instance.
|
|
159
|
+
data: Optional data payload (used when event is a string).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Tuple of (event_name, serialized_payload).
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
TypeError: If event is a BaseModel and data is also provided.
|
|
166
|
+
"""
|
|
167
|
+
if isinstance(event, BaseModel):
|
|
168
|
+
if data is not None:
|
|
169
|
+
raise TypeError(
|
|
170
|
+
"Cannot provide both a BaseModel instance as event and a data argument. "
|
|
171
|
+
"Use emit(MyModel(...)) or emit('event_name', data=...), not both."
|
|
172
|
+
)
|
|
173
|
+
return get_event_name(event), to_jsonable_python(event)
|
|
174
|
+
return event, to_jsonable_python(data) if isinstance(data, BaseModel) else data
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _validate_response(response: Any, response_model: TypeForm[T] | None) -> T | Any:
|
|
178
|
+
"""Validate response against response_model if provided.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
response: The raw response from socketio.
|
|
182
|
+
response_model: Optional type form (single type, union, or Annotated).
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
186
|
+
"""
|
|
187
|
+
if response_model is not None:
|
|
188
|
+
return TypeAdapter(response_model).validate_python(response)
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _extract_dependencies(handler: Callable) -> dict[str, Callable]:
|
|
193
|
+
"""Extract Depends() declarations from a handler's type hints and defaults.
|
|
194
|
+
|
|
195
|
+
Checks both ``Annotated[T, Depends(...)]`` metadata and plain default values.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
handler: The event handler function.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Dict mapping parameter name to the dependency callable.
|
|
202
|
+
"""
|
|
203
|
+
deps: dict[str, Callable] = {}
|
|
204
|
+
|
|
205
|
+
# Check Annotated metadata: param: Annotated[Redis, Depends(get_redis)]
|
|
206
|
+
try:
|
|
207
|
+
hints = get_type_hints(handler, include_extras=True)
|
|
208
|
+
except Exception:
|
|
209
|
+
hints = {}
|
|
210
|
+
for name, hint in hints.items():
|
|
211
|
+
if get_origin(hint) is Annotated:
|
|
212
|
+
for metadata in get_args(hint)[1:]:
|
|
213
|
+
if (
|
|
214
|
+
isinstance(metadata, _DependsClass)
|
|
215
|
+
and metadata.dependency is not None
|
|
216
|
+
):
|
|
217
|
+
deps[name] = metadata.dependency
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
# Check default values: param: Redis = Depends(get_redis)
|
|
221
|
+
sig = inspect.signature(handler)
|
|
222
|
+
for name, param in sig.parameters.items():
|
|
223
|
+
if name not in deps and isinstance(param.default, _DependsClass):
|
|
224
|
+
if param.default.dependency is not None:
|
|
225
|
+
deps[name] = param.default.dependency
|
|
226
|
+
|
|
227
|
+
return deps
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _resolve_dependencies(
|
|
231
|
+
deps: dict[str, Callable],
|
|
232
|
+
*,
|
|
233
|
+
app: Any = None,
|
|
234
|
+
stack: AsyncExitStack,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Resolve dependency callables into their values.
|
|
237
|
+
|
|
238
|
+
Supports sync/async callables and sync/async generators.
|
|
239
|
+
Generator dependencies are wrapped as context managers and registered
|
|
240
|
+
with the ``AsyncExitStack`` so teardown runs automatically when the
|
|
241
|
+
stack exits (following FastAPI's pattern).
|
|
242
|
+
|
|
243
|
+
If a dependency's signature includes a parameter typed as
|
|
244
|
+
``Request``, a ``SioRequest`` shim is injected automatically.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
deps: Dict mapping parameter name to dependency callable.
|
|
248
|
+
app: Optional FastAPI app instance for Request injection.
|
|
249
|
+
stack: AsyncExitStack for managing generator dependency lifecycle.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Dict mapping parameter name to resolved value.
|
|
253
|
+
"""
|
|
254
|
+
resolved: dict[str, Any] = {}
|
|
255
|
+
|
|
256
|
+
for name, dep_fn in deps.items():
|
|
257
|
+
# Build kwargs: inject SioRequest for Request-typed params
|
|
258
|
+
kwargs: dict[str, Any] = {}
|
|
259
|
+
if Request is not None and app is not None:
|
|
260
|
+
for pname, param in inspect.signature(dep_fn).parameters.items():
|
|
261
|
+
if param.annotation is Request:
|
|
262
|
+
kwargs[pname] = SioRequest(app=app)
|
|
263
|
+
|
|
264
|
+
if asyncio.iscoroutinefunction(dep_fn):
|
|
265
|
+
resolved[name] = await dep_fn(**kwargs)
|
|
266
|
+
elif inspect.isasyncgenfunction(dep_fn):
|
|
267
|
+
cm = asynccontextmanager(dep_fn)(**kwargs)
|
|
268
|
+
resolved[name] = await stack.enter_async_context(cm)
|
|
269
|
+
elif inspect.isgeneratorfunction(dep_fn):
|
|
270
|
+
cm = contextmanager(dep_fn)(**kwargs)
|
|
271
|
+
resolved[name] = stack.enter_context(cm)
|
|
272
|
+
else:
|
|
273
|
+
resolved[name] = dep_fn(**kwargs)
|
|
274
|
+
|
|
275
|
+
return resolved
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _create_async_handler_wrapper(handler: Callable, *, app: Any = None) -> Callable:
|
|
279
|
+
"""Wrap async handler with Pydantic validation, DI, and serialization.
|
|
280
|
+
|
|
281
|
+
Uses pydantic's validate_call to validate input arguments and return value
|
|
282
|
+
based on the function's type annotations. Resolves any Depends()
|
|
283
|
+
dependencies before calling the handler. Generator dependencies are
|
|
284
|
+
managed via AsyncExitStack for automatic cleanup.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
handler: The async event handler function.
|
|
288
|
+
app: Optional FastAPI app for Request injection in dependencies.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Wrapped async handler with validation and dependency injection.
|
|
292
|
+
"""
|
|
293
|
+
deps = _extract_dependencies(handler)
|
|
294
|
+
|
|
295
|
+
if deps:
|
|
296
|
+
|
|
297
|
+
@wraps(handler)
|
|
298
|
+
async def _dep_handler(*args: Any, **kwargs: Any) -> Any:
|
|
299
|
+
async with AsyncExitStack() as stack:
|
|
300
|
+
resolved = await _resolve_dependencies(deps, app=app, stack=stack)
|
|
301
|
+
kwargs.update(resolved)
|
|
302
|
+
return await handler(*args, **kwargs)
|
|
303
|
+
|
|
304
|
+
sig = inspect.signature(handler)
|
|
305
|
+
_dep_handler.__signature__ = sig.replace(
|
|
306
|
+
parameters=[p for n, p in sig.parameters.items() if n not in deps]
|
|
307
|
+
)
|
|
308
|
+
_dep_handler.__annotations__ = {
|
|
309
|
+
k: v for k, v in handler.__annotations__.items() if k not in deps
|
|
310
|
+
}
|
|
311
|
+
validated = validate_call(validate_return=True)(_dep_handler)
|
|
312
|
+
else:
|
|
313
|
+
validated = validate_call(validate_return=True)(handler)
|
|
314
|
+
|
|
315
|
+
@wraps(handler)
|
|
316
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
317
|
+
result = await validated(*args, **kwargs)
|
|
318
|
+
if isinstance(result, BaseModel):
|
|
319
|
+
return to_jsonable_python(result)
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
return wrapper
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _create_sync_handler_wrapper(handler: Callable) -> Callable:
|
|
326
|
+
"""Wrap sync handler with Pydantic validation and serialization.
|
|
327
|
+
|
|
328
|
+
Uses pydantic's validate_call to validate input arguments and return value
|
|
329
|
+
based on the function's type annotations.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
handler: The sync event handler function.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Wrapped sync handler with validation.
|
|
336
|
+
"""
|
|
337
|
+
validated = validate_call(validate_return=True)(handler)
|
|
338
|
+
|
|
339
|
+
@wraps(handler)
|
|
340
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
341
|
+
result = validated(*args, **kwargs)
|
|
342
|
+
if isinstance(result, BaseModel):
|
|
343
|
+
return to_jsonable_python(result)
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
return wrapper
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# =============================================================================
|
|
350
|
+
# Async Client Wrapper
|
|
351
|
+
# =============================================================================
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class AsyncClientWrapper:
|
|
355
|
+
"""Typed wrapper for socketio.AsyncClient.
|
|
356
|
+
|
|
357
|
+
Provides typed emit, call, and on methods while passing through all other
|
|
358
|
+
attributes to the underlying AsyncClient instance.
|
|
359
|
+
|
|
360
|
+
Example:
|
|
361
|
+
>>> import socketio
|
|
362
|
+
>>> from zndraw_socketio import wrap
|
|
363
|
+
>>> sio = wrap(socketio.AsyncClient())
|
|
364
|
+
>>> await sio.emit(Ping(message="hello"))
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
def __init__(self, sio: AsyncClient) -> None:
|
|
368
|
+
"""Initialize wrapper with an AsyncClient instance.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
sio: The socketio AsyncClient to wrap.
|
|
372
|
+
"""
|
|
373
|
+
self._sio = sio
|
|
374
|
+
|
|
375
|
+
def __getattr__(self, name: str) -> Any:
|
|
376
|
+
"""Delegate attribute access to the underlying socketio instance."""
|
|
377
|
+
return getattr(self._sio, name)
|
|
378
|
+
|
|
379
|
+
# emit overloads
|
|
380
|
+
@overload
|
|
381
|
+
async def emit(
|
|
382
|
+
self,
|
|
383
|
+
event: BaseModel,
|
|
384
|
+
**kwargs: Any,
|
|
385
|
+
) -> None: ...
|
|
386
|
+
|
|
387
|
+
@overload
|
|
388
|
+
async def emit(
|
|
389
|
+
self,
|
|
390
|
+
event: str,
|
|
391
|
+
data: Any = None,
|
|
392
|
+
**kwargs: Any,
|
|
393
|
+
) -> None: ...
|
|
394
|
+
|
|
395
|
+
async def emit(
|
|
396
|
+
self,
|
|
397
|
+
event: str | BaseModel,
|
|
398
|
+
data: Any = None,
|
|
399
|
+
**kwargs: Any,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Emit an event to the server.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
event: Either a string event name or a BaseModel instance.
|
|
405
|
+
If BaseModel, event name is derived from the class name.
|
|
406
|
+
data: Optional data payload (used when event is a string).
|
|
407
|
+
**kwargs: Additional arguments passed to socketio's emit.
|
|
408
|
+
"""
|
|
409
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
410
|
+
await self._sio.emit(event_name, payload, **kwargs)
|
|
411
|
+
|
|
412
|
+
@overload
|
|
413
|
+
async def call(
|
|
414
|
+
self,
|
|
415
|
+
event: BaseModel,
|
|
416
|
+
*,
|
|
417
|
+
response_model: TypeForm[T],
|
|
418
|
+
**kwargs: Any,
|
|
419
|
+
) -> T: ...
|
|
420
|
+
|
|
421
|
+
@overload
|
|
422
|
+
async def call(
|
|
423
|
+
self,
|
|
424
|
+
event: BaseModel,
|
|
425
|
+
**kwargs: Any,
|
|
426
|
+
) -> Any: ...
|
|
427
|
+
|
|
428
|
+
@overload
|
|
429
|
+
async def call(
|
|
430
|
+
self,
|
|
431
|
+
event: str,
|
|
432
|
+
data: Any = None,
|
|
433
|
+
*,
|
|
434
|
+
response_model: TypeForm[T],
|
|
435
|
+
**kwargs: Any,
|
|
436
|
+
) -> T: ...
|
|
437
|
+
|
|
438
|
+
@overload
|
|
439
|
+
async def call(
|
|
440
|
+
self,
|
|
441
|
+
event: str,
|
|
442
|
+
data: Any = None,
|
|
443
|
+
**kwargs: Any,
|
|
444
|
+
) -> Any: ...
|
|
445
|
+
|
|
446
|
+
async def call(
|
|
447
|
+
self,
|
|
448
|
+
event: str | BaseModel,
|
|
449
|
+
data: Any = None,
|
|
450
|
+
*,
|
|
451
|
+
response_model: Any = None,
|
|
452
|
+
**kwargs: Any,
|
|
453
|
+
) -> Any:
|
|
454
|
+
"""Emit an event and wait for a response.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
event: Either a string event name or a BaseModel instance.
|
|
458
|
+
data: Optional data payload (used when event is a string).
|
|
459
|
+
response_model: Optional type to validate response against (PEP 747 TypeForm).
|
|
460
|
+
**kwargs: Additional arguments passed to socketio's call.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
464
|
+
"""
|
|
465
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
466
|
+
response = await self._sio.call(event_name, payload, **kwargs)
|
|
467
|
+
return _validate_response(response, response_model)
|
|
468
|
+
|
|
469
|
+
def on(
|
|
470
|
+
self,
|
|
471
|
+
event: str | Type[BaseModel],
|
|
472
|
+
handler: Callable | None = None,
|
|
473
|
+
**kwargs: Any,
|
|
474
|
+
) -> Callable[[Callable], Callable] | Callable:
|
|
475
|
+
"""Register an event handler.
|
|
476
|
+
|
|
477
|
+
Can be used as a decorator or called directly with a handler.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
event: Either a string event name or a BaseModel class.
|
|
481
|
+
If BaseModel class, event name is derived from the class name.
|
|
482
|
+
handler: Optional handler function (if not using as decorator).
|
|
483
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Decorator that registers the handler with validation, or the handler
|
|
487
|
+
if called directly.
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
>>> @tsio.on(Ping)
|
|
491
|
+
... async def handle_ping(data: Ping) -> Pong:
|
|
492
|
+
... return Pong(reply=data.message)
|
|
493
|
+
|
|
494
|
+
>>> @tsio.on(Ping, namespace='/chat')
|
|
495
|
+
... async def handle_ping(data: Ping) -> Pong:
|
|
496
|
+
... return Pong(reply=data.message)
|
|
497
|
+
"""
|
|
498
|
+
if isinstance(event, type) and issubclass(event, BaseModel):
|
|
499
|
+
event_name = get_event_name(event)
|
|
500
|
+
else:
|
|
501
|
+
event_name = event
|
|
502
|
+
|
|
503
|
+
def decorator(handler: Callable) -> Callable:
|
|
504
|
+
wrapped = _create_async_handler_wrapper(handler)
|
|
505
|
+
self._sio.on(event_name, wrapped, **kwargs)
|
|
506
|
+
return handler
|
|
507
|
+
|
|
508
|
+
if handler is not None:
|
|
509
|
+
return decorator(handler)
|
|
510
|
+
return decorator
|
|
511
|
+
|
|
512
|
+
def event(self, handler: Callable | None = None, **kwargs: Any) -> Callable:
|
|
513
|
+
"""Register an event handler using the function name as the event name.
|
|
514
|
+
|
|
515
|
+
This decorator uses the function name directly as the event name and
|
|
516
|
+
wraps the handler with Pydantic validation based on type annotations.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
handler: The event handler function.
|
|
520
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
The original handler (unmodified).
|
|
524
|
+
|
|
525
|
+
Example:
|
|
526
|
+
>>> @tsio.event
|
|
527
|
+
... async def ping(data: Ping) -> Pong:
|
|
528
|
+
... return Pong(reply=data.message)
|
|
529
|
+
|
|
530
|
+
>>> @tsio.event(namespace='/chat')
|
|
531
|
+
... async def ping(data: Ping) -> Pong:
|
|
532
|
+
... return Pong(reply=data.message)
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
def decorator(handler: Callable) -> Callable:
|
|
536
|
+
event_name = handler.__name__
|
|
537
|
+
wrapped = _create_async_handler_wrapper(handler)
|
|
538
|
+
self._sio.on(event_name, wrapped, **kwargs)
|
|
539
|
+
return handler
|
|
540
|
+
|
|
541
|
+
if handler is not None:
|
|
542
|
+
return decorator(handler)
|
|
543
|
+
return decorator
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# =============================================================================
|
|
547
|
+
# Async Server Wrapper
|
|
548
|
+
# =============================================================================
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class AsyncServerWrapper:
|
|
552
|
+
"""Typed wrapper for socketio.AsyncServer.
|
|
553
|
+
|
|
554
|
+
Provides typed emit, call, and on methods while passing through all other
|
|
555
|
+
attributes to the underlying AsyncServer instance.
|
|
556
|
+
|
|
557
|
+
Also provides FastAPI integration via Depends() support and exception
|
|
558
|
+
handlers for Socket.IO events.
|
|
559
|
+
|
|
560
|
+
Example:
|
|
561
|
+
>>> import socketio
|
|
562
|
+
>>> from fastapi import FastAPI, Depends
|
|
563
|
+
>>> from zndraw_socketio import wrap
|
|
564
|
+
>>> from typing import Annotated
|
|
565
|
+
>>>
|
|
566
|
+
>>> app = FastAPI()
|
|
567
|
+
>>> tsio = wrap(socketio.AsyncServer(async_mode='asgi'))
|
|
568
|
+
>>>
|
|
569
|
+
>>> # Create type alias for dependency injection
|
|
570
|
+
>>> SioServer = Annotated[AsyncServerWrapper, Depends(tsio)]
|
|
571
|
+
>>>
|
|
572
|
+
>>> @app.post("/notify")
|
|
573
|
+
... async def notify(server: SioServer):
|
|
574
|
+
... await server.emit("notification", {"msg": "hello"})
|
|
575
|
+
... return {"status": "sent"}
|
|
576
|
+
>>>
|
|
577
|
+
>>> @tsio.exception_handler(ValueError)
|
|
578
|
+
... async def handle_error(ctx: EventContext, exc: ValueError):
|
|
579
|
+
... return {"error": str(exc)}
|
|
580
|
+
>>>
|
|
581
|
+
>>> # Create combined ASGI app
|
|
582
|
+
>>> combined_app = socketio.ASGIApp(tsio, app)
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
def __init__(self, sio: AsyncServer, app: "FastAPI | None" = None) -> None:
|
|
586
|
+
"""Initialize wrapper with an AsyncServer instance.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
sio: The socketio AsyncServer to wrap.
|
|
590
|
+
app: Optional FastAPI application instance. When provided,
|
|
591
|
+
dependencies that accept a ``Request`` parameter will
|
|
592
|
+
receive a shim with ``request.app`` pointing to this instance.
|
|
593
|
+
"""
|
|
594
|
+
self._sio = sio
|
|
595
|
+
self._app = app
|
|
596
|
+
# {namespace: {ExceptionType: handler_fn}}
|
|
597
|
+
# namespace=None means global handler
|
|
598
|
+
self._exception_handlers: dict[str | None, dict[type[Exception], Callable]] = {}
|
|
599
|
+
|
|
600
|
+
def __getattr__(self, name: str) -> Any:
|
|
601
|
+
"""Delegate attribute access to the underlying socketio instance."""
|
|
602
|
+
return getattr(self._sio, name)
|
|
603
|
+
|
|
604
|
+
def __call__(self) -> AsyncServerWrapper:
|
|
605
|
+
"""Return the wrapper instance for FastAPI Depends().
|
|
606
|
+
|
|
607
|
+
This makes the wrapper callable, allowing it to be used directly
|
|
608
|
+
with FastAPI's dependency injection. Returning the wrapper (rather
|
|
609
|
+
than the raw server) ensures that routes have access to typed
|
|
610
|
+
emit() and call() methods.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
The AsyncServerWrapper instance.
|
|
614
|
+
|
|
615
|
+
Example:
|
|
616
|
+
>>> from typing import Annotated
|
|
617
|
+
>>> from fastapi import Depends
|
|
618
|
+
>>>
|
|
619
|
+
>>> SioServer = Annotated[AsyncServerWrapper, Depends(tsio)]
|
|
620
|
+
>>>
|
|
621
|
+
>>> @app.get("/emit")
|
|
622
|
+
... async def emit(sio: SioServer):
|
|
623
|
+
... await sio.emit(MyModel(data="hello"))
|
|
624
|
+
"""
|
|
625
|
+
return self
|
|
626
|
+
|
|
627
|
+
def exception_handler(
|
|
628
|
+
self,
|
|
629
|
+
exc_type: type[Exception],
|
|
630
|
+
namespace: str | None = None,
|
|
631
|
+
) -> Callable[[Callable], Callable]:
|
|
632
|
+
"""Register an exception handler for Socket.IO events.
|
|
633
|
+
|
|
634
|
+
Similar to FastAPI's `@app.exception_handler()` decorator, this allows
|
|
635
|
+
catching exceptions from event handlers and returning structured responses.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
exc_type: The exception type to handle.
|
|
639
|
+
namespace: Optional namespace to scope the handler to.
|
|
640
|
+
If None, the handler applies globally to all namespaces.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Decorator that registers the async handler function.
|
|
644
|
+
|
|
645
|
+
Example:
|
|
646
|
+
>>> @tsio.exception_handler(ValidationError)
|
|
647
|
+
... async def handle_validation(ctx: EventContext, exc: ValidationError):
|
|
648
|
+
... return {"error": "validation_error", "details": exc.errors()}
|
|
649
|
+
>>>
|
|
650
|
+
>>> @tsio.exception_handler(ValueError, namespace="/chat")
|
|
651
|
+
... async def handle_chat_error(ctx: EventContext, exc: ValueError):
|
|
652
|
+
... return {"error": "chat_error", "message": str(exc)}
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
def decorator(handler: Callable) -> Callable:
|
|
656
|
+
if namespace not in self._exception_handlers:
|
|
657
|
+
self._exception_handlers[namespace] = {}
|
|
658
|
+
self._exception_handlers[namespace][exc_type] = handler
|
|
659
|
+
return handler
|
|
660
|
+
|
|
661
|
+
return decorator
|
|
662
|
+
|
|
663
|
+
def _find_exception_handler(
|
|
664
|
+
self,
|
|
665
|
+
exc: Exception,
|
|
666
|
+
namespace: str,
|
|
667
|
+
) -> Callable | None:
|
|
668
|
+
"""Find the most specific exception handler for the given exception.
|
|
669
|
+
|
|
670
|
+
Resolution order (most specific first):
|
|
671
|
+
1. Namespace-specific handler for exact exception type
|
|
672
|
+
2. Namespace-specific handler for parent exception type (MRO order)
|
|
673
|
+
3. Global handler for exact exception type
|
|
674
|
+
4. Global handler for parent exception type (MRO order)
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
exc: The exception that was raised.
|
|
678
|
+
namespace: The namespace where the event was triggered.
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
The handler function if found, None otherwise.
|
|
682
|
+
"""
|
|
683
|
+
exc_type = type(exc)
|
|
684
|
+
|
|
685
|
+
# Check namespace-specific handlers first, then global (None)
|
|
686
|
+
for ns in (namespace, None):
|
|
687
|
+
handlers = self._exception_handlers.get(ns, {})
|
|
688
|
+
|
|
689
|
+
# Walk MRO to find matching handler
|
|
690
|
+
for cls in exc_type.__mro__:
|
|
691
|
+
if cls in handlers:
|
|
692
|
+
return handlers[cls]
|
|
693
|
+
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
# emit overloads
|
|
697
|
+
@overload
|
|
698
|
+
async def emit(
|
|
699
|
+
self,
|
|
700
|
+
event: BaseModel,
|
|
701
|
+
**kwargs: Any,
|
|
702
|
+
) -> None: ...
|
|
703
|
+
|
|
704
|
+
@overload
|
|
705
|
+
async def emit(
|
|
706
|
+
self,
|
|
707
|
+
event: str,
|
|
708
|
+
data: Any = None,
|
|
709
|
+
**kwargs: Any,
|
|
710
|
+
) -> None: ...
|
|
711
|
+
|
|
712
|
+
async def emit(
|
|
713
|
+
self,
|
|
714
|
+
event: str | BaseModel,
|
|
715
|
+
data: Any = None,
|
|
716
|
+
**kwargs: Any,
|
|
717
|
+
) -> None:
|
|
718
|
+
"""Emit an event to connected clients.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
event: Either a string event name or a BaseModel instance.
|
|
722
|
+
data: Optional data payload (used when event is a string).
|
|
723
|
+
**kwargs: Additional arguments passed to socketio's emit (to, room, etc).
|
|
724
|
+
"""
|
|
725
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
726
|
+
await self._sio.emit(event_name, payload, **kwargs)
|
|
727
|
+
|
|
728
|
+
# call overloads (see PEP 747 note in AsyncClientWrapper)
|
|
729
|
+
@overload
|
|
730
|
+
async def call(
|
|
731
|
+
self,
|
|
732
|
+
event: BaseModel,
|
|
733
|
+
*,
|
|
734
|
+
response_model: TypeForm[T],
|
|
735
|
+
**kwargs: Any,
|
|
736
|
+
) -> T: ...
|
|
737
|
+
|
|
738
|
+
@overload
|
|
739
|
+
async def call(
|
|
740
|
+
self,
|
|
741
|
+
event: BaseModel,
|
|
742
|
+
**kwargs: Any,
|
|
743
|
+
) -> Any: ...
|
|
744
|
+
|
|
745
|
+
@overload
|
|
746
|
+
async def call(
|
|
747
|
+
self,
|
|
748
|
+
event: str,
|
|
749
|
+
data: Any = None,
|
|
750
|
+
*,
|
|
751
|
+
response_model: TypeForm[T],
|
|
752
|
+
**kwargs: Any,
|
|
753
|
+
) -> T: ...
|
|
754
|
+
|
|
755
|
+
@overload
|
|
756
|
+
async def call(
|
|
757
|
+
self,
|
|
758
|
+
event: str,
|
|
759
|
+
data: Any = None,
|
|
760
|
+
**kwargs: Any,
|
|
761
|
+
) -> Any: ...
|
|
762
|
+
|
|
763
|
+
async def call(
|
|
764
|
+
self,
|
|
765
|
+
event: str | BaseModel,
|
|
766
|
+
data: Any = None,
|
|
767
|
+
*,
|
|
768
|
+
response_model: Any = None,
|
|
769
|
+
**kwargs: Any,
|
|
770
|
+
) -> T | Any:
|
|
771
|
+
"""Emit an event and wait for a response from a client.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
event: Either a string event name or a BaseModel instance.
|
|
775
|
+
data: Optional data payload (used when event is a string).
|
|
776
|
+
response_model: Optional Pydantic model type to validate response.
|
|
777
|
+
**kwargs: Additional arguments passed to socketio's call (to, sid, etc).
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
781
|
+
"""
|
|
782
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
783
|
+
response = await self._sio.call(event_name, payload, **kwargs)
|
|
784
|
+
return _validate_response(response, response_model)
|
|
785
|
+
|
|
786
|
+
def on(
|
|
787
|
+
self,
|
|
788
|
+
event: str | Type[BaseModel],
|
|
789
|
+
handler: Callable | None = None,
|
|
790
|
+
**kwargs: Any,
|
|
791
|
+
) -> Callable[[Callable], Callable] | Callable:
|
|
792
|
+
"""Register an event handler with exception handling support.
|
|
793
|
+
|
|
794
|
+
Wraps handlers with Pydantic validation and exception handling that
|
|
795
|
+
routes to registered exception handlers.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
event: Either a string event name or a BaseModel class.
|
|
799
|
+
handler: Optional handler function (if not using as decorator).
|
|
800
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
Decorator that registers the handler with validation and exception handling.
|
|
804
|
+
"""
|
|
805
|
+
if isinstance(event, type) and issubclass(event, BaseModel):
|
|
806
|
+
event_name = get_event_name(event)
|
|
807
|
+
else:
|
|
808
|
+
event_name = event
|
|
809
|
+
|
|
810
|
+
namespace = kwargs.get("namespace", "/")
|
|
811
|
+
|
|
812
|
+
def decorator(handler: Callable) -> Callable:
|
|
813
|
+
# Wrap with validation
|
|
814
|
+
validated_wrapped = _create_async_handler_wrapper(handler, app=self._app)
|
|
815
|
+
|
|
816
|
+
# Add exception handling wrapper
|
|
817
|
+
@wraps(validated_wrapped)
|
|
818
|
+
async def exc_wrapped(sid: str, *args: Any, **kw: Any) -> Any:
|
|
819
|
+
data = args[0] if args else kw.get("data")
|
|
820
|
+
try:
|
|
821
|
+
return await validated_wrapped(sid, *args, **kw)
|
|
822
|
+
except Exception as exc:
|
|
823
|
+
exc_handler = self._find_exception_handler(exc, namespace)
|
|
824
|
+
if exc_handler is None:
|
|
825
|
+
raise
|
|
826
|
+
ctx = EventContext(
|
|
827
|
+
sid=sid,
|
|
828
|
+
event=event_name,
|
|
829
|
+
namespace=namespace,
|
|
830
|
+
data=data,
|
|
831
|
+
sio=self,
|
|
832
|
+
)
|
|
833
|
+
result = await exc_handler(ctx, exc)
|
|
834
|
+
if isinstance(result, BaseModel):
|
|
835
|
+
return to_jsonable_python(result)
|
|
836
|
+
return result
|
|
837
|
+
|
|
838
|
+
self._sio.on(event_name, exc_wrapped, **kwargs)
|
|
839
|
+
return handler
|
|
840
|
+
|
|
841
|
+
if handler is not None:
|
|
842
|
+
return decorator(handler)
|
|
843
|
+
return decorator
|
|
844
|
+
|
|
845
|
+
def event(self, handler: Callable | None = None, **kwargs: Any) -> Callable:
|
|
846
|
+
"""Register an event handler using the function name as the event name.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
handler: The event handler function.
|
|
850
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
The original handler (unmodified).
|
|
854
|
+
"""
|
|
855
|
+
namespace = kwargs.get("namespace", "/")
|
|
856
|
+
|
|
857
|
+
def decorator(handler: Callable) -> Callable:
|
|
858
|
+
event_name = handler.__name__
|
|
859
|
+
# Wrap with validation
|
|
860
|
+
validated_wrapped = _create_async_handler_wrapper(handler, app=self._app)
|
|
861
|
+
|
|
862
|
+
# Add exception handling wrapper
|
|
863
|
+
@wraps(validated_wrapped)
|
|
864
|
+
async def exc_wrapped(sid: str, *args: Any, **kw: Any) -> Any:
|
|
865
|
+
data = args[0] if args else kw.get("data")
|
|
866
|
+
try:
|
|
867
|
+
return await validated_wrapped(sid, *args, **kw)
|
|
868
|
+
except Exception as exc:
|
|
869
|
+
exc_handler = self._find_exception_handler(exc, namespace)
|
|
870
|
+
if exc_handler is None:
|
|
871
|
+
raise
|
|
872
|
+
ctx = EventContext(
|
|
873
|
+
sid=sid,
|
|
874
|
+
event=event_name,
|
|
875
|
+
namespace=namespace,
|
|
876
|
+
data=data,
|
|
877
|
+
sio=self,
|
|
878
|
+
)
|
|
879
|
+
result = await exc_handler(ctx, exc)
|
|
880
|
+
if isinstance(result, BaseModel):
|
|
881
|
+
return to_jsonable_python(result)
|
|
882
|
+
return result
|
|
883
|
+
|
|
884
|
+
self._sio.on(event_name, exc_wrapped, **kwargs)
|
|
885
|
+
return handler
|
|
886
|
+
|
|
887
|
+
if handler is not None:
|
|
888
|
+
return decorator(handler)
|
|
889
|
+
return decorator
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# =============================================================================
|
|
893
|
+
# Sync Client Wrapper
|
|
894
|
+
# =============================================================================
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class SyncClientWrapper:
|
|
898
|
+
"""Typed wrapper for socketio.Client.
|
|
899
|
+
|
|
900
|
+
Provides typed emit, call, and on methods while passing through all other
|
|
901
|
+
attributes to the underlying Client instance.
|
|
902
|
+
"""
|
|
903
|
+
|
|
904
|
+
def __init__(self, sio: Client) -> None:
|
|
905
|
+
"""Initialize wrapper with a Client instance.
|
|
906
|
+
|
|
907
|
+
Args:
|
|
908
|
+
sio: The socketio Client to wrap.
|
|
909
|
+
"""
|
|
910
|
+
self._sio = sio
|
|
911
|
+
|
|
912
|
+
def __getattr__(self, name: str) -> Any:
|
|
913
|
+
"""Delegate attribute access to the underlying socketio instance."""
|
|
914
|
+
return getattr(self._sio, name)
|
|
915
|
+
|
|
916
|
+
# emit overloads
|
|
917
|
+
@overload
|
|
918
|
+
def emit(
|
|
919
|
+
self,
|
|
920
|
+
event: BaseModel,
|
|
921
|
+
**kwargs: Any,
|
|
922
|
+
) -> None: ...
|
|
923
|
+
|
|
924
|
+
@overload
|
|
925
|
+
def emit(
|
|
926
|
+
self,
|
|
927
|
+
event: str,
|
|
928
|
+
data: Any = None,
|
|
929
|
+
**kwargs: Any,
|
|
930
|
+
) -> None: ...
|
|
931
|
+
|
|
932
|
+
def emit(
|
|
933
|
+
self,
|
|
934
|
+
event: str | BaseModel,
|
|
935
|
+
data: Any = None,
|
|
936
|
+
**kwargs: Any,
|
|
937
|
+
) -> None:
|
|
938
|
+
"""Emit an event to the server.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
event: Either a string event name or a BaseModel instance.
|
|
942
|
+
data: Optional data payload (used when event is a string).
|
|
943
|
+
**kwargs: Additional arguments passed to socketio's emit.
|
|
944
|
+
"""
|
|
945
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
946
|
+
self._sio.emit(event_name, payload, **kwargs)
|
|
947
|
+
|
|
948
|
+
@overload
|
|
949
|
+
def call(
|
|
950
|
+
self,
|
|
951
|
+
event: BaseModel,
|
|
952
|
+
*,
|
|
953
|
+
response_model: TypeForm[T],
|
|
954
|
+
**kwargs: Any,
|
|
955
|
+
) -> T: ...
|
|
956
|
+
|
|
957
|
+
@overload
|
|
958
|
+
def call(
|
|
959
|
+
self,
|
|
960
|
+
event: BaseModel,
|
|
961
|
+
**kwargs: Any,
|
|
962
|
+
) -> Any: ...
|
|
963
|
+
|
|
964
|
+
@overload
|
|
965
|
+
def call(
|
|
966
|
+
self,
|
|
967
|
+
event: str,
|
|
968
|
+
data: Any = None,
|
|
969
|
+
*,
|
|
970
|
+
response_model: TypeForm[T],
|
|
971
|
+
**kwargs: Any,
|
|
972
|
+
) -> T: ...
|
|
973
|
+
|
|
974
|
+
@overload
|
|
975
|
+
def call(
|
|
976
|
+
self,
|
|
977
|
+
event: str,
|
|
978
|
+
data: Any = None,
|
|
979
|
+
**kwargs: Any,
|
|
980
|
+
) -> Any: ...
|
|
981
|
+
|
|
982
|
+
def call(
|
|
983
|
+
self,
|
|
984
|
+
event: str | BaseModel,
|
|
985
|
+
data: Any = None,
|
|
986
|
+
*,
|
|
987
|
+
response_model: Any = None,
|
|
988
|
+
**kwargs: Any,
|
|
989
|
+
) -> Any:
|
|
990
|
+
"""Emit an event and wait for a response.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
event: Either a string event name or a BaseModel instance.
|
|
994
|
+
data: Optional data payload (used when event is a string).
|
|
995
|
+
response_model: Optional type to validate response against (PEP 747 TypeForm).
|
|
996
|
+
**kwargs: Additional arguments passed to socketio's call.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
1000
|
+
"""
|
|
1001
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1002
|
+
response = self._sio.call(event_name, payload, **kwargs)
|
|
1003
|
+
return _validate_response(response, response_model)
|
|
1004
|
+
|
|
1005
|
+
def on(
|
|
1006
|
+
self,
|
|
1007
|
+
event: str | Type[BaseModel],
|
|
1008
|
+
handler: Callable | None = None,
|
|
1009
|
+
**kwargs: Any,
|
|
1010
|
+
) -> Callable[[Callable], Callable] | Callable:
|
|
1011
|
+
"""Register an event handler.
|
|
1012
|
+
|
|
1013
|
+
Args:
|
|
1014
|
+
event: Either a string event name or a BaseModel class.
|
|
1015
|
+
handler: Optional handler function (if not using as decorator).
|
|
1016
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
1017
|
+
|
|
1018
|
+
Returns:
|
|
1019
|
+
Decorator that registers the handler with validation.
|
|
1020
|
+
"""
|
|
1021
|
+
if isinstance(event, type) and issubclass(event, BaseModel):
|
|
1022
|
+
event_name = get_event_name(event)
|
|
1023
|
+
else:
|
|
1024
|
+
event_name = event
|
|
1025
|
+
|
|
1026
|
+
def decorator(handler: Callable) -> Callable:
|
|
1027
|
+
wrapped = _create_sync_handler_wrapper(handler)
|
|
1028
|
+
self._sio.on(event_name, wrapped, **kwargs)
|
|
1029
|
+
return handler
|
|
1030
|
+
|
|
1031
|
+
if handler is not None:
|
|
1032
|
+
return decorator(handler)
|
|
1033
|
+
return decorator
|
|
1034
|
+
|
|
1035
|
+
def event(self, handler: Callable | None = None, **kwargs: Any) -> Callable:
|
|
1036
|
+
"""Register an event handler using the function name as the event name.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
handler: The event handler function.
|
|
1040
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
The original handler (unmodified).
|
|
1044
|
+
"""
|
|
1045
|
+
|
|
1046
|
+
def decorator(handler: Callable) -> Callable:
|
|
1047
|
+
event_name = handler.__name__
|
|
1048
|
+
wrapped = _create_sync_handler_wrapper(handler)
|
|
1049
|
+
self._sio.on(event_name, wrapped, **kwargs)
|
|
1050
|
+
return handler
|
|
1051
|
+
|
|
1052
|
+
if handler is not None:
|
|
1053
|
+
return decorator(handler)
|
|
1054
|
+
return decorator
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
# =============================================================================
|
|
1058
|
+
# Async Simple Client Wrapper
|
|
1059
|
+
# =============================================================================
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
class AsyncSimpleClientWrapper:
|
|
1063
|
+
"""Typed wrapper for socketio.AsyncSimpleClient.
|
|
1064
|
+
|
|
1065
|
+
Provides typed emit, call, and receive methods while passing through all
|
|
1066
|
+
other attributes to the underlying AsyncSimpleClient instance.
|
|
1067
|
+
|
|
1068
|
+
The SimpleClient API uses receive() instead of event handlers, making it
|
|
1069
|
+
ideal for testing and simple scripts.
|
|
1070
|
+
|
|
1071
|
+
Example:
|
|
1072
|
+
>>> import socketio
|
|
1073
|
+
>>> from zndraw_socketio import wrap
|
|
1074
|
+
>>> tsio = wrap(socketio.AsyncSimpleClient())
|
|
1075
|
+
>>> await tsio.connect('http://localhost:5000')
|
|
1076
|
+
>>> await tsio.emit(Ping(message="hello"))
|
|
1077
|
+
>>> event_name, data = await tsio.receive(response_model=Pong)
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
def __init__(self, sio: AsyncSimpleClient) -> None:
|
|
1081
|
+
"""Initialize wrapper with an AsyncSimpleClient instance.
|
|
1082
|
+
|
|
1083
|
+
Args:
|
|
1084
|
+
sio: The socketio AsyncSimpleClient to wrap.
|
|
1085
|
+
"""
|
|
1086
|
+
self._sio = sio
|
|
1087
|
+
|
|
1088
|
+
def __getattr__(self, name: str) -> Any:
|
|
1089
|
+
"""Delegate attribute access to the underlying socketio instance."""
|
|
1090
|
+
return getattr(self._sio, name)
|
|
1091
|
+
|
|
1092
|
+
# emit overloads
|
|
1093
|
+
@overload
|
|
1094
|
+
async def emit(
|
|
1095
|
+
self,
|
|
1096
|
+
event: BaseModel,
|
|
1097
|
+
) -> None: ...
|
|
1098
|
+
|
|
1099
|
+
@overload
|
|
1100
|
+
async def emit(
|
|
1101
|
+
self,
|
|
1102
|
+
event: str,
|
|
1103
|
+
data: Any = None,
|
|
1104
|
+
) -> None: ...
|
|
1105
|
+
|
|
1106
|
+
async def emit(
|
|
1107
|
+
self,
|
|
1108
|
+
event: str | BaseModel,
|
|
1109
|
+
data: Any = None,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
"""Emit an event to the server.
|
|
1112
|
+
|
|
1113
|
+
Args:
|
|
1114
|
+
event: Either a string event name or a BaseModel instance.
|
|
1115
|
+
If BaseModel, event name is derived from the class name.
|
|
1116
|
+
data: Optional data payload (used when event is a string).
|
|
1117
|
+
"""
|
|
1118
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1119
|
+
await self._sio.emit(event_name, payload)
|
|
1120
|
+
|
|
1121
|
+
# call overloads
|
|
1122
|
+
@overload
|
|
1123
|
+
async def call(
|
|
1124
|
+
self,
|
|
1125
|
+
event: BaseModel,
|
|
1126
|
+
*,
|
|
1127
|
+
response_model: TypeForm[T],
|
|
1128
|
+
timeout: int = 60,
|
|
1129
|
+
) -> T: ...
|
|
1130
|
+
|
|
1131
|
+
@overload
|
|
1132
|
+
async def call(
|
|
1133
|
+
self,
|
|
1134
|
+
event: BaseModel,
|
|
1135
|
+
*,
|
|
1136
|
+
timeout: int = 60,
|
|
1137
|
+
) -> Any: ...
|
|
1138
|
+
|
|
1139
|
+
@overload
|
|
1140
|
+
async def call(
|
|
1141
|
+
self,
|
|
1142
|
+
event: str,
|
|
1143
|
+
data: Any = None,
|
|
1144
|
+
*,
|
|
1145
|
+
response_model: TypeForm[T],
|
|
1146
|
+
timeout: int = 60,
|
|
1147
|
+
) -> T: ...
|
|
1148
|
+
|
|
1149
|
+
@overload
|
|
1150
|
+
async def call(
|
|
1151
|
+
self,
|
|
1152
|
+
event: str,
|
|
1153
|
+
data: Any = None,
|
|
1154
|
+
*,
|
|
1155
|
+
timeout: int = 60,
|
|
1156
|
+
) -> Any: ...
|
|
1157
|
+
|
|
1158
|
+
async def call(
|
|
1159
|
+
self,
|
|
1160
|
+
event: str | BaseModel,
|
|
1161
|
+
data: Any = None,
|
|
1162
|
+
*,
|
|
1163
|
+
response_model: Any = None,
|
|
1164
|
+
timeout: int = 60,
|
|
1165
|
+
) -> Any:
|
|
1166
|
+
"""Emit an event and wait for a response.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
event: Either a string event name or a BaseModel instance.
|
|
1170
|
+
data: Optional data payload (used when event is a string).
|
|
1171
|
+
response_model: Optional type to validate response against (PEP 747 TypeForm).
|
|
1172
|
+
timeout: Timeout in seconds (default 60).
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
1176
|
+
"""
|
|
1177
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1178
|
+
response = await self._sio.call(event_name, payload, timeout=timeout)
|
|
1179
|
+
return _validate_response(response, response_model)
|
|
1180
|
+
|
|
1181
|
+
# receive overloads
|
|
1182
|
+
@overload
|
|
1183
|
+
async def receive(
|
|
1184
|
+
self,
|
|
1185
|
+
*,
|
|
1186
|
+
response_model: TypeForm[T],
|
|
1187
|
+
timeout: float | None = None,
|
|
1188
|
+
) -> tuple[str, T]: ...
|
|
1189
|
+
|
|
1190
|
+
@overload
|
|
1191
|
+
async def receive(
|
|
1192
|
+
self,
|
|
1193
|
+
*,
|
|
1194
|
+
timeout: float | None = None,
|
|
1195
|
+
) -> tuple[str, Any]: ...
|
|
1196
|
+
|
|
1197
|
+
async def receive(
|
|
1198
|
+
self,
|
|
1199
|
+
*,
|
|
1200
|
+
response_model: Any = None,
|
|
1201
|
+
timeout: float | None = None,
|
|
1202
|
+
) -> tuple[str, Any]:
|
|
1203
|
+
"""Wait for an event from the server.
|
|
1204
|
+
|
|
1205
|
+
Args:
|
|
1206
|
+
response_model: Optional type to validate the event data against.
|
|
1207
|
+
timeout: Timeout in seconds (None for no timeout).
|
|
1208
|
+
|
|
1209
|
+
Returns:
|
|
1210
|
+
Tuple of (event_name, validated_data). If response_model is provided,
|
|
1211
|
+
the data is validated against it.
|
|
1212
|
+
|
|
1213
|
+
Raises:
|
|
1214
|
+
TimeoutError: If timeout is reached before receiving an event.
|
|
1215
|
+
"""
|
|
1216
|
+
result = await self._sio.receive(timeout=timeout)
|
|
1217
|
+
event_name = result[0]
|
|
1218
|
+
# SimpleClient receive returns [event_name, *args]
|
|
1219
|
+
event_data = result[1] if len(result) > 1 else None
|
|
1220
|
+
validated_data = _validate_response(event_data, response_model)
|
|
1221
|
+
return event_name, validated_data
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
# =============================================================================
|
|
1225
|
+
# Simple Client Wrapper
|
|
1226
|
+
# =============================================================================
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
class SimpleClientWrapper:
|
|
1230
|
+
"""Typed wrapper for socketio.SimpleClient.
|
|
1231
|
+
|
|
1232
|
+
Provides typed emit, call, and receive methods while passing through all
|
|
1233
|
+
other attributes to the underlying SimpleClient instance.
|
|
1234
|
+
|
|
1235
|
+
The SimpleClient API uses receive() instead of event handlers, making it
|
|
1236
|
+
ideal for testing and simple scripts.
|
|
1237
|
+
|
|
1238
|
+
Example:
|
|
1239
|
+
>>> import socketio
|
|
1240
|
+
>>> from zndraw_socketio import wrap
|
|
1241
|
+
>>> tsio = wrap(socketio.SimpleClient())
|
|
1242
|
+
>>> tsio.connect('http://localhost:5000')
|
|
1243
|
+
>>> tsio.emit(Ping(message="hello"))
|
|
1244
|
+
>>> event_name, data = tsio.receive(response_model=Pong)
|
|
1245
|
+
"""
|
|
1246
|
+
|
|
1247
|
+
def __init__(self, sio: SimpleClient) -> None:
|
|
1248
|
+
"""Initialize wrapper with a SimpleClient instance.
|
|
1249
|
+
|
|
1250
|
+
Args:
|
|
1251
|
+
sio: The socketio SimpleClient to wrap.
|
|
1252
|
+
"""
|
|
1253
|
+
self._sio = sio
|
|
1254
|
+
|
|
1255
|
+
def __getattr__(self, name: str) -> Any:
|
|
1256
|
+
"""Delegate attribute access to the underlying socketio instance."""
|
|
1257
|
+
return getattr(self._sio, name)
|
|
1258
|
+
|
|
1259
|
+
@overload
|
|
1260
|
+
def emit(
|
|
1261
|
+
self,
|
|
1262
|
+
event: BaseModel,
|
|
1263
|
+
) -> None: ...
|
|
1264
|
+
|
|
1265
|
+
@overload
|
|
1266
|
+
def emit(
|
|
1267
|
+
self,
|
|
1268
|
+
event: str,
|
|
1269
|
+
data: Any = None,
|
|
1270
|
+
) -> None: ...
|
|
1271
|
+
|
|
1272
|
+
def emit(
|
|
1273
|
+
self,
|
|
1274
|
+
event: str | BaseModel,
|
|
1275
|
+
data: Any = None,
|
|
1276
|
+
) -> None:
|
|
1277
|
+
"""Emit an event to the server.
|
|
1278
|
+
|
|
1279
|
+
Args:
|
|
1280
|
+
event: Either a string event name or a BaseModel instance.
|
|
1281
|
+
If BaseModel, event name is derived from the class name.
|
|
1282
|
+
data: Optional data payload (used when event is a string).
|
|
1283
|
+
"""
|
|
1284
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1285
|
+
self._sio.emit(event_name, payload)
|
|
1286
|
+
|
|
1287
|
+
@overload
|
|
1288
|
+
def call(
|
|
1289
|
+
self,
|
|
1290
|
+
event: BaseModel,
|
|
1291
|
+
*,
|
|
1292
|
+
response_model: TypeForm[T],
|
|
1293
|
+
timeout: int = 60,
|
|
1294
|
+
) -> T: ...
|
|
1295
|
+
|
|
1296
|
+
@overload
|
|
1297
|
+
def call(
|
|
1298
|
+
self,
|
|
1299
|
+
event: BaseModel,
|
|
1300
|
+
*,
|
|
1301
|
+
timeout: int = 60,
|
|
1302
|
+
) -> Any: ...
|
|
1303
|
+
|
|
1304
|
+
@overload
|
|
1305
|
+
def call(
|
|
1306
|
+
self,
|
|
1307
|
+
event: str,
|
|
1308
|
+
data: Any = None,
|
|
1309
|
+
*,
|
|
1310
|
+
response_model: TypeForm[T],
|
|
1311
|
+
timeout: int = 60,
|
|
1312
|
+
) -> T: ...
|
|
1313
|
+
|
|
1314
|
+
@overload
|
|
1315
|
+
def call(
|
|
1316
|
+
self,
|
|
1317
|
+
event: str,
|
|
1318
|
+
data: Any = None,
|
|
1319
|
+
*,
|
|
1320
|
+
timeout: int = 60,
|
|
1321
|
+
) -> Any: ...
|
|
1322
|
+
|
|
1323
|
+
def call(
|
|
1324
|
+
self,
|
|
1325
|
+
event: str | BaseModel,
|
|
1326
|
+
data: Any = None,
|
|
1327
|
+
*,
|
|
1328
|
+
response_model: Any = None,
|
|
1329
|
+
timeout: int = 60,
|
|
1330
|
+
) -> Any:
|
|
1331
|
+
"""Emit an event and wait for a response.
|
|
1332
|
+
|
|
1333
|
+
Args:
|
|
1334
|
+
event: Either a string event name or a BaseModel instance.
|
|
1335
|
+
data: Optional data payload (used when event is a string).
|
|
1336
|
+
response_model: Optional type to validate response against (PEP 747 TypeForm).
|
|
1337
|
+
timeout: Timeout in seconds (default 60).
|
|
1338
|
+
|
|
1339
|
+
Returns:
|
|
1340
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
1341
|
+
"""
|
|
1342
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1343
|
+
response = self._sio.call(event_name, payload, timeout=timeout)
|
|
1344
|
+
return _validate_response(response, response_model)
|
|
1345
|
+
|
|
1346
|
+
@overload
|
|
1347
|
+
def receive(
|
|
1348
|
+
self,
|
|
1349
|
+
*,
|
|
1350
|
+
response_model: TypeForm[T],
|
|
1351
|
+
timeout: float | None = None,
|
|
1352
|
+
) -> tuple[str, T]: ...
|
|
1353
|
+
|
|
1354
|
+
@overload
|
|
1355
|
+
def receive(
|
|
1356
|
+
self,
|
|
1357
|
+
*,
|
|
1358
|
+
timeout: float | None = None,
|
|
1359
|
+
) -> tuple[str, Any]: ...
|
|
1360
|
+
|
|
1361
|
+
def receive(
|
|
1362
|
+
self,
|
|
1363
|
+
*,
|
|
1364
|
+
response_model: Any = None,
|
|
1365
|
+
timeout: float | None = None,
|
|
1366
|
+
) -> tuple[str, Any]:
|
|
1367
|
+
"""Wait for an event from the server.
|
|
1368
|
+
|
|
1369
|
+
Args:
|
|
1370
|
+
response_model: Optional type to validate the event data against.
|
|
1371
|
+
timeout: Timeout in seconds (None for no timeout).
|
|
1372
|
+
|
|
1373
|
+
Returns:
|
|
1374
|
+
Tuple of (event_name, validated_data). If response_model is provided,
|
|
1375
|
+
the data is validated against it.
|
|
1376
|
+
|
|
1377
|
+
Raises:
|
|
1378
|
+
TimeoutError: If timeout is reached before receiving an event.
|
|
1379
|
+
"""
|
|
1380
|
+
result = self._sio.receive(timeout=timeout)
|
|
1381
|
+
event_name = result[0]
|
|
1382
|
+
# SimpleClient receive returns [event_name, *args]
|
|
1383
|
+
event_data = result[1] if len(result) > 1 else None
|
|
1384
|
+
validated_data = _validate_response(event_data, response_model)
|
|
1385
|
+
return event_name, validated_data
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
# =============================================================================
|
|
1389
|
+
# Sync Server Wrapper
|
|
1390
|
+
# =============================================================================
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
class SyncServerWrapper:
|
|
1394
|
+
"""Typed wrapper for socketio.Server.
|
|
1395
|
+
|
|
1396
|
+
Provides typed emit, call, and on methods while passing through all other
|
|
1397
|
+
attributes to the underlying Server instance.
|
|
1398
|
+
"""
|
|
1399
|
+
|
|
1400
|
+
def __init__(self, sio: Server) -> None:
|
|
1401
|
+
"""Initialize wrapper with a Server instance.
|
|
1402
|
+
|
|
1403
|
+
Args:
|
|
1404
|
+
sio: The socketio Server to wrap.
|
|
1405
|
+
"""
|
|
1406
|
+
self._sio = sio
|
|
1407
|
+
|
|
1408
|
+
def __getattr__(self, name: str) -> Any:
|
|
1409
|
+
"""Delegate attribute access to the underlying socketio instance."""
|
|
1410
|
+
return getattr(self._sio, name)
|
|
1411
|
+
|
|
1412
|
+
# emit overloads
|
|
1413
|
+
@overload
|
|
1414
|
+
def emit(
|
|
1415
|
+
self,
|
|
1416
|
+
event: BaseModel,
|
|
1417
|
+
**kwargs: Any,
|
|
1418
|
+
) -> None: ...
|
|
1419
|
+
|
|
1420
|
+
@overload
|
|
1421
|
+
def emit(
|
|
1422
|
+
self,
|
|
1423
|
+
event: str,
|
|
1424
|
+
data: Any = None,
|
|
1425
|
+
**kwargs: Any,
|
|
1426
|
+
) -> None: ...
|
|
1427
|
+
|
|
1428
|
+
def emit(
|
|
1429
|
+
self,
|
|
1430
|
+
event: str | BaseModel,
|
|
1431
|
+
data: Any = None,
|
|
1432
|
+
**kwargs: Any,
|
|
1433
|
+
) -> None:
|
|
1434
|
+
"""Emit an event to connected clients.
|
|
1435
|
+
|
|
1436
|
+
Args:
|
|
1437
|
+
event: Either a string event name or a BaseModel instance.
|
|
1438
|
+
data: Optional data payload (used when event is a string).
|
|
1439
|
+
**kwargs: Additional arguments passed to socketio's emit.
|
|
1440
|
+
"""
|
|
1441
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1442
|
+
self._sio.emit(event_name, payload, **kwargs)
|
|
1443
|
+
|
|
1444
|
+
# call overloads (see PEP 747 note in AsyncClientWrapper)
|
|
1445
|
+
@overload
|
|
1446
|
+
def call(
|
|
1447
|
+
self,
|
|
1448
|
+
event: BaseModel,
|
|
1449
|
+
*,
|
|
1450
|
+
response_model: TypeForm[T],
|
|
1451
|
+
**kwargs: Any,
|
|
1452
|
+
) -> T: ...
|
|
1453
|
+
|
|
1454
|
+
@overload
|
|
1455
|
+
def call(
|
|
1456
|
+
self,
|
|
1457
|
+
event: BaseModel,
|
|
1458
|
+
**kwargs: Any,
|
|
1459
|
+
) -> Any: ...
|
|
1460
|
+
|
|
1461
|
+
@overload
|
|
1462
|
+
def call(
|
|
1463
|
+
self,
|
|
1464
|
+
event: str,
|
|
1465
|
+
data: Any = None,
|
|
1466
|
+
*,
|
|
1467
|
+
response_model: TypeForm[T],
|
|
1468
|
+
**kwargs: Any,
|
|
1469
|
+
) -> T: ...
|
|
1470
|
+
|
|
1471
|
+
@overload
|
|
1472
|
+
def call(
|
|
1473
|
+
self,
|
|
1474
|
+
event: str,
|
|
1475
|
+
data: Any = None,
|
|
1476
|
+
**kwargs: Any,
|
|
1477
|
+
) -> Any: ...
|
|
1478
|
+
|
|
1479
|
+
def call(
|
|
1480
|
+
self,
|
|
1481
|
+
event: str | BaseModel,
|
|
1482
|
+
data: Any = None,
|
|
1483
|
+
*,
|
|
1484
|
+
response_model: Any = None,
|
|
1485
|
+
**kwargs: Any,
|
|
1486
|
+
) -> Any:
|
|
1487
|
+
"""Emit an event and wait for a response from a client.
|
|
1488
|
+
|
|
1489
|
+
Args:
|
|
1490
|
+
event: Either a string event name or a BaseModel instance.
|
|
1491
|
+
data: Optional data payload (used when event is a string).
|
|
1492
|
+
response_model: Optional type to validate response against (PEP 747 TypeForm).
|
|
1493
|
+
**kwargs: Additional arguments passed to socketio's call.
|
|
1494
|
+
|
|
1495
|
+
Returns:
|
|
1496
|
+
Validated response if response_model is provided, otherwise raw response.
|
|
1497
|
+
"""
|
|
1498
|
+
event_name, payload = _resolve_emit_args(event, data)
|
|
1499
|
+
response = self._sio.call(event_name, payload, **kwargs)
|
|
1500
|
+
return _validate_response(response, response_model)
|
|
1501
|
+
|
|
1502
|
+
def on(
|
|
1503
|
+
self,
|
|
1504
|
+
event: str | Type[BaseModel],
|
|
1505
|
+
handler: Callable | None = None,
|
|
1506
|
+
**kwargs: Any,
|
|
1507
|
+
) -> Callable[[Callable], Callable] | Callable:
|
|
1508
|
+
"""Register an event handler.
|
|
1509
|
+
|
|
1510
|
+
Args:
|
|
1511
|
+
event: Either a string event name or a BaseModel class.
|
|
1512
|
+
handler: Optional handler function (if not using as decorator).
|
|
1513
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
1514
|
+
|
|
1515
|
+
Returns:
|
|
1516
|
+
Decorator that registers the handler with validation.
|
|
1517
|
+
"""
|
|
1518
|
+
if isinstance(event, type) and issubclass(event, BaseModel):
|
|
1519
|
+
event_name = get_event_name(event)
|
|
1520
|
+
else:
|
|
1521
|
+
event_name = event
|
|
1522
|
+
|
|
1523
|
+
def decorator(handler: Callable) -> Callable:
|
|
1524
|
+
wrapped = _create_sync_handler_wrapper(handler)
|
|
1525
|
+
self._sio.on(event_name, wrapped, **kwargs)
|
|
1526
|
+
return handler
|
|
1527
|
+
|
|
1528
|
+
if handler is not None:
|
|
1529
|
+
return decorator(handler)
|
|
1530
|
+
return decorator
|
|
1531
|
+
|
|
1532
|
+
def event(self, handler: Callable | None = None, **kwargs: Any) -> Callable:
|
|
1533
|
+
"""Register an event handler using the function name as the event name.
|
|
1534
|
+
|
|
1535
|
+
Args:
|
|
1536
|
+
handler: The event handler function.
|
|
1537
|
+
**kwargs: Additional arguments passed to socketio's on (e.g., namespace).
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
The original handler (unmodified).
|
|
1541
|
+
"""
|
|
1542
|
+
|
|
1543
|
+
def decorator(handler: Callable) -> Callable:
|
|
1544
|
+
event_name = handler.__name__
|
|
1545
|
+
wrapped = _create_sync_handler_wrapper(handler)
|
|
1546
|
+
self._sio.on(event_name, wrapped, **kwargs)
|
|
1547
|
+
return handler
|
|
1548
|
+
|
|
1549
|
+
if handler is not None:
|
|
1550
|
+
return decorator(handler)
|
|
1551
|
+
return decorator
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
# =============================================================================
|
|
1555
|
+
# Factory Function
|
|
1556
|
+
# =============================================================================
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
@overload
|
|
1560
|
+
def wrap(sio: AsyncSimpleClient, *, app: Any = None) -> AsyncSimpleClientWrapper: ...
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
@overload
|
|
1564
|
+
def wrap(sio: SimpleClient, *, app: Any = None) -> SimpleClientWrapper: ...
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
@overload
|
|
1568
|
+
def wrap(sio: AsyncClient, *, app: Any = None) -> AsyncClientWrapper: ...
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
@overload
|
|
1572
|
+
def wrap(sio: AsyncServer, *, app: Any = None) -> AsyncServerWrapper: ...
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
@overload
|
|
1576
|
+
def wrap(sio: Client, *, app: Any = None) -> SyncClientWrapper: ...
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
@overload
|
|
1580
|
+
def wrap(sio: Server, *, app: Any = None) -> SyncServerWrapper: ...
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def wrap(
|
|
1584
|
+
sio: AsyncSimpleClient | SimpleClient | AsyncClient | AsyncServer | Client | Server,
|
|
1585
|
+
*,
|
|
1586
|
+
app: Any = None,
|
|
1587
|
+
) -> (
|
|
1588
|
+
AsyncSimpleClientWrapper
|
|
1589
|
+
| SimpleClientWrapper
|
|
1590
|
+
| AsyncClientWrapper
|
|
1591
|
+
| AsyncServerWrapper
|
|
1592
|
+
| SyncClientWrapper
|
|
1593
|
+
| SyncServerWrapper
|
|
1594
|
+
):
|
|
1595
|
+
"""Wrap a socketio instance with typed emit, call, and on methods.
|
|
1596
|
+
|
|
1597
|
+
This is the main entry point for the wrapper API. It auto-detects the
|
|
1598
|
+
type of socketio instance and returns the appropriate wrapper.
|
|
1599
|
+
|
|
1600
|
+
Args:
|
|
1601
|
+
sio: A socketio Client, AsyncClient, Server, AsyncServer,
|
|
1602
|
+
SimpleClient, or AsyncSimpleClient instance.
|
|
1603
|
+
app: Optional FastAPI application instance. When provided,
|
|
1604
|
+
dependencies that accept a ``Request`` parameter will
|
|
1605
|
+
receive a shim with ``request.app`` pointing to this instance.
|
|
1606
|
+
|
|
1607
|
+
Returns:
|
|
1608
|
+
The appropriate wrapper class for the given socketio instance.
|
|
1609
|
+
|
|
1610
|
+
Raises:
|
|
1611
|
+
TypeError: If sio is not a recognized socketio instance type.
|
|
1612
|
+
|
|
1613
|
+
Example:
|
|
1614
|
+
>>> import socketio
|
|
1615
|
+
>>> from zndraw_socketio import wrap
|
|
1616
|
+
>>>
|
|
1617
|
+
>>> # Wrap standard client
|
|
1618
|
+
>>> tsio = wrap(socketio.AsyncClient())
|
|
1619
|
+
>>> await tsio.emit(Ping(message="hello"))
|
|
1620
|
+
>>>
|
|
1621
|
+
>>> # Wrap simple client
|
|
1622
|
+
>>> tsio = wrap(socketio.SimpleClient())
|
|
1623
|
+
>>> tsio.emit(Ping(message="hello"))
|
|
1624
|
+
>>> event_name, data = tsio.receive(response_model=Pong)
|
|
1625
|
+
"""
|
|
1626
|
+
# Check SimpleClient types first (they are subclasses in some sense)
|
|
1627
|
+
if isinstance(sio, AsyncSimpleClient):
|
|
1628
|
+
return AsyncSimpleClientWrapper(sio)
|
|
1629
|
+
elif isinstance(sio, SimpleClient):
|
|
1630
|
+
return SimpleClientWrapper(sio)
|
|
1631
|
+
elif isinstance(sio, AsyncClient):
|
|
1632
|
+
return AsyncClientWrapper(sio)
|
|
1633
|
+
elif isinstance(sio, AsyncServer):
|
|
1634
|
+
return AsyncServerWrapper(sio, app=app)
|
|
1635
|
+
elif isinstance(sio, Client):
|
|
1636
|
+
return SyncClientWrapper(sio)
|
|
1637
|
+
elif isinstance(sio, Server):
|
|
1638
|
+
return SyncServerWrapper(sio)
|
|
1639
|
+
raise TypeError(f"Expected socketio instance, got {type(sio)}")
|