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.
Files changed (47) hide show
  1. {django_socket-0.2.0 → django_socket-0.2.2}/PKG-INFO +14 -1
  2. {django_socket-0.2.0 → django_socket-0.2.2}/README.md +4 -0
  3. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/__init__.py +4 -1
  4. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/apps.py +5 -2
  5. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/dispatch.py +1 -2
  6. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/groups.py +40 -5
  7. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/commands/runserver.py +1 -1
  8. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/commands/ws.py +2 -2
  9. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/middleware.py +2 -1
  10. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/static/django_socket/client.js +20 -4
  11. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/testing.py +10 -2
  12. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/websocket.py +1 -1
  13. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/PKG-INFO +14 -1
  14. django_socket-0.2.2/pyproject.toml +105 -0
  15. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_authentication.py +1 -2
  16. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_groups.py +2 -1
  17. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_integracion.py +26 -3
  18. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_middleware_ratelimit.py +0 -1
  19. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_redis_layer.py +51 -0
  20. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_testing.py +0 -1
  21. django_socket-0.2.0/pyproject.toml +0 -61
  22. {django_socket-0.2.0 → django_socket-0.2.2}/LICENSE +0 -0
  23. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/asgi.py +0 -0
  24. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/auth.py +0 -0
  25. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/authentication.py +0 -0
  26. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/checks.py +0 -0
  27. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/events.py +0 -0
  28. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/__init__.py +0 -0
  29. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/management/commands/__init__.py +0 -0
  30. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/patch.py +0 -0
  31. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/py.typed +0 -0
  32. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/ratelimit.py +0 -0
  33. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/routing.py +0 -0
  34. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/templatetags/__init__.py +0 -0
  35. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket/templatetags/django_socket.py +0 -0
  36. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/SOURCES.txt +0 -0
  37. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/dependency_links.txt +0 -0
  38. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/requires.txt +0 -0
  39. {django_socket-0.2.0 → django_socket-0.2.2}/django_socket.egg-info/top_level.txt +0 -0
  40. {django_socket-0.2.0 → django_socket-0.2.2}/setup.cfg +0 -0
  41. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_asgi_app.py +0 -0
  42. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_auth.py +0 -0
  43. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_backpressure.py +0 -0
  44. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_dispatch.py +0 -0
  45. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_json.py +0 -0
  46. {django_socket-0.2.0 → django_socket-0.2.2}/tests/test_routing.py +0 -0
  47. {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.0
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
- __version__ = "0.1.0"
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 checks # noqa: F401 (se registran al importarse)
18
- from . import patch, routing
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
- async def startup(self) -> None:
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: pip install redis"
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}, default=str
176
+ {"group": group, "data": data, "origin": self._origin},
177
+ default=str,
159
178
  )
160
179
  try:
161
- await self._redis.publish(self.channel, carga)
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. Si Redis sigue caido, lo dira el bucle."""
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 (critical, error, warning, info, debug, trace).",
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: localhost)'}",
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 (clientes nativos)",
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
- clave = getattr(user, "pk", None) if getattr(user, "is_authenticated", False) else None
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 = false;
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 despertar() {
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. Es AssertionError para que lea como un fallo de test."""
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__ = ["WebSocketClient", "TimeoutDelEsperado", "WebSocketDisconnect", "InvalidJSON"]
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.get_event_loop().create_task(self._cerrar_en_diferido(code, reason))
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.0
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
- user = await crear_usuario("ramon")
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(); await b.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
+ )
@@ -1,6 +1,5 @@
1
1
  """Middleware y limite de tasa: lo que un proyecto grande necesita alrededor."""
2
2
 
3
- import asyncio
4
3
 
5
4
  import pytest
6
5
  from django.test import override_settings
@@ -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
@@ -9,7 +9,6 @@ from django_socket.testing import (
9
9
  WebSocketDisconnect,
10
10
  )
11
11
 
12
-
13
12
  # --------------------------------------------------------------- lo basico
14
13
 
15
14
 
@@ -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