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.
- django_socket/__init__.py +61 -0
- django_socket/apps.py +32 -0
- django_socket/asgi.py +62 -0
- django_socket/auth.py +10 -0
- django_socket/authentication.py +278 -0
- django_socket/checks.py +114 -0
- django_socket/dispatch.py +208 -0
- django_socket/events.py +143 -0
- django_socket/groups.py +287 -0
- django_socket/management/__init__.py +0 -0
- django_socket/management/commands/__init__.py +0 -0
- django_socket/management/commands/runserver.py +81 -0
- django_socket/management/commands/ws.py +64 -0
- django_socket/middleware.py +166 -0
- django_socket/patch.py +54 -0
- django_socket/py.typed +0 -0
- django_socket/ratelimit.py +93 -0
- django_socket/routing.py +148 -0
- django_socket/static/django_socket/client.js +262 -0
- django_socket/templatetags/__init__.py +0 -0
- django_socket/templatetags/django_socket.py +28 -0
- django_socket/testing.py +330 -0
- django_socket/websocket.py +602 -0
- django_socket-0.2.0.dist-info/METADATA +1208 -0
- django_socket-0.2.0.dist-info/RECORD +28 -0
- django_socket-0.2.0.dist-info/WHEEL +5 -0
- django_socket-0.2.0.dist-info/licenses/LICENSE +21 -0
- django_socket-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* django_socket - cliente con reconexion automatica.
|
|
3
|
+
*
|
|
4
|
+
* const sock = djangoSocket("/chat/general/");
|
|
5
|
+
* sock.on("mensaje", (datos) => pintar(datos.texto));
|
|
6
|
+
* sock.send({type: "mensaje", texto: "hola"});
|
|
7
|
+
*
|
|
8
|
+
* Lo que aporta frente a `new WebSocket(...)` a pelo:
|
|
9
|
+
* - reconecta con backoff exponencial y jitter
|
|
10
|
+
* - NO reconecta cuando el servidor cierra a proposito (4401 falta login,
|
|
11
|
+
* 4404 ruta inexistente...): ahi reintentar es un bucle infinito
|
|
12
|
+
* - encola lo que envies mientras esta caido y lo suelta al volver
|
|
13
|
+
* - JSON en los dos sentidos, y enrutado por `type` como el Events de Python
|
|
14
|
+
* - deja de reintentar si el navegador esta sin red (y opcionalmente
|
|
15
|
+
* si la pestaña esta oculta: {pauseWhenHidden: true})
|
|
16
|
+
*/
|
|
17
|
+
(function (global) {
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
// Cierres que significan "no lo vuelvas a intentar".
|
|
21
|
+
// 1000 el servidor cerro limpiamente 1008 violacion de politica
|
|
22
|
+
// 4000-4999 decisiones de tu aplicacion (login, ruta, datos invalidos)
|
|
23
|
+
function esDefinitivo(code) {
|
|
24
|
+
return code === 1000 || code === 1008 || (code >= 4000 && code <= 4999);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function urlAbsoluta(ruta) {
|
|
28
|
+
if (/^wss?:\/\//.test(ruta)) return ruta;
|
|
29
|
+
var proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
30
|
+
return proto + "//" + location.host + (ruta[0] === "/" ? ruta : "/" + ruta);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function djangoSocket(ruta, opciones) {
|
|
34
|
+
var o = Object.assign(
|
|
35
|
+
{
|
|
36
|
+
key: "type", // el campo que enruta, igual que Events(key=)
|
|
37
|
+
reconnect: true,
|
|
38
|
+
minDelay: 500, // primer reintento, en ms
|
|
39
|
+
maxDelay: 15000, // tope del backoff
|
|
40
|
+
maxRetries: Infinity,
|
|
41
|
+
queue: true, // guardar los envios mientras no hay conexion
|
|
42
|
+
maxQueue: 100,
|
|
43
|
+
pauseWhenHidden: false, // ver la nota en esperarAEstarListo()
|
|
44
|
+
protocols: undefined,
|
|
45
|
+
onOpen: null, // (esReconexion) => {}
|
|
46
|
+
onMessage: null, // (datos, evento) => {} para lo que no case
|
|
47
|
+
onClose: null, // (evento, vaAReintentar) => {}
|
|
48
|
+
onError: null,
|
|
49
|
+
onRetry: null, // (intento, esperaMs) => {}
|
|
50
|
+
shouldReconnect: null, // (evento) => bool para decidirlo tu
|
|
51
|
+
},
|
|
52
|
+
opciones || {}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
var url = urlAbsoluta(ruta);
|
|
56
|
+
var ws = null;
|
|
57
|
+
var handlers = {};
|
|
58
|
+
var pendientes = [];
|
|
59
|
+
var intentos = 0;
|
|
60
|
+
var temporizador = null;
|
|
61
|
+
var cerradoPorNosotros = false;
|
|
62
|
+
var haAbiertoAlgunaVez = false;
|
|
63
|
+
|
|
64
|
+
var api = {
|
|
65
|
+
get readyState() {
|
|
66
|
+
return ws ? ws.readyState : WebSocket.CLOSED;
|
|
67
|
+
},
|
|
68
|
+
get connected() {
|
|
69
|
+
return !!ws && ws.readyState === WebSocket.OPEN;
|
|
70
|
+
},
|
|
71
|
+
get url() {
|
|
72
|
+
return url;
|
|
73
|
+
},
|
|
74
|
+
get pending() {
|
|
75
|
+
return pendientes.length;
|
|
76
|
+
},
|
|
77
|
+
on: on,
|
|
78
|
+
off: off,
|
|
79
|
+
send: send,
|
|
80
|
+
close: close,
|
|
81
|
+
reconnect: forzarReconexion,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------- handlers
|
|
85
|
+
|
|
86
|
+
function on(tipo, fn) {
|
|
87
|
+
(handlers[tipo] = handlers[tipo] || []).push(fn);
|
|
88
|
+
return api; // encadenable: .on(..).on(..)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function off(tipo, fn) {
|
|
92
|
+
if (!handlers[tipo]) return api;
|
|
93
|
+
if (!fn) delete handlers[tipo];
|
|
94
|
+
else handlers[tipo] = handlers[tipo].filter(function (f) { return f !== fn; });
|
|
95
|
+
return api;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function despachar(datos, evento) {
|
|
99
|
+
var tipo = datos && typeof datos === "object" ? datos[o.key] : undefined;
|
|
100
|
+
// "*" es un respaldo, no un espia: solo corre cuando nadie mas cogio el
|
|
101
|
+
// mensaje. Es la misma semantica que el Events de Python, y tenerlas
|
|
102
|
+
// distintas a cada lado del mismo protocolo es pedir un bug.
|
|
103
|
+
var lista = handlers[tipo] && handlers[tipo].length
|
|
104
|
+
? handlers[tipo]
|
|
105
|
+
: handlers["*"] || [];
|
|
106
|
+
if (lista.length) {
|
|
107
|
+
lista.slice().forEach(function (fn) { fn(datos, evento); });
|
|
108
|
+
} else if (o.onMessage) {
|
|
109
|
+
o.onMessage(datos, evento);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --------------------------------------------------------------- enviar
|
|
114
|
+
|
|
115
|
+
function send(datos) {
|
|
116
|
+
var cuerpo =
|
|
117
|
+
typeof datos === "string" || datos instanceof Blob || datos instanceof ArrayBuffer
|
|
118
|
+
? datos
|
|
119
|
+
: JSON.stringify(datos);
|
|
120
|
+
|
|
121
|
+
if (api.connected) {
|
|
122
|
+
ws.send(cuerpo);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (o.queue) {
|
|
126
|
+
if (pendientes.length >= o.maxQueue) pendientes.shift(); // tira el mas viejo
|
|
127
|
+
pendientes.push(cuerpo);
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function vaciarCola() {
|
|
133
|
+
var cola = pendientes;
|
|
134
|
+
pendientes = [];
|
|
135
|
+
cola.forEach(function (cuerpo) { ws.send(cuerpo); });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------- ciclo de vida
|
|
139
|
+
|
|
140
|
+
function conectar() {
|
|
141
|
+
clearTimeout(temporizador);
|
|
142
|
+
cerradoPorNosotros = false;
|
|
143
|
+
try {
|
|
144
|
+
ws = o.protocols ? new WebSocket(url, o.protocols) : new WebSocket(url);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (o.onError) o.onError(err);
|
|
147
|
+
programarReintento({ code: 1006, reason: String(err) });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ws.onopen = function () {
|
|
152
|
+
var esReconexion = haAbiertoAlgunaVez;
|
|
153
|
+
haAbiertoAlgunaVez = true;
|
|
154
|
+
intentos = 0;
|
|
155
|
+
vaciarCola();
|
|
156
|
+
if (o.onOpen) o.onOpen(esReconexion);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
ws.onmessage = function (evento) {
|
|
160
|
+
var datos = evento.data;
|
|
161
|
+
if (typeof datos === "string") {
|
|
162
|
+
try {
|
|
163
|
+
datos = JSON.parse(datos);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
/* no era JSON: se pasa el texto tal cual */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
despachar(datos, evento);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
ws.onerror = function (evento) {
|
|
172
|
+
if (o.onError) o.onError(evento);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
ws.onclose = function (evento) {
|
|
176
|
+
var reintentara = decideReintentar(evento);
|
|
177
|
+
if (o.onClose) o.onClose(evento, reintentara);
|
|
178
|
+
if (reintentara) programarReintento(evento);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function decideReintentar(evento) {
|
|
183
|
+
if (cerradoPorNosotros || !o.reconnect) return false;
|
|
184
|
+
if (intentos >= o.maxRetries) return false;
|
|
185
|
+
if (o.shouldReconnect) return !!o.shouldReconnect(evento);
|
|
186
|
+
return !esDefinitivo(evento.code);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function programarReintento(evento) {
|
|
190
|
+
// Backoff exponencial con jitter: sin el, N clientes que se caen a la vez
|
|
191
|
+
// vuelven todos en el mismo instante y tumban el servidor otra vez.
|
|
192
|
+
var base = Math.min(o.minDelay * Math.pow(2, intentos), o.maxDelay);
|
|
193
|
+
var espera = Math.round(base * (0.5 + Math.random() * 0.5));
|
|
194
|
+
intentos += 1;
|
|
195
|
+
if (o.onRetry) o.onRetry(intentos, espera);
|
|
196
|
+
|
|
197
|
+
temporizador = setTimeout(function () {
|
|
198
|
+
if (debeEsperar()) {
|
|
199
|
+
esperarAEstarListo();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
conectar();
|
|
203
|
+
}, espera);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function debeEsperar() {
|
|
207
|
+
// Sin red no hay nada que intentar: espera al evento "online".
|
|
208
|
+
if (navigator.onLine === false) return true;
|
|
209
|
+
// Pausar por estar la pestaña oculta NO es el defecto a proposito. Un
|
|
210
|
+
// chat en segundo plano que deja de reconectar en silencio esta roto:
|
|
211
|
+
// vuelves y te has perdido todo sin un solo aviso. Actívalo tu si tu
|
|
212
|
+
// caso tolera quedarse atras: {pauseWhenHidden: true}.
|
|
213
|
+
return o.pauseWhenHidden && document.visibilityState === "hidden";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
var esperando = false;
|
|
217
|
+
|
|
218
|
+
function esperarAEstarListo() {
|
|
219
|
+
if (esperando) return; // no acumules parejas de listeners
|
|
220
|
+
esperando = true;
|
|
221
|
+
|
|
222
|
+
function despertar() {
|
|
223
|
+
if (!esperando) return; // otro evento nos desperto primero
|
|
224
|
+
esperando = false;
|
|
225
|
+
window.removeEventListener("online", despertar);
|
|
226
|
+
document.removeEventListener("visibilitychange", alVerse);
|
|
227
|
+
intentos = 0; // volver es buena señal: reintenta ya
|
|
228
|
+
conectar();
|
|
229
|
+
}
|
|
230
|
+
function alVerse() {
|
|
231
|
+
if (!debeEsperar()) despertar();
|
|
232
|
+
}
|
|
233
|
+
window.addEventListener("online", despertar);
|
|
234
|
+
document.addEventListener("visibilitychange", alVerse);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function forzarReconexion() {
|
|
238
|
+
intentos = 0;
|
|
239
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
240
|
+
cerradoPorNosotros = false;
|
|
241
|
+
ws.close(1000);
|
|
242
|
+
}
|
|
243
|
+
conectar();
|
|
244
|
+
return api;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function close(code, reason) {
|
|
248
|
+
cerradoPorNosotros = true;
|
|
249
|
+
clearTimeout(temporizador);
|
|
250
|
+
if (ws) ws.close(code || 1000, reason || "");
|
|
251
|
+
return api;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
conectar();
|
|
255
|
+
return api;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
djangoSocket.esDefinitivo = esDefinitivo;
|
|
259
|
+
|
|
260
|
+
global.djangoSocket = djangoSocket;
|
|
261
|
+
if (typeof module === "object" && module.exports) module.exports = djangoSocket;
|
|
262
|
+
})(typeof window !== "undefined" ? window : this);
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Etiqueta para incluir el cliente JS.
|
|
2
|
+
|
|
3
|
+
{% load django_socket %}
|
|
4
|
+
{% ws_client %}
|
|
5
|
+
|
|
6
|
+
Deja disponible `djangoSocket(ruta, opciones)` en la pagina.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from django import template
|
|
10
|
+
from django.templatetags.static import static
|
|
11
|
+
from django.utils.html import format_html
|
|
12
|
+
|
|
13
|
+
register = template.Library()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register.simple_tag
|
|
17
|
+
def ws_client(defer: bool = False):
|
|
18
|
+
return format_html(
|
|
19
|
+
'<script src="{}"{}></script>',
|
|
20
|
+
static("django_socket/client.js"),
|
|
21
|
+
" defer" if defer else "",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register.simple_tag
|
|
26
|
+
def ws_client_url():
|
|
27
|
+
"""Solo la URL, por si la metes en tu propio bundle."""
|
|
28
|
+
return static("django_socket/client.js")
|
django_socket/testing.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Cliente de pruebas: testea tus handlers sin levantar ningun servidor.
|
|
2
|
+
|
|
3
|
+
from django_socket.testing import WebSocketClient
|
|
4
|
+
|
|
5
|
+
async def test_el_chat_reparte():
|
|
6
|
+
async with WebSocketClient("/chat/general/") as a, \\
|
|
7
|
+
WebSocketClient("/chat/general/") as b:
|
|
8
|
+
await b.send_json({"type": "mensaje", "texto": "hola"})
|
|
9
|
+
assert (await a.receive_json())["texto"] == "hola"
|
|
10
|
+
|
|
11
|
+
Habla el protocolo ASGI directamente contra el dispatcher, asi que pasa por el
|
|
12
|
+
mismo camino que una conexion real -- ruta, conversores, validacion de origen,
|
|
13
|
+
sesion, grupos -- pero sin sockets, sin puertos y en milisegundos.
|
|
14
|
+
|
|
15
|
+
Todos los `receive` llevan timeout: un test que espera algo que no llega falla
|
|
16
|
+
en un segundo en vez de colgarse.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .websocket import InvalidJSON, Message, WebSocketDisconnect
|
|
25
|
+
|
|
26
|
+
TIMEOUT = 1.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TimeoutDelEsperado(AssertionError):
|
|
30
|
+
"""No llego nada en el plazo. Es AssertionError para que lea como un fallo de test."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebSocketClient:
|
|
34
|
+
"""
|
|
35
|
+
Un cliente websocket de mentira contra el dispatcher de verdad.
|
|
36
|
+
|
|
37
|
+
`path` la ruta, tal cual la escribiria un navegador ("/chat/x/")
|
|
38
|
+
`user` un User: se le crea sesion y `sock.user` lo vera autenticado
|
|
39
|
+
`headers` cabeceras extra (`origin` ya va puesto)
|
|
40
|
+
`cookies` dict de cookies; se combina con la sesion de `user`
|
|
41
|
+
`query` "token=abc&n=3"
|
|
42
|
+
`subprotocols` los que ofreceria el navegador
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
path: str,
|
|
48
|
+
*,
|
|
49
|
+
user=None,
|
|
50
|
+
headers: dict | None = None,
|
|
51
|
+
cookies: dict | None = None,
|
|
52
|
+
query: str = "",
|
|
53
|
+
subprotocols=(),
|
|
54
|
+
origin: str = "http://testserver",
|
|
55
|
+
):
|
|
56
|
+
self.path = path
|
|
57
|
+
self.user = user
|
|
58
|
+
self._headers = dict(headers or {})
|
|
59
|
+
self._cookies = dict(cookies or {})
|
|
60
|
+
self._query = query
|
|
61
|
+
self._subprotocols = list(subprotocols)
|
|
62
|
+
if origin is not None:
|
|
63
|
+
self._headers.setdefault("origin", origin)
|
|
64
|
+
|
|
65
|
+
self._al_servidor: asyncio.Queue = asyncio.Queue()
|
|
66
|
+
self._al_cliente: asyncio.Queue = asyncio.Queue()
|
|
67
|
+
self._buffer: list[dict] = [] # lo que ya sacamos de la cola
|
|
68
|
+
self._tarea: asyncio.Task | None = None
|
|
69
|
+
|
|
70
|
+
self.accepted = False
|
|
71
|
+
self.subprotocol: str | None = None
|
|
72
|
+
self.close_code: int | None = None
|
|
73
|
+
self.close_reason: str = ""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def connected(self) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Si tienes una conexion usable.
|
|
79
|
+
|
|
80
|
+
Distinto de `accepted`, que dice literalmente si el servidor mando un
|
|
81
|
+
`websocket.accept`. Un rechazo con codigo (4404, 4401...) se ve en el
|
|
82
|
+
protocolo como accept + close, porque cerrar sin aceptar dejaria al
|
|
83
|
+
navegador con un 1006 sin motivo. Para "¿me dejo entrar?" usa este.
|
|
84
|
+
"""
|
|
85
|
+
return self.accepted and self.close_code is None
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------ ciclo de vida
|
|
88
|
+
|
|
89
|
+
async def connect(self, timeout: float = TIMEOUT) -> "WebSocketClient":
|
|
90
|
+
"""
|
|
91
|
+
Abre la conexion y espera la respuesta al handshake.
|
|
92
|
+
|
|
93
|
+
No lanza si el servidor rechaza: mira `connected` y `close_code`, que
|
|
94
|
+
es lo que quieres afirmar cuando pruebas un rechazo.
|
|
95
|
+
"""
|
|
96
|
+
if self.user is not None:
|
|
97
|
+
self._cookies.setdefault(
|
|
98
|
+
await _nombre_cookie_sesion(), await _crear_sesion(self.user)
|
|
99
|
+
)
|
|
100
|
+
if self._cookies:
|
|
101
|
+
self._headers["cookie"] = "; ".join(
|
|
102
|
+
f"{k}={v}" for k, v in self._cookies.items()
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
from . import dispatch
|
|
106
|
+
|
|
107
|
+
self._al_servidor.put_nowait({"type": "websocket.connect"})
|
|
108
|
+
self._tarea = asyncio.create_task(
|
|
109
|
+
dispatch.handle_websocket(self._scope(), self._recibir, self._enviar)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
evento = await self._siguiente(timeout, que="la respuesta al handshake")
|
|
113
|
+
if evento["type"] == "websocket.accept":
|
|
114
|
+
self.accepted = True
|
|
115
|
+
self.subprotocol = evento.get("subprotocol")
|
|
116
|
+
await self._mirar_si_cierra_enseguida()
|
|
117
|
+
else:
|
|
118
|
+
self._anotar_cierre(evento)
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
async def _mirar_si_cierra_enseguida(self) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Deja avanzar al handler y anota el cierre si ya viene de camino, para
|
|
124
|
+
que `connected` diga la verdad nada mas volver de connect().
|
|
125
|
+
|
|
126
|
+
Lo que se saca de la cola se guarda en el buffer, asi que `receive()`
|
|
127
|
+
lo sigue viendo en orden: no se pierde ni un mensaje ni el cierre.
|
|
128
|
+
"""
|
|
129
|
+
for _ in range(3):
|
|
130
|
+
await asyncio.sleep(0)
|
|
131
|
+
while not self._al_cliente.empty():
|
|
132
|
+
evento = self._al_cliente.get_nowait()
|
|
133
|
+
self._buffer.append(evento)
|
|
134
|
+
if evento["type"] == "websocket.close":
|
|
135
|
+
self._anotar_cierre(evento)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
async def disconnect(self, code: int = 1000) -> None:
|
|
139
|
+
"""El cliente se va. Espera a que el handler termine su limpieza."""
|
|
140
|
+
if self._tarea is None or self._tarea.done():
|
|
141
|
+
return
|
|
142
|
+
self._al_servidor.put_nowait({"type": "websocket.disconnect", "code": code})
|
|
143
|
+
try:
|
|
144
|
+
await asyncio.wait_for(asyncio.shield(self._tarea), timeout=TIMEOUT)
|
|
145
|
+
except asyncio.TimeoutError:
|
|
146
|
+
self._tarea.cancel()
|
|
147
|
+
self._vaciar_cierres()
|
|
148
|
+
|
|
149
|
+
async def __aenter__(self) -> "WebSocketClient":
|
|
150
|
+
return await self.connect()
|
|
151
|
+
|
|
152
|
+
async def __aexit__(self, *exc) -> None:
|
|
153
|
+
await self.disconnect()
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------ enviar
|
|
156
|
+
|
|
157
|
+
async def send(self, data: Any) -> None:
|
|
158
|
+
"""str -> texto, bytes -> binario, cualquier otra cosa -> JSON."""
|
|
159
|
+
if isinstance(data, str):
|
|
160
|
+
await self.send_text(data)
|
|
161
|
+
elif isinstance(data, (bytes, bytearray)):
|
|
162
|
+
await self.send_bytes(bytes(data))
|
|
163
|
+
else:
|
|
164
|
+
await self.send_json(data)
|
|
165
|
+
|
|
166
|
+
async def send_text(self, text: str) -> None:
|
|
167
|
+
self._al_servidor.put_nowait({"type": "websocket.receive", "text": text})
|
|
168
|
+
await asyncio.sleep(0) # deja correr al handler
|
|
169
|
+
|
|
170
|
+
async def send_bytes(self, data: bytes) -> None:
|
|
171
|
+
self._al_servidor.put_nowait({"type": "websocket.receive", "bytes": data})
|
|
172
|
+
await asyncio.sleep(0)
|
|
173
|
+
|
|
174
|
+
async def send_json(self, data: Any) -> None:
|
|
175
|
+
import json
|
|
176
|
+
|
|
177
|
+
await self.send_text(json.dumps(data, default=str))
|
|
178
|
+
|
|
179
|
+
# ----------------------------------------------------------------- recibir
|
|
180
|
+
|
|
181
|
+
async def receive(self, timeout: float = TIMEOUT) -> Message:
|
|
182
|
+
"""
|
|
183
|
+
El siguiente mensaje. Lanza `WebSocketDisconnect` si el servidor cierra
|
|
184
|
+
y `TimeoutDelEsperado` si no llega nada.
|
|
185
|
+
"""
|
|
186
|
+
evento = await self._siguiente(timeout, que="un mensaje")
|
|
187
|
+
if evento["type"] == "websocket.close":
|
|
188
|
+
self._anotar_cierre(evento)
|
|
189
|
+
raise WebSocketDisconnect(self.close_code, self.close_reason)
|
|
190
|
+
return Message(text=evento.get("text"), data=evento.get("bytes"))
|
|
191
|
+
|
|
192
|
+
async def receive_text(self, timeout: float = TIMEOUT) -> str:
|
|
193
|
+
msg = await self.receive(timeout)
|
|
194
|
+
if msg.text is None:
|
|
195
|
+
raise AssertionError("Se esperaba texto y llego un frame binario.")
|
|
196
|
+
return msg.text
|
|
197
|
+
|
|
198
|
+
async def receive_bytes(self, timeout: float = TIMEOUT) -> bytes:
|
|
199
|
+
msg = await self.receive(timeout)
|
|
200
|
+
if msg.bytes is None:
|
|
201
|
+
raise AssertionError("Se esperaba binario y llego un frame de texto.")
|
|
202
|
+
return msg.bytes
|
|
203
|
+
|
|
204
|
+
async def receive_json(self, timeout: float = TIMEOUT) -> Any:
|
|
205
|
+
return (await self.receive(timeout)).json()
|
|
206
|
+
|
|
207
|
+
async def receive_all(self, timeout: float = 0.1) -> list[Message]:
|
|
208
|
+
"""Todo lo que haya pendiente ahora mismo. Util tras un broadcast."""
|
|
209
|
+
mensajes = []
|
|
210
|
+
while True:
|
|
211
|
+
try:
|
|
212
|
+
mensajes.append(await self.receive(timeout))
|
|
213
|
+
except (TimeoutDelEsperado, WebSocketDisconnect):
|
|
214
|
+
return mensajes
|
|
215
|
+
|
|
216
|
+
async def receive_nothing(self, timeout: float = 0.1) -> bool:
|
|
217
|
+
"""True si NO llega nada. Para afirmar que un grupo esta aislado."""
|
|
218
|
+
try:
|
|
219
|
+
await self._siguiente(timeout, que="nada")
|
|
220
|
+
except TimeoutDelEsperado:
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
async def wait_closed(self, timeout: float = TIMEOUT) -> int:
|
|
225
|
+
"""Espera a que el servidor cierre y devuelve el codigo."""
|
|
226
|
+
if self.close_code is not None:
|
|
227
|
+
return self.close_code
|
|
228
|
+
try:
|
|
229
|
+
while True:
|
|
230
|
+
evento = await self._siguiente(timeout, que="el cierre")
|
|
231
|
+
if evento["type"] == "websocket.close":
|
|
232
|
+
self._anotar_cierre(evento)
|
|
233
|
+
return self.close_code
|
|
234
|
+
except TimeoutDelEsperado:
|
|
235
|
+
raise AssertionError(
|
|
236
|
+
f"El servidor no cerro en {timeout}s (sigue abierto)."
|
|
237
|
+
) from None
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------ dentro
|
|
240
|
+
|
|
241
|
+
def _scope(self) -> dict:
|
|
242
|
+
cabeceras = [
|
|
243
|
+
(k.lower().encode("latin-1"), str(v).encode("latin-1"))
|
|
244
|
+
for k, v in self._headers.items()
|
|
245
|
+
if v is not None
|
|
246
|
+
]
|
|
247
|
+
return {
|
|
248
|
+
"type": "websocket",
|
|
249
|
+
"path": self.path,
|
|
250
|
+
"raw_path": self.path.encode(),
|
|
251
|
+
"query_string": self._query.encode(),
|
|
252
|
+
"headers": cabeceras,
|
|
253
|
+
"subprotocols": self._subprotocols,
|
|
254
|
+
"client": ("127.0.0.1", 54321),
|
|
255
|
+
"server": ("testserver", 80),
|
|
256
|
+
"scheme": "ws",
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async def _recibir(self) -> dict:
|
|
260
|
+
return await self._al_servidor.get()
|
|
261
|
+
|
|
262
|
+
async def _enviar(self, message: dict) -> None:
|
|
263
|
+
await self._al_cliente.put(message)
|
|
264
|
+
|
|
265
|
+
async def _siguiente(self, timeout: float, que: str) -> dict:
|
|
266
|
+
if self._buffer:
|
|
267
|
+
return self._buffer.pop(0)
|
|
268
|
+
try:
|
|
269
|
+
return await asyncio.wait_for(self._al_cliente.get(), timeout=timeout)
|
|
270
|
+
except asyncio.TimeoutError:
|
|
271
|
+
self._reventar_si_el_handler_murio()
|
|
272
|
+
raise TimeoutDelEsperado(
|
|
273
|
+
f"No llego {que} de {self.path} en {timeout}s."
|
|
274
|
+
) from None
|
|
275
|
+
|
|
276
|
+
def _reventar_si_el_handler_murio(self) -> None:
|
|
277
|
+
"""Si el handler murio de verdad, ese error es mas util que el timeout."""
|
|
278
|
+
if self._tarea is not None and self._tarea.done():
|
|
279
|
+
exc = self._tarea.exception()
|
|
280
|
+
if exc is not None:
|
|
281
|
+
raise exc
|
|
282
|
+
|
|
283
|
+
def _anotar_cierre(self, evento: dict) -> None:
|
|
284
|
+
self.close_code = evento.get("code", 1000)
|
|
285
|
+
self.close_reason = evento.get("reason", "")
|
|
286
|
+
|
|
287
|
+
def _vaciar_cierres(self) -> None:
|
|
288
|
+
while not self._al_cliente.empty():
|
|
289
|
+
self._buffer.append(self._al_cliente.get_nowait())
|
|
290
|
+
for evento in self._buffer:
|
|
291
|
+
if evento["type"] == "websocket.close":
|
|
292
|
+
self._anotar_cierre(evento)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --------------------------------------------------------------------- sesion
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _nombre_cookie_sesion() -> str:
|
|
299
|
+
from django.conf import settings
|
|
300
|
+
|
|
301
|
+
return settings.SESSION_COOKIE_NAME
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _crear_sesion(user) -> str:
|
|
305
|
+
"""Deja una sesion autenticada en la BD, como haria un login de verdad."""
|
|
306
|
+
from importlib import import_module
|
|
307
|
+
|
|
308
|
+
from django.conf import settings
|
|
309
|
+
from django.contrib.auth import (
|
|
310
|
+
BACKEND_SESSION_KEY,
|
|
311
|
+
HASH_SESSION_KEY,
|
|
312
|
+
SESSION_KEY,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
engine = import_module(settings.SESSION_ENGINE)
|
|
316
|
+
sesion = engine.SessionStore()
|
|
317
|
+
sesion[SESSION_KEY] = str(user.pk)
|
|
318
|
+
sesion[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
|
|
319
|
+
sesion[HASH_SESSION_KEY] = user.get_session_auth_hash()
|
|
320
|
+
|
|
321
|
+
if hasattr(sesion, "acreate"):
|
|
322
|
+
await sesion.acreate()
|
|
323
|
+
else: # pragma: no cover - Django < 5.0
|
|
324
|
+
from asgiref.sync import sync_to_async
|
|
325
|
+
|
|
326
|
+
await sync_to_async(sesion.create)()
|
|
327
|
+
return sesion.session_key
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
__all__ = ["WebSocketClient", "TimeoutDelEsperado", "WebSocketDisconnect", "InvalidJSON"]
|