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