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,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)
@@ -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()