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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.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
|