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,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}")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Middleware: envuelve cada conexion, para lo que hay que hacer en todas.
|
|
2
|
+
|
|
3
|
+
Trazas, metricas, reportar errores a Sentry, limitar conexiones por usuario.
|
|
4
|
+
Un middleware es `async def (sock, siguiente)`, y `siguiente()` corre el resto
|
|
5
|
+
de la cadena y al final tu handler:
|
|
6
|
+
|
|
7
|
+
# miapp/ws.py
|
|
8
|
+
import time, logging
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("miapp.sockets")
|
|
11
|
+
|
|
12
|
+
async def medir(sock, siguiente):
|
|
13
|
+
inicio = time.monotonic()
|
|
14
|
+
try:
|
|
15
|
+
await siguiente()
|
|
16
|
+
finally:
|
|
17
|
+
log.info("%s duro %.1fs", sock.path, time.monotonic() - inicio)
|
|
18
|
+
|
|
19
|
+
# settings.py
|
|
20
|
+
DJANGO_SOCKET = {"MIDDLEWARE": ["miapp.ws.medir"]}
|
|
21
|
+
|
|
22
|
+
Se aplican en orden: el primero de la lista es el mas externo, igual que el
|
|
23
|
+
MIDDLEWARE de Django.
|
|
24
|
+
|
|
25
|
+
Para cortar una conexion, cierra y no llames a `siguiente()`:
|
|
26
|
+
|
|
27
|
+
async def solo_de_pago(sock, siguiente):
|
|
28
|
+
if not await es_de_pago(sock.user):
|
|
29
|
+
await sock.close(4403, "Plan insuficiente")
|
|
30
|
+
return
|
|
31
|
+
await siguiente()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from typing import Awaitable, Callable
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("django_socket")
|
|
40
|
+
|
|
41
|
+
Siguiente = Callable[[], Awaitable[None]]
|
|
42
|
+
Middleware = Callable[..., Awaitable[None]]
|
|
43
|
+
|
|
44
|
+
_cadena: list[Middleware] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_middleware() -> list[Middleware]:
|
|
48
|
+
"""Lee y cachea la lista de settings. Se resuelve una vez, no por conexion."""
|
|
49
|
+
global _cadena
|
|
50
|
+
if _cadena is None:
|
|
51
|
+
from django.conf import settings
|
|
52
|
+
from django.utils.module_loading import import_string
|
|
53
|
+
|
|
54
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
55
|
+
_cadena = [
|
|
56
|
+
import_string(m) if isinstance(m, str) else m
|
|
57
|
+
for m in conf.get("MIDDLEWARE", [])
|
|
58
|
+
]
|
|
59
|
+
if _cadena:
|
|
60
|
+
logger.debug(
|
|
61
|
+
"django_socket: %d middleware(s): %s",
|
|
62
|
+
len(_cadena),
|
|
63
|
+
", ".join(getattr(m, "__name__", str(m)) for m in _cadena),
|
|
64
|
+
)
|
|
65
|
+
return _cadena
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def limpiar_cache() -> None:
|
|
69
|
+
"""Solo para tests: obliga a releer MIDDLEWARE de settings."""
|
|
70
|
+
global _cadena
|
|
71
|
+
_cadena = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def aplicar(sock, handler_final: Siguiente) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Corre la cadena y, al final, el handler.
|
|
77
|
+
|
|
78
|
+
Se monta de dentro hacia fuera para que el primero de la lista quede el mas
|
|
79
|
+
externo, que es lo que la gente espera al leer un MIDDLEWARE de Django.
|
|
80
|
+
"""
|
|
81
|
+
cadena = get_middleware()
|
|
82
|
+
if not cadena:
|
|
83
|
+
await handler_final()
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
siguiente = handler_final
|
|
87
|
+
for mw in reversed(cadena):
|
|
88
|
+
siguiente = _envolver(mw, sock, siguiente)
|
|
89
|
+
await siguiente()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _envolver(mw: Middleware, sock, siguiente: Siguiente) -> Siguiente:
|
|
93
|
+
async def llamada() -> None:
|
|
94
|
+
await mw(sock, siguiente)
|
|
95
|
+
|
|
96
|
+
llamada.__name__ = getattr(mw, "__name__", "middleware")
|
|
97
|
+
return llamada
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------- middlewares utiles
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def max_conexiones_por_usuario(limite: int = 5, code: int = 4429):
|
|
104
|
+
"""
|
|
105
|
+
Corta al usuario que abre mas de `limite` sockets a la vez.
|
|
106
|
+
|
|
107
|
+
DJANGO_SOCKET = {
|
|
108
|
+
"MIDDLEWARE": [max_conexiones_por_usuario(10)],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Cuenta por proceso. Con varios workers el limite real es
|
|
112
|
+
`limite x workers`; para un tope global harian falta contadores en Redis, y
|
|
113
|
+
eso vale la pena solo si de verdad te hace falta esa precision.
|
|
114
|
+
"""
|
|
115
|
+
from collections import defaultdict
|
|
116
|
+
|
|
117
|
+
abiertas: dict[object, int] = defaultdict(int)
|
|
118
|
+
|
|
119
|
+
async def middleware(sock, siguiente):
|
|
120
|
+
user = getattr(sock, "user", None)
|
|
121
|
+
clave = getattr(user, "pk", None) if getattr(user, "is_authenticated", False) else None
|
|
122
|
+
if clave is None:
|
|
123
|
+
clave = (sock.client or ("?", 0))[0] # anonimos, por IP
|
|
124
|
+
|
|
125
|
+
if abiertas[clave] >= limite:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"django_socket: %r supero %d conexiones simultaneas en %s",
|
|
128
|
+
clave, limite, sock.path,
|
|
129
|
+
)
|
|
130
|
+
await sock.close(code, "Too many connections")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
abiertas[clave] += 1
|
|
134
|
+
try:
|
|
135
|
+
await siguiente()
|
|
136
|
+
finally:
|
|
137
|
+
abiertas[clave] -= 1
|
|
138
|
+
if abiertas[clave] <= 0:
|
|
139
|
+
abiertas.pop(clave, None)
|
|
140
|
+
|
|
141
|
+
middleware.__name__ = "max_conexiones_por_usuario"
|
|
142
|
+
return middleware
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def registrar(nivel: int = logging.INFO, logger_name: str = "django_socket.access"):
|
|
146
|
+
"""Una linea por conexion: ruta, usuario, duracion y como termino."""
|
|
147
|
+
import time
|
|
148
|
+
|
|
149
|
+
log = logging.getLogger(logger_name)
|
|
150
|
+
|
|
151
|
+
async def middleware(sock, siguiente):
|
|
152
|
+
inicio = time.monotonic()
|
|
153
|
+
try:
|
|
154
|
+
await siguiente()
|
|
155
|
+
finally:
|
|
156
|
+
log.log(
|
|
157
|
+
nivel,
|
|
158
|
+
"%s user=%s dur=%.2fs code=%s",
|
|
159
|
+
sock.path,
|
|
160
|
+
getattr(sock.user, "pk", None) or "anon",
|
|
161
|
+
time.monotonic() - inicio,
|
|
162
|
+
sock.close_code,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
middleware.__name__ = "registrar"
|
|
166
|
+
return middleware
|
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/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Limite de mensajes entrantes por socket.
|
|
2
|
+
|
|
3
|
+
DJANGO_SOCKET = {"RATE_LIMIT": "60/m"} # para todas las rutas
|
|
4
|
+
@ws("chat/", rate_limit="10/s") # o solo para esta
|
|
5
|
+
|
|
6
|
+
Es un *token bucket*, no un contador por ventana, y la diferencia importa: un
|
|
7
|
+
contador rechaza el mensaje 11 aunque los 10 anteriores fueran de hace 59
|
|
8
|
+
segundos. El cubo se rellena de forma continua, asi que aguanta la rafaga
|
|
9
|
+
normal de alguien escribiendo rapido y solo corta cuando el ritmo *sostenido*
|
|
10
|
+
pasa del limite.
|
|
11
|
+
|
|
12
|
+
Al agotarse se cierra con **4429**. Para dejar pasar picos mas grandes, sube el
|
|
13
|
+
`burst`:
|
|
14
|
+
|
|
15
|
+
@ws("cursor/", rate_limit="30/s", burst=100)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
UNIDADES = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
|
|
24
|
+
_FORMATO = re.compile(r"^\s*(\d+)\s*/\s*(\d*)\s*([smhd])\s*$", re.I)
|
|
25
|
+
|
|
26
|
+
CLOSE_RATE_LIMIT = 4429
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parsear(spec: str) -> tuple[float, float]:
|
|
30
|
+
"""
|
|
31
|
+
"60/m" -> (60 mensajes, 60 segundos). Tambien "100/5m", "10/s".
|
|
32
|
+
|
|
33
|
+
Devuelve (cantidad, periodo_en_segundos).
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(spec, str):
|
|
36
|
+
raise ValueError(f"rate_limit debe ser una cadena como '60/m', no {spec!r}")
|
|
37
|
+
|
|
38
|
+
m = _FORMATO.match(spec)
|
|
39
|
+
if not m:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"rate_limit invalido: {spec!r}. El formato es "
|
|
42
|
+
f"'<cantidad>/<periodo>', por ejemplo '60/m', '10/s' o '100/5m'."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
cantidad, multiplo, unidad = m.groups()
|
|
46
|
+
if int(cantidad) <= 0:
|
|
47
|
+
raise ValueError(f"rate_limit invalido: {spec!r}. La cantidad debe ser > 0.")
|
|
48
|
+
return float(cantidad), float(multiplo or 1) * UNIDADES[unidad.lower()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Cubo:
|
|
52
|
+
"""Token bucket. `consumir()` devuelve False cuando ya no queda margen."""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("capacidad", "por_segundo", "restante", "sello")
|
|
55
|
+
|
|
56
|
+
def __init__(self, cantidad: float, periodo: float, burst: float | None = None):
|
|
57
|
+
self.capacidad = float(burst if burst is not None else cantidad)
|
|
58
|
+
self.por_segundo = cantidad / periodo
|
|
59
|
+
self.restante = self.capacidad
|
|
60
|
+
self.sello = time.monotonic()
|
|
61
|
+
|
|
62
|
+
def consumir(self, coste: float = 1.0) -> bool:
|
|
63
|
+
ahora = time.monotonic()
|
|
64
|
+
self.restante = min(
|
|
65
|
+
self.capacidad, self.restante + (ahora - self.sello) * self.por_segundo
|
|
66
|
+
)
|
|
67
|
+
self.sello = ahora
|
|
68
|
+
if self.restante < coste:
|
|
69
|
+
return False
|
|
70
|
+
self.restante -= coste
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def espera(self) -> float:
|
|
75
|
+
"""Segundos hasta que vuelva a haber margen. Util para avisar al cliente."""
|
|
76
|
+
if self.restante >= 1:
|
|
77
|
+
return 0.0
|
|
78
|
+
return (1 - self.restante) / self.por_segundo
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def crear(spec=None, burst=None) -> Cubo | None:
|
|
82
|
+
"""Cubo para una ruta, o None si no hay limite configurado en ningun sitio."""
|
|
83
|
+
if spec is None:
|
|
84
|
+
from django.conf import settings
|
|
85
|
+
|
|
86
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
87
|
+
spec = conf.get("RATE_LIMIT")
|
|
88
|
+
burst = burst if burst is not None else conf.get("RATE_LIMIT_BURST")
|
|
89
|
+
|
|
90
|
+
if not spec:
|
|
91
|
+
return None
|
|
92
|
+
cantidad, periodo = parsear(spec)
|
|
93
|
+
return Cubo(cantidad, periodo, burst)
|
django_socket/routing.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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: Any
|
|
17
|
+
group: str | None
|
|
18
|
+
route: str
|
|
19
|
+
rate_limit: str | None
|
|
20
|
+
burst: float | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_routes: list[Route] = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ws(
|
|
27
|
+
route: str,
|
|
28
|
+
*,
|
|
29
|
+
group: str | None = None,
|
|
30
|
+
auth: Any = True,
|
|
31
|
+
rate_limit: str | None = None,
|
|
32
|
+
burst: float | None = None,
|
|
33
|
+
name: str | None = None,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Registra un handler WebSocket.
|
|
37
|
+
|
|
38
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
39
|
+
async def chat(sock, room):
|
|
40
|
+
async for msg in sock:
|
|
41
|
+
await sock.broadcast(msg.text)
|
|
42
|
+
|
|
43
|
+
`route` usa la sintaxis de `django.urls.path` y sus mismos conversores
|
|
44
|
+
(`<int:pk>`, `<slug:x>`, `<uuid:x>`...), asi que los parametros llegan al
|
|
45
|
+
handler ya convertidos.
|
|
46
|
+
|
|
47
|
+
`group` se rellena con esos mismos parametros: el socket entra en el grupo
|
|
48
|
+
al conectar, sale al desconectar, y `sock.broadcast(dato)` va ahi por
|
|
49
|
+
defecto.
|
|
50
|
+
|
|
51
|
+
`auth` decide como se resuelve `sock.user`:
|
|
52
|
+
|
|
53
|
+
auth=True los autenticadores de settings (por defecto,
|
|
54
|
+
la sesion de Django)
|
|
55
|
+
auth=False ninguno; `sock.user` queda a None
|
|
56
|
+
auth="token" solo por token
|
|
57
|
+
auth=["session", "token"] el primero que reconozca a alguien
|
|
58
|
+
auth=mi_funcion async(sock) -> user | None
|
|
59
|
+
|
|
60
|
+
Ver `django_socket.authentication`.
|
|
61
|
+
|
|
62
|
+
`rate_limit` acota los mensajes entrantes de cada socket ("60/m", "10/s").
|
|
63
|
+
Al pasarse se cierra con 4429. `burst` deja pasar picos mayores sin subir
|
|
64
|
+
el ritmo sostenido. Ver `django_socket.ratelimit`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorator(handler: Callable) -> Callable:
|
|
68
|
+
if not inspect.iscoroutinefunction(handler):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"@ws espera 'async def', y {handler.__name__} es una funcion "
|
|
71
|
+
f"normal.\n"
|
|
72
|
+
f" async def {handler.__name__}(sock, ...):\n"
|
|
73
|
+
f"Un WebSocket vive en el loop de eventos. Para tocar el ORM "
|
|
74
|
+
f"usa su API async (await Model.objects.aget(...)) o envuelve "
|
|
75
|
+
f"lo sincrono en asgiref.sync.sync_to_async."
|
|
76
|
+
)
|
|
77
|
+
normalized = route.lstrip("/")
|
|
78
|
+
_check_group_template(group, normalized, handler)
|
|
79
|
+
if auth is not False:
|
|
80
|
+
# Falla al importar si el autenticador no existe, no en la primera
|
|
81
|
+
# conexion del primer usuario.
|
|
82
|
+
from .authentication import resolver_lista
|
|
83
|
+
|
|
84
|
+
resolver_lista(auth)
|
|
85
|
+
if rate_limit is not None:
|
|
86
|
+
from .ratelimit import parsear
|
|
87
|
+
|
|
88
|
+
parsear(rate_limit) # revienta ahora si el formato esta mal
|
|
89
|
+
|
|
90
|
+
for existing in _routes:
|
|
91
|
+
if existing.route == normalized:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"La ruta '{normalized}' ya la tiene registrada "
|
|
94
|
+
f"{existing.handler.__module__}.{existing.handler.__name__}."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
_routes.append(
|
|
98
|
+
Route(
|
|
99
|
+
pattern=RoutePattern(normalized, is_endpoint=True),
|
|
100
|
+
handler=handler,
|
|
101
|
+
name=name or handler.__name__,
|
|
102
|
+
auth=auth,
|
|
103
|
+
group=group,
|
|
104
|
+
route=normalized,
|
|
105
|
+
rate_limit=rate_limit,
|
|
106
|
+
burst=burst,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return handler
|
|
110
|
+
|
|
111
|
+
return decorator
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_group_template(group: str | None, route: str, handler: Callable) -> None:
|
|
115
|
+
"""Falla al importar, no en la primera conexion, si el grupo no cuadra."""
|
|
116
|
+
if not group:
|
|
117
|
+
return
|
|
118
|
+
referenced = {
|
|
119
|
+
field for _, field, _, _ in string.Formatter().parse(group) if field
|
|
120
|
+
}
|
|
121
|
+
available = set(RoutePattern(route, is_endpoint=True).regex.groupindex)
|
|
122
|
+
missing = referenced - available
|
|
123
|
+
if missing:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"group={group!r} en {handler.__name__} usa "
|
|
126
|
+
f"{sorted(missing)}, que no existe(n) en la ruta '{route}'. "
|
|
127
|
+
f"Disponibles: {sorted(available) or 'ninguno'}."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve(path: str) -> tuple[Route, dict[str, Any]] | None:
|
|
132
|
+
"""Devuelve (ruta, kwargs) para un path ASGI, o None si no casa ninguna."""
|
|
133
|
+
candidate = path.lstrip("/")
|
|
134
|
+
for r in _routes:
|
|
135
|
+
match = r.pattern.match(candidate)
|
|
136
|
+
if match is not None:
|
|
137
|
+
_, _, kwargs = match
|
|
138
|
+
return r, kwargs
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_routes() -> list[Route]:
|
|
143
|
+
return list(_routes)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def clear_routes() -> None:
|
|
147
|
+
"""Solo para tests."""
|
|
148
|
+
_routes.clear()
|