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