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.
@@ -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)}")