django-socket 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (55) hide show
  1. {django_socket-0.2.1 → django_socket-0.3.0}/PKG-INFO +13 -12
  2. {django_socket-0.2.1 → django_socket-0.3.0}/README.md +11 -11
  3. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/__init__.py +18 -10
  4. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/apps.py +8 -5
  5. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/asgi.py +14 -14
  6. django_socket-0.3.0/django_socket/auth.py +10 -0
  7. django_socket-0.3.0/django_socket/authentication.py +283 -0
  8. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/checks.py +23 -23
  9. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/dispatch.py +39 -38
  10. django_socket-0.3.0/django_socket/events.py +143 -0
  11. django_socket-0.3.0/django_socket/groups.py +325 -0
  12. django_socket-0.3.0/django_socket/management/commands/runserver.py +81 -0
  13. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/management/commands/ws.py +15 -14
  14. django_socket-0.3.0/django_socket/middleware.py +180 -0
  15. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/patch.py +11 -11
  16. django_socket-0.3.0/django_socket/ratelimit.py +92 -0
  17. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/routing.py +37 -37
  18. django_socket-0.3.0/django_socket/static/django_socket/client.js +280 -0
  19. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/templatetags/django_socket.py +3 -3
  20. django_socket-0.3.0/django_socket/testing.py +340 -0
  21. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/websocket.py +174 -167
  22. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/PKG-INFO +13 -12
  23. {django_socket-0.2.1 → django_socket-0.3.0}/pyproject.toml +28 -1
  24. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_asgi_app.py +2 -2
  25. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_authentication.py +18 -19
  26. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_backpressure.py +2 -2
  27. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_groups.py +2 -1
  28. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_integracion.py +50 -5
  29. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_json.py +7 -7
  30. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_middleware_ratelimit.py +34 -35
  31. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_redis_layer.py +58 -7
  32. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_testing.py +2 -3
  33. django_socket-0.2.1/django_socket/auth.py +0 -10
  34. django_socket-0.2.1/django_socket/authentication.py +0 -278
  35. django_socket-0.2.1/django_socket/events.py +0 -143
  36. django_socket-0.2.1/django_socket/groups.py +0 -287
  37. django_socket-0.2.1/django_socket/management/commands/runserver.py +0 -81
  38. django_socket-0.2.1/django_socket/middleware.py +0 -166
  39. django_socket-0.2.1/django_socket/ratelimit.py +0 -93
  40. django_socket-0.2.1/django_socket/static/django_socket/client.js +0 -262
  41. django_socket-0.2.1/django_socket/testing.py +0 -330
  42. {django_socket-0.2.1 → django_socket-0.3.0}/LICENSE +0 -0
  43. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/management/__init__.py +0 -0
  44. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/management/commands/__init__.py +0 -0
  45. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/py.typed +0 -0
  46. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/templatetags/__init__.py +0 -0
  47. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/SOURCES.txt +0 -0
  48. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/dependency_links.txt +0 -0
  49. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/requires.txt +0 -0
  50. {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/top_level.txt +0 -0
  51. {django_socket-0.2.1 → django_socket-0.3.0}/setup.cfg +0 -0
  52. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_auth.py +0 -0
  53. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_dispatch.py +0 -0
  54. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_routing.py +0 -0
  55. {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-socket
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/ramon3198/django-socket
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
24
  Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
25
26
  Classifier: Topic :: Internet :: WWW/HTTP
26
27
  Classifier: Typing :: Typed
27
28
  Requires-Python: >=3.10
@@ -155,14 +156,14 @@ python manage.py ws
155
156
  ```
156
157
 
157
158
  ```
158
- Rutas WebSocket
159
+ WebSocket routes
159
160
  ws:///echo/ myapp.sockets.echo
160
161
 
161
- Integracion
162
- Capa de difusion memory
163
- asgi.py no hace falta tocarlo (ASGIHandler ampliado)
164
- Origenes permitidos ALLOWED_HOSTS=[] (DEBUG: localhost)
165
- Origin ausente aceptado (clientes nativos)
162
+ Integration
163
+ Broadcast layer memory
164
+ asgi.py nothing to do there (ASGIHandler widened)
165
+ Allowed origins ALLOWED_HOSTS=[] (DEBUG)
166
+ Missing Origin accepted (native clients)
166
167
  ```
167
168
 
168
169
  Run this first whenever something doesn't work: it tells you which routes exist,
@@ -662,15 +663,15 @@ async def paid_only(sock, next_):
662
663
  Two come included:
663
664
 
664
665
  ```python
665
- from django_socket.middleware import max_conexiones_por_usuario, registrar
666
+ from django_socket.middleware import log_connections, max_connections_per_user
666
667
 
667
668
  DJANGO_SOCKET = {"MIDDLEWARE": [
668
- max_conexiones_por_usuario(10), # closes the 11th with 4429
669
- registrar(), # one log line per connection
669
+ max_connections_per_user(10), # closes the 11th with 4429
670
+ log_connections(), # one log line per connection
670
671
  ]}
671
672
  ```
672
673
 
673
- `max_conexiones_por_usuario` counts per process: with N workers the real ceiling
674
+ `max_connections_per_user` counts per process: with N workers the real ceiling
674
675
  is `limit × N`. A global one would need counters in Redis, which is only worth
675
676
  it if you actually need that precision.
676
677
 
@@ -878,7 +879,7 @@ sustained rate:
878
879
  ```
879
880
 
880
881
  The limit is per socket. It is not a defence against a botnet opening thousands
881
- of connections — for that you want `max_conexiones_por_usuario` and something in
882
+ of connections — for that you want `max_connections_per_user` and something in
882
883
  front of the app.
883
884
 
884
885
  ---
@@ -112,14 +112,14 @@ python manage.py ws
112
112
  ```
113
113
 
114
114
  ```
115
- Rutas WebSocket
115
+ WebSocket routes
116
116
  ws:///echo/ myapp.sockets.echo
117
117
 
118
- Integracion
119
- Capa de difusion memory
120
- asgi.py no hace falta tocarlo (ASGIHandler ampliado)
121
- Origenes permitidos ALLOWED_HOSTS=[] (DEBUG: localhost)
122
- Origin ausente aceptado (clientes nativos)
118
+ Integration
119
+ Broadcast layer memory
120
+ asgi.py nothing to do there (ASGIHandler widened)
121
+ Allowed origins ALLOWED_HOSTS=[] (DEBUG)
122
+ Missing Origin accepted (native clients)
123
123
  ```
124
124
 
125
125
  Run this first whenever something doesn't work: it tells you which routes exist,
@@ -619,15 +619,15 @@ async def paid_only(sock, next_):
619
619
  Two come included:
620
620
 
621
621
  ```python
622
- from django_socket.middleware import max_conexiones_por_usuario, registrar
622
+ from django_socket.middleware import log_connections, max_connections_per_user
623
623
 
624
624
  DJANGO_SOCKET = {"MIDDLEWARE": [
625
- max_conexiones_por_usuario(10), # closes the 11th with 4429
626
- registrar(), # one log line per connection
625
+ max_connections_per_user(10), # closes the 11th with 4429
626
+ log_connections(), # one log line per connection
627
627
  ]}
628
628
  ```
629
629
 
630
- `max_conexiones_por_usuario` counts per process: with N workers the real ceiling
630
+ `max_connections_per_user` counts per process: with N workers the real ceiling
631
631
  is `limit × N`. A global one would need counters in Redis, which is only worth
632
632
  it if you actually need that precision.
633
633
 
@@ -835,7 +835,7 @@ sustained rate:
835
835
  ```
836
836
 
837
837
  The limit is per socket. It is not a defence against a botnet opening thousands
838
- of connections — for that you want `max_conexiones_por_usuario` and something in
838
+ of connections — for that you want `max_connections_per_user` and something in
839
839
  front of the app.
840
840
 
841
841
  ---
@@ -1,19 +1,24 @@
1
1
  """
2
- django_socket -- WebSockets en Django sin ceremonia.
2
+ django_socket -- WebSockets in Django without ceremony.
3
3
 
4
- Instalar es: `pip install django-socket` y añadirlo a INSTALLED_APPS. Ya esta.
4
+ Installing is: `pip install django-socket` and adding it to INSTALLED_APPS.
5
+ That is all.
5
6
 
6
- # miapp/sockets.py
7
+ # myapp/sockets.py
7
8
  from django_socket import ws
8
9
 
9
10
  @ws("chat/<str:room>/", group="room:{room}")
10
11
  async def chat(sock, room):
11
12
  async for msg in sock:
12
- await sock.broadcast({"de": str(sock.user), "texto": msg.text})
13
+ await sock.broadcast({"from": str(sock.user), "text": msg.text})
13
14
  """
14
15
 
15
16
  from .asgi import ASGIApplication
16
- from .authentication import extraer_token, login_required
17
+ from .authentication import (
18
+ extract_token,
19
+ extraer_token, # noqa: F401 0.2.x alias, removed at 1.0
20
+ login_required,
21
+ )
17
22
  from .events import Events
18
23
  from .groups import (
19
24
  BaseLayer,
@@ -33,23 +38,26 @@ from .websocket import (
33
38
  WebSocketDisconnect,
34
39
  )
35
40
 
36
- __version__ = "0.1.0"
41
+ # Single source of truth for the version. `pyproject.toml` reads it from here
42
+ # via `dynamic = ["version"]`, so the two cannot drift: they used to be
43
+ # declared separately and the module stayed at 0.1.0 while PyPI was on 0.2.1.
44
+ __version__ = "0.3.0"
37
45
 
38
46
  __all__ = [
39
- # Lo que usaras el 99% del tiempo
47
+ # What you will use 99% of the time
40
48
  "ws",
41
49
  "Events",
42
50
  "broadcast",
43
51
  "broadcast_sync",
44
52
  "login_required",
45
- "extraer_token",
46
- # Tipos, para anotar
53
+ "extract_token",
54
+ # Types, for annotations
47
55
  "WebSocket",
48
56
  "Message",
49
57
  "WebSocketDisconnect",
50
58
  "WebSocketClosed",
51
59
  "InvalidJSON",
52
- # Puntos de extension
60
+ # Extension points
53
61
  "ASGIApplication",
54
62
  "BaseLayer",
55
63
  "MemoryLayer",
@@ -14,10 +14,13 @@ class DjangoSocketConfig(AppConfig):
14
14
  from django.conf import settings
15
15
  from django.utils.module_loading import autodiscover_modules
16
16
 
17
- from . import checks # noqa: F401 (se registran al importarse)
18
- from . import patch, routing
17
+ from . import (
18
+ checks, # noqa: F401 (registered on import)
19
+ patch,
20
+ routing,
21
+ )
19
22
 
20
- # Importa <cada_app>/sockets.py, igual que el admin con admin.py.
23
+ # Import <each_app>/sockets.py, the same way admin does with admin.py.
21
24
  autodiscover_modules("sockets")
22
25
 
23
26
  conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
@@ -26,7 +29,7 @@ class DjangoSocketConfig(AppConfig):
26
29
 
27
30
  found = routing.get_routes()
28
31
  logger.debug(
29
- "django_socket: %d ruta(s): %s",
32
+ "django_socket: %d path(s): %s",
30
33
  len(found),
31
- ", ".join(f"/{r.route}" for r in found) or "(ninguna)",
34
+ ", ".join(f"/{r.route}" for r in found) or "(none)",
32
35
  )
@@ -1,11 +1,11 @@
1
- """Punto de entrada ASGI explicito.
1
+ """Explicit ASGI entry point.
2
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`).
3
+ You normally **need nothing from here**: adding "django_socket" to
4
+ INSTALLED_APPS is enough, and the `asgi.py` that `startproject` generated
5
+ serves WebSockets as-is (see `patch.py`).
6
6
 
7
- `ASGIApplication` existe para quien prefiere declararlo a mano, para componer
8
- con otro middleware ASGI, o para quien puso PATCH_ASGI=False.
7
+ `ASGIApplication` exists for anyone who would rather declare it by hand, to
8
+ compose with other ASGI middleware, or for anyone who set PATCH_ASGI=False.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -15,20 +15,20 @@ from . import dispatch
15
15
 
16
16
  class ASGIApplication:
17
17
  """
18
- Uso explicito en tu `asgi.py`:
18
+ Explicit use in your `asgi.py`:
19
19
 
20
20
  from django_socket import ASGIApplication
21
21
  application = ASGIApplication()
22
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).
23
+ HTTP traffic goes to Django untouched; only the 'websocket' scope is
24
+ intercepted (and 'lifespan', to start and stop the fan-out layer).
25
25
  """
26
26
 
27
27
  def __init__(self, http_app=None):
28
28
  if http_app is None:
29
29
  from django.core.asgi import get_asgi_application
30
30
 
31
- http_app = _wrap_static(get_asgi_application()) # hace django.setup()
31
+ http_app = _wrap_static(get_asgi_application()) # runs django.setup()
32
32
  self.http_app = http_app
33
33
 
34
34
  async def __call__(self, scope, receive, send):
@@ -42,16 +42,16 @@ class ASGIApplication:
42
42
 
43
43
  def factory():
44
44
  """
45
- App ASGI construida al vuelo, sin que el proyecto declare nada.
45
+ An ASGI app built on the fly, with nothing declared by the project.
46
46
 
47
- La usa `runserver` cuando no hay ASGI_APPLICATION en settings, para que la
48
- libreria funcione recien instalada.
47
+ `runserver` uses it when there is no ASGI_APPLICATION in settings, so the
48
+ library works straight after installing.
49
49
  """
50
50
  return ASGIApplication()
51
51
 
52
52
 
53
53
  def _wrap_static(app):
54
- """En DEBUG sirve /static/ igual que hace `runserver`, sin tocar websockets."""
54
+ """In DEBUG serve /static/ like `runserver` does, without touching sockets."""
55
55
  from django.apps import apps
56
56
  from django.conf import settings
57
57
 
@@ -0,0 +1,10 @@
1
+ """Compatibility. The implementation now lives in `authentication.py`.
2
+
3
+ Kept because `django_socket.auth` existed in 0.1.0 and somebody may have
4
+ imported it. The newer parts (pluggable authenticators, token) are in
5
+ `django_socket.authentication`.
6
+ """
7
+
8
+ from .authentication import login_required, resolve # noqa: F401
9
+
10
+ __all__ = ["resolve", "login_required"]
@@ -0,0 +1,283 @@
1
+ """Pluggable authentication.
2
+
3
+ An authenticator is `async def (sock) -> user | None`. They are tried in order
4
+ and the first one that returns something wins:
5
+
6
+ DJANGO_SOCKET = {
7
+ "AUTH": ["session", "token"],
8
+ "TOKEN_RESOLVER": "myapp.auth.from_jwt",
9
+ }
10
+
11
+ or per route, when only one endpoint needs it:
12
+
13
+ @ws("feed/", auth="token")
14
+ @ws("panel/", auth=["session", "token"])
15
+ @ws("public/", auth=False) # don't even try
16
+
17
+ Writing your own is just a function:
18
+
19
+ async def by_api_key(sock):
20
+ key = sock.query_params.get("k")
21
+ return await Client.objects.filter(api_key=key).afirst()
22
+
23
+ DJANGO_SOCKET = {"AUTH": ["myapp.auth.by_api_key"]}
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import Any, Callable
30
+
31
+ logger = logging.getLogger("django_socket")
32
+
33
+ # How the token arrives, in order of preference.
34
+ SCHEME = "bearer"
35
+
36
+
37
+ # ------------------------------------------------------------------- session
38
+
39
+
40
+ class _SessionCarrier:
41
+ """The minimum `django.contrib.auth.aget_user` expects from a request."""
42
+
43
+ __slots__ = ("session",)
44
+
45
+ def __init__(self, session):
46
+ self.session = session
47
+
48
+
49
+ async def session(sock) -> Any | None:
50
+ """
51
+ Django's session cookie. This is the default.
52
+
53
+ It only works when the browser sends the cookie, which means a same-site
54
+ frontend. A SPA on another domain or a mobile app has no cookie: use
55
+ `token`.
56
+ """
57
+ from importlib import import_module
58
+
59
+ from django.apps import apps
60
+ from django.conf import settings
61
+
62
+ if not (
63
+ apps.is_installed("django.contrib.auth")
64
+ and apps.is_installed("django.contrib.sessions")
65
+ ):
66
+ return None
67
+
68
+ engine = import_module(settings.SESSION_ENGINE)
69
+ key = sock.cookies.get(settings.SESSION_COOKIE_NAME)
70
+ sock.session = engine.SessionStore(key)
71
+
72
+ try:
73
+ from django.contrib.auth import aget_user
74
+ except ImportError: # Django < 5.0
75
+ from asgiref.sync import sync_to_async
76
+ from django.contrib.auth import get_user
77
+
78
+ user = await sync_to_async(get_user)(_SessionCarrier(sock.session))
79
+ else:
80
+ user = await aget_user(_SessionCarrier(sock.session))
81
+
82
+ return user if getattr(user, "is_authenticated", False) else None
83
+
84
+
85
+ # --------------------------------------------------------------------- token
86
+
87
+
88
+ def extract_token(sock) -> str | None:
89
+ """
90
+ Pull the token from wherever the client was able to put it.
91
+
92
+ There are three places because **a browser cannot set headers** on a
93
+ WebSocket: the `new WebSocket(url, protocols)` API only lets you touch the
94
+ URL and `Sec-WebSocket-Protocol`. So:
95
+
96
+ 1. `Sec-WebSocket-Protocol: bearer, <token>` -- the recommended route for
97
+ browsers. Not in the URL, so it never reaches your access logs.
98
+ 2. `Authorization: Bearer <token>` -- for native clients, which can set
99
+ headers.
100
+ 3. `?token=<token>` -- works everywhere, but **ends up written in the
101
+ access logs of your server and of every proxy in between**. Use it only
102
+ with short-lived, single-use tokens.
103
+ """
104
+ protos = [p.strip() for p in sock.subprotocols]
105
+ if len(protos) >= 2 and protos[0].lower() == SCHEME:
106
+ return protos[1]
107
+
108
+ header = sock.headers.get("authorization", "")
109
+ if header.lower().startswith(SCHEME + " "):
110
+ return header[len(SCHEME) + 1:].strip()
111
+
112
+ return sock.query_params.get("token")
113
+
114
+
115
+ async def token(sock) -> Any | None:
116
+ """
117
+ A token, validated by the function you point us at.
118
+
119
+ The library cannot validate your token -- it might be a JWT, DRF's, or
120
+ something of your own -- so it only handles the transport and delegates the
121
+ part that matters:
122
+
123
+ DJANGO_SOCKET = {"TOKEN_RESOLVER": "myapp.auth.from_jwt"}
124
+
125
+ async def from_jwt(token):
126
+ data = jwt.decode(token, KEY, algorithms=["HS256"])
127
+ return await User.objects.filter(pk=data["sub"]).afirst()
128
+
129
+ If you set no `TOKEN_RESOLVER` and have `rest_framework.authtoken`
130
+ installed, that one is used as a reasonable shortcut.
131
+ """
132
+ raw = extract_token(sock)
133
+ if not raw:
134
+ return None
135
+
136
+ resolver = _get_token_resolver()
137
+ if resolver is None:
138
+ logger.warning(
139
+ "django_socket: a token arrived but nothing validates it. "
140
+ "Set DJANGO_SOCKET['TOKEN_RESOLVER'] to an "
141
+ "async(token) -> user | None function."
142
+ )
143
+ return None
144
+
145
+ try:
146
+ return await resolver(raw)
147
+ except Exception:
148
+ # An invalid token is normal, not an incident: don't fill the log with
149
+ # a traceback on every attempt.
150
+ logger.debug("django_socket: the resolver rejected the token", exc_info=True)
151
+ return None
152
+
153
+
154
+ _resolver_cache: Callable | None = None
155
+ _resolver_resolved = False
156
+
157
+
158
+ def _get_token_resolver() -> Callable | None:
159
+ global _resolver_cache, _resolver_resolved
160
+ if _resolver_resolved:
161
+ return _resolver_cache
162
+
163
+ from django.conf import settings
164
+ from django.utils.module_loading import import_string
165
+
166
+ conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
167
+ path = conf.get("TOKEN_RESOLVER")
168
+
169
+ if path:
170
+ _resolver_cache = import_string(path) if isinstance(path, str) else path
171
+ else:
172
+ _resolver_cache = _drf_resolver()
173
+
174
+ _resolver_resolved = True
175
+ return _resolver_cache
176
+
177
+
178
+ def _drf_resolver() -> Callable | None:
179
+ """A shortcut for anyone already using `rest_framework.authtoken`."""
180
+ from django.apps import apps
181
+
182
+ if not apps.is_installed("rest_framework.authtoken"):
183
+ return None
184
+
185
+ async def from_drf(raw):
186
+ from rest_framework.authtoken.models import Token as DRFToken
187
+
188
+ row = await DRFToken.objects.select_related("user").filter(key=raw).afirst()
189
+ return row.user if row else None
190
+
191
+ return from_drf
192
+
193
+
194
+ # ------------------------------------------------------------------ registry
195
+
196
+ BUILTIN: dict[str, Callable] = {"session": session, "token": token}
197
+
198
+
199
+ def resolve_authenticators(spec) -> list[Callable]:
200
+ """Normalize whatever `auth=` or settings hold into a list of functions."""
201
+ from django.utils.module_loading import import_string
202
+
203
+ if spec is True or spec is None:
204
+ spec = _default_spec()
205
+ if isinstance(spec, (str, bytes)) or callable(spec):
206
+ spec = [spec]
207
+
208
+ out = []
209
+ for item in spec:
210
+ if callable(item):
211
+ out.append(item)
212
+ elif item in BUILTIN:
213
+ out.append(BUILTIN[item])
214
+ else:
215
+ try:
216
+ out.append(import_string(item))
217
+ except ImportError as exc:
218
+ raise ValueError(
219
+ f"Unknown authenticator: {item!r}. Use one of "
220
+ f"{sorted(BUILTIN)}, an importable path, or an "
221
+ f"async(sock) -> user | None function."
222
+ ) from exc
223
+ return out
224
+
225
+
226
+ def _default_spec():
227
+ from django.conf import settings
228
+
229
+ conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
230
+ return conf.get("AUTH", ["session"])
231
+
232
+
233
+ async def resolve(sock, spec=True) -> None:
234
+ """Fill `sock.user` with the first authenticator that recognises anyone."""
235
+ from django.contrib.auth.models import AnonymousUser
236
+
237
+ for authenticator in resolve_authenticators(spec):
238
+ try:
239
+ user = await authenticator(sock)
240
+ except Exception:
241
+ logger.exception(
242
+ "django_socket: authenticator %s failed",
243
+ getattr(authenticator, "__name__", authenticator),
244
+ )
245
+ continue
246
+ if user is not None:
247
+ sock.user = user
248
+ return
249
+
250
+ sock.user = AnonymousUser()
251
+
252
+
253
+ def _clear_resolver_cache() -> None:
254
+ """Tests only: force TOKEN_RESOLVER to be read from settings again."""
255
+ global _resolver_cache, _resolver_resolved
256
+ _resolver_cache, _resolver_resolved = None, False
257
+
258
+
259
+ def login_required(handler):
260
+ """
261
+ Close the connection with 4401 when the user is not authenticated.
262
+
263
+ @ws("panel/")
264
+ @login_required
265
+ async def panel(sock): ...
266
+ """
267
+ import functools
268
+
269
+ @functools.wraps(handler)
270
+ async def wrapper(sock, *args, **kwargs):
271
+ user = getattr(sock, "user", None)
272
+ if user is None or not user.is_authenticated:
273
+ await sock.close(4401, "Authentication required")
274
+ return
275
+ return await handler(sock, *args, **kwargs)
276
+
277
+ return wrapper
278
+
279
+
280
+ # --------------------------------------------------------- 0.2.x compatibility
281
+ # `extraer_token` was exported from `django_socket` in 0.2.x. Kept so upgrading
282
+ # does not break anyone; removed at 1.0.
283
+ extraer_token = extract_token
@@ -1,15 +1,15 @@
1
- """Avisos via `manage.py check`, para que los fallos de integracion salgan
2
- antes de desplegar y no como un socket que calla.
1
+ """Warnings via `manage.py check`, so integration mistakes surface before a
2
+ deploy rather than as a socket that quietly does nothing.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
7
  from django.core.checks import Error, Warning, register
8
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
9
+ W001 = "django_socket.W001" # memory layer with several workers
10
+ W002 = "django_socket.W002" # no routes registered
11
+ W003 = "django_socket.W003" # origins open to everything
12
+ E001 = "django_socket.E001" # unknown option in DJANGO_SOCKET
13
13
 
14
14
  KNOWN_KEYS = {
15
15
  "LAYER",
@@ -39,8 +39,8 @@ def check_settings(app_configs, **kwargs):
39
39
  if unknown:
40
40
  problems.append(
41
41
  Error(
42
- f"Opcion(es) desconocida(s) en DJANGO_SOCKET: {sorted(unknown)}.",
43
- hint=f"Las validas son: {sorted(KNOWN_KEYS)}.",
42
+ f"Unknown option(s) in DJANGO_SOCKET: {sorted(unknown)}.",
43
+ hint=f"The valid ones are: {sorted(KNOWN_KEYS)}.",
44
44
  id=E001,
45
45
  )
46
46
  )
@@ -49,11 +49,11 @@ def check_settings(app_configs, **kwargs):
49
49
  if allowed and "*" in allowed and not settings.DEBUG:
50
50
  problems.append(
51
51
  Warning(
52
- "DJANGO_SOCKET['ALLOWED_ORIGINS'] contiene '*' con DEBUG=False.",
52
+ "DJANGO_SOCKET['ALLOWED_ORIGINS'] contains '*' with DEBUG=False.",
53
53
  hint=(
54
- "Cualquier web podra abrir un socket contra la tuya con las "
55
- "cookies de sesion de tus usuarios (cross-site WebSocket "
56
- "hijacking). Enumera los origenes que confias."
54
+ "Any website will be able to open a socket against yours carrying "
55
+ "your users' session cookies (cross-site WebSocket "
56
+ "hijacking). List the origins you trust."
57
57
  ),
58
58
  id=W003,
59
59
  )
@@ -70,22 +70,22 @@ def check_routes(app_configs, **kwargs):
70
70
  return []
71
71
  return [
72
72
  Warning(
73
- "django_socket esta instalado pero no hay ninguna ruta websocket.",
73
+ "django_socket is installed but there are no websocket routes.",
74
74
  hint=(
75
- "Crea <tu_app>/sockets.py y decora un 'async def' con @ws('...'). "
76
- "Se autodescubre igual que admin.py."
75
+ "Create <your_app>/sockets.py and decorate an 'async def' with "
76
+ "@ws('...'). It is auto-discovered, the same way admin.py is."
77
77
  ),
78
78
  id=W002,
79
79
  )
80
80
  ]
81
81
 
82
82
 
83
- W004 = "django_socket.W004" # token por query sin resolver configurado
83
+ W004 = "django_socket.W004" # token auth with no resolver configured
84
84
 
85
85
 
86
86
  @register()
87
87
  def check_auth(app_configs, **kwargs):
88
- """Avisa de la combinacion que deja a todo el mundo anonimo en silencio."""
88
+ """Warn about the combination that silently leaves everyone anonymous."""
89
89
  from django.conf import settings
90
90
 
91
91
  conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
@@ -99,14 +99,14 @@ def check_auth(app_configs, **kwargs):
99
99
  if not apps.is_installed("rest_framework.authtoken"):
100
100
  return [
101
101
  Warning(
102
- "DJANGO_SOCKET['AUTH'] incluye 'token' pero no hay "
102
+ "DJANGO_SOCKET['AUTH'] includes 'token' but there is no "
103
103
  "TOKEN_RESOLVER.",
104
104
  hint=(
105
- "La libreria transporta el token pero no sabe validarlo. "
106
- "Define TOKEN_RESOLVER con una funcion "
107
- "async(token) -> user | None, o instala "
108
- "rest_framework.authtoken. Sin eso, todo el mundo "
109
- "entra como anonimo y no es evidente por que."
105
+ "The library carries the token but cannot validate it. "
106
+ "Set TOKEN_RESOLVER to an async(token) -> user | None "
107
+ "function, or install rest_framework.authtoken. "
108
+ "Without it everyone comes in anonymous and it is not "
109
+ "obvious why."
110
110
  ),
111
111
  id=W004,
112
112
  )