django-socket 0.2.0__tar.gz → 0.2.2__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.
- {django_socket-0.2.0 → django_socket-0.2.2}/PKG-INFO +14 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/README.md +4 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/__init__.py +4 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/apps.py +5 -2
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/dispatch.py +1 -2
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/groups.py +40 -5
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/commands/runserver.py +1 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/commands/ws.py +2 -2
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/middleware.py +2 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/static/django_socket/client.js +20 -4
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/testing.py +10 -2
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/websocket.py +1 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/PKG-INFO +14 -1
- django_socket-0.2.2/pyproject.toml +105 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_authentication.py +1 -2
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_groups.py +2 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_integracion.py +26 -3
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_middleware_ratelimit.py +0 -1
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_redis_layer.py +51 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_testing.py +0 -1
- django_socket-0.2.0/pyproject.toml +0 -61
- {django_socket-0.2.0 → django_socket-0.2.2}/LICENSE +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/asgi.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/auth.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/authentication.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/checks.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/events.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/__init__.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/commands/__init__.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/patch.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/py.typed +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/ratelimit.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/routing.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/templatetags/__init__.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/templatetags/django_socket.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/SOURCES.txt +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/dependency_links.txt +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/requires.txt +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/top_level.txt +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/setup.cfg +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_asgi_app.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_auth.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_backpressure.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_dispatch.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_json.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_routing.py +0 -0
- {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_websocket.py +0 -0
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-socket
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
7
7
|
Project-URL: Documentation, https://github.com/ramon3198/django-socket#readme
|
|
8
8
|
Project-URL: Changelog, https://github.com/ramon3198/django-socket/blob/main/CHANGELOG.md
|
|
9
9
|
Project-URL: Issues, https://github.com/ramon3198/django-socket/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
11
|
Classifier: Environment :: Web Environment
|
|
11
12
|
Classifier: Framework :: Django
|
|
12
13
|
Classifier: Framework :: Django :: 4.2
|
|
13
14
|
Classifier: Framework :: Django :: 5.0
|
|
14
15
|
Classifier: Framework :: Django :: 5.1
|
|
15
16
|
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Framework :: Django :: 6.0
|
|
18
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
19
|
Classifier: Intended Audience :: Developers
|
|
17
20
|
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
26
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
Classifier: Typing :: Typed
|
|
19
28
|
Requires-Python: >=3.10
|
|
20
29
|
Description-Content-Type: text/markdown
|
|
21
30
|
License-File: LICENSE
|
|
@@ -1197,6 +1206,10 @@ python manage.py migrate
|
|
|
1197
1206
|
python manage.py runserver
|
|
1198
1207
|
```
|
|
1199
1208
|
|
|
1209
|
+
- `http://127.0.0.1:8000/ejemplos/` — **four patterns people actually build**,
|
|
1210
|
+
each one runnable in the page: background task progress, per-user
|
|
1211
|
+
notifications, presence, and a live dashboard. The commented source is in
|
|
1212
|
+
[`ejemplos/sockets.py`](ejemplos/sockets.py).
|
|
1200
1213
|
- `http://127.0.0.1:8000/sala/general/` — chat, open it in two tabs. To see the
|
|
1201
1214
|
reconnect, stop the server and start it again.
|
|
1202
1215
|
- `chat/sockets.py` — every example in one file.
|
|
@@ -1162,6 +1162,10 @@ python manage.py migrate
|
|
|
1162
1162
|
python manage.py runserver
|
|
1163
1163
|
```
|
|
1164
1164
|
|
|
1165
|
+
- `http://127.0.0.1:8000/ejemplos/` — **four patterns people actually build**,
|
|
1166
|
+
each one runnable in the page: background task progress, per-user
|
|
1167
|
+
notifications, presence, and a live dashboard. The commented source is in
|
|
1168
|
+
[`ejemplos/sockets.py`](ejemplos/sockets.py).
|
|
1165
1169
|
- `http://127.0.0.1:8000/sala/general/` — chat, open it in two tabs. To see the
|
|
1166
1170
|
reconnect, stop the server and start it again.
|
|
1167
1171
|
- `chat/sockets.py` — every example in one file.
|
|
@@ -33,7 +33,10 @@ from .websocket import (
|
|
|
33
33
|
WebSocketDisconnect,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# Fuente de verdad de la version. `pyproject.toml` la lee de aqui con
|
|
37
|
+
# `dynamic = ["version"]`, asi que no pueden divergir: antes se declaraban por
|
|
38
|
+
# separado y el modulo se quedo en 0.1.0 mientras PyPI iba por 0.2.1.
|
|
39
|
+
__version__ = "0.2.2"
|
|
37
40
|
|
|
38
41
|
__all__ = [
|
|
39
42
|
# Lo que usaras el 99% del tiempo
|
|
@@ -14,8 +14,11 @@ 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
|
|
18
|
-
|
|
17
|
+
from . import (
|
|
18
|
+
checks, # noqa: F401 (se registran al importarse)
|
|
19
|
+
patch,
|
|
20
|
+
routing,
|
|
21
|
+
)
|
|
19
22
|
|
|
20
23
|
# Importa <cada_app>/sockets.py, igual que el admin con admin.py.
|
|
21
24
|
autodiscover_modules("sockets")
|
|
@@ -12,9 +12,8 @@ from urllib.parse import urlparse
|
|
|
12
12
|
|
|
13
13
|
from asgiref.sync import ThreadSensitiveContext
|
|
14
14
|
|
|
15
|
-
from . import authentication
|
|
15
|
+
from . import authentication, groups, routing
|
|
16
16
|
from . import middleware as mw
|
|
17
|
-
from . import groups, routing
|
|
18
17
|
from .websocket import InvalidJSON, RateLimited, WebSocket, WebSocketDisconnect
|
|
19
18
|
|
|
20
19
|
logger = logging.getLogger("django_socket")
|
|
@@ -115,18 +115,36 @@ class RedisLayer(MemoryLayer):
|
|
|
115
115
|
# entregamos localmente.
|
|
116
116
|
self._origin = f"{id(self)}"
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
def _conectar(self):
|
|
119
|
+
"""
|
|
120
|
+
Crea el cliente de Redis. Idempotente.
|
|
121
|
+
|
|
122
|
+
Vive aparte de `startup()` porque hay procesos que solo publican -- un
|
|
123
|
+
worker de Celery, un cron, un management command -- y ahi nadie llama a
|
|
124
|
+
`startup()`: no hay conexiones websocket ni evento lifespan que lo
|
|
125
|
+
disparen. Sin esto, `send()` encontraba `_redis = None`, el publish
|
|
126
|
+
lanzaba AttributeError, el except lo enmascaraba como caida de Redis y
|
|
127
|
+
el mensaje se perdia en silencio. Justo el caso de la barra de progreso
|
|
128
|
+
de Celery, que es el mas buscado.
|
|
129
|
+
"""
|
|
130
|
+
if self._redis is not None:
|
|
131
|
+
return self._redis
|
|
119
132
|
try:
|
|
120
133
|
import redis.asyncio as aioredis
|
|
121
134
|
except ImportError as exc: # pragma: no cover
|
|
122
135
|
raise RuntimeError(
|
|
123
|
-
"RedisLayer necesita el paquete 'redis'. Instalalo con
|
|
136
|
+
"RedisLayer necesita el paquete 'redis'. Instalalo con:\n"
|
|
137
|
+
" pip install redis"
|
|
124
138
|
) from exc
|
|
125
139
|
self._redis = aioredis.from_url(
|
|
126
140
|
self.url,
|
|
127
141
|
health_check_interval=self.LATIDO, # sin esto no detecta la caida
|
|
128
142
|
retry_on_timeout=True,
|
|
129
143
|
)
|
|
144
|
+
return self._redis
|
|
145
|
+
|
|
146
|
+
async def startup(self) -> None:
|
|
147
|
+
self._conectar()
|
|
130
148
|
self._pubsub = self._redis.pubsub()
|
|
131
149
|
await self._pubsub.subscribe(self.channel)
|
|
132
150
|
self._conectado = True
|
|
@@ -155,10 +173,24 @@ class RedisLayer(MemoryLayer):
|
|
|
155
173
|
await self._deliver_local(group, data, exclude=exclude)
|
|
156
174
|
# ...y avisa al resto de procesos.
|
|
157
175
|
carga = json.dumps(
|
|
158
|
-
{"group": group, "data": data, "origin": self._origin},
|
|
176
|
+
{"group": group, "data": data, "origin": self._origin},
|
|
177
|
+
default=str,
|
|
159
178
|
)
|
|
160
179
|
try:
|
|
161
|
-
|
|
180
|
+
# Conexion perezosa: un proceso que solo publica nunca paso por
|
|
181
|
+
# startup(), y antes eso hacia que el mensaje se perdiera callando.
|
|
182
|
+
redis = self._conectar()
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.error(
|
|
185
|
+
"django_socket: no se pudo crear el cliente de Redis (%s: %s). "
|
|
186
|
+
"El grupo %r solo recibio la entrega local. Revisa REDIS_URL y "
|
|
187
|
+
"que el paquete 'redis' este instalado.",
|
|
188
|
+
type(exc).__name__, exc, group,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
await redis.publish(self.channel, carga)
|
|
162
194
|
except Exception as exc:
|
|
163
195
|
# Que Redis falle no puede tumbar la conexion del usuario: la
|
|
164
196
|
# entrega local ya se hizo y el handler debe seguir vivo. Se pierde
|
|
@@ -211,7 +243,10 @@ class RedisLayer(MemoryLayer):
|
|
|
211
243
|
await self._deliver_local(payload["group"], payload["data"])
|
|
212
244
|
|
|
213
245
|
async def _resuscribir(self) -> None:
|
|
214
|
-
"""Vuelve a suscribirse tras un corte.
|
|
246
|
+
"""Vuelve a suscribirse tras un corte.
|
|
247
|
+
|
|
248
|
+
Si Redis sigue caido, lo dira el bucle en la siguiente vuelta.
|
|
249
|
+
"""
|
|
215
250
|
try:
|
|
216
251
|
await self._pubsub.aclose()
|
|
217
252
|
except Exception:
|
|
@@ -20,7 +20,7 @@ class Command(RunserverCommand):
|
|
|
20
20
|
parser.add_argument(
|
|
21
21
|
"--log-level",
|
|
22
22
|
default="info",
|
|
23
|
-
help="Nivel de log de uvicorn (
|
|
23
|
+
help="Nivel de log de uvicorn (error, warning, info, debug, trace).",
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
def run(self, **options):
|
|
@@ -44,11 +44,11 @@ class Command(BaseCommand):
|
|
|
44
44
|
self._row(
|
|
45
45
|
"Origenes permitidos",
|
|
46
46
|
conf.get("ALLOWED_ORIGINS")
|
|
47
|
-
or f"ALLOWED_HOSTS={list(settings.ALLOWED_HOSTS) or '[] (DEBUG
|
|
47
|
+
or f"ALLOWED_HOSTS={list(settings.ALLOWED_HOSTS) or '[] (DEBUG)'}",
|
|
48
48
|
)
|
|
49
49
|
self._row(
|
|
50
50
|
"Origin ausente",
|
|
51
|
-
"rechazado" if conf.get("REQUIRE_ORIGIN") else "aceptado (
|
|
51
|
+
"rechazado" if conf.get("REQUIRE_ORIGIN") else "aceptado (nativos)",
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
if settings.DEBUG and conf.get("LAYER", "memory") == "memory":
|
|
@@ -118,7 +118,8 @@ def max_conexiones_por_usuario(limite: int = 5, code: int = 4429):
|
|
|
118
118
|
|
|
119
119
|
async def middleware(sock, siguiente):
|
|
120
120
|
user = getattr(sock, "user", None)
|
|
121
|
-
|
|
121
|
+
autenticado = getattr(user, "is_authenticated", False)
|
|
122
|
+
clave = getattr(user, "pk", None) if autenticado else None
|
|
122
123
|
if clave is None:
|
|
123
124
|
clave = (sock.client or ("?", 0))[0] # anonimos, por IP
|
|
124
125
|
|
|
@@ -139,7 +139,11 @@
|
|
|
139
139
|
|
|
140
140
|
function conectar() {
|
|
141
141
|
clearTimeout(temporizador);
|
|
142
|
-
cerradoPorNosotros
|
|
142
|
+
// Ojo: aqui NO se toca `cerradoPorNosotros`. Resetearlo al conectar
|
|
143
|
+
// resucitaba sockets que la aplicacion habia cerrado a proposito: si el
|
|
144
|
+
// cierre ocurria mientras esperabamos al evento "online", al volver la
|
|
145
|
+
// red el listener llamaba a conectar() y el flag se limpiaba solo.
|
|
146
|
+
// Quien decide reconectar es forzarReconexion(); ahi si se limpia.
|
|
143
147
|
try {
|
|
144
148
|
ws = o.protocols ? new WebSocket(url, o.protocols) : new WebSocket(url);
|
|
145
149
|
} catch (err) {
|
|
@@ -214,30 +218,38 @@
|
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
var esperando = false;
|
|
221
|
+
var dejarDeEsperar = null; // desmonta los listeners de la espera
|
|
217
222
|
|
|
218
223
|
function esperarAEstarListo() {
|
|
219
224
|
if (esperando) return; // no acumules parejas de listeners
|
|
220
225
|
esperando = true;
|
|
221
226
|
|
|
222
|
-
function
|
|
223
|
-
if (!esperando) return; // otro evento nos desperto primero
|
|
227
|
+
function quitar() {
|
|
224
228
|
esperando = false;
|
|
229
|
+
dejarDeEsperar = null;
|
|
225
230
|
window.removeEventListener("online", despertar);
|
|
226
231
|
document.removeEventListener("visibilitychange", alVerse);
|
|
232
|
+
}
|
|
233
|
+
function despertar() {
|
|
234
|
+
if (!esperando) return; // otro evento nos desperto primero
|
|
235
|
+
quitar();
|
|
236
|
+
if (cerradoPorNosotros) return; // nos cerraron mientras esperabamos
|
|
227
237
|
intentos = 0; // volver es buena señal: reintenta ya
|
|
228
238
|
conectar();
|
|
229
239
|
}
|
|
230
240
|
function alVerse() {
|
|
231
241
|
if (!debeEsperar()) despertar();
|
|
232
242
|
}
|
|
243
|
+
dejarDeEsperar = quitar;
|
|
233
244
|
window.addEventListener("online", despertar);
|
|
234
245
|
document.addEventListener("visibilitychange", alVerse);
|
|
235
246
|
}
|
|
236
247
|
|
|
237
248
|
function forzarReconexion() {
|
|
238
249
|
intentos = 0;
|
|
250
|
+
cerradoPorNosotros = false; // reconectar a mano cancela el close()
|
|
251
|
+
if (dejarDeEsperar) dejarDeEsperar();
|
|
239
252
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
240
|
-
cerradoPorNosotros = false;
|
|
241
253
|
ws.close(1000);
|
|
242
254
|
}
|
|
243
255
|
conectar();
|
|
@@ -247,6 +259,10 @@
|
|
|
247
259
|
function close(code, reason) {
|
|
248
260
|
cerradoPorNosotros = true;
|
|
249
261
|
clearTimeout(temporizador);
|
|
262
|
+
// Si estabamos aparcados esperando a que vuelva la red, hay que
|
|
263
|
+
// desmontar esos listeners: si no, el "online" de dentro de un rato
|
|
264
|
+
// reconectaria un socket que ya diste por cerrado.
|
|
265
|
+
if (dejarDeEsperar) dejarDeEsperar();
|
|
250
266
|
if (ws) ws.close(code || 1000, reason || "");
|
|
251
267
|
return api;
|
|
252
268
|
}
|
|
@@ -27,7 +27,10 @@ TIMEOUT = 1.0
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class TimeoutDelEsperado(AssertionError):
|
|
30
|
-
"""No llego nada en el plazo.
|
|
30
|
+
"""No llego nada en el plazo.
|
|
31
|
+
|
|
32
|
+
Es AssertionError para que lea como un fallo de test, no como un error.
|
|
33
|
+
"""
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
class WebSocketClient:
|
|
@@ -327,4 +330,9 @@ async def _crear_sesion(user) -> str:
|
|
|
327
330
|
return sesion.session_key
|
|
328
331
|
|
|
329
332
|
|
|
330
|
-
__all__ = [
|
|
333
|
+
__all__ = [
|
|
334
|
+
"InvalidJSON",
|
|
335
|
+
"TimeoutDelEsperado",
|
|
336
|
+
"WebSocketClient",
|
|
337
|
+
"WebSocketDisconnect",
|
|
338
|
+
]
|
|
@@ -508,7 +508,7 @@ class WebSocket:
|
|
|
508
508
|
self.close_code = code
|
|
509
509
|
if self._writer is not None:
|
|
510
510
|
self._writer.cancel()
|
|
511
|
-
asyncio.
|
|
511
|
+
asyncio.get_running_loop().create_task(self._cerrar_en_diferido(code, reason))
|
|
512
512
|
|
|
513
513
|
async def _cerrar_en_diferido(self, code: int, reason: str) -> None:
|
|
514
514
|
try:
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-socket
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
7
7
|
Project-URL: Documentation, https://github.com/ramon3198/django-socket#readme
|
|
8
8
|
Project-URL: Changelog, https://github.com/ramon3198/django-socket/blob/main/CHANGELOG.md
|
|
9
9
|
Project-URL: Issues, https://github.com/ramon3198/django-socket/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
11
|
Classifier: Environment :: Web Environment
|
|
11
12
|
Classifier: Framework :: Django
|
|
12
13
|
Classifier: Framework :: Django :: 4.2
|
|
13
14
|
Classifier: Framework :: Django :: 5.0
|
|
14
15
|
Classifier: Framework :: Django :: 5.1
|
|
15
16
|
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Framework :: Django :: 6.0
|
|
18
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
19
|
Classifier: Intended Audience :: Developers
|
|
17
20
|
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
26
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
Classifier: Typing :: Typed
|
|
19
28
|
Requires-Python: >=3.10
|
|
20
29
|
Description-Content-Type: text/markdown
|
|
21
30
|
License-File: LICENSE
|
|
@@ -1197,6 +1206,10 @@ python manage.py migrate
|
|
|
1197
1206
|
python manage.py runserver
|
|
1198
1207
|
```
|
|
1199
1208
|
|
|
1209
|
+
- `http://127.0.0.1:8000/ejemplos/` — **four patterns people actually build**,
|
|
1210
|
+
each one runnable in the page: background task progress, per-user
|
|
1211
|
+
notifications, presence, and a live dashboard. The commented source is in
|
|
1212
|
+
[`ejemplos/sockets.py`](ejemplos/sockets.py).
|
|
1200
1213
|
- `http://127.0.0.1:8000/sala/general/` — chat, open it in two tabs. To see the
|
|
1201
1214
|
reconnect, stop the server and start it again.
|
|
1202
1215
|
- `chat/sockets.py` — every example in one file.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-socket"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
classifiers = [
|
|
13
|
+
# Los directorios de paquetes (Django Packages entre ellos) leen estos
|
|
14
|
+
# classifiers, no los campos modernos de metadatos. Sin las lineas de
|
|
15
|
+
# "Python :: 3.x" la rejilla comparativa muestra literalmente
|
|
16
|
+
# "Python 3? No", que es peor que no aparecer.
|
|
17
|
+
#
|
|
18
|
+
# No va el classifier de licencia a proposito: PEP 639 lo sustituye por el
|
|
19
|
+
# campo `license = "MIT"` de arriba, y setuptools rechaza tener los dos.
|
|
20
|
+
# Algun directorio todavia lee el antiguo y mostrara "UNKNOWN"; se aguanta,
|
|
21
|
+
# el moderno es el correcto y PyPI lo muestra bien.
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Environment :: Web Environment",
|
|
24
|
+
"Framework :: Django",
|
|
25
|
+
"Framework :: Django :: 4.2",
|
|
26
|
+
"Framework :: Django :: 5.0",
|
|
27
|
+
"Framework :: Django :: 5.1",
|
|
28
|
+
"Framework :: Django :: 5.2",
|
|
29
|
+
"Framework :: Django :: 6.0",
|
|
30
|
+
"Framework :: Django :: 6.1",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"Programming Language :: Python :: 3",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Programming Language :: Python :: 3.14",
|
|
38
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
dependencies = [
|
|
42
|
+
"django>=4.2",
|
|
43
|
+
"asgiref>=3.6",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/ramon3198/django-socket"
|
|
48
|
+
Documentation = "https://github.com/ramon3198/django-socket#readme"
|
|
49
|
+
Changelog = "https://github.com/ramon3198/django-socket/blob/main/CHANGELOG.md"
|
|
50
|
+
Issues = "https://github.com/ramon3198/django-socket/issues"
|
|
51
|
+
|
|
52
|
+
[project.optional-dependencies]
|
|
53
|
+
redis = ["redis>=5.0"]
|
|
54
|
+
dev = [
|
|
55
|
+
"uvicorn[standard]>=0.27",
|
|
56
|
+
"websockets>=12",
|
|
57
|
+
"pytest>=8",
|
|
58
|
+
"pytest-asyncio>=0.23",
|
|
59
|
+
"pytest-django>=4.8",
|
|
60
|
+
"redis>=5.0",
|
|
61
|
+
"coverage>=7",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.setuptools.dynamic]
|
|
65
|
+
version = {attr = "django_socket.__version__"}
|
|
66
|
+
|
|
67
|
+
[tool.setuptools]
|
|
68
|
+
packages = [
|
|
69
|
+
"django_socket",
|
|
70
|
+
"django_socket.management",
|
|
71
|
+
"django_socket.management.commands",
|
|
72
|
+
"django_socket.templatetags",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.setuptools.package-data]
|
|
76
|
+
django_socket = ["static/django_socket/*.js", "py.typed"]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
80
|
+
asyncio_mode = "auto"
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
filterwarnings = ["error::RuntimeWarning"]
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
line-length = 88
|
|
86
|
+
target-version = "py310"
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint]
|
|
89
|
+
# Conjunto explicito, no el que traiga la version de turno: pyflakes (bugs
|
|
90
|
+
# reales), errores de pycodestyle, e imports ordenados. Deliberadamente fuera
|
|
91
|
+
# quedan BLE001 y S110: esta libreria tiene `except Exception: pass` a
|
|
92
|
+
# proposito en varios sitios -- cerrar un socket cuyo cliente ya se fue, por
|
|
93
|
+
# ejemplo -- y activarlos solo llenaria el codigo de noqa.
|
|
94
|
+
select = ["E", "F", "I"]
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint.per-file-ignores]
|
|
97
|
+
# Los tests declaran handlers dentro de la funcion de test; no se "usan" pero
|
|
98
|
+
# el decorador @ws los registra. Y en tests y bancos de pruebas una linea larga
|
|
99
|
+
# suele ser una asercion o una URL que parte peor de lo que gana: la libreria
|
|
100
|
+
# si respeta los 88, que es donde importa.
|
|
101
|
+
"tests/*" = ["F811", "E501"]
|
|
102
|
+
"test_*.py" = ["F811", "E501"]
|
|
103
|
+
"bench_*.py" = ["E501"]
|
|
104
|
+
# Generado por `django-admin startproject`: no es codigo nuestro.
|
|
105
|
+
"demo/settings.py" = ["E501"]
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Autenticacion conectable: token, autenticadores propios, y el orden."""
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
-
from django.contrib.auth.models import AnonymousUser
|
|
5
4
|
from django.test import override_settings
|
|
6
5
|
|
|
7
6
|
from django_socket import dispatch, extraer_token, ws
|
|
@@ -90,7 +89,7 @@ async def resolver_de_prueba(crudo):
|
|
|
90
89
|
}
|
|
91
90
|
)
|
|
92
91
|
async def test_un_token_valido_autentica(crear_usuario):
|
|
93
|
-
|
|
92
|
+
await crear_usuario("ramon")
|
|
94
93
|
|
|
95
94
|
@ws("feed/")
|
|
96
95
|
async def feed(sock):
|
|
@@ -21,7 +21,8 @@ async def test_join_y_broadcast_llega_a_todos(transporte):
|
|
|
21
21
|
await b.join("sala")
|
|
22
22
|
|
|
23
23
|
await a.broadcast({"hola": 1})
|
|
24
|
-
await a.drain()
|
|
24
|
+
await a.drain()
|
|
25
|
+
await b.drain()
|
|
25
26
|
|
|
26
27
|
assert ta.textos[-1] == '{"hola": 1}'
|
|
27
28
|
assert tb.textos[-1] == '{"hola": 1}'
|
|
@@ -8,7 +8,6 @@ from django.test import override_settings
|
|
|
8
8
|
|
|
9
9
|
from django_socket import patch, ws
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
# ------------------------------------------------------------------- parche
|
|
13
12
|
|
|
14
13
|
|
|
@@ -44,10 +43,10 @@ async def test_el_handler_de_django_ahora_acepta_websocket(transporte):
|
|
|
44
43
|
|
|
45
44
|
async def test_el_http_sigue_yendo_a_django():
|
|
46
45
|
"""Ampliar la puerta no debe desviar el trafico normal."""
|
|
47
|
-
from django.core.handlers.asgi import ASGIHandler
|
|
48
|
-
|
|
49
46
|
import asyncio
|
|
50
47
|
|
|
48
|
+
from django.core.handlers.asgi import ASGIHandler
|
|
49
|
+
|
|
51
50
|
recibido = []
|
|
52
51
|
cuerpo_enviado = False
|
|
53
52
|
|
|
@@ -240,3 +239,27 @@ def test_el_js_va_dentro_del_paquete():
|
|
|
240
239
|
fuente = js.read_text(encoding="utf-8")
|
|
241
240
|
assert "global.djangoSocket" in fuente
|
|
242
241
|
assert "esDefinitivo" in fuente # la logica de no-reintentar
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_la_version_esta_declarada_una_sola_vez():
|
|
245
|
+
"""
|
|
246
|
+
`__version__` vive en el modulo y `pyproject.toml` la lee de ahi con
|
|
247
|
+
`dynamic`. Antes se declaraban por separado y divergieron: el modulo decia
|
|
248
|
+
0.1.0 mientras PyPI iba por 0.2.1, asi que quien la leyera para reportar un
|
|
249
|
+
bug daba una version que no era.
|
|
250
|
+
"""
|
|
251
|
+
import re
|
|
252
|
+
from pathlib import Path
|
|
253
|
+
|
|
254
|
+
import django_socket
|
|
255
|
+
|
|
256
|
+
assert re.fullmatch(r"\d+\.\d+\.\d+\S*", django_socket.__version__), (
|
|
257
|
+
f"version con pinta rara: {django_socket.__version__!r}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
261
|
+
if pyproject.is_file():
|
|
262
|
+
texto = pyproject.read_text(encoding="utf-8")
|
|
263
|
+
assert 'dynamic = ["version"]' in texto, (
|
|
264
|
+
"pyproject volvio a declarar la version a mano; pueden divergir"
|
|
265
|
+
)
|
|
@@ -284,3 +284,54 @@ async def test_un_mensaje_corrupto_no_mata_el_listener(capas, caplog):
|
|
|
284
284
|
a, _ = capas
|
|
285
285
|
await a._entregar({"data": b"esto no es json"})
|
|
286
286
|
assert "ilegible" in caplog.text
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------- procesos que SOLO publican
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def test_publicar_sin_haber_llamado_a_startup(url_redis, transporte):
|
|
293
|
+
"""
|
|
294
|
+
Un worker de Celery, un cron o un management command solo publican: no
|
|
295
|
+
tienen conexiones websocket ni evento lifespan, asi que nadie llama a
|
|
296
|
+
`startup()`.
|
|
297
|
+
|
|
298
|
+
Antes esto perdia el mensaje en silencio -- `_redis` era None, el publish
|
|
299
|
+
lanzaba AttributeError y el except lo enmascaraba como una caida de Redis.
|
|
300
|
+
Era justo el caso de la barra de progreso de Celery que documentan los
|
|
301
|
+
ejemplos, o sea el mas buscado de todos.
|
|
302
|
+
"""
|
|
303
|
+
receptor = RedisLayer(url=url_redis, prefix="solopub")
|
|
304
|
+
await receptor.startup() # este si es un proceso web
|
|
305
|
+
sock, t = socket_falso(transporte, receptor)
|
|
306
|
+
await sock.join("tarea:1")
|
|
307
|
+
|
|
308
|
+
# Y este es el "worker": capa nueva, sin startup().
|
|
309
|
+
publicador = RedisLayer(url=url_redis, prefix="solopub")
|
|
310
|
+
assert publicador._redis is None, "no deberia haber conectado todavia"
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
await publicador.send("tarea:1", {"progreso": 50})
|
|
314
|
+
assert publicador._redis is not None, "send() deberia haber conectado"
|
|
315
|
+
assert await esperar(lambda: t.textos), "el mensaje se perdio"
|
|
316
|
+
assert json.loads(t.textos[-1]) == {"progreso": 50}
|
|
317
|
+
finally:
|
|
318
|
+
await publicador.shutdown()
|
|
319
|
+
await receptor.shutdown()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def test_el_publicador_perezoso_no_arranca_el_listener(url_redis):
|
|
323
|
+
"""Quien solo publica no necesita suscribirse: seria una conexion de mas."""
|
|
324
|
+
capa = RedisLayer(url=url_redis, prefix="solopub")
|
|
325
|
+
try:
|
|
326
|
+
await capa.send("g", "x")
|
|
327
|
+
assert capa._redis is not None, "deberia tener cliente para publicar"
|
|
328
|
+
assert capa._listener is None, "no deberia haber montado el listener"
|
|
329
|
+
finally:
|
|
330
|
+
await capa.shutdown()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def test_una_url_invalida_lo_dice_en_vez_de_callar(caplog):
|
|
334
|
+
"""Si no se puede ni crear el cliente, que el log diga que mirar."""
|
|
335
|
+
capa = RedisLayer(url="esto-no-es-una-url")
|
|
336
|
+
await capa.send("g", "x") # no debe lanzar
|
|
337
|
+
assert "REDIS_URL" in caplog.text
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "django-socket"
|
|
7
|
-
version = "0.2.0"
|
|
8
|
-
description = "WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.10"
|
|
11
|
-
license = "MIT"
|
|
12
|
-
classifiers = [
|
|
13
|
-
"Environment :: Web Environment",
|
|
14
|
-
"Framework :: Django",
|
|
15
|
-
"Framework :: Django :: 4.2",
|
|
16
|
-
"Framework :: Django :: 5.0",
|
|
17
|
-
"Framework :: Django :: 5.1",
|
|
18
|
-
"Framework :: Django :: 5.2",
|
|
19
|
-
"Intended Audience :: Developers",
|
|
20
|
-
"Programming Language :: Python :: 3",
|
|
21
|
-
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
-
]
|
|
23
|
-
dependencies = [
|
|
24
|
-
"django>=4.2",
|
|
25
|
-
"asgiref>=3.6",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
[project.urls]
|
|
29
|
-
Homepage = "https://github.com/ramon3198/django-socket"
|
|
30
|
-
Documentation = "https://github.com/ramon3198/django-socket#readme"
|
|
31
|
-
Changelog = "https://github.com/ramon3198/django-socket/blob/main/CHANGELOG.md"
|
|
32
|
-
Issues = "https://github.com/ramon3198/django-socket/issues"
|
|
33
|
-
|
|
34
|
-
[project.optional-dependencies]
|
|
35
|
-
redis = ["redis>=5.0"]
|
|
36
|
-
dev = [
|
|
37
|
-
"uvicorn[standard]>=0.27",
|
|
38
|
-
"websockets>=12",
|
|
39
|
-
"pytest>=8",
|
|
40
|
-
"pytest-asyncio>=0.23",
|
|
41
|
-
"pytest-django>=4.8",
|
|
42
|
-
"redis>=5.0",
|
|
43
|
-
"coverage>=7",
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
[tool.setuptools]
|
|
47
|
-
packages = [
|
|
48
|
-
"django_socket",
|
|
49
|
-
"django_socket.management",
|
|
50
|
-
"django_socket.management.commands",
|
|
51
|
-
"django_socket.templatetags",
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
[tool.setuptools.package-data]
|
|
55
|
-
django_socket = ["static/django_socket/*.js", "py.typed"]
|
|
56
|
-
|
|
57
|
-
[tool.pytest.ini_options]
|
|
58
|
-
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
59
|
-
asyncio_mode = "auto"
|
|
60
|
-
testpaths = ["tests"]
|
|
61
|
-
filterwarnings = ["error::RuntimeWarning"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|