svc-infra 0.1.595__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.

Files changed (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.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.