25 literal names covered 34 modules. plex/client.py, plex/stream.py, plex/subtitles.py, plex/sync.py and two Plex route modules all called themselves `siftlode.plex`; downloads/pipeline.py called itself `siftlode.worker`; routes/channels.py called itself `siftlode.api`. The `logger` column is in every stored row so E22.6's console can filter on it, and so an operator can pin a level to one part of the system — neither works when six modules share a name. `logs.get_logger(__name__)` derives it: app/plex/sync.py -> siftlode.plex.sync. One name per module, and it cannot drift from the file it sits in. tests/test_logger_names.py pins the derivation, that everything stays under the one `siftlode` tree the handler and the tests attach to, and that no module hand-rolls a name again unless the line says why (`# logger-name-ok: <why>`). Two do: get_logger's own call, and `siftlode.http`, which is named for the traffic it carries rather than the module it lives in — it replaced uvicorn's access log and is what an operator pins when they want request lines and nothing else. app/scheduler.py called its logger `logger`; every other module calls it `log`. ruff gains LOG, G, T20, TRY400, TRY401 and S112. Four findings cleared with them: worker.py logged `str(exc)[:120]` where the traceback was the whole question, and three prints/f-strings that are deliberate now say so on the line. S110 and BLE001 stay off until S3 — 23 handlers are outstanding and each is a judgement about one of them; enabling the rules first would just mean 23 blanket noqas.
62 lines
2.3 KiB
Python
62 lines
2.3 KiB
Python
"""In-process WebSocket connection registry for live server→client push.
|
|
|
|
Generic groundwork (notification module phase 2 is the first user): connections are kept in
|
|
this process's memory, keyed by user_id, so a request handler can push an event to all of a
|
|
user's open tabs/devices. Single-uvicorn-process assumption — if we ever run multiple workers,
|
|
replace the in-memory map with a Redis pub/sub fan-out behind the same `push()` interface (same
|
|
note as app.ratelimit).
|
|
|
|
`push()` is intentionally SYNC-callable: ordinary (sync) route handlers run in a threadpool, so
|
|
they schedule the actual send onto the WebSocket event loop via run_coroutine_threadsafe. The
|
|
connect/disconnect/_push coroutines all run on that one loop, so the connection map is only ever
|
|
mutated from a single thread.
|
|
"""
|
|
|
|
import asyncio
|
|
from collections import defaultdict
|
|
|
|
from starlette.websockets import WebSocket
|
|
from app import logs
|
|
|
|
log = logs.get_logger(__name__)
|
|
|
|
|
|
class ConnectionManager:
|
|
def __init__(self) -> None:
|
|
self._conns: dict[int, set[WebSocket]] = defaultdict(set)
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
async def connect(self, user_id: int, ws: WebSocket) -> None:
|
|
self._loop = asyncio.get_running_loop()
|
|
await ws.accept()
|
|
self._conns[user_id].add(ws)
|
|
|
|
async def disconnect(self, user_id: int, ws: WebSocket) -> None:
|
|
conns = self._conns.get(user_id)
|
|
if conns:
|
|
conns.discard(ws)
|
|
if not conns:
|
|
self._conns.pop(user_id, None)
|
|
|
|
async def _push(self, user_id: int, payload: dict) -> None:
|
|
for ws in list(self._conns.get(user_id, ())):
|
|
try:
|
|
await ws.send_json(payload)
|
|
except Exception:
|
|
# Stale socket; it'll be cleaned up by its own disconnect handler.
|
|
pass
|
|
|
|
def push(self, user_id: int, payload: dict) -> None:
|
|
"""Schedule a push onto the WebSocket event loop. Safe to call from a sync route
|
|
handler (threadpool). No-op if no WebSocket has ever connected (no loop captured)."""
|
|
loop = self._loop
|
|
if loop is None:
|
|
return
|
|
try:
|
|
asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop)
|
|
except Exception:
|
|
log.debug("ws push failed", exc_info=True)
|
|
|
|
|
|
manager = ConnectionManager()
|