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,60 @@
1
+ """
2
+ django_socket -- WebSockets en Django sin ceremonia.
3
+
4
+ Instalar es: `pip install django-socket` y añadirlo a INSTALLED_APPS. Ya esta.
5
+
6
+ # miapp/sockets.py
7
+ from django_socket import ws
8
+
9
+ @ws("chat/<str:room>/", group="room:{room}")
10
+ async def chat(sock, room):
11
+ async for msg in sock:
12
+ await sock.broadcast({"de": str(sock.user), "texto": msg.text})
13
+ """
14
+
15
+ from .asgi import ASGIApplication
16
+ from .auth import login_required
17
+ from .events import Events
18
+ from .groups import (
19
+ BaseLayer,
20
+ MemoryLayer,
21
+ RedisLayer,
22
+ broadcast,
23
+ broadcast_sync,
24
+ group_size,
25
+ set_layer,
26
+ )
27
+ from .routing import get_routes, ws
28
+ from .websocket import (
29
+ InvalidJSON,
30
+ Message,
31
+ WebSocket,
32
+ WebSocketClosed,
33
+ WebSocketDisconnect,
34
+ )
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ # Lo que usaras el 99% del tiempo
40
+ "ws",
41
+ "Events",
42
+ "broadcast",
43
+ "broadcast_sync",
44
+ "login_required",
45
+ # Tipos, para anotar
46
+ "WebSocket",
47
+ "Message",
48
+ "WebSocketDisconnect",
49
+ "WebSocketClosed",
50
+ "InvalidJSON",
51
+ # Puntos de extension
52
+ "ASGIApplication",
53
+ "BaseLayer",
54
+ "MemoryLayer",
55
+ "RedisLayer",
56
+ "set_layer",
57
+ "group_size",
58
+ "get_routes",
59
+ "__version__",
60
+ ]
django_socket/apps.py ADDED
@@ -0,0 +1,32 @@
1
+ import logging
2
+
3
+ from django.apps import AppConfig
4
+
5
+ logger = logging.getLogger("django_socket")
6
+
7
+
8
+ class DjangoSocketConfig(AppConfig):
9
+ name = "django_socket"
10
+ label = "django_socket"
11
+ verbose_name = "Django Socket"
12
+
13
+ def ready(self):
14
+ from django.conf import settings
15
+ from django.utils.module_loading import autodiscover_modules
16
+
17
+ from . import checks # noqa: F401 (se registran al importarse)
18
+ from . import patch, routing
19
+
20
+ # Importa <cada_app>/sockets.py, igual que el admin con admin.py.
21
+ autodiscover_modules("sockets")
22
+
23
+ conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
24
+ if conf.get("PATCH_ASGI", True):
25
+ patch.install()
26
+
27
+ found = routing.get_routes()
28
+ logger.debug(
29
+ "django_socket: %d ruta(s): %s",
30
+ len(found),
31
+ ", ".join(f"/{r.route}" for r in found) or "(ninguna)",
32
+ )
django_socket/asgi.py ADDED
@@ -0,0 +1,62 @@
1
+ """Punto de entrada ASGI explicito.
2
+
3
+ Normalmente **no necesitas nada de aqui**: basta con añadir "django_socket" a
4
+ INSTALLED_APPS y el `asgi.py` que genero `startproject` sirve WebSockets tal
5
+ cual (ver `patch.py`).
6
+
7
+ `ASGIApplication` existe para quien prefiere declararlo a mano, para componer
8
+ con otro middleware ASGI, o para quien puso PATCH_ASGI=False.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from . import dispatch
14
+
15
+
16
+ class ASGIApplication:
17
+ """
18
+ Uso explicito en tu `asgi.py`:
19
+
20
+ from django_socket import ASGIApplication
21
+ application = ASGIApplication()
22
+
23
+ El trafico HTTP va a Django sin tocarse; solo se intercepta el scope
24
+ 'websocket' (y 'lifespan', para arrancar y parar la capa de difusion).
25
+ """
26
+
27
+ def __init__(self, http_app=None):
28
+ if http_app is None:
29
+ from django.core.asgi import get_asgi_application
30
+
31
+ http_app = _wrap_static(get_asgi_application()) # hace django.setup()
32
+ self.http_app = http_app
33
+
34
+ async def __call__(self, scope, receive, send):
35
+ kind = scope["type"]
36
+ if kind == "websocket":
37
+ return await dispatch.handle_websocket(scope, receive, send)
38
+ if kind == "lifespan":
39
+ return await dispatch.handle_lifespan(scope, receive, send)
40
+ return await self.http_app(scope, receive, send)
41
+
42
+
43
+ def factory():
44
+ """
45
+ App ASGI construida al vuelo, sin que el proyecto declare nada.
46
+
47
+ La usa `runserver` cuando no hay ASGI_APPLICATION en settings, para que la
48
+ libreria funcione recien instalada.
49
+ """
50
+ return ASGIApplication()
51
+
52
+
53
+ def _wrap_static(app):
54
+ """En DEBUG sirve /static/ igual que hace `runserver`, sin tocar websockets."""
55
+ from django.apps import apps
56
+ from django.conf import settings
57
+
58
+ if not settings.DEBUG or not apps.is_installed("django.contrib.staticfiles"):
59
+ return app
60
+ from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
61
+
62
+ return ASGIStaticFilesHandler(app)
django_socket/auth.py ADDED
@@ -0,0 +1,81 @@
1
+ """Sesion y usuario de Django a partir de las cookies del handshake.
2
+
3
+ Todo es async de verdad: Django 5+ expone `aget_user` y `SessionStore.aget`,
4
+ asi que no hace falta pasar por un thread pool.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from importlib import import_module
11
+
12
+ logger = logging.getLogger("django_socket")
13
+
14
+
15
+ class _SessionCarrier:
16
+ """Lo minimo que `django.contrib.auth.aget_user` espera de un request."""
17
+
18
+ __slots__ = ("session",)
19
+
20
+ def __init__(self, session):
21
+ self.session = session
22
+
23
+
24
+ def _auth_installed() -> bool:
25
+ from django.apps import apps
26
+
27
+ return apps.is_installed("django.contrib.auth") and apps.is_installed(
28
+ "django.contrib.sessions"
29
+ )
30
+
31
+
32
+ async def resolve(sock) -> None:
33
+ """Rellena `sock.session` y `sock.user`. Nunca lanza."""
34
+ from django.conf import settings
35
+
36
+ if not _auth_installed():
37
+ return
38
+
39
+ engine = import_module(settings.SESSION_ENGINE)
40
+ session_key = sock.cookies.get(settings.SESSION_COOKIE_NAME)
41
+ session = engine.SessionStore(session_key)
42
+ sock.session = session
43
+
44
+ carrier = _SessionCarrier(session)
45
+ try:
46
+ try:
47
+ from django.contrib.auth import aget_user
48
+ except ImportError:
49
+ # Django < 5.0 no tiene ni aget_user ni SessionStore.aget: al hilo.
50
+ from asgiref.sync import sync_to_async
51
+ from django.contrib.auth import get_user
52
+
53
+ sock.user = await sync_to_async(get_user)(carrier)
54
+ else:
55
+ sock.user = await aget_user(carrier)
56
+ except Exception:
57
+ logger.exception("django_socket: fallo al resolver el usuario")
58
+ from django.contrib.auth.models import AnonymousUser
59
+
60
+ sock.user = AnonymousUser()
61
+
62
+
63
+ def login_required(handler):
64
+ """
65
+ Cierra la conexion con 4401 si el usuario no esta autenticado.
66
+
67
+ @ws("panel/")
68
+ @login_required
69
+ async def panel(sock): ...
70
+ """
71
+ import functools
72
+
73
+ @functools.wraps(handler)
74
+ async def wrapper(sock, *args, **kwargs):
75
+ user = getattr(sock, "user", None)
76
+ if user is None or not user.is_authenticated:
77
+ await sock.close(4401, "Authentication required")
78
+ return
79
+ return await handler(sock, *args, **kwargs)
80
+
81
+ return wrapper
@@ -0,0 +1,75 @@
1
+ """Avisos via `manage.py check`, para que los fallos de integracion salgan
2
+ antes de desplegar y no como un socket que calla.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from django.core.checks import Error, Warning, register
8
+
9
+ W001 = "django_socket.W001" # capa memory con varios workers
10
+ W002 = "django_socket.W002" # ninguna ruta registrada
11
+ W003 = "django_socket.W003" # origenes abiertos a todo
12
+ E001 = "django_socket.E001" # opcion desconocida en DJANGO_SOCKET
13
+
14
+ KNOWN_KEYS = {
15
+ "LAYER",
16
+ "REDIS_URL",
17
+ "PREFIX",
18
+ "ALLOWED_ORIGINS",
19
+ "REQUIRE_ORIGIN",
20
+ "PATCH_ASGI",
21
+ "SEND_QUEUE_MAX",
22
+ "SEND_QUEUE_FULL",
23
+ }
24
+
25
+
26
+ @register()
27
+ def check_settings(app_configs, **kwargs):
28
+ from django.conf import settings
29
+
30
+ problems = []
31
+ conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
32
+
33
+ unknown = set(conf) - KNOWN_KEYS
34
+ if unknown:
35
+ problems.append(
36
+ Error(
37
+ f"Opcion(es) desconocida(s) en DJANGO_SOCKET: {sorted(unknown)}.",
38
+ hint=f"Las validas son: {sorted(KNOWN_KEYS)}.",
39
+ id=E001,
40
+ )
41
+ )
42
+
43
+ allowed = conf.get("ALLOWED_ORIGINS")
44
+ if allowed and "*" in allowed and not settings.DEBUG:
45
+ problems.append(
46
+ Warning(
47
+ "DJANGO_SOCKET['ALLOWED_ORIGINS'] contiene '*' con DEBUG=False.",
48
+ hint=(
49
+ "Cualquier web podra abrir un socket contra la tuya con las "
50
+ "cookies de sesion de tus usuarios (cross-site WebSocket "
51
+ "hijacking). Enumera los origenes que confias."
52
+ ),
53
+ id=W003,
54
+ )
55
+ )
56
+
57
+ return problems
58
+
59
+
60
+ @register()
61
+ def check_routes(app_configs, **kwargs):
62
+ from . import routing
63
+
64
+ if routing.get_routes():
65
+ return []
66
+ return [
67
+ Warning(
68
+ "django_socket esta instalado pero no hay ninguna ruta websocket.",
69
+ hint=(
70
+ "Crea <tu_app>/sockets.py y decora un 'async def' con @ws('...'). "
71
+ "Se autodescubre igual que admin.py."
72
+ ),
73
+ id=W002,
74
+ )
75
+ ]
@@ -0,0 +1,191 @@
1
+ """El nucleo: atiende los scopes 'websocket' y 'lifespan'.
2
+
3
+ Vive aparte de `asgi.py` porque hay dos caminos que llegan aqui: el parche
4
+ sobre `ASGIHandler` (modo cero-configuracion) y `ASGIApplication` explicito.
5
+ Los dos comparten este codigo.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from urllib.parse import urlparse
12
+
13
+ from asgiref.sync import ThreadSensitiveContext
14
+
15
+ from . import auth as auth_mod
16
+ from . import groups, routing
17
+ from .websocket import InvalidJSON, WebSocket, WebSocketDisconnect
18
+
19
+ logger = logging.getLogger("django_socket")
20
+
21
+ # Codigos de cierre propios (rango privado 4000-4999).
22
+ CLOSE_NO_ROUTE = 4404
23
+ CLOSE_BAD_DATA = 4400 # el cliente mando algo que no se puede parsear
24
+ CLOSE_SERVER_ERROR = 1011
25
+
26
+ _layer_started = False
27
+
28
+
29
+ def _settings():
30
+ from django.conf import settings
31
+
32
+ return getattr(settings, "DJANGO_SOCKET", {}) or {}
33
+
34
+
35
+ async def _start_layer() -> None:
36
+ global _layer_started
37
+ if not _layer_started:
38
+ await groups.get_layer().startup()
39
+ _layer_started = True
40
+
41
+
42
+ async def _stop_layer() -> None:
43
+ global _layer_started
44
+ if _layer_started:
45
+ await groups.get_layer().shutdown()
46
+ _layer_started = False
47
+
48
+
49
+ # ---------------------------------------------------------------- lifespan
50
+
51
+
52
+ async def handle_lifespan(scope, receive, send) -> None:
53
+ while True:
54
+ message = await receive()
55
+ if message["type"] == "lifespan.startup":
56
+ try:
57
+ await _start_layer()
58
+ except Exception as exc:
59
+ logger.exception("django_socket: fallo en el arranque")
60
+ await send({"type": "lifespan.startup.failed", "message": str(exc)})
61
+ return
62
+ await send({"type": "lifespan.startup.complete"})
63
+ elif message["type"] == "lifespan.shutdown":
64
+ try:
65
+ await _stop_layer()
66
+ finally:
67
+ await send({"type": "lifespan.shutdown.complete"})
68
+ return
69
+
70
+
71
+ # --------------------------------------------------------------- websocket
72
+
73
+
74
+ async def handle_websocket(scope, receive, send) -> None:
75
+ # El primer evento del protocolo siempre es websocket.connect.
76
+ event = await receive()
77
+ if event["type"] != "websocket.connect":
78
+ return
79
+
80
+ await _start_layer() # por si el servidor no soporta lifespan
81
+ sock = WebSocket(scope, receive, send, layer=groups.get_layer())
82
+
83
+ if not origin_allowed(sock):
84
+ logger.warning(
85
+ "django_socket: origen rechazado %r para %s",
86
+ sock.headers.get("origin"),
87
+ sock.path,
88
+ )
89
+ # En seco: un origen ajeno no debe tener un socket abierto ni un instante.
90
+ await sock.deny()
91
+ return
92
+
93
+ match = routing.resolve(sock.path)
94
+ if match is None:
95
+ logger.warning(
96
+ "django_socket: ninguna ruta casa con %s. Registradas: %s",
97
+ sock.path,
98
+ ", ".join(f"/{r.route}" for r in routing.get_routes()) or "(ninguna)",
99
+ )
100
+ await sock.close(CLOSE_NO_ROUTE, "No route")
101
+ return
102
+
103
+ route, kwargs = match
104
+ sock.path_params = kwargs
105
+
106
+ async with ThreadSensitiveContext():
107
+ if route.auth:
108
+ await auth_mod.resolve(sock)
109
+
110
+ try:
111
+ if route.group:
112
+ # group="room:{room}" se rellena con los parametros de la ruta.
113
+ await sock.join(route.group.format(**kwargs))
114
+ await route.handler(sock, **kwargs)
115
+ except WebSocketDisconnect:
116
+ pass # el cliente se fue; salida normal
117
+ except InvalidJSON as exc:
118
+ # Culpa del cliente, no del servidor: un aviso y un codigo que lo
119
+ # diga. Nada de traceback ni de 1011, que harian pensar que el bug
120
+ # es tuyo cada vez que alguien mande basura por el socket.
121
+ logger.warning(
122
+ "django_socket: %s en %s (cliente %s)", exc, sock.path, sock.client
123
+ )
124
+ await sock.close(CLOSE_BAD_DATA, "Invalid JSON")
125
+ return
126
+ except Exception:
127
+ logger.exception("django_socket: excepcion en el handler de %s", sock.path)
128
+ await sock.close(CLOSE_SERVER_ERROR, "Internal error")
129
+ return
130
+ await sock.close()
131
+
132
+
133
+ # ------------------------------------------------------------------ origen
134
+
135
+
136
+ def origin_allowed(sock) -> bool:
137
+ """
138
+ Los WebSockets no estan sujetos a la politica de mismo origen: sin esta
139
+ comprobacion cualquier web podria abrir un socket autenticado contra la
140
+ tuya (cross-site WebSocket hijacking).
141
+
142
+ Un Origin ausente se acepta: los navegadores siempre lo mandan, asi que
143
+ solo lo omiten clientes nativos. Ponlo estricto con REQUIRE_ORIGIN.
144
+ """
145
+ from django.conf import settings
146
+
147
+ conf = _settings()
148
+ origin = sock.headers.get("origin")
149
+ if origin is None:
150
+ return not conf.get("REQUIRE_ORIGIN", False)
151
+
152
+ allowed = conf.get("ALLOWED_ORIGINS")
153
+ if allowed is not None:
154
+ if "*" in allowed:
155
+ return True
156
+ return origin in allowed or _host_of(origin) in allowed
157
+
158
+ # Por defecto: ALLOWED_HOSTS + CSRF_TRUSTED_ORIGINS, como hace Django.
159
+ host = _host_of(origin)
160
+ if not host:
161
+ return False
162
+
163
+ trusted = {
164
+ _host_of(o) for o in getattr(settings, "CSRF_TRUSTED_ORIGINS", []) or []
165
+ }
166
+ if host in trusted:
167
+ return True
168
+
169
+ hosts = list(getattr(settings, "ALLOWED_HOSTS", []) or [])
170
+ if settings.DEBUG and not hosts:
171
+ hosts = ["localhost", "127.0.0.1", "[::1]"]
172
+ return any(_host_matches(host, pattern) for pattern in hosts)
173
+
174
+
175
+ def _host_of(origin: str) -> str:
176
+ """'https://ejemplo.com:8000' -> 'ejemplo.com'."""
177
+ try:
178
+ return (urlparse(origin).hostname or "").lower()
179
+ except ValueError:
180
+ return ""
181
+
182
+
183
+ def _host_matches(host: str, pattern: str) -> bool:
184
+ pattern = pattern.lower()
185
+ if pattern == "*":
186
+ return True
187
+ if pattern.startswith("."): # ".ejemplo.com" cubre subdominios y el apex
188
+ return host == pattern[1:] or host.endswith(pattern)
189
+ if pattern.startswith("*."):
190
+ return host == pattern[2:] or host.endswith(pattern[1:])
191
+ return host == pattern
@@ -0,0 +1,143 @@
1
+ """Enrutado de mensajes JSON por tipo.
2
+
3
+ Casi toda app que habla JSON por el socket manda `{"type": "algo", ...}` y
4
+ acaba con un if/elif largo dentro del bucle. Esto lo convierte en funciones con
5
+ nombre, sin que dejes de ver el flujo:
6
+
7
+ from django_socket import Events, ws
8
+
9
+ chat = Events()
10
+
11
+ @chat.on("mensaje")
12
+ async def mensaje(sock, datos):
13
+ await sock.broadcast({"type": "mensaje", "texto": datos["texto"]})
14
+
15
+ @chat.on("escribiendo")
16
+ async def escribiendo(sock): # si no usas los datos, no los pidas
17
+ await sock.broadcast({"type": "escribiendo"}, exclude_self=True)
18
+
19
+ @ws("chat/<str:room>/", group="room:{room}")
20
+ async def handler(sock, room):
21
+ await chat.run(sock)
22
+
23
+ Es opcional del todo: `async for msg in sock` sigue estando ahi y no necesitas
24
+ saber que esto existe.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import inspect
30
+ import logging
31
+
32
+ from .websocket import InvalidJSON
33
+
34
+ logger = logging.getLogger("django_socket")
35
+
36
+ CUALQUIERA = "*"
37
+
38
+
39
+ class Events:
40
+ """
41
+ Despacha mensajes JSON segun un campo (por defecto `"type"`).
42
+
43
+ `key` -- el campo que decide el tipo. Usa `Events(key="action")` si tu
44
+ protocolo se llama de otra forma.
45
+ `strict` -- que hacer con un tipo que nadie maneja. Por defecto se ignora
46
+ (y se anota en el log); con `strict=True` se cierra con 4400.
47
+ """
48
+
49
+ def __init__(self, key: str = "type", strict: bool = False):
50
+ self.key = key
51
+ self.strict = strict
52
+ self._handlers: dict[str, tuple] = {}
53
+
54
+ def on(self, *tipos: str):
55
+ """
56
+ Registra un handler para uno o varios tipos.
57
+
58
+ @chat.on("mensaje")
59
+ @chat.on("entrar", "salir") # varios de golpe
60
+ @chat.on("*") # lo que no case con nada mas
61
+
62
+ El handler puede pedir los datos o no:
63
+
64
+ async def handler(sock, datos) -> datos = el mensaje sin el campo key
65
+ async def handler(sock) -> te basta con saber que llego
66
+ """
67
+ if not tipos:
68
+ raise TypeError("@on() necesita al menos un tipo: @on('mensaje')")
69
+
70
+ def decorator(fn):
71
+ if not inspect.iscoroutinefunction(fn):
72
+ raise TypeError(
73
+ f"@on espera 'async def', y {fn.__name__} es una funcion "
74
+ f"normal."
75
+ )
76
+ quiere_datos = _quiere_datos(fn)
77
+ for tipo in tipos:
78
+ if tipo in self._handlers:
79
+ anterior = self._handlers[tipo][0].__name__
80
+ raise ValueError(
81
+ f"El tipo {tipo!r} ya lo maneja {anterior}."
82
+ )
83
+ self._handlers[tipo] = (fn, quiere_datos)
84
+ return fn
85
+
86
+ return decorator
87
+
88
+ @property
89
+ def tipos(self) -> list[str]:
90
+ return sorted(self._handlers)
91
+
92
+ async def run(self, sock) -> None:
93
+ """Consume mensajes hasta que el cliente cierra."""
94
+ async for msg in sock:
95
+ await self.handle(sock, msg.json())
96
+
97
+ async def handle(self, sock, datos) -> None:
98
+ """Despacha un mensaje ya parseado. Util para testear un handler suelto."""
99
+ if not isinstance(datos, dict):
100
+ raise InvalidJSON(datos, f"se esperaba un objeto con {self.key!r}")
101
+
102
+ tipo = datos.get(self.key)
103
+ entrada = self._handlers.get(tipo) or self._handlers.get(CUALQUIERA)
104
+
105
+ if entrada is None:
106
+ if self.strict:
107
+ raise InvalidJSON(
108
+ datos, f"tipo {tipo!r} desconocido; hay: {self.tipos}"
109
+ )
110
+ # A nivel WARNING a proposito: casi siempre es una errata en el
111
+ # nombre del tipo, y en DEBUG no la ve nadie. Si tu protocolo manda
112
+ # tipos que de verdad quieres ignorar, registra un @on("*") vacio.
113
+ logger.warning(
114
+ "django_socket: nadie maneja %s=%r en %s (registrados: %s)",
115
+ self.key, tipo, sock.path, self.tipos or "ninguno",
116
+ )
117
+ return
118
+
119
+ handler, quiere_datos = entrada
120
+ if quiere_datos:
121
+ await handler(sock, {k: v for k, v in datos.items() if k != self.key})
122
+ else:
123
+ await handler(sock)
124
+
125
+
126
+ def _quiere_datos(fn) -> bool:
127
+ """
128
+ Mira si el handler pide el payload ademas del socket.
129
+
130
+ Se resuelve una vez al registrar, no en cada mensaje.
131
+ """
132
+ params = [
133
+ p for p in inspect.signature(fn).parameters.values()
134
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
135
+ ]
136
+ if len(params) == 1:
137
+ return False
138
+ if len(params) == 2:
139
+ return True
140
+ raise TypeError(
141
+ f"{fn.__name__} debe aceptar (sock) o (sock, datos), no "
142
+ f"{len(params)} parametros posicionales."
143
+ )