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.
- django_socket/__init__.py +60 -0
- django_socket/apps.py +32 -0
- django_socket/asgi.py +62 -0
- django_socket/auth.py +81 -0
- django_socket/checks.py +75 -0
- django_socket/dispatch.py +191 -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/patch.py +54 -0
- django_socket/routing.py +120 -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 +587 -0
- django_socket-0.1.0.dist-info/METADATA +1008 -0
- django_socket-0.1.0.dist-info/RECORD +24 -0
- django_socket-0.1.0.dist-info/WHEEL +5 -0
- django_socket-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_socket-0.1.0.dist-info/top_level.txt +1 -0
django_socket/groups.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Capa de difusion: grupos y broadcast.
|
|
2
|
+
|
|
3
|
+
`MemoryLayer` sirve para un solo proceso (dev, o un unico worker).
|
|
4
|
+
`RedisLayer` reparte el fan-out entre procesos via pub/sub.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("django_socket")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseLayer:
|
|
18
|
+
async def startup(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
async def shutdown(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
async def add(self, group: str, sock) -> None:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
async def discard(self, group: str, sock) -> None:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
async def send(self, group: str, data: Any, *, exclude=None) -> None:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
async def size(self, group: str) -> int:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryLayer(BaseLayer):
|
|
36
|
+
"""Grupos en memoria del proceso. Sin dependencias externas."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._groups: dict[str, set] = {}
|
|
40
|
+
|
|
41
|
+
async def add(self, group: str, sock) -> None:
|
|
42
|
+
self._groups.setdefault(group, set()).add(sock)
|
|
43
|
+
|
|
44
|
+
async def discard(self, group: str, sock) -> None:
|
|
45
|
+
members = self._groups.get(group)
|
|
46
|
+
if members:
|
|
47
|
+
members.discard(sock)
|
|
48
|
+
if not members:
|
|
49
|
+
del self._groups[group]
|
|
50
|
+
|
|
51
|
+
async def size(self, group: str) -> int:
|
|
52
|
+
return len(self._groups.get(group, ()))
|
|
53
|
+
|
|
54
|
+
async def send(self, group: str, data: Any, *, exclude=None) -> None:
|
|
55
|
+
await self._deliver_local(group, data, exclude=exclude)
|
|
56
|
+
|
|
57
|
+
async def _deliver_local(self, group: str, data: Any, *, exclude=None) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Reparte encolando, sin esperar a que nadie lea.
|
|
60
|
+
|
|
61
|
+
Esperar seria el bug: un solo miembro que no consume dejaria colgado
|
|
62
|
+
para siempre al que difunde. Cada socket tiene un buzon acotado; si se
|
|
63
|
+
llena, ese cliente va demasiado atrasado y se le echa en vez de dejar
|
|
64
|
+
que arrastre a los demas.
|
|
65
|
+
"""
|
|
66
|
+
miembros = [s for s in self._groups.get(group, ()) if s is not exclude]
|
|
67
|
+
if not miembros:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
lentos = []
|
|
71
|
+
for sock in miembros:
|
|
72
|
+
if not await sock.enqueue(data):
|
|
73
|
+
lentos.append(sock)
|
|
74
|
+
|
|
75
|
+
# Cede el turno una vez por difusion, no una por miembro: es donde
|
|
76
|
+
# corren los escritores. Sin esto, un handler que difunde en bucle
|
|
77
|
+
# (`for fila in lote: await sock.broadcast(fila)`) no soltaria nunca el
|
|
78
|
+
# loop, los buzones se llenarian y acabaria echando a clientes sanos.
|
|
79
|
+
await asyncio.sleep(0)
|
|
80
|
+
|
|
81
|
+
for sock in lentos:
|
|
82
|
+
logger.warning(
|
|
83
|
+
"django_socket: %r no consume (buzon lleno); se le echa del "
|
|
84
|
+
"grupo %r. Sube DJANGO_SOCKET['SEND_QUEUE_MAX'] si tu caso "
|
|
85
|
+
"manda rafagas legitimas.",
|
|
86
|
+
sock, group,
|
|
87
|
+
)
|
|
88
|
+
sock.evict()
|
|
89
|
+
await self.discard(group, sock)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RedisLayer(MemoryLayer):
|
|
93
|
+
"""
|
|
94
|
+
Mantiene los miembros locales igual que MemoryLayer, pero publica cada
|
|
95
|
+
broadcast en Redis para que los demas procesos entreguen a los suyos.
|
|
96
|
+
|
|
97
|
+
Sobrevive a que Redis se caiga: la entrega local sigue funcionando, los
|
|
98
|
+
sockets de los usuarios no se cierran, y al volver Redis el proceso se
|
|
99
|
+
resuscribe solo. Lo que se publique mientras esta caido se pierde -- esto
|
|
100
|
+
es pub/sub, no una cola.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
ESPERA_MAX = 10.0 # tope del backoff al reconectar
|
|
104
|
+
LATIDO = 15.0 # health check de redis-py, en segundos
|
|
105
|
+
|
|
106
|
+
def __init__(self, url: str = "redis://localhost:6379/0", prefix: str = "djws"):
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.url = url
|
|
109
|
+
self.channel = f"{prefix}:broadcast"
|
|
110
|
+
self._redis = None
|
|
111
|
+
self._pubsub = None
|
|
112
|
+
self._listener: asyncio.Task | None = None
|
|
113
|
+
self._conectado = False
|
|
114
|
+
# Identifica a este proceso para no entregar dos veces lo que ya
|
|
115
|
+
# entregamos localmente.
|
|
116
|
+
self._origin = f"{id(self)}"
|
|
117
|
+
|
|
118
|
+
async def startup(self) -> None:
|
|
119
|
+
try:
|
|
120
|
+
import redis.asyncio as aioredis
|
|
121
|
+
except ImportError as exc: # pragma: no cover
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"RedisLayer necesita el paquete 'redis'. Instalalo con: pip install redis"
|
|
124
|
+
) from exc
|
|
125
|
+
self._redis = aioredis.from_url(
|
|
126
|
+
self.url,
|
|
127
|
+
health_check_interval=self.LATIDO, # sin esto no detecta la caida
|
|
128
|
+
retry_on_timeout=True,
|
|
129
|
+
)
|
|
130
|
+
self._pubsub = self._redis.pubsub()
|
|
131
|
+
await self._pubsub.subscribe(self.channel)
|
|
132
|
+
self._conectado = True
|
|
133
|
+
self._listener = asyncio.create_task(self._listen())
|
|
134
|
+
logger.info("django_socket: RedisLayer conectada a %s", self.url)
|
|
135
|
+
|
|
136
|
+
async def shutdown(self) -> None:
|
|
137
|
+
self._conectado = False
|
|
138
|
+
if self._listener:
|
|
139
|
+
self._listener.cancel()
|
|
140
|
+
try:
|
|
141
|
+
await self._listener
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
pass
|
|
144
|
+
if self._pubsub:
|
|
145
|
+
try:
|
|
146
|
+
await self._pubsub.unsubscribe(self.channel)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
await self._pubsub.aclose()
|
|
150
|
+
if self._redis:
|
|
151
|
+
await self._redis.aclose()
|
|
152
|
+
|
|
153
|
+
async def send(self, group: str, data: Any, *, exclude=None) -> None:
|
|
154
|
+
# Entrega local inmediata (asi `exclude` funciona por identidad)...
|
|
155
|
+
await self._deliver_local(group, data, exclude=exclude)
|
|
156
|
+
# ...y avisa al resto de procesos.
|
|
157
|
+
carga = json.dumps(
|
|
158
|
+
{"group": group, "data": data, "origin": self._origin}, default=str
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
await self._redis.publish(self.channel, carga)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
# Que Redis falle no puede tumbar la conexion del usuario: la
|
|
164
|
+
# entrega local ya se hizo y el handler debe seguir vivo. Se pierde
|
|
165
|
+
# el fan-out a los demas procesos, y por eso se loguea como error.
|
|
166
|
+
logger.error(
|
|
167
|
+
"django_socket: no se pudo publicar en Redis (%s: %s). "
|
|
168
|
+
"El grupo %r solo recibio la entrega local.",
|
|
169
|
+
type(exc).__name__, exc, group,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _listen(self) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Bucle de escucha resistente.
|
|
175
|
+
|
|
176
|
+
Se usa `get_message(timeout=...)` en vez de `listen()` para tener un
|
|
177
|
+
despertar periodico: ahi es donde redis-py corre su health check y
|
|
178
|
+
donde podemos detectar que la conexion se fue. Con `listen()` a secas
|
|
179
|
+
el proceso se queda sordo para siempre tras una caida.
|
|
180
|
+
"""
|
|
181
|
+
espera = 0.5
|
|
182
|
+
while True:
|
|
183
|
+
try:
|
|
184
|
+
mensaje = await self._pubsub.get_message(
|
|
185
|
+
ignore_subscribe_messages=True, timeout=1.0
|
|
186
|
+
)
|
|
187
|
+
espera = 0.5 # todo bien, resetea el backoff
|
|
188
|
+
if mensaje is not None and mensaje.get("type") == "message":
|
|
189
|
+
await self._entregar(mensaje)
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
raise
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
if not self._conectado:
|
|
194
|
+
return
|
|
195
|
+
logger.warning(
|
|
196
|
+
"django_socket: se perdio la conexion con Redis (%s). "
|
|
197
|
+
"Reintentando en %.1fs.", type(exc).__name__, espera,
|
|
198
|
+
)
|
|
199
|
+
await asyncio.sleep(espera)
|
|
200
|
+
espera = min(espera * 2, self.ESPERA_MAX)
|
|
201
|
+
await self._resuscribir()
|
|
202
|
+
|
|
203
|
+
async def _entregar(self, mensaje) -> None:
|
|
204
|
+
try:
|
|
205
|
+
payload = json.loads(mensaje["data"])
|
|
206
|
+
except (ValueError, KeyError, TypeError):
|
|
207
|
+
logger.warning("django_socket: mensaje ilegible en %s", self.channel)
|
|
208
|
+
return
|
|
209
|
+
if payload.get("origin") == self._origin:
|
|
210
|
+
return # ya lo entregamos localmente
|
|
211
|
+
await self._deliver_local(payload["group"], payload["data"])
|
|
212
|
+
|
|
213
|
+
async def _resuscribir(self) -> None:
|
|
214
|
+
"""Vuelve a suscribirse tras un corte. Si Redis sigue caido, lo dira el bucle."""
|
|
215
|
+
try:
|
|
216
|
+
await self._pubsub.aclose()
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
self._pubsub = self._redis.pubsub()
|
|
220
|
+
await self._pubsub.subscribe(self.channel)
|
|
221
|
+
logger.info("django_socket: resuscrito a %s", self.channel)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------- layer global
|
|
225
|
+
|
|
226
|
+
_layer: BaseLayer | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_layer() -> BaseLayer:
|
|
230
|
+
global _layer
|
|
231
|
+
if _layer is None:
|
|
232
|
+
_layer = _build_layer()
|
|
233
|
+
return _layer
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def set_layer(layer: BaseLayer) -> None:
|
|
237
|
+
global _layer
|
|
238
|
+
_layer = layer
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_layer() -> BaseLayer:
|
|
242
|
+
from django.conf import settings
|
|
243
|
+
|
|
244
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
245
|
+
backend = conf.get("LAYER", "memory")
|
|
246
|
+
if backend == "memory":
|
|
247
|
+
return MemoryLayer()
|
|
248
|
+
if backend == "redis":
|
|
249
|
+
return RedisLayer(
|
|
250
|
+
url=conf.get("REDIS_URL", "redis://localhost:6379/0"),
|
|
251
|
+
prefix=conf.get("PREFIX", "djws"),
|
|
252
|
+
)
|
|
253
|
+
if callable(backend):
|
|
254
|
+
return backend()
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"DJANGO_SOCKET['LAYER'] invalido: {backend!r}. Usa 'memory', 'redis' "
|
|
257
|
+
f"o un callable que devuelva una BaseLayer."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------- API de usuario
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def broadcast(data: Any, *, to: str) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Envia a todos los miembros de un grupo, desde fuera de un handler.
|
|
267
|
+
|
|
268
|
+
await broadcast({"aviso": "mantenimiento"}, to="room:1")
|
|
269
|
+
"""
|
|
270
|
+
await get_layer().send(to, data)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def group_size(group: str) -> int:
|
|
274
|
+
"""Cuantos sockets locales hay en el grupo."""
|
|
275
|
+
return await get_layer().size(group)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def broadcast_sync(data: Any, *, to: str) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Igual que `broadcast`, para vistas sincronas, señales o tareas Celery.
|
|
281
|
+
|
|
282
|
+
Con la capa 'memory' solo alcanza a los sockets del mismo proceso; para
|
|
283
|
+
llegar a todos los workers necesitas LAYER='redis'.
|
|
284
|
+
"""
|
|
285
|
+
from asgiref.sync import async_to_sync
|
|
286
|
+
|
|
287
|
+
async_to_sync(broadcast)(data, to=to)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""`manage.py runserver` sobre uvicorn, para que los WebSockets funcionen en dev.
|
|
2
|
+
|
|
3
|
+
El runserver de Django es WSGI puro y rechaza el scope 'websocket'. Este
|
|
4
|
+
comando reutiliza el parseo de argumentos de Django (addrport, --ipv6,
|
|
5
|
+
--noreload) y arranca uvicorn contra tu ASGI_APPLICATION.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.management import CommandError
|
|
12
|
+
from django.core.management.commands.runserver import Command as RunserverCommand
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Command(RunserverCommand):
|
|
16
|
+
help = "Arranca un servidor de desarrollo ASGI (uvicorn) con soporte WebSocket."
|
|
17
|
+
|
|
18
|
+
def add_arguments(self, parser):
|
|
19
|
+
super().add_arguments(parser)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--log-level",
|
|
22
|
+
default="info",
|
|
23
|
+
help="Nivel de log de uvicorn (critical, error, warning, info, debug, trace).",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def run(self, **options):
|
|
27
|
+
try:
|
|
28
|
+
import uvicorn
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise CommandError(
|
|
31
|
+
"django_socket necesita uvicorn para el servidor de desarrollo.\n"
|
|
32
|
+
" pip install 'uvicorn[standard]'"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
app_path, is_factory, origen = self._import_string()
|
|
36
|
+
|
|
37
|
+
from ... import routing
|
|
38
|
+
|
|
39
|
+
rutas = routing.get_routes()
|
|
40
|
+
self.stdout.write(
|
|
41
|
+
self.style.SUCCESS(
|
|
42
|
+
f"django_socket sobre uvicorn -- http://{self.addr}:{self.port}/"
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
self.stdout.write(
|
|
46
|
+
f" {len(rutas)} ruta(s) websocket"
|
|
47
|
+
+ (": " + ", ".join(f"/{r.route}" for r in rutas) if rutas else "")
|
|
48
|
+
)
|
|
49
|
+
self.stdout.write(f" app: {app_path} ({origen})")
|
|
50
|
+
self.stdout.write("Ctrl-C para salir.\n")
|
|
51
|
+
|
|
52
|
+
uvicorn.run(
|
|
53
|
+
app_path,
|
|
54
|
+
factory=is_factory,
|
|
55
|
+
host=self.addr,
|
|
56
|
+
port=int(self.port),
|
|
57
|
+
reload=options["use_reloader"],
|
|
58
|
+
log_level=options["log_level"],
|
|
59
|
+
# Django ya loguea las peticiones cuando DEBUG esta activo.
|
|
60
|
+
access_log=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _import_string(self) -> tuple[str, bool, str]:
|
|
64
|
+
"""
|
|
65
|
+
Devuelve (ruta_de_importacion, es_factory, de_donde_sale).
|
|
66
|
+
|
|
67
|
+
Si el proyecto declara ASGI_APPLICATION la respetamos; si no, montamos
|
|
68
|
+
una al vuelo, para que la libreria funcione recien instalada sin pedir
|
|
69
|
+
ni una linea de configuracion.
|
|
70
|
+
"""
|
|
71
|
+
path = getattr(settings, "ASGI_APPLICATION", None)
|
|
72
|
+
if not path:
|
|
73
|
+
return "django_socket.asgi:factory", True, "generada al vuelo"
|
|
74
|
+
|
|
75
|
+
module, _, attr = path.rpartition(".")
|
|
76
|
+
if not module:
|
|
77
|
+
raise CommandError(
|
|
78
|
+
f"ASGI_APPLICATION invalido: {path!r}. Deberia ser algo como "
|
|
79
|
+
f'"miproyecto.asgi.application".'
|
|
80
|
+
)
|
|
81
|
+
return f"{module}:{attr}", False, "ASGI_APPLICATION"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`manage.py ws` -- que rutas hay y como esta montado todo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.management.base import BaseCommand
|
|
7
|
+
|
|
8
|
+
from ... import patch, routing
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Lista las rutas WebSocket registradas y revisa la integracion."
|
|
13
|
+
|
|
14
|
+
def handle(self, *args, **options):
|
|
15
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
16
|
+
routes = routing.get_routes()
|
|
17
|
+
|
|
18
|
+
self.stdout.write(self.style.MIGRATE_HEADING("Rutas WebSocket"))
|
|
19
|
+
if not routes:
|
|
20
|
+
self.stdout.write(
|
|
21
|
+
" ninguna. Crea <tu_app>/sockets.py y decora un 'async def' con @ws()."
|
|
22
|
+
)
|
|
23
|
+
for r in routes:
|
|
24
|
+
flags = []
|
|
25
|
+
if r.group:
|
|
26
|
+
flags.append(f"group={r.group}")
|
|
27
|
+
if not r.auth:
|
|
28
|
+
flags.append("auth=False")
|
|
29
|
+
suffix = f" [{', '.join(flags)}]" if flags else ""
|
|
30
|
+
where = f"{r.handler.__module__}.{r.handler.__name__}"
|
|
31
|
+
self.stdout.write(f" ws:///{r.route}".ljust(42) + f"{where}{suffix}")
|
|
32
|
+
|
|
33
|
+
self.stdout.write("")
|
|
34
|
+
self.stdout.write(self.style.MIGRATE_HEADING("Integracion"))
|
|
35
|
+
self._row("Capa de difusion", conf.get("LAYER", "memory"))
|
|
36
|
+
if conf.get("LAYER") == "redis":
|
|
37
|
+
self._row("Redis", conf.get("REDIS_URL", "redis://localhost:6379/0"))
|
|
38
|
+
self._row(
|
|
39
|
+
"asgi.py",
|
|
40
|
+
"no hace falta tocarlo (ASGIHandler ampliado)"
|
|
41
|
+
if patch.is_installed()
|
|
42
|
+
else "PATCH_ASGI=False -> debes usar ASGIApplication() a mano",
|
|
43
|
+
)
|
|
44
|
+
self._row(
|
|
45
|
+
"Origenes permitidos",
|
|
46
|
+
conf.get("ALLOWED_ORIGINS")
|
|
47
|
+
or f"ALLOWED_HOSTS={list(settings.ALLOWED_HOSTS) or '[] (DEBUG: localhost)'}",
|
|
48
|
+
)
|
|
49
|
+
self._row(
|
|
50
|
+
"Origin ausente",
|
|
51
|
+
"rechazado" if conf.get("REQUIRE_ORIGIN") else "aceptado (clientes nativos)",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if settings.DEBUG and conf.get("LAYER", "memory") == "memory":
|
|
55
|
+
self.stdout.write("")
|
|
56
|
+
self.stdout.write(
|
|
57
|
+
self.style.WARNING(
|
|
58
|
+
" Aviso: con la capa 'memory' un broadcast no cruza entre\n"
|
|
59
|
+
" procesos. En produccion con varios workers usa LAYER='redis'."
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _row(self, label: str, value) -> None:
|
|
64
|
+
self.stdout.write(f" {label:<22}{value}")
|
django_socket/patch.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Hace que el `asgi.py` que genera `startproject` sirva WebSockets sin tocarlo.
|
|
2
|
+
|
|
3
|
+
`django.core.asgi.get_asgi_application()` devuelve un `ASGIHandler` que rechaza
|
|
4
|
+
todo scope que no sea 'http' -- el propio codigo de Django lleva ahi un
|
|
5
|
+
`# FIXME: Allow to override this.`. Como `django.setup()` ejecuta los `ready()`
|
|
6
|
+
de las apps *antes* de instanciar el handler, desde nuestro `ready()` llegamos
|
|
7
|
+
a tiempo de ensanchar esa puerta.
|
|
8
|
+
|
|
9
|
+
El resultado es que integrar la libreria son dos pasos: instalarla y añadirla a
|
|
10
|
+
INSTALLED_APPS. Desactivalo con DJANGO_SOCKET = {"PATCH_ASGI": False} si
|
|
11
|
+
prefieres declarar `ASGIApplication()` a mano.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("django_socket")
|
|
19
|
+
|
|
20
|
+
FLAG = "_django_socket_patched"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def install() -> bool:
|
|
24
|
+
"""Devuelve True si el parche quedo instalado (o ya lo estaba)."""
|
|
25
|
+
from django.core.handlers.asgi import ASGIHandler
|
|
26
|
+
|
|
27
|
+
if getattr(ASGIHandler, FLAG, False):
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
original_call = ASGIHandler.__call__
|
|
31
|
+
|
|
32
|
+
async def __call__(self, scope, receive, send):
|
|
33
|
+
kind = scope["type"]
|
|
34
|
+
if kind == "websocket":
|
|
35
|
+
from . import dispatch
|
|
36
|
+
|
|
37
|
+
return await dispatch.handle_websocket(scope, receive, send)
|
|
38
|
+
if kind == "lifespan":
|
|
39
|
+
from . import dispatch
|
|
40
|
+
|
|
41
|
+
return await dispatch.handle_lifespan(scope, receive, send)
|
|
42
|
+
return await original_call(self, scope, receive, send)
|
|
43
|
+
|
|
44
|
+
__call__.__doc__ = ASGIHandler.__call__.__doc__
|
|
45
|
+
ASGIHandler.__call__ = __call__
|
|
46
|
+
setattr(ASGIHandler, FLAG, True)
|
|
47
|
+
logger.debug("django_socket: ASGIHandler ampliado con websocket + lifespan")
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_installed() -> bool:
|
|
52
|
+
from django.core.handlers.asgi import ASGIHandler
|
|
53
|
+
|
|
54
|
+
return getattr(ASGIHandler, FLAG, False)
|
django_socket/routing.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Registro y resolucion de rutas WebSocket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import string
|
|
7
|
+
from typing import Any, Callable, NamedTuple
|
|
8
|
+
|
|
9
|
+
from django.urls.resolvers import RoutePattern
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Route(NamedTuple):
|
|
13
|
+
pattern: RoutePattern
|
|
14
|
+
handler: Callable
|
|
15
|
+
name: str
|
|
16
|
+
auth: bool
|
|
17
|
+
group: str | None
|
|
18
|
+
route: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_routes: list[Route] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ws(
|
|
25
|
+
route: str,
|
|
26
|
+
*,
|
|
27
|
+
group: str | None = None,
|
|
28
|
+
auth: bool = True,
|
|
29
|
+
name: str | None = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Registra un handler WebSocket.
|
|
33
|
+
|
|
34
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
35
|
+
async def chat(sock, room):
|
|
36
|
+
async for msg in sock:
|
|
37
|
+
await sock.broadcast(msg.text)
|
|
38
|
+
|
|
39
|
+
`route` usa la sintaxis de `django.urls.path` y sus mismos conversores
|
|
40
|
+
(`<int:pk>`, `<slug:x>`, `<uuid:x>`...), asi que los parametros llegan al
|
|
41
|
+
handler ya convertidos.
|
|
42
|
+
|
|
43
|
+
`group` se rellena con esos mismos parametros: el socket entra en el grupo
|
|
44
|
+
al conectar, sale al desconectar, y `sock.broadcast(dato)` va ahi por
|
|
45
|
+
defecto.
|
|
46
|
+
|
|
47
|
+
`auth=False` salta la resolucion de sesion y usuario si el endpoint es
|
|
48
|
+
publico (una consulta menos por conexion).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def decorator(handler: Callable) -> Callable:
|
|
52
|
+
if not inspect.iscoroutinefunction(handler):
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"@ws espera 'async def', y {handler.__name__} es una funcion "
|
|
55
|
+
f"normal.\n"
|
|
56
|
+
f" async def {handler.__name__}(sock, ...):\n"
|
|
57
|
+
f"Un WebSocket vive en el loop de eventos. Para tocar el ORM "
|
|
58
|
+
f"usa su API async (await Model.objects.aget(...)) o envuelve "
|
|
59
|
+
f"lo sincrono en asgiref.sync.sync_to_async."
|
|
60
|
+
)
|
|
61
|
+
normalized = route.lstrip("/")
|
|
62
|
+
_check_group_template(group, normalized, handler)
|
|
63
|
+
|
|
64
|
+
for existing in _routes:
|
|
65
|
+
if existing.route == normalized:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"La ruta '{normalized}' ya la tiene registrada "
|
|
68
|
+
f"{existing.handler.__module__}.{existing.handler.__name__}."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_routes.append(
|
|
72
|
+
Route(
|
|
73
|
+
pattern=RoutePattern(normalized, is_endpoint=True),
|
|
74
|
+
handler=handler,
|
|
75
|
+
name=name or handler.__name__,
|
|
76
|
+
auth=auth,
|
|
77
|
+
group=group,
|
|
78
|
+
route=normalized,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return handler
|
|
82
|
+
|
|
83
|
+
return decorator
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_group_template(group: str | None, route: str, handler: Callable) -> None:
|
|
87
|
+
"""Falla al importar, no en la primera conexion, si el grupo no cuadra."""
|
|
88
|
+
if not group:
|
|
89
|
+
return
|
|
90
|
+
referenced = {
|
|
91
|
+
field for _, field, _, _ in string.Formatter().parse(group) if field
|
|
92
|
+
}
|
|
93
|
+
available = set(RoutePattern(route, is_endpoint=True).regex.groupindex)
|
|
94
|
+
missing = referenced - available
|
|
95
|
+
if missing:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"group={group!r} en {handler.__name__} usa "
|
|
98
|
+
f"{sorted(missing)}, que no existe(n) en la ruta '{route}'. "
|
|
99
|
+
f"Disponibles: {sorted(available) or 'ninguno'}."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve(path: str) -> tuple[Route, dict[str, Any]] | None:
|
|
104
|
+
"""Devuelve (ruta, kwargs) para un path ASGI, o None si no casa ninguna."""
|
|
105
|
+
candidate = path.lstrip("/")
|
|
106
|
+
for r in _routes:
|
|
107
|
+
match = r.pattern.match(candidate)
|
|
108
|
+
if match is not None:
|
|
109
|
+
_, _, kwargs = match
|
|
110
|
+
return r, kwargs
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_routes() -> list[Route]:
|
|
115
|
+
return list(_routes)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def clear_routes() -> None:
|
|
119
|
+
"""Solo para tests."""
|
|
120
|
+
_routes.clear()
|