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.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -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 +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import logging
7
+ from typing import Dict, Iterable
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def canonical_body(payload: Dict) -> bytes:
13
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
14
+
15
+
16
+ def sign(secret: str, payload: Dict) -> str:
17
+ body = canonical_body(payload)
18
+ return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
19
+
20
+
21
+ def verify(secret: str, payload: Dict, signature: str) -> bool:
22
+ expected = sign(secret, payload)
23
+ try:
24
+ return hmac.compare_digest(expected, signature)
25
+ except Exception as e:
26
+ logger.warning("Webhook signature verification failed: %s", e)
27
+ return False
28
+
29
+
30
+ def verify_any(secrets: Iterable[str], payload: Dict, signature: str) -> bool:
31
+ for s in secrets:
32
+ if verify(s, payload, signature):
33
+ return True
34
+ return False
@@ -0,0 +1,79 @@
1
+ """
2
+ WebSocket infrastructure for svc-infra.
3
+
4
+ Provides client and server-side WebSocket utilities.
5
+
6
+ Quick Start (Client):
7
+ from svc_infra.websocket import websocket_client
8
+
9
+ async with websocket_client("wss://api.example.com") as ws:
10
+ await ws.send_json({"hello": "world"})
11
+ async for message in ws:
12
+ print(message)
13
+
14
+ Quick Start (Server):
15
+ from fastapi import FastAPI, WebSocket
16
+ from svc_infra.websocket import add_websocket_manager
17
+
18
+ app = FastAPI()
19
+ manager = add_websocket_manager(app)
20
+
21
+ @app.websocket("/ws/{user_id}")
22
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
23
+ await manager.connect(user_id, websocket)
24
+ try:
25
+ async for msg in websocket.iter_json():
26
+ await manager.broadcast(msg)
27
+ finally:
28
+ await manager.disconnect(user_id, websocket)
29
+
30
+ Quick Start (Auth):
31
+ Use the dual router system for WebSocket authentication:
32
+
33
+ from svc_infra.api.fastapi.dual import ws_protected_router
34
+ from svc_infra.api.fastapi.auth.ws_security import WSIdentity
35
+
36
+ router = ws_protected_router()
37
+
38
+ @router.websocket("/ws")
39
+ async def ws_endpoint(websocket: WebSocket, user: WSIdentity):
40
+ await manager.connect(user.id, websocket)
41
+ ...
42
+ """
43
+
44
+ from .add import add_websocket_manager, get_ws_manager
45
+ from .client import WebSocketClient
46
+ from .config import WebSocketConfig
47
+ from .easy import easy_websocket_client, websocket_client
48
+ from .exceptions import (
49
+ AuthenticationError,
50
+ ConnectionClosedError,
51
+ ConnectionFailedError,
52
+ MessageTooLargeError,
53
+ WebSocketError,
54
+ )
55
+ from .manager import ConnectionManager
56
+ from .models import ConnectionInfo, ConnectionState, WebSocketMessage
57
+
58
+ __all__ = [
59
+ # Main API (simple)
60
+ "websocket_client",
61
+ "add_websocket_manager",
62
+ "get_ws_manager",
63
+ # Core classes (when you need more control)
64
+ "WebSocketClient",
65
+ "ConnectionManager",
66
+ "WebSocketConfig",
67
+ # Models
68
+ "ConnectionState",
69
+ "WebSocketMessage",
70
+ "ConnectionInfo",
71
+ # Exceptions
72
+ "WebSocketError",
73
+ "ConnectionClosedError",
74
+ "ConnectionFailedError",
75
+ "AuthenticationError",
76
+ "MessageTooLargeError",
77
+ # Backward compat
78
+ "easy_websocket_client",
79
+ ]
@@ -0,0 +1,140 @@
1
+ """
2
+ FastAPI integration for WebSocket infrastructure.
3
+
4
+ Provides:
5
+ - add_websocket_manager: Add a connection manager to a FastAPI app
6
+ - get_ws_manager: Dependency to retrieve the manager
7
+
8
+ Example:
9
+ from fastapi import FastAPI, WebSocket, Depends
10
+ from svc_infra.websocket import add_websocket_manager, get_ws_manager, ConnectionManager
11
+
12
+ app = FastAPI()
13
+ add_websocket_manager(app)
14
+
15
+ @app.websocket("/ws/{user_id}")
16
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
17
+ manager = get_ws_manager(app)
18
+ await manager.connect(user_id, websocket)
19
+ try:
20
+ async for msg in websocket.iter_json():
21
+ await manager.broadcast(msg)
22
+ finally:
23
+ await manager.disconnect(user_id, websocket)
24
+
25
+ @app.get("/ws/stats")
26
+ async def ws_stats(manager: ConnectionManager = Depends(get_ws_manager)):
27
+ return {"connections": manager.connection_count}
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import TYPE_CHECKING, cast
33
+
34
+ from .manager import ConnectionManager
35
+
36
+ if TYPE_CHECKING:
37
+ from fastapi import FastAPI, Request
38
+
39
+ _WS_MANAGER_ATTR = "_svc_infra_ws_manager"
40
+
41
+
42
+ def add_websocket_manager(
43
+ app: FastAPI,
44
+ manager: ConnectionManager | None = None,
45
+ ) -> ConnectionManager:
46
+ """
47
+ Add a WebSocket connection manager to a FastAPI app.
48
+
49
+ The manager is stored on app.state and can be retrieved via get_ws_manager().
50
+
51
+ Args:
52
+ app: FastAPI application instance
53
+ manager: Optional pre-configured ConnectionManager.
54
+ If not provided, a new one is created.
55
+
56
+ Returns:
57
+ The ConnectionManager instance (created or provided)
58
+
59
+ Example:
60
+ app = FastAPI()
61
+ manager = add_websocket_manager(app)
62
+
63
+ @app.websocket("/ws/{user_id}")
64
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
65
+ await manager.connect(user_id, websocket)
66
+ try:
67
+ async for msg in websocket.iter_json():
68
+ await manager.broadcast(msg)
69
+ finally:
70
+ await manager.disconnect(user_id, websocket)
71
+ """
72
+ if manager is None:
73
+ manager = ConnectionManager()
74
+
75
+ setattr(app.state, _WS_MANAGER_ATTR, manager)
76
+ return manager
77
+
78
+
79
+ def get_ws_manager(app_or_request: FastAPI | Request) -> ConnectionManager:
80
+ """
81
+ Get the WebSocket manager from a FastAPI app or request.
82
+
83
+ Can be used as a FastAPI dependency or called directly.
84
+
85
+ Args:
86
+ app_or_request: Either a FastAPI app instance or a Request object
87
+
88
+ Returns:
89
+ The ConnectionManager instance
90
+
91
+ Raises:
92
+ RuntimeError: If no manager has been added to the app
93
+
94
+ Example (as dependency):
95
+ @app.get("/ws/stats")
96
+ async def ws_stats(manager: ConnectionManager = Depends(get_ws_manager)):
97
+ return {
98
+ "connections": manager.connection_count,
99
+ "users": manager.active_users,
100
+ }
101
+
102
+ Example (direct call):
103
+ @app.websocket("/ws/{user_id}")
104
+ async def ws_endpoint(websocket: WebSocket, user_id: str):
105
+ manager = get_ws_manager(websocket.app)
106
+ await manager.connect(user_id, websocket)
107
+ """
108
+ # Handle both FastAPI app and Request objects
109
+ if hasattr(app_or_request, "app"):
110
+ # It's a Request object
111
+ app = app_or_request.app
112
+ else:
113
+ # It's a FastAPI app
114
+ app = app_or_request
115
+
116
+ manager = getattr(app.state, _WS_MANAGER_ATTR, None)
117
+ if manager is None:
118
+ raise RuntimeError(
119
+ "WebSocket manager not found. "
120
+ "Did you forget to call add_websocket_manager(app)?"
121
+ )
122
+ return cast(ConnectionManager, manager)
123
+
124
+
125
+ def get_ws_manager_dependency(request: Request) -> ConnectionManager:
126
+ """
127
+ FastAPI dependency to get the WebSocket manager.
128
+
129
+ This is an alternative to get_ws_manager that works directly as a Depends().
130
+
131
+ Example:
132
+ from fastapi import Depends
133
+
134
+ @app.get("/ws/stats")
135
+ async def ws_stats(
136
+ manager: ConnectionManager = Depends(get_ws_manager_dependency)
137
+ ):
138
+ return {"connections": manager.connection_count}
139
+ """
140
+ return get_ws_manager(request)
@@ -0,0 +1,282 @@
1
+ """
2
+ WebSocket client for connecting to external services.
3
+
4
+ Provides:
5
+ - WebSocketClient: Async WebSocket client with context manager support
6
+ - websocket_connect: Context manager/async iterator for connections
7
+
8
+ Example:
9
+ from svc_infra.websocket import WebSocketClient
10
+
11
+ async with WebSocketClient("wss://api.example.com") as ws:
12
+ await ws.send_json({"type": "hello"})
13
+ async for message in ws:
14
+ print(message)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from contextlib import asynccontextmanager
22
+ from typing import TYPE_CHECKING, Any, AsyncIterator
23
+
24
+ from websockets.asyncio.client import connect
25
+ from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
26
+ from websockets.typing import Subprotocol
27
+
28
+ from .config import WebSocketConfig, get_default_config
29
+ from .exceptions import ConnectionClosedError, ConnectionFailedError, WebSocketError
30
+
31
+ if TYPE_CHECKING:
32
+ from websockets.asyncio.client import ClientConnection
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class WebSocketClient:
38
+ """
39
+ Async WebSocket client for connecting to external services.
40
+
41
+ Features:
42
+ - Async context manager support
43
+ - Auto-reconnection with exponential backoff (via websocket_connect)
44
+ - Configurable ping/pong keepalive
45
+ - Send text, bytes, or JSON
46
+ - Async iterator for receiving messages
47
+
48
+ Example:
49
+ async with WebSocketClient("wss://api.example.com") as ws:
50
+ await ws.send_json({"type": "hello"})
51
+ async for message in ws:
52
+ print(message)
53
+
54
+ Args:
55
+ url: WebSocket URL (ws:// or wss://)
56
+ config: WebSocket configuration (timeouts, ping/pong, etc.)
57
+ headers: Additional HTTP headers for the handshake
58
+ subprotocols: List of subprotocols to negotiate
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ url: str,
64
+ *,
65
+ config: WebSocketConfig | None = None,
66
+ headers: dict[str, str] | None = None,
67
+ subprotocols: list[str] | None = None,
68
+ ):
69
+ self.url = url
70
+ self.config = config or get_default_config()
71
+ self.headers = headers or {}
72
+ self.subprotocols = subprotocols
73
+ self._connection: ClientConnection | None = None
74
+ self._closed = False
75
+
76
+ async def __aenter__(self) -> "WebSocketClient":
77
+ await self.connect()
78
+ return self
79
+
80
+ async def __aexit__(self, *args: object) -> None:
81
+ await self.close()
82
+
83
+ async def connect(self) -> None:
84
+ """Establish WebSocket connection.
85
+
86
+ Raises:
87
+ ConnectionFailedError: If connection cannot be established
88
+ """
89
+ try:
90
+ # Cast subprotocols to Subprotocol type for type safety
91
+ subprotocols_typed: list[Subprotocol] | None = None
92
+ if self.subprotocols:
93
+ subprotocols_typed = [Subprotocol(s) for s in self.subprotocols]
94
+
95
+ self._connection = await connect(
96
+ self.url,
97
+ additional_headers=self.headers,
98
+ subprotocols=subprotocols_typed,
99
+ open_timeout=self.config.open_timeout,
100
+ ping_interval=self.config.ping_interval,
101
+ ping_timeout=self.config.ping_timeout,
102
+ close_timeout=self.config.close_timeout,
103
+ max_size=self.config.max_message_size,
104
+ max_queue=self.config.max_queue_size,
105
+ )
106
+ self._closed = False
107
+ logger.debug("Connected to %s", self.url)
108
+ except Exception as e:
109
+ raise ConnectionFailedError(f"Failed to connect to {self.url}: {e}") from e
110
+
111
+ async def close(self, code: int = 1000, reason: str = "") -> None:
112
+ """Close the connection gracefully.
113
+
114
+ Args:
115
+ code: WebSocket close code (default: 1000 = normal closure)
116
+ reason: Close reason message
117
+ """
118
+ if self._connection and not self._closed:
119
+ self._closed = True
120
+ try:
121
+ await self._connection.close(code=code, reason=reason)
122
+ logger.debug("Closed connection to %s", self.url)
123
+ except Exception as e:
124
+ logger.warning("Error closing connection to %s: %s", self.url, e)
125
+
126
+ async def send(self, data: str | bytes) -> None:
127
+ """Send text or binary message.
128
+
129
+ Args:
130
+ data: Message content (str for text, bytes for binary)
131
+
132
+ Raises:
133
+ WebSocketError: If not connected
134
+ ConnectionClosedError: If connection is closed
135
+ """
136
+ if not self._connection:
137
+ raise WebSocketError("Not connected")
138
+ try:
139
+ await self._connection.send(data)
140
+ except ConnectionClosedOK:
141
+ raise ConnectionClosedError(1000, "Normal closure")
142
+ except ConnectionClosed as e:
143
+ raise ConnectionClosedError(e.code, e.reason) from e
144
+
145
+ async def send_json(self, data: Any) -> None:
146
+ """Send JSON-serialized message.
147
+
148
+ Args:
149
+ data: Object to serialize and send
150
+
151
+ Raises:
152
+ WebSocketError: If not connected
153
+ ConnectionClosedError: If connection is closed
154
+ TypeError/ValueError: If data cannot be serialized
155
+ """
156
+ await self.send(json.dumps(data))
157
+
158
+ async def recv(self) -> str | bytes:
159
+ """Receive next message.
160
+
161
+ Returns:
162
+ Message content (str for text frames, bytes for binary)
163
+
164
+ Raises:
165
+ WebSocketError: If not connected
166
+ ConnectionClosedError: If connection is closed
167
+ """
168
+ if not self._connection:
169
+ raise WebSocketError("Not connected")
170
+ try:
171
+ result = await self._connection.recv()
172
+ return str(result) if isinstance(result, str) else bytes(result)
173
+ except ConnectionClosedOK:
174
+ raise ConnectionClosedError(1000, "Normal closure")
175
+ except ConnectionClosed as e:
176
+ raise ConnectionClosedError(e.code, e.reason) from e
177
+
178
+ async def recv_json(self) -> Any:
179
+ """Receive and parse JSON message.
180
+
181
+ Returns:
182
+ Parsed JSON object
183
+
184
+ Raises:
185
+ WebSocketError: If not connected
186
+ ConnectionClosedError: If connection is closed
187
+ json.JSONDecodeError: If message is not valid JSON
188
+ """
189
+ data = await self.recv()
190
+ if isinstance(data, bytes):
191
+ data = data.decode("utf-8")
192
+ return json.loads(data)
193
+
194
+ async def __aiter__(self) -> AsyncIterator[str | bytes]:
195
+ """Iterate over incoming messages until closed.
196
+
197
+ Yields:
198
+ Message content (str for text, bytes for binary)
199
+
200
+ Raises:
201
+ ConnectionClosedError: If connection is closed abnormally
202
+ """
203
+ if not self._connection:
204
+ raise WebSocketError("Not connected")
205
+ try:
206
+ async for message in self._connection:
207
+ yield message
208
+ except ConnectionClosedOK:
209
+ return
210
+ except ConnectionClosed as e:
211
+ raise ConnectionClosedError(e.code, e.reason) from e
212
+
213
+ @property
214
+ def is_connected(self) -> bool:
215
+ """Check if connection is open."""
216
+ return self._connection is not None and not self._closed
217
+
218
+ @property
219
+ def latency(self) -> float:
220
+ """Connection latency in seconds (from ping/pong).
221
+
222
+ Returns 0.0 if not connected or no ping has been sent.
223
+ """
224
+ return self._connection.latency if self._connection else 0.0
225
+
226
+
227
+ @asynccontextmanager
228
+ async def websocket_connect(
229
+ url: str,
230
+ *,
231
+ config: WebSocketConfig | None = None,
232
+ headers: dict[str, str] | None = None,
233
+ auto_reconnect: bool = False,
234
+ ) -> AsyncIterator[WebSocketClient]:
235
+ """
236
+ Context manager for WebSocket connections.
237
+
238
+ Args:
239
+ url: WebSocket URL (ws:// or wss://)
240
+ config: WebSocket configuration
241
+ headers: Additional HTTP headers
242
+ auto_reconnect: If True, auto-reconnects on connection loss
243
+
244
+ Yields:
245
+ WebSocketClient instance
246
+
247
+ Example (simple):
248
+ async with websocket_connect("wss://api.example.com") as ws:
249
+ await ws.send_json({"hello": "world"})
250
+
251
+ Example (with auto-reconnect):
252
+ # Note: with auto_reconnect=True, this becomes an async iterator
253
+ async for ws in websocket_connect(url, auto_reconnect=True):
254
+ try:
255
+ async for msg in ws:
256
+ process(msg)
257
+ except ConnectionClosedError:
258
+ continue # Will reconnect
259
+ """
260
+ if auto_reconnect:
261
+ # Use websockets' built-in reconnection iterator
262
+ cfg = config or get_default_config()
263
+ async for connection in connect(
264
+ url,
265
+ additional_headers=headers or {},
266
+ open_timeout=cfg.open_timeout,
267
+ ping_interval=cfg.ping_interval,
268
+ ping_timeout=cfg.ping_timeout,
269
+ close_timeout=cfg.close_timeout,
270
+ max_size=cfg.max_message_size,
271
+ ):
272
+ client = WebSocketClient(url, config=config, headers=headers)
273
+ client._connection = connection
274
+ client._closed = False
275
+ yield client
276
+ else:
277
+ client = WebSocketClient(url, config=config, headers=headers)
278
+ await client.connect()
279
+ try:
280
+ yield client
281
+ finally:
282
+ await client.close()
@@ -0,0 +1,69 @@
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(
31
+ default=10.0, description="Connection timeout in seconds"
32
+ )
33
+ close_timeout: float = Field(
34
+ default=10.0, description="Close handshake timeout in seconds"
35
+ )
36
+
37
+ # Keepalive (ping/pong)
38
+ ping_interval: float | None = Field(
39
+ default=20.0, description="Ping interval in seconds (None to disable)"
40
+ )
41
+ ping_timeout: float | None = Field(
42
+ default=20.0, description="Pong response timeout in seconds"
43
+ )
44
+
45
+ # Message limits
46
+ max_message_size: int = Field(
47
+ default=1_048_576, description="Max message size in bytes (1MB default)"
48
+ )
49
+ max_queue_size: int = Field(default=16, description="Max queued messages")
50
+
51
+ # Reconnection policy
52
+ reconnect_enabled: bool = Field(
53
+ default=True, description="Enable auto-reconnection"
54
+ )
55
+ reconnect_max_attempts: int = Field(
56
+ default=5, description="Max reconnect attempts (0=infinite)"
57
+ )
58
+ reconnect_backoff_base: float = Field(
59
+ default=1.0, description="Base backoff in seconds"
60
+ )
61
+ reconnect_backoff_max: float = Field(
62
+ default=60.0, description="Max backoff in seconds"
63
+ )
64
+ reconnect_jitter: float = Field(default=0.1, description="Jitter factor (0-1)")
65
+
66
+
67
+ def get_default_config() -> WebSocketConfig:
68
+ """Load WebSocket config from environment with defaults."""
69
+ 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