django-socket 0.2.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,602 @@
1
+ """El objeto WebSocket que recibe cada handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from http.cookies import SimpleCookie
8
+ from typing import Any
9
+ from urllib.parse import parse_qs
10
+
11
+
12
+ class WebSocketDisconnect(Exception):
13
+ """El cliente cerro la conexion."""
14
+
15
+ def __init__(self, code: int = 1000, reason: str = ""):
16
+ self.code = code
17
+ self.reason = reason
18
+ super().__init__(f"WebSocket cerrado (code={code})")
19
+
20
+
21
+ class WebSocketClosed(Exception):
22
+ """Se intento usar un socket que el servidor ya cerro."""
23
+
24
+
25
+ class RateLimited(Exception):
26
+ """El cliente manda mas deprisa de lo permitido."""
27
+
28
+ def __init__(self, espera: float, code: int):
29
+ self.espera = espera
30
+ self.code = code
31
+ super().__init__(f"Rate limit excedido; reintenta en {espera:.1f}s")
32
+
33
+
34
+ class InvalidJSON(ValueError):
35
+ """
36
+ El cliente mando algo que no es JSON valido.
37
+
38
+ Es una ValueError, asi que `except ValueError` de toda la vida sigue
39
+ valiendo. Existe como tipo propio para que el dispatcher la distinga de un
40
+ fallo del servidor: culpa del cliente se cierra con 4400, no con un 1011
41
+ que ademas te llena el log de tracebacks que no son tuyos.
42
+ """
43
+
44
+ def __init__(self, crudo, motivo=""):
45
+ self.crudo = crudo
46
+ muestra = str(crudo)
47
+ if len(muestra) > 80:
48
+ muestra = muestra[:77] + "..."
49
+ super().__init__(f"JSON invalido del cliente: {muestra!r}"
50
+ + (f" ({motivo})" if motivo else ""))
51
+
52
+
53
+ _encoder = None
54
+
55
+
56
+ def _get_encoder():
57
+ """
58
+ DjangoJSONEncoder, cargado tarde para no tocar settings al importar.
59
+
60
+ Importa porque es el que sabe de tipos de Django, y sobre todo por las
61
+ fechas. `str(aware)` da "2026-08-26 19:43:30.251057+00:00"; el encoder da
62
+ "2026-08-26T19:43:30.251Z". Dos diferencias que si cuentan:
63
+
64
+ * ISO-8601 es el unico formato que la spec de ECMAScript obliga a `Date`
65
+ a parsear. Lo demas es un fallback de cada motor -- V8 es permisivo y lo
66
+ acepta, otros historicamente no.
67
+ * `str()` emite microsegundos (6 digitos) y `Date` solo entiende
68
+ milisegundos; el encoder trunca a 3, que es lo que JS puede representar.
69
+
70
+ Y de paso `Decimal` sale como cadena para no perder precision, y `UUID` y
71
+ las cadenas lazy de traduccion se serializan solas.
72
+ """
73
+ global _encoder
74
+ if _encoder is None:
75
+ from django.core.serializers.json import DjangoJSONEncoder
76
+
77
+ class SocketJSONEncoder(DjangoJSONEncoder):
78
+ def default(self, o):
79
+ try:
80
+ return super().default(o)
81
+ except TypeError:
82
+ raise TypeError(
83
+ f"No se puede enviar un {type(o).__name__} por el "
84
+ f"socket. Los tipos de Django habituales (datetime, "
85
+ f"date, time, timedelta, Decimal, UUID, cadenas lazy) "
86
+ f"van solos; el resto conviertelo tu: un modelo a dict, "
87
+ f"un QuerySet a lista. Si prefieres el comportamiento "
88
+ f"antiguo: sock.send_json(dato, default=str)."
89
+ ) from None
90
+
91
+ _encoder = SocketJSONEncoder
92
+ return _encoder
93
+
94
+
95
+ class Message:
96
+ """Un mensaje entrante. Usa `.text`, `.bytes` o `.json()`."""
97
+
98
+ __slots__ = ("text", "bytes")
99
+
100
+ def __init__(self, text: str | None = None, data: bytes | None = None):
101
+ self.text = text
102
+ self.bytes = data
103
+
104
+ def json(self, **kwargs) -> Any:
105
+ """Parsea el mensaje como JSON. Lanza `InvalidJSON` si no lo es."""
106
+ crudo = self.text
107
+ if crudo is None:
108
+ if self.bytes is None:
109
+ raise InvalidJSON("", "mensaje vacio")
110
+ try:
111
+ crudo = self.bytes.decode()
112
+ except UnicodeDecodeError as exc:
113
+ raise InvalidJSON(self.bytes, "no es UTF-8 valido") from exc
114
+ try:
115
+ return json.loads(crudo, **kwargs)
116
+ except ValueError as exc:
117
+ raise InvalidJSON(crudo, str(exc)) from None
118
+
119
+ @property
120
+ def is_text(self) -> bool:
121
+ return self.text is not None
122
+
123
+ def __eq__(self, other) -> bool:
124
+ """Permite `if msg == "ping"` sin sacar .text a mano."""
125
+ if isinstance(other, str):
126
+ return self.text == other
127
+ if isinstance(other, (bytes, bytearray)):
128
+ return self.bytes == bytes(other)
129
+ if isinstance(other, Message):
130
+ return self.text == other.text and self.bytes == other.bytes
131
+ return NotImplemented
132
+
133
+ def __hash__(self) -> int:
134
+ return hash((self.text, self.bytes))
135
+
136
+ def __str__(self) -> str:
137
+ return self.text if self.text is not None else repr(self.bytes)
138
+
139
+ def __repr__(self) -> str:
140
+ preview = str(self)
141
+ if len(preview) > 40:
142
+ preview = preview[:37] + "..."
143
+ return f"<Message {preview!r}>"
144
+
145
+
146
+ CONNECTING, OPEN, CLOSED = "connecting", "open", "closed"
147
+
148
+
149
+ class WebSocket:
150
+ """
151
+ Envoltura sobre el par (receive, send) de ASGI.
152
+
153
+ No hace falta llamar a `accept()`: el handshake se completa solo la primera
154
+ vez que envias, recibes o iteras. Llamalo a mano solo si necesitas fijar un
155
+ subprotocolo o headers, y llama a `close()` de entrada para rechazar.
156
+ """
157
+
158
+ def __init__(self, scope, receive, send, *, layer=None):
159
+ self.scope = scope
160
+ self._receive = receive
161
+ self._send = send
162
+ self._layer = layer
163
+ self._state = CONNECTING
164
+ self._groups: set[str] = set()
165
+ self.group: str | None = None # destino por defecto de broadcast()
166
+ self._rate = None # cubo de rate limit
167
+ self._outbox: asyncio.Queue | None = None # solo para difusion
168
+ self._outbox_full: str = "close"
169
+ self._writer: asyncio.Task | None = None
170
+ self.close_code: int | None = None
171
+ # Rellenados por el dispatcher antes de invocar el handler.
172
+ self.user = None
173
+ self.session = None
174
+ self.path_params: dict[str, Any] = {}
175
+
176
+ # ------------------------------------------------------------------ datos
177
+
178
+ @property
179
+ def path(self) -> str:
180
+ return self.scope.get("path", "")
181
+
182
+ @property
183
+ def headers(self) -> dict[str, str]:
184
+ if not hasattr(self, "_headers"):
185
+ self._headers = {
186
+ k.decode("latin-1").lower(): v.decode("latin-1")
187
+ for k, v in self.scope.get("headers", [])
188
+ }
189
+ return self._headers
190
+
191
+ @property
192
+ def query_params(self) -> dict[str, str]:
193
+ """Solo el primer valor de cada clave; usa `query_lists` si se repiten."""
194
+ if not hasattr(self, "_qp"):
195
+ raw = self.scope.get("query_string", b"").decode("utf-8", "replace")
196
+ self._ql = parse_qs(raw, keep_blank_values=True)
197
+ self._qp = {k: v[0] for k, v in self._ql.items()}
198
+ return self._qp
199
+
200
+ @property
201
+ def query_lists(self) -> dict[str, list[str]]:
202
+ self.query_params # fuerza el parseo
203
+ return self._ql
204
+
205
+ @property
206
+ def cookies(self) -> dict[str, str]:
207
+ if not hasattr(self, "_cookies"):
208
+ jar = SimpleCookie()
209
+ jar.load(self.headers.get("cookie", ""))
210
+ self._cookies = {k: v.value for k, v in jar.items()}
211
+ return self._cookies
212
+
213
+ @property
214
+ def subprotocols(self) -> list[str]:
215
+ return self.scope.get("subprotocols", [])
216
+
217
+ @property
218
+ def client(self) -> tuple[str, int] | None:
219
+ c = self.scope.get("client")
220
+ return tuple(c) if c else None
221
+
222
+ @property
223
+ def connected(self) -> bool:
224
+ return self._state == OPEN
225
+
226
+ @property
227
+ def groups(self) -> frozenset[str]:
228
+ """De que grupos eres miembro ahora mismo (vacio tras desconectar).
229
+
230
+ Distinto de `sock.group`, que es el destino por defecto de broadcast()
231
+ y sigue apuntando al mismo sitio despues de la desconexion.
232
+ """
233
+ return frozenset(self._groups)
234
+
235
+ def __repr__(self) -> str:
236
+ return f"<WebSocket {self.path} {self._state}>"
237
+
238
+ # ------------------------------------------------------------- handshake
239
+
240
+ async def accept(self, subprotocol: str | None = None, headers=None) -> None:
241
+ if self._state != CONNECTING:
242
+ return
243
+ msg = {"type": "websocket.accept", "subprotocol": subprotocol}
244
+ if headers:
245
+ msg["headers"] = [
246
+ (
247
+ k.encode() if isinstance(k, str) else k,
248
+ v.encode() if isinstance(v, str) else v,
249
+ )
250
+ for k, v in headers
251
+ ]
252
+ await self._send(msg)
253
+ self._state = OPEN
254
+
255
+ async def _ensure_open(self) -> None:
256
+ if self._state == CONNECTING:
257
+ await self.accept()
258
+ elif self._state == CLOSED:
259
+ raise WebSocketClosed("El socket ya esta cerrado.")
260
+
261
+ async def close(self, code: int = 1000, reason: str = "") -> None:
262
+ """
263
+ Cierra entregando `code` y `reason` al cliente.
264
+
265
+ Si el handshake aun no se completo lo completa primero: cerrar sin
266
+ aceptar hace que el servidor conteste un HTTP 403 y el navegador reciba
267
+ un `onclose` con code 1006 y sin motivo. Aceptando y cerrando acto
268
+ seguido, el JS del cliente recibe tu codigo tal cual y puede distinguir
269
+ "falta login" de "sala inexistente". Usa `deny()` si prefieres tumbar
270
+ el handshake.
271
+ """
272
+ if self._state == CLOSED:
273
+ return
274
+ await self._vaciar_buzon()
275
+ await self._leave_all()
276
+ try:
277
+ if self._state == CONNECTING:
278
+ await self.accept()
279
+ await self._send(
280
+ {"type": "websocket.close", "code": code, "reason": reason}
281
+ )
282
+ except Exception:
283
+ pass # el cliente ya se fue
284
+ self._state = CLOSED
285
+ self.close_code = code
286
+
287
+ async def deny(self, code: int = 403) -> None:
288
+ """
289
+ Rechaza el handshake sin aceptarlo: el cliente ve un HTTP 403 y nunca
290
+ llega a existir un WebSocket. Mas seguro cuando la conexion no deberia
291
+ haberse intentado siquiera (origen no permitido).
292
+ """
293
+ if self._state != CONNECTING:
294
+ return await self.close(1008, "Denied")
295
+ try:
296
+ await self._send({"type": "websocket.close", "code": code})
297
+ except Exception:
298
+ pass
299
+ self._state = CLOSED
300
+ self.close_code = code
301
+
302
+ # --------------------------------------------------------------- entrada
303
+
304
+ async def receive(self) -> Message:
305
+ """
306
+ El siguiente mensaje. Lanza `WebSocketDisconnect` cuando ya no hay
307
+ conexion, venga de donde venga.
308
+
309
+ Ese "venga de donde venga" importa: el socket tambien puede cerrarse
310
+ sin que el cliente se vaya -- porque le echamos por no consumir, o
311
+ porque murio su escritor. Antes eso salia como `WebSocketClosed`, que
312
+ no es `WebSocketDisconnect`, asi que reventaba el `async for` del
313
+ handler en vez de terminarlo: traza en el log, cierre con 1011, y el
314
+ codigo de limpieza posterior sin ejecutar. Aparecio con 6000
315
+ conexiones, que es justo cuando empieza a haber expulsiones.
316
+ """
317
+ if self._state == CLOSED:
318
+ raise WebSocketDisconnect(
319
+ self.close_code if self.close_code is not None else 1006,
320
+ "el socket ya estaba cerrado",
321
+ )
322
+ await self._ensure_open()
323
+ event = await self._receive()
324
+ if event["type"] == "websocket.receive" and self._rate is not None:
325
+ if not self._rate.consumir():
326
+ from .ratelimit import CLOSE_RATE_LIMIT
327
+
328
+ raise RateLimited(self._rate.espera, CLOSE_RATE_LIMIT)
329
+ if event["type"] == "websocket.disconnect":
330
+ self._state = CLOSED
331
+ self.close_code = event.get("code", 1005)
332
+ await self._leave_all()
333
+ raise WebSocketDisconnect(self.close_code, event.get("reason", ""))
334
+ return Message(text=event.get("text"), data=event.get("bytes"))
335
+
336
+ async def receive_text(self) -> str:
337
+ msg = await self.receive()
338
+ if msg.text is None:
339
+ raise TypeError("Se esperaba un frame de texto y llego uno binario.")
340
+ return msg.text
341
+
342
+ async def receive_bytes(self) -> bytes:
343
+ msg = await self.receive()
344
+ if msg.bytes is None:
345
+ raise TypeError("Se esperaba un frame binario y llego uno de texto.")
346
+ return msg.bytes
347
+
348
+ async def receive_json(self, **kwargs) -> Any:
349
+ return (await self.receive()).json(**kwargs)
350
+
351
+ def __aiter__(self):
352
+ return self
353
+
354
+ async def __anext__(self) -> Message:
355
+ try:
356
+ return await self.receive()
357
+ except WebSocketDisconnect:
358
+ raise StopAsyncIteration
359
+
360
+ async def iter_text(self):
361
+ async for msg in self:
362
+ if msg.text is not None:
363
+ yield msg.text
364
+
365
+ async def iter_json(self):
366
+ async for msg in self:
367
+ yield msg.json()
368
+
369
+ # ---------------------------------------------------------------- salida
370
+
371
+ async def send(self, data: Any) -> None:
372
+ """str -> texto, bytes -> binario, cualquier otra cosa -> JSON."""
373
+ if isinstance(data, str):
374
+ await self.send_text(data)
375
+ elif isinstance(data, (bytes, bytearray, memoryview)):
376
+ await self.send_bytes(bytes(data))
377
+ else:
378
+ await self.send_json(data)
379
+
380
+ async def send_text(self, text: str) -> None:
381
+ await self._ensure_open()
382
+ await self._send({"type": "websocket.send", "text": text})
383
+
384
+ async def send_bytes(self, data: bytes) -> None:
385
+ await self._ensure_open()
386
+ await self._send({"type": "websocket.send", "bytes": data})
387
+
388
+ async def send_json(self, data: Any, **kwargs) -> None:
389
+ """
390
+ Serializa con el codificador de Django: `datetime` sale en ISO-8601,
391
+ `Decimal` como cadena, `UUID` y las cadenas lazy tambien.
392
+
393
+ Un objeto que no sepa serializar lanza un TypeError que dice cual es y
394
+ que hacer, en vez de mandar `"Usuario object (3)"` al navegador y que
395
+ te enteres en produccion.
396
+ """
397
+ if "default" not in kwargs:
398
+ kwargs.setdefault("cls", _get_encoder())
399
+ await self.send_text(json.dumps(data, **kwargs))
400
+
401
+ # ---------------------------------------------------------------- grupos
402
+
403
+ async def join(self, *groups: str) -> None:
404
+ """El primer grupo al que entras pasa a ser el destino por defecto."""
405
+ for g in groups:
406
+ await self._layer.add(g, self)
407
+ self._groups.add(g)
408
+ if self.group is None:
409
+ self.group = g
410
+
411
+ async def leave(self, *groups: str) -> None:
412
+ for g in groups:
413
+ await self._layer.discard(g, self)
414
+ self._groups.discard(g)
415
+ if self.group == g:
416
+ self.group = next(iter(self._groups), None)
417
+
418
+ async def broadcast(
419
+ self, data: Any, *, to: str | None = None, exclude_self: bool = False
420
+ ) -> None:
421
+ """
422
+ Envia a todos los miembros de un grupo.
423
+
424
+ await sock.broadcast(data) # al grupo por defecto
425
+ await sock.broadcast(data, to="otro:grupo")
426
+ await sock.broadcast(data, exclude_self=True)
427
+ """
428
+ target = to or self.group
429
+ if target is None:
430
+ raise ValueError(
431
+ "sock.broadcast(data) sin grupo por defecto. Entra en uno con "
432
+ "await sock.join('mi:grupo'), declaralo en la ruta con "
433
+ "@ws(..., group='mi:{param}') o indica el destino con "
434
+ "sock.broadcast(data, to='mi:grupo')."
435
+ )
436
+ await self._layer.send(target, data, exclude=self if exclude_self else None)
437
+
438
+ # -------------------------------------------------------------- fan-out
439
+
440
+ async def enqueue(self, data: Any) -> bool:
441
+ """
442
+ Encola un mensaje de difusion. `False` si este cliente va tan atrasado
443
+ que hay que echarlo.
444
+
445
+ No espera a que se escriba, y eso es el punto. `sock.send()` si espera
446
+ --ahi el bloqueo es sano: si el cliente no puede seguirte, tu handler
447
+ va mas despacio--. Pero en un broadcast esperar es ruinoso: uno solo
448
+ que no lea deja colgado para siempre al que difunde, y con el su bucle
449
+ de lectura y su limpieza. Medido: uvicorn frena a los ~24 MB, y a
450
+ partir de ahi `send()` no vuelve.
451
+ """
452
+ if self._state == CLOSED:
453
+ return False
454
+
455
+ if self._outbox is None:
456
+ maximo, self._outbox_full = _config_outbox()
457
+ self._outbox = asyncio.Queue(maxsize=maximo)
458
+ if self._writer is None or self._writer.done():
459
+ self._writer = asyncio.create_task(self._drenar())
460
+
461
+ try:
462
+ self._outbox.put_nowait(data)
463
+ return True
464
+ except asyncio.QueueFull:
465
+ if self._outbox_full == "drop_oldest":
466
+ # Para flujos que toleran huecos (posiciones, telemetria):
467
+ # mas vale perder lo viejo que echar al cliente.
468
+ try:
469
+ self._outbox.get_nowait()
470
+ except asyncio.QueueEmpty:
471
+ pass
472
+ self._outbox.put_nowait(data)
473
+ return True
474
+ return False
475
+
476
+ async def _drenar(self) -> None:
477
+ """Escribe el buzon en orden. Si el socket muere, se sale de los grupos."""
478
+ try:
479
+ while True:
480
+ data = await self._outbox.get()
481
+ try:
482
+ await self.send(data)
483
+ except Exception:
484
+ self._outbox.task_done()
485
+ self._state = CLOSED
486
+ await self._leave_all()
487
+ return
488
+ self._outbox.task_done()
489
+ except asyncio.CancelledError:
490
+ raise
491
+
492
+ def evict(self, code: int = 1013, reason: str = "Client too slow") -> None:
493
+ """
494
+ Echa a un cliente que no consume, sin esperarle.
495
+
496
+ No se puede `await close()`: escribir hacia el esta bloqueado, que es
497
+ justo el motivo por el que lo estamos echando. Se cancela el escritor y
498
+ el cierre sale en segundo plano con plazo.
499
+
500
+ El handler de ese cliente sigue parado en `receive()` hasta que el
501
+ servidor ASGI le entregue el `websocket.disconnect`; con uvicorn eso
502
+ llega como mucho en ping_interval + ping_timeout (40 s por defecto).
503
+ Mientras tanto no consume nada y ya esta fuera de todos los grupos.
504
+ """
505
+ if self._state == CLOSED:
506
+ return
507
+ self._state = CLOSED
508
+ self.close_code = code
509
+ if self._writer is not None:
510
+ self._writer.cancel()
511
+ asyncio.get_event_loop().create_task(self._cerrar_en_diferido(code, reason))
512
+
513
+ async def _cerrar_en_diferido(self, code: int, reason: str) -> None:
514
+ try:
515
+ await asyncio.wait_for(
516
+ self._send({"type": "websocket.close", "code": code, "reason": reason}),
517
+ timeout=5,
518
+ )
519
+ except Exception:
520
+ pass
521
+
522
+ async def drain(self, timeout: float = 1.0) -> None:
523
+ """
524
+ Espera a que salga todo lo encolado para difusion.
525
+
526
+ En produccion no hace falta: el escritor va solo y `broadcast` no
527
+ espera a nadie a proposito. En un test si, para afirmar sobre lo que ya
528
+ llego en vez de dormir a ciegas y cruzar los dedos.
529
+ """
530
+ if self._outbox is None:
531
+ return
532
+ try:
533
+ await asyncio.wait_for(self._outbox.join(), timeout=timeout)
534
+ except asyncio.TimeoutError:
535
+ pass
536
+
537
+ async def _vaciar_buzon(self, plazo: float = 1.0) -> None:
538
+ """Da al escritor una ultima oportunidad antes de cerrar."""
539
+ if self._outbox is None or self._writer is None or self._writer.done():
540
+ return
541
+ try:
542
+ await asyncio.wait_for(self._outbox.join(), timeout=plazo)
543
+ except Exception:
544
+ pass
545
+
546
+ def _parar_escritor(self) -> None:
547
+ """
548
+ Cancela la tarea escritora. Idempotente.
549
+
550
+ Va en la limpieza comun y no solo en `close()`, porque el camino
551
+ habitual es el otro: el cliente se desconecta, `receive()` marca el
552
+ socket cerrado, y entonces `close()` sale de vuelta sin llegar a
553
+ cancelar nada. Era una tarea huerfana por cada conexion que terminaba.
554
+ """
555
+ if self._writer is None or self._writer.done():
556
+ return
557
+ # No te canceles a ti mismo: _drenar() tambien pasa por aqui al fallar.
558
+ try:
559
+ if asyncio.current_task() is self._writer:
560
+ return
561
+ except RuntimeError:
562
+ pass
563
+ self._writer.cancel()
564
+
565
+ async def _leave_all(self) -> None:
566
+ """
567
+ Saca el socket de sus grupos, pero NO borra `self.group`.
568
+
569
+ `groups` es de que grupos eres miembro; `group` es a donde apunta
570
+ broadcast() por defecto. Al desconectar dejas de ser miembro, pero el
571
+ destino sigue siendo valido: si no, el patron mas comun que existe
572
+
573
+ async for msg in sock:
574
+ ...
575
+ await sock.broadcast({"tipo": "sale"}) # <- ya sin grupo
576
+
577
+ se quedaria sin destino justo en la linea en la que hace falta.
578
+ """
579
+ self._parar_escritor()
580
+ if self._groups and self._layer is not None:
581
+ for g in list(self._groups):
582
+ await self._layer.discard(g, self)
583
+ self._groups.clear()
584
+
585
+
586
+ def _config_outbox() -> tuple[int, str]:
587
+ """
588
+ Tamaño del buzon de difusion y que hacer cuando se llena.
589
+
590
+ El 256 no es un numero redondo elegido a ojo: medido, un proceso publica
591
+ ~1.500 broadcast/s contra Redis, asi que un buzon de 64 se llenaria en
592
+ 42 ms a maxima tasa -- menos de lo que dura un hipo de red movil o una
593
+ pausa de GC, y echarias a clientes sanos. Con 256 el margen sube a ~170 ms
594
+ en el peor caso, y a decenas de segundos al ritmo de un chat normal.
595
+
596
+ El coste en memoria solo lo pagan los clientes atascados: uno que consume
597
+ tiene el buzon a cero. Son `atascados x 256 x tamaño_del_mensaje`.
598
+ """
599
+ from django.conf import settings
600
+
601
+ conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
602
+ return int(conf.get("SEND_QUEUE_MAX", 256)), conf.get("SEND_QUEUE_FULL", "close")