svc-infra 0.1.595__py3-none-any.whl → 1.1.0__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 (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,57 @@
1
+ """
2
+ WebSocket configuration with environment variable support.
3
+
4
+ Environment Variables (WS_ prefix):
5
+ WS_OPEN_TIMEOUT: Connection timeout in seconds (default: 10.0)
6
+ WS_CLOSE_TIMEOUT: Close handshake timeout (default: 10.0)
7
+ WS_PING_INTERVAL: Keepalive ping interval, None to disable (default: 20.0)
8
+ WS_PING_TIMEOUT: Pong response timeout (default: 20.0)
9
+ WS_MAX_MESSAGE_SIZE: Max message size in bytes (default: 1048576 = 1MB)
10
+ WS_MAX_QUEUE_SIZE: Max queued messages (default: 16)
11
+ WS_RECONNECT_ENABLED: Enable auto-reconnection (default: true)
12
+ WS_RECONNECT_MAX_ATTEMPTS: Max reconnect attempts, 0=infinite (default: 5)
13
+ WS_RECONNECT_BACKOFF_BASE: Base backoff in seconds (default: 1.0)
14
+ WS_RECONNECT_BACKOFF_MAX: Max backoff in seconds (default: 60.0)
15
+ WS_RECONNECT_JITTER: Jitter factor 0-1 (default: 0.1)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pydantic import Field
21
+ from pydantic_settings import BaseSettings, SettingsConfigDict
22
+
23
+
24
+ class WebSocketConfig(BaseSettings):
25
+ """WebSocket client configuration with environment variable support."""
26
+
27
+ model_config = SettingsConfigDict(env_prefix="WS_")
28
+
29
+ # Connection settings
30
+ open_timeout: float = Field(default=10.0, description="Connection timeout in seconds")
31
+ close_timeout: float = Field(default=10.0, description="Close handshake timeout in seconds")
32
+
33
+ # Keepalive (ping/pong)
34
+ ping_interval: float | None = Field(
35
+ default=20.0, description="Ping interval in seconds (None to disable)"
36
+ )
37
+ ping_timeout: float | None = Field(default=20.0, description="Pong response timeout in seconds")
38
+
39
+ # Message limits
40
+ max_message_size: int = Field(
41
+ default=1_048_576, description="Max message size in bytes (1MB default)"
42
+ )
43
+ max_queue_size: int = Field(default=16, description="Max queued messages")
44
+
45
+ # Reconnection policy
46
+ reconnect_enabled: bool = Field(default=True, description="Enable auto-reconnection")
47
+ reconnect_max_attempts: int = Field(
48
+ default=5, description="Max reconnect attempts (0=infinite)"
49
+ )
50
+ reconnect_backoff_base: float = Field(default=1.0, description="Base backoff in seconds")
51
+ reconnect_backoff_max: float = Field(default=60.0, description="Max backoff in seconds")
52
+ reconnect_jitter: float = Field(default=0.1, description="Jitter factor (0-1)")
53
+
54
+
55
+ def get_default_config() -> WebSocketConfig:
56
+ """Load WebSocket config from environment with defaults."""
57
+ return WebSocketConfig()
@@ -0,0 +1,76 @@
1
+ """
2
+ Easy builders for WebSocket infrastructure.
3
+
4
+ Provides simple factory functions with sensible defaults.
5
+
6
+ Example:
7
+ from svc_infra.websocket import websocket_client
8
+
9
+ async with websocket_client("wss://api.openai.com/v1/realtime") as ws:
10
+ await ws.send_json({"type": "session.update"})
11
+ async for event in ws:
12
+ print(event)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from .client import WebSocketClient
20
+ from .config import WebSocketConfig, get_default_config
21
+
22
+
23
+ def websocket_client(
24
+ url: str,
25
+ *,
26
+ headers: dict[str, str] | None = None,
27
+ subprotocols: list[str] | None = None,
28
+ **config_overrides: Any,
29
+ ) -> WebSocketClient:
30
+ """
31
+ Create a WebSocket client with sensible defaults.
32
+
33
+ Config can be overridden via kwargs or WS_* environment variables.
34
+
35
+ Args:
36
+ url: WebSocket URL to connect to
37
+ headers: Optional headers to send with the connection
38
+ subprotocols: Optional list of subprotocols to negotiate
39
+ **config_overrides: Override any WebSocketConfig field
40
+
41
+ Returns:
42
+ WebSocketClient ready to be used as async context manager
43
+
44
+ Example:
45
+ async with websocket_client("wss://api.openai.com/v1/realtime") as ws:
46
+ await ws.send_json({"type": "session.update"})
47
+ async for event in ws:
48
+ print(event)
49
+
50
+ # With custom config
51
+ async with websocket_client(
52
+ "wss://...",
53
+ headers={"Authorization": "Bearer token"},
54
+ ping_interval=30,
55
+ max_message_size=16 * 1024 * 1024, # 16MB for audio
56
+ ) as ws:
57
+ ...
58
+ """
59
+ config = get_default_config()
60
+
61
+ # Apply any overrides
62
+ if config_overrides:
63
+ config_dict = config.model_dump()
64
+ config_dict.update(config_overrides)
65
+ config = WebSocketConfig(**config_dict)
66
+
67
+ return WebSocketClient(
68
+ url,
69
+ config=config,
70
+ headers=headers,
71
+ subprotocols=subprotocols,
72
+ )
73
+
74
+
75
+ # Backward compatibility alias
76
+ easy_websocket_client = websocket_client
@@ -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,343 @@
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 collections.abc import Awaitable, Callable
31
+ from datetime import UTC, datetime
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ from .models import ConnectionInfo
35
+
36
+ if TYPE_CHECKING:
37
+ from starlette.websockets import WebSocket
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class ConnectionManager:
43
+ """
44
+ Server-side WebSocket connection manager.
45
+
46
+ Features:
47
+ - Track multiple connections per user
48
+ - Room/group support for targeted broadcasts
49
+ - Connection lifecycle hooks
50
+ - Thread-safe with asyncio.Lock
51
+
52
+ Example:
53
+ manager = ConnectionManager()
54
+
55
+ @app.websocket("/ws/{user_id}")
56
+ async def websocket_endpoint(websocket: WebSocket, user_id: str):
57
+ await manager.connect(user_id, websocket)
58
+ try:
59
+ async for message in websocket.iter_json():
60
+ await manager.broadcast(message)
61
+ finally:
62
+ await manager.disconnect(user_id, websocket)
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ self._lock = asyncio.Lock()
67
+ # user_id -> list of (connection_id, WebSocket, ConnectionInfo)
68
+ self._connections: dict[str, list[tuple[str, WebSocket, ConnectionInfo]]] = defaultdict(
69
+ list
70
+ )
71
+ # room -> set of user_ids
72
+ self._rooms: dict[str, set[str]] = defaultdict(set)
73
+ # Lifecycle hooks
74
+ self._on_connect: Callable[[str, WebSocket], Awaitable[None]] | None = None
75
+ self._on_disconnect: Callable[[str, WebSocket], Awaitable[None]] | None = None
76
+
77
+ async def connect(
78
+ self,
79
+ user_id: str,
80
+ websocket: WebSocket,
81
+ *,
82
+ metadata: dict[str, Any] | None = None,
83
+ accept: bool = True,
84
+ ) -> str:
85
+ """
86
+ Register a new connection for a user.
87
+
88
+ Args:
89
+ user_id: Unique identifier for the user
90
+ websocket: The WebSocket connection
91
+ metadata: Optional metadata to store with the connection
92
+ accept: Whether to call websocket.accept() (default: True)
93
+
94
+ Returns:
95
+ connection_id for tracking multiple connections per user
96
+ """
97
+ if accept:
98
+ await websocket.accept()
99
+
100
+ connection_id = str(uuid.uuid4())
101
+ now = datetime.now(UTC)
102
+ info = ConnectionInfo(
103
+ user_id=user_id,
104
+ connection_id=connection_id,
105
+ connected_at=now,
106
+ last_activity=now,
107
+ metadata=metadata or {},
108
+ )
109
+
110
+ async with self._lock:
111
+ self._connections[user_id].append((connection_id, websocket, info))
112
+
113
+ logger.debug(
114
+ "User %s connected (connection_id=%s, total=%d)",
115
+ user_id,
116
+ connection_id,
117
+ self.connection_count,
118
+ )
119
+
120
+ if self._on_connect:
121
+ await self._on_connect(user_id, websocket)
122
+
123
+ return connection_id
124
+
125
+ async def disconnect(self, user_id: str, websocket: WebSocket | None = None) -> None:
126
+ """
127
+ Remove connection(s) for a user.
128
+
129
+ Args:
130
+ user_id: Unique identifier for the user
131
+ websocket: If provided, only that connection is removed.
132
+ Otherwise, all connections for the user are removed.
133
+ """
134
+ removed_websocket = websocket
135
+
136
+ async with self._lock:
137
+ if websocket:
138
+ # Remove specific connection
139
+ self._connections[user_id] = [
140
+ (cid, ws, info)
141
+ for cid, ws, info in self._connections[user_id]
142
+ if ws is not websocket
143
+ ]
144
+ else:
145
+ # Remove all connections for user
146
+ if self._connections[user_id]:
147
+ # Get first websocket for disconnect callback
148
+ removed_websocket = self._connections[user_id][0][1]
149
+ self._connections[user_id] = []
150
+
151
+ # Clean up empty user entry
152
+ if not self._connections[user_id]:
153
+ del self._connections[user_id]
154
+ # Remove from all rooms
155
+ for room in list(self._rooms.keys()):
156
+ self._rooms[room].discard(user_id)
157
+ if not self._rooms[room]:
158
+ del self._rooms[room]
159
+
160
+ logger.debug(
161
+ "User %s disconnected (total=%d)",
162
+ user_id,
163
+ self.connection_count,
164
+ )
165
+
166
+ if self._on_disconnect and removed_websocket:
167
+ await self._on_disconnect(user_id, removed_websocket)
168
+
169
+ async def send_to_user(self, user_id: str, message: Any) -> int:
170
+ """
171
+ Send message to all connections for a user.
172
+
173
+ Args:
174
+ user_id: Target user ID
175
+ message: Message to send (str, bytes, or JSON-serializable object)
176
+
177
+ Returns:
178
+ Number of connections message was sent to
179
+ """
180
+ sent = 0
181
+ async with self._lock:
182
+ connections = list(self._connections.get(user_id, []))
183
+
184
+ for _, ws, info in connections:
185
+ try:
186
+ await self._send_message(ws, message)
187
+ # Update last activity
188
+ info.last_activity = datetime.now(UTC)
189
+ sent += 1
190
+ except Exception as e:
191
+ logger.debug("Failed to send to user %s: %s", user_id, e)
192
+
193
+ return sent
194
+
195
+ async def broadcast(self, message: Any, *, exclude_user: str | None = None) -> int:
196
+ """
197
+ Broadcast message to all connected users.
198
+
199
+ Args:
200
+ message: Message to send (str, bytes, or JSON-serializable object)
201
+ exclude_user: Optional user ID to exclude from broadcast
202
+
203
+ Returns:
204
+ Number of connections message was sent to
205
+ """
206
+ sent = 0
207
+ async with self._lock:
208
+ all_connections = [
209
+ (uid, ws, info)
210
+ for uid, conns in self._connections.items()
211
+ for _, ws, info in conns
212
+ if uid != exclude_user
213
+ ]
214
+
215
+ for uid, ws, info in all_connections:
216
+ try:
217
+ await self._send_message(ws, message)
218
+ info.last_activity = datetime.now(UTC)
219
+ sent += 1
220
+ except Exception as e:
221
+ logger.debug("Failed to broadcast to user %s: %s", uid, e)
222
+
223
+ return sent
224
+
225
+ async def _send_message(self, websocket: WebSocket, message: Any) -> None:
226
+ """Send a message to a websocket, handling different message types."""
227
+ if isinstance(message, str):
228
+ await websocket.send_text(message)
229
+ elif isinstance(message, bytes):
230
+ await websocket.send_bytes(message)
231
+ else:
232
+ await websocket.send_json(message)
233
+
234
+ # Room/group support
235
+
236
+ async def join_room(self, user_id: str, room: str) -> None:
237
+ """
238
+ Add user to a room.
239
+
240
+ Args:
241
+ user_id: User to add
242
+ room: Room name
243
+ """
244
+ async with self._lock:
245
+ self._rooms[room].add(user_id)
246
+ logger.debug("User %s joined room %s", user_id, room)
247
+
248
+ async def leave_room(self, user_id: str, room: str) -> None:
249
+ """
250
+ Remove user from a room.
251
+
252
+ Args:
253
+ user_id: User to remove
254
+ room: Room name
255
+ """
256
+ async with self._lock:
257
+ self._rooms[room].discard(user_id)
258
+ if not self._rooms[room]:
259
+ del self._rooms[room]
260
+ logger.debug("User %s left room %s", user_id, room)
261
+
262
+ async def broadcast_to_room(
263
+ self, room: str, message: Any, *, exclude_user: str | None = None
264
+ ) -> int:
265
+ """
266
+ Broadcast message to all users in a room.
267
+
268
+ Args:
269
+ room: Target room name
270
+ message: Message to send
271
+ exclude_user: Optional user ID to exclude
272
+
273
+ Returns:
274
+ Number of connections message was sent to
275
+ """
276
+ sent = 0
277
+ async with self._lock:
278
+ user_ids = set(self._rooms.get(room, set()))
279
+
280
+ for user_id in user_ids:
281
+ if user_id != exclude_user:
282
+ sent += await self.send_to_user(user_id, message)
283
+
284
+ return sent
285
+
286
+ def get_room_users(self, room: str) -> list[str]:
287
+ """Get list of user IDs in a room."""
288
+ return list(self._rooms.get(room, set()))
289
+
290
+ # Lifecycle hooks
291
+
292
+ def on_connect(
293
+ self, callback: Callable[[str, WebSocket], Awaitable[None]]
294
+ ) -> Callable[[str, WebSocket], Awaitable[None]]:
295
+ """
296
+ Register callback for new connections.
297
+
298
+ Can be used as a decorator:
299
+ @manager.on_connect
300
+ async def handle_connect(user_id: str, websocket: WebSocket):
301
+ print(f"{user_id} connected")
302
+ """
303
+ self._on_connect = callback
304
+ return callback
305
+
306
+ def on_disconnect(
307
+ self, callback: Callable[[str, WebSocket], Awaitable[None]]
308
+ ) -> Callable[[str, WebSocket], Awaitable[None]]:
309
+ """
310
+ Register callback for disconnections.
311
+
312
+ Can be used as a decorator:
313
+ @manager.on_disconnect
314
+ async def handle_disconnect(user_id: str, websocket: WebSocket):
315
+ print(f"{user_id} disconnected")
316
+ """
317
+ self._on_disconnect = callback
318
+ return callback
319
+
320
+ # Introspection
321
+
322
+ @property
323
+ def active_users(self) -> list[str]:
324
+ """List of connected user IDs."""
325
+ return list(self._connections.keys())
326
+
327
+ @property
328
+ def connection_count(self) -> int:
329
+ """Total number of active connections."""
330
+ return sum(len(conns) for conns in self._connections.values())
331
+
332
+ @property
333
+ def room_count(self) -> int:
334
+ """Number of active rooms."""
335
+ return len(self._rooms)
336
+
337
+ def get_user_connections(self, user_id: str) -> list[ConnectionInfo]:
338
+ """Get connection info for a user."""
339
+ return [info for _, _, info in self._connections.get(user_id, [])]
340
+
341
+ def is_user_connected(self, user_id: str) -> bool:
342
+ """Check if a user has any active connections."""
343
+ 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.