Metadata-Version: 2.4
Name: loopws
Version: 0.3.1
Summary: WebSocket event bus — real-time event push over persistent connections
Project-URL: Homepage, https://github.com/jcolano/loopWS
Project-URL: Repository, https://github.com/jcolano/loopWS
Project-URL: Documentation, https://github.com/jcolano/loopWS/blob/main/WEBSOCKETS_INTEGRATION_GUIDE.md
Project-URL: Changelog, https://github.com/jcolano/loopWS/blob/main/docs/CHANGELOG.md
Project-URL: Issues, https://github.com/jcolano/loopWS/issues
Author-email: Juan Olano <juan_olano@yahoo.com>
License-Expression: MIT
License-File: LICENSE
Keywords: asyncio,event-bus,pubsub,real-time,redis,websocket
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications
Classifier: Topic :: Internet
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: fakeredis[aio]>=2.20; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine>=5.0; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == 'redis'
Description-Content-Type: text/markdown

# loopws

A standalone WebSocket event bus for Python — real-time event push over persistent connections. Pure Python + asyncio, zero required dependencies, pluggable Redis backend for multi-server deployments.

## What it is

loopws is a **library**, not a service. You bring your own WebSocket framework (FastAPI, Starlette, aiohttp, anything that satisfies its structural protocol) and your own presence store (in-memory for dev, Redis for prod). loopws provides:

- A per-connection handler with built-in dispatch, ping/pong keepalive, subscribe/unsubscribe, and lifecycle hooks.
- A local connection registry that tracks which connections are on this server.
- A subscription matcher with `service:event` patterns (`loopbooks:*`, `*:invoice.paid`, etc.).
- In-process push helpers — `push_to_connection`, `push_to_account`, `push_to_channel`, plus the back-compat `push_event` / `push_event_strict` aliases (0.3.1+).
- A cross-server pub/sub manager with exponential-backoff-with-jitter reconnect.
- A frozen-dataclass `LoopWSConfig` that bundles every timing tunable.

## Install

```bash
pip install loopws                 # core only, zero dependencies
pip install loopws[redis]          # with Redis adapter
pip install loopws[dev]            # with test suite extras
```

Requires Python 3.11+.

## Connection identity

Every connection is identified by `(account_id, connection_id)`, both **strings** chosen by the consumer:

| Field | Purpose |
|---|---|
| `account_id` | Tenant / user / customer identity (UUID, session token, customer ID — any string) |
| `connection_id` | Unique-within-account handle (device serial, browser tab UUID, sensor MAC, etc.) |

This works for multi-device authenticated apps (a user with phone + laptop), anonymous browser sessions (one ID per tab), IoT (one ID per sensor), and server-to-server use (one ID per worker instance).

## Minimal example (FastAPI)

```python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from loopws import BaseWebSocketHandler, LocalConnectionManager

class InMemoryStore:
    def __init__(self): self._d = {}
    async def register(self, a, c, s): self._d[(a, c)] = s
    async def unregister(self, a, c):  self._d.pop((a, c), None)
    async def get_server(self, a, c):  return self._d.get((a, c))
    async def refresh(self, a, c):     pass

store = InMemoryStore()
connection_mgr = LocalConnectionManager(store, server_id="pod-1")
app = FastAPI()

@app.websocket("/ws")
async def endpoint(ws: WebSocket, account_id: str, connection_id: str):
    await ws.accept()
    handler = BaseWebSocketHandler(
        ws=ws, account_id=account_id, connection_id=connection_id,
        connection_mgr=connection_mgr,
        services={"myapp"},
        default_subscriptions={"myapp:*"},
    )
    await connection_mgr.register(account_id, connection_id, ws, handler=handler)
    await handler.on_connect()
    handler.start_keepalive()
    try:
        while True:
            await handler.handle_message(await ws.receive_text())
    except WebSocketDisconnect:
        pass
    finally:
        handler.stop_keepalive()
        await handler.on_disconnect()
        await connection_mgr.unregister(account_id, connection_id)
```

Push events from anywhere in the process:

```python
from loopws import push_to_connection, push_to_account, push_to_channel

# To a single connection
result = await push_to_connection(
    connection_mgr,
    account_id="user-42",
    connection_id="laptop-1",
    service="myapp",
    event="invoice.paid",
    data={"invoice_id": 9001},
)
# result: "delivered" | "filtered" | "not_connected" | "send_failed"

# Fan-out across all of an account's local connections
await push_to_account(connection_mgr, "user-42", "myapp", "invoice.paid", {"invoice_id": 9001})

# Fan-out to all locally subscribed connections (any account)
await push_to_channel(connection_mgr, "myapp", "global.announcement", {"msg": "Maintenance at 2am"})
```

### `push_event` — back-compat alias (0.3.1+)

If your call sites still pass a `UUID` for `account_id` or an `int` for the
connection identifier (the pre-0.3.0 shape), use `push_event` — it coerces
both to `str` for you so you can adopt 0.3.x without rewriting call sites.

```python
from uuid import UUID
from loopws import push_event

# Accepts UUID + int — coerces internally before dispatching.
result = await push_event(
    connection_mgr,
    account_id=UUID("…"),
    device_id=42,           # int OK; coerced to "42"
    service="myapp",
    event="invoice.paid",
    data={"invoice_id": 9001},
)
```

For a strict-typed counterpart with no coercion — useful when you've fully
migrated to the `(str, str)` contract and want a type-checker to flag any
regression — use `push_event_strict`. It's behaviourally equivalent to
`push_to_connection`; the distinct name lets a codebase standardize on the
`push_event*` vocabulary.

```python
from loopws import push_event_strict

await push_event_strict(connection_mgr, "user-42", "laptop-1",
                        "myapp", "invoice.paid", {"invoice_id": 9001})
```

## Multi-server with Redis

```python
import redis.asyncio as aioredis
from loopws import PubSubManager
from loopws.redis import RedisConnectionManager, RedisPubSubBackend

redis_client = aioredis.from_url("redis://localhost:6379")
store   = RedisConnectionManager(redis_client)          # presence with TTL
backend = RedisPubSubBackend(redis_client)              # publish + subscribe

pubsub = PubSubManager(backend, server_id="pod-1")
pubsub.set_message_handler(on_cross_server_message)
await pubsub.start()
```

`PubSubManager` runs a real subscriber loop that reconnects on Redis failures with exponential backoff + full jitter.

## Documentation

- **[INTEGRATION_GUIDE.md](https://github.com/jcolano/loopWS/blob/main/WEBSOCKETS_INTEGRATION_GUIDE.md)** — step-by-step integration for new consumers
- **[SPECIFICATION.md](https://github.com/jcolano/loopWS/blob/main/docs/SPECIFICATION.md)** — full technical & functional spec
- **[CHANGELOG.md](https://github.com/jcolano/loopWS/blob/main/docs/CHANGELOG.md)** — release history
- **[UPGRADING.md](https://github.com/jcolano/loopWS/blob/main/docs/UPGRADING.md)** — migration notes per release
- **[OPERATIONS_RUNBOOK.md](https://github.com/jcolano/loopWS/blob/main/OPERATIONS_RUNBOOK.md)** — deploy and ops guide

## License

MIT — see [LICENSE](https://github.com/jcolano/loopWS/blob/main/LICENSE).
