svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl
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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket exception types.
|
|
3
|
+
|
|
4
|
+
Provides a hierarchy of exceptions for WebSocket operations:
|
|
5
|
+
- WebSocketError: Base exception for all WebSocket errors
|
|
6
|
+
- ConnectionClosedError: Connection was closed unexpectedly
|
|
7
|
+
- ConnectionFailedError: Failed to establish connection
|
|
8
|
+
- AuthenticationError: WebSocket authentication failed
|
|
9
|
+
- MessageTooLargeError: Message exceeds max_message_size
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebSocketError(Exception):
|
|
16
|
+
"""Base exception for WebSocket operations."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionClosedError(WebSocketError):
|
|
22
|
+
"""Connection was closed unexpectedly.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
code: WebSocket close code (e.g., 1000 for normal, 1006 for abnormal)
|
|
26
|
+
reason: Close reason message
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, code: int | None = None, reason: str = ""):
|
|
30
|
+
self.code = code
|
|
31
|
+
self.reason = reason
|
|
32
|
+
super().__init__(f"Connection closed: {code} {reason}".strip())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConnectionFailedError(WebSocketError):
|
|
36
|
+
"""Failed to establish WebSocket connection.
|
|
37
|
+
|
|
38
|
+
Raised when the initial connection handshake fails due to network
|
|
39
|
+
issues, invalid URL, server rejection, etc.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthenticationError(WebSocketError):
|
|
46
|
+
"""WebSocket authentication failed.
|
|
47
|
+
|
|
48
|
+
Raised when JWT validation fails or required credentials are missing.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MessageTooLargeError(WebSocketError):
|
|
55
|
+
"""Message exceeds max_message_size configuration.
|
|
56
|
+
|
|
57
|
+
Raised when attempting to send or receive a message larger than
|
|
58
|
+
the configured maximum.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
pass
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server-side WebSocket connection manager.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- ConnectionManager: Track multiple connections per user
|
|
6
|
+
- Room/group support for targeted broadcasts
|
|
7
|
+
- Connection lifecycle hooks
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
from svc_infra.websocket import ConnectionManager
|
|
11
|
+
|
|
12
|
+
manager = ConnectionManager()
|
|
13
|
+
|
|
14
|
+
@app.websocket("/ws/{user_id}")
|
|
15
|
+
async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
|
16
|
+
await manager.connect(user_id, websocket)
|
|
17
|
+
try:
|
|
18
|
+
async for message in websocket.iter_json():
|
|
19
|
+
await manager.broadcast(message)
|
|
20
|
+
finally:
|
|
21
|
+
await manager.disconnect(user_id, websocket)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import uuid
|
|
29
|
+
from collections import defaultdict
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
32
|
+
|
|
33
|
+
from .models import ConnectionInfo
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from starlette.websockets import WebSocket
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConnectionManager:
|
|
42
|
+
"""
|
|
43
|
+
Server-side WebSocket connection manager.
|
|
44
|
+
|
|
45
|
+
Features:
|
|
46
|
+
- Track multiple connections per user
|
|
47
|
+
- Room/group support for targeted broadcasts
|
|
48
|
+
- Connection lifecycle hooks
|
|
49
|
+
- Thread-safe with asyncio.Lock
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
manager = ConnectionManager()
|
|
53
|
+
|
|
54
|
+
@app.websocket("/ws/{user_id}")
|
|
55
|
+
async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
|
56
|
+
await manager.connect(user_id, websocket)
|
|
57
|
+
try:
|
|
58
|
+
async for message in websocket.iter_json():
|
|
59
|
+
await manager.broadcast(message)
|
|
60
|
+
finally:
|
|
61
|
+
await manager.disconnect(user_id, websocket)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._lock = asyncio.Lock()
|
|
66
|
+
# user_id -> list of (connection_id, WebSocket, ConnectionInfo)
|
|
67
|
+
self._connections: dict[str, list[tuple[str, WebSocket, ConnectionInfo]]] = (
|
|
68
|
+
defaultdict(list)
|
|
69
|
+
)
|
|
70
|
+
# room -> set of user_ids
|
|
71
|
+
self._rooms: dict[str, set[str]] = defaultdict(set)
|
|
72
|
+
# Lifecycle hooks
|
|
73
|
+
self._on_connect: Callable[[str, WebSocket], Awaitable[None]] | None = None
|
|
74
|
+
self._on_disconnect: Callable[[str, WebSocket], Awaitable[None]] | None = None
|
|
75
|
+
|
|
76
|
+
async def connect(
|
|
77
|
+
self,
|
|
78
|
+
user_id: str,
|
|
79
|
+
websocket: WebSocket,
|
|
80
|
+
*,
|
|
81
|
+
metadata: dict[str, Any] | None = None,
|
|
82
|
+
accept: bool = True,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Register a new connection for a user.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
user_id: Unique identifier for the user
|
|
89
|
+
websocket: The WebSocket connection
|
|
90
|
+
metadata: Optional metadata to store with the connection
|
|
91
|
+
accept: Whether to call websocket.accept() (default: True)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
connection_id for tracking multiple connections per user
|
|
95
|
+
"""
|
|
96
|
+
if accept:
|
|
97
|
+
await websocket.accept()
|
|
98
|
+
|
|
99
|
+
connection_id = str(uuid.uuid4())
|
|
100
|
+
now = datetime.now(timezone.utc)
|
|
101
|
+
info = ConnectionInfo(
|
|
102
|
+
user_id=user_id,
|
|
103
|
+
connection_id=connection_id,
|
|
104
|
+
connected_at=now,
|
|
105
|
+
last_activity=now,
|
|
106
|
+
metadata=metadata or {},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async with self._lock:
|
|
110
|
+
self._connections[user_id].append((connection_id, websocket, info))
|
|
111
|
+
|
|
112
|
+
logger.debug(
|
|
113
|
+
"User %s connected (connection_id=%s, total=%d)",
|
|
114
|
+
user_id,
|
|
115
|
+
connection_id,
|
|
116
|
+
self.connection_count,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if self._on_connect:
|
|
120
|
+
await self._on_connect(user_id, websocket)
|
|
121
|
+
|
|
122
|
+
return connection_id
|
|
123
|
+
|
|
124
|
+
async def disconnect(
|
|
125
|
+
self, user_id: str, websocket: WebSocket | None = None
|
|
126
|
+
) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Remove connection(s) for a user.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
user_id: Unique identifier for the user
|
|
132
|
+
websocket: If provided, only that connection is removed.
|
|
133
|
+
Otherwise, all connections for the user are removed.
|
|
134
|
+
"""
|
|
135
|
+
removed_websocket = websocket
|
|
136
|
+
|
|
137
|
+
async with self._lock:
|
|
138
|
+
if websocket:
|
|
139
|
+
# Remove specific connection
|
|
140
|
+
self._connections[user_id] = [
|
|
141
|
+
(cid, ws, info)
|
|
142
|
+
for cid, ws, info in self._connections[user_id]
|
|
143
|
+
if ws is not websocket
|
|
144
|
+
]
|
|
145
|
+
else:
|
|
146
|
+
# Remove all connections for user
|
|
147
|
+
if self._connections[user_id]:
|
|
148
|
+
# Get first websocket for disconnect callback
|
|
149
|
+
removed_websocket = self._connections[user_id][0][1]
|
|
150
|
+
self._connections[user_id] = []
|
|
151
|
+
|
|
152
|
+
# Clean up empty user entry
|
|
153
|
+
if not self._connections[user_id]:
|
|
154
|
+
del self._connections[user_id]
|
|
155
|
+
# Remove from all rooms
|
|
156
|
+
for room in list(self._rooms.keys()):
|
|
157
|
+
self._rooms[room].discard(user_id)
|
|
158
|
+
if not self._rooms[room]:
|
|
159
|
+
del self._rooms[room]
|
|
160
|
+
|
|
161
|
+
logger.debug(
|
|
162
|
+
"User %s disconnected (total=%d)",
|
|
163
|
+
user_id,
|
|
164
|
+
self.connection_count,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if self._on_disconnect and removed_websocket:
|
|
168
|
+
await self._on_disconnect(user_id, removed_websocket)
|
|
169
|
+
|
|
170
|
+
async def send_to_user(self, user_id: str, message: Any) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Send message to all connections for a user.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
user_id: Target user ID
|
|
176
|
+
message: Message to send (str, bytes, or JSON-serializable object)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Number of connections message was sent to
|
|
180
|
+
"""
|
|
181
|
+
sent = 0
|
|
182
|
+
async with self._lock:
|
|
183
|
+
connections = list(self._connections.get(user_id, []))
|
|
184
|
+
|
|
185
|
+
for _, ws, info in connections:
|
|
186
|
+
try:
|
|
187
|
+
await self._send_message(ws, message)
|
|
188
|
+
# Update last activity
|
|
189
|
+
info.last_activity = datetime.now(timezone.utc)
|
|
190
|
+
sent += 1
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.debug("Failed to send to user %s: %s", user_id, e)
|
|
193
|
+
|
|
194
|
+
return sent
|
|
195
|
+
|
|
196
|
+
async def broadcast(self, message: Any, *, exclude_user: str | None = None) -> int:
|
|
197
|
+
"""
|
|
198
|
+
Broadcast message to all connected users.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
message: Message to send (str, bytes, or JSON-serializable object)
|
|
202
|
+
exclude_user: Optional user ID to exclude from broadcast
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Number of connections message was sent to
|
|
206
|
+
"""
|
|
207
|
+
sent = 0
|
|
208
|
+
async with self._lock:
|
|
209
|
+
all_connections = [
|
|
210
|
+
(uid, ws, info)
|
|
211
|
+
for uid, conns in self._connections.items()
|
|
212
|
+
for _, ws, info in conns
|
|
213
|
+
if uid != exclude_user
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
for uid, ws, info in all_connections:
|
|
217
|
+
try:
|
|
218
|
+
await self._send_message(ws, message)
|
|
219
|
+
info.last_activity = datetime.now(timezone.utc)
|
|
220
|
+
sent += 1
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.debug("Failed to broadcast to user %s: %s", uid, e)
|
|
223
|
+
|
|
224
|
+
return sent
|
|
225
|
+
|
|
226
|
+
async def _send_message(self, websocket: WebSocket, message: Any) -> None:
|
|
227
|
+
"""Send a message to a websocket, handling different message types."""
|
|
228
|
+
if isinstance(message, str):
|
|
229
|
+
await websocket.send_text(message)
|
|
230
|
+
elif isinstance(message, bytes):
|
|
231
|
+
await websocket.send_bytes(message)
|
|
232
|
+
else:
|
|
233
|
+
await websocket.send_json(message)
|
|
234
|
+
|
|
235
|
+
# Room/group support
|
|
236
|
+
|
|
237
|
+
async def join_room(self, user_id: str, room: str) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Add user to a room.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
user_id: User to add
|
|
243
|
+
room: Room name
|
|
244
|
+
"""
|
|
245
|
+
async with self._lock:
|
|
246
|
+
self._rooms[room].add(user_id)
|
|
247
|
+
logger.debug("User %s joined room %s", user_id, room)
|
|
248
|
+
|
|
249
|
+
async def leave_room(self, user_id: str, room: str) -> None:
|
|
250
|
+
"""
|
|
251
|
+
Remove user from a room.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
user_id: User to remove
|
|
255
|
+
room: Room name
|
|
256
|
+
"""
|
|
257
|
+
async with self._lock:
|
|
258
|
+
self._rooms[room].discard(user_id)
|
|
259
|
+
if not self._rooms[room]:
|
|
260
|
+
del self._rooms[room]
|
|
261
|
+
logger.debug("User %s left room %s", user_id, room)
|
|
262
|
+
|
|
263
|
+
async def broadcast_to_room(
|
|
264
|
+
self, room: str, message: Any, *, exclude_user: str | None = None
|
|
265
|
+
) -> int:
|
|
266
|
+
"""
|
|
267
|
+
Broadcast message to all users in a room.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
room: Target room name
|
|
271
|
+
message: Message to send
|
|
272
|
+
exclude_user: Optional user ID to exclude
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Number of connections message was sent to
|
|
276
|
+
"""
|
|
277
|
+
sent = 0
|
|
278
|
+
async with self._lock:
|
|
279
|
+
user_ids = set(self._rooms.get(room, set()))
|
|
280
|
+
|
|
281
|
+
for user_id in user_ids:
|
|
282
|
+
if user_id != exclude_user:
|
|
283
|
+
sent += await self.send_to_user(user_id, message)
|
|
284
|
+
|
|
285
|
+
return sent
|
|
286
|
+
|
|
287
|
+
def get_room_users(self, room: str) -> list[str]:
|
|
288
|
+
"""Get list of user IDs in a room."""
|
|
289
|
+
return list(self._rooms.get(room, set()))
|
|
290
|
+
|
|
291
|
+
# Lifecycle hooks
|
|
292
|
+
|
|
293
|
+
def on_connect(
|
|
294
|
+
self, callback: Callable[[str, WebSocket], Awaitable[None]]
|
|
295
|
+
) -> Callable[[str, WebSocket], Awaitable[None]]:
|
|
296
|
+
"""
|
|
297
|
+
Register callback for new connections.
|
|
298
|
+
|
|
299
|
+
Can be used as a decorator:
|
|
300
|
+
@manager.on_connect
|
|
301
|
+
async def handle_connect(user_id: str, websocket: WebSocket):
|
|
302
|
+
print(f"{user_id} connected")
|
|
303
|
+
"""
|
|
304
|
+
self._on_connect = callback
|
|
305
|
+
return callback
|
|
306
|
+
|
|
307
|
+
def on_disconnect(
|
|
308
|
+
self, callback: Callable[[str, WebSocket], Awaitable[None]]
|
|
309
|
+
) -> Callable[[str, WebSocket], Awaitable[None]]:
|
|
310
|
+
"""
|
|
311
|
+
Register callback for disconnections.
|
|
312
|
+
|
|
313
|
+
Can be used as a decorator:
|
|
314
|
+
@manager.on_disconnect
|
|
315
|
+
async def handle_disconnect(user_id: str, websocket: WebSocket):
|
|
316
|
+
print(f"{user_id} disconnected")
|
|
317
|
+
"""
|
|
318
|
+
self._on_disconnect = callback
|
|
319
|
+
return callback
|
|
320
|
+
|
|
321
|
+
# Introspection
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def active_users(self) -> list[str]:
|
|
325
|
+
"""List of connected user IDs."""
|
|
326
|
+
return list(self._connections.keys())
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def connection_count(self) -> int:
|
|
330
|
+
"""Total number of active connections."""
|
|
331
|
+
return sum(len(conns) for conns in self._connections.values())
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def room_count(self) -> int:
|
|
335
|
+
"""Number of active rooms."""
|
|
336
|
+
return len(self._rooms)
|
|
337
|
+
|
|
338
|
+
def get_user_connections(self, user_id: str) -> list[ConnectionInfo]:
|
|
339
|
+
"""Get connection info for a user."""
|
|
340
|
+
return [info for _, _, info in self._connections.get(user_id, [])]
|
|
341
|
+
|
|
342
|
+
def is_user_connected(self, user_id: str) -> bool:
|
|
343
|
+
"""Check if a user has any active connections."""
|
|
344
|
+
return user_id in self._connections and len(self._connections[user_id]) > 0
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for WebSocket infrastructure.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- ConnectionState: Enum for connection lifecycle states
|
|
6
|
+
- WebSocketMessage: Wrapper for messages with metadata
|
|
7
|
+
- ConnectionInfo: Metadata for tracked connections
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConnectionState(str, Enum):
|
|
20
|
+
"""WebSocket connection lifecycle states."""
|
|
21
|
+
|
|
22
|
+
CONNECTING = "connecting"
|
|
23
|
+
OPEN = "open"
|
|
24
|
+
CLOSING = "closing"
|
|
25
|
+
CLOSED = "closed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WebSocketMessage(BaseModel):
|
|
29
|
+
"""Wrapper for WebSocket messages with metadata."""
|
|
30
|
+
|
|
31
|
+
data: str | bytes = Field(..., description="Message content (text or binary)")
|
|
32
|
+
is_binary: bool = Field(default=False, description="True if data is binary")
|
|
33
|
+
received_at: datetime | None = Field(
|
|
34
|
+
default=None, description="Timestamp when message was received"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConnectionInfo(BaseModel):
|
|
41
|
+
"""Metadata for a tracked WebSocket connection."""
|
|
42
|
+
|
|
43
|
+
user_id: str = Field(..., description="User identifier")
|
|
44
|
+
connection_id: str = Field(..., description="Unique connection identifier")
|
|
45
|
+
connected_at: datetime = Field(..., description="When connection was established")
|
|
46
|
+
last_activity: datetime = Field(..., description="Last message activity timestamp")
|
|
47
|
+
metadata: dict[str, Any] = Field(
|
|
48
|
+
default_factory=dict, description="Additional connection metadata"
|
|
49
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 nfrax
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|