svc-infra 0.1.706__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 (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/webhooks/add.py CHANGED
@@ -19,7 +19,7 @@ import json
19
19
  import logging
20
20
  import os
21
21
  from collections.abc import Callable, Iterable, Mapping
22
- from datetime import datetime, timezone
22
+ from datetime import UTC, datetime
23
23
  from typing import Any, Protocol, TypeGuard, TypeVar, cast
24
24
 
25
25
  from fastapi import FastAPI
@@ -59,7 +59,7 @@ class RedisOutboxStore(OutboxStore):
59
59
  for environments where a fully fledged SQL implementation is unavailable.
60
60
  """
61
61
 
62
- def __init__(self, client: "Redis", *, prefix: str = "webhooks:outbox"):
62
+ def __init__(self, client: Redis, *, prefix: str = "webhooks:outbox"):
63
63
  if Redis is None: # pragma: no cover - defensive guard
64
64
  raise RuntimeError("redis-py is required for RedisOutboxStore")
65
65
  self._client = client
@@ -79,13 +79,13 @@ class RedisOutboxStore(OutboxStore):
79
79
 
80
80
  # Protocol methods --------------------------------------------------
81
81
  def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
82
- incr_result = cast(Any, self._client.incr(self._seq_key))
82
+ incr_result = cast("Any", self._client.incr(self._seq_key))
83
83
  # Redis incr always returns an int for the sync client. Be defensive for mocks.
84
84
  try:
85
85
  msg_id = int(incr_result)
86
86
  except (TypeError, ValueError):
87
87
  msg_id = 0
88
- created_at = datetime.now(timezone.utc)
88
+ created_at = datetime.now(UTC)
89
89
  record: dict[str, str] = {
90
90
  "id": str(msg_id),
91
91
  "topic": topic,
@@ -96,29 +96,21 @@ class RedisOutboxStore(OutboxStore):
96
96
  }
97
97
  self._client.hset(self._msg_key(msg_id), mapping=record)
98
98
  self._client.rpush(self._queue_key, msg_id)
99
- return OutboxMessage(
100
- id=msg_id, topic=topic, payload=payload, created_at=created_at
101
- )
99
+ return OutboxMessage(id=msg_id, topic=topic, payload=payload, created_at=created_at)
102
100
 
103
101
  def fetch_next(self, topics: Iterable[str] | None = None) -> OutboxMessage | None:
104
102
  allowed = set(topics) if topics else None
105
- ids = cast(list[Any], self._client.lrange(self._queue_key, 0, -1))
103
+ ids = cast("list[Any]", self._client.lrange(self._queue_key, 0, -1))
106
104
  for raw_id in ids:
107
- raw_id_str = (
108
- raw_id.decode()
109
- if isinstance(raw_id, (bytes, bytearray))
110
- else str(raw_id)
111
- )
105
+ raw_id_str = raw_id.decode() if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
112
106
  msg_id = int(raw_id_str)
113
- msg = cast(dict[Any, Any], self._client.hgetall(self._msg_key(msg_id)))
107
+ msg = cast("dict[Any, Any]", self._client.hgetall(self._msg_key(msg_id)))
114
108
  if not msg:
115
109
  continue
116
110
  topic = msg.get(b"topic")
117
111
  if topic is None:
118
112
  continue
119
- topic_str = (
120
- topic.decode() if isinstance(topic, (bytes, bytearray)) else str(topic)
121
- )
113
+ topic_str = topic.decode() if isinstance(topic, (bytes, bytearray)) else str(topic)
122
114
  if allowed is not None and topic_str not in allowed:
123
115
  continue
124
116
  attempts = int(msg.get(b"attempts", 0))
@@ -142,7 +134,7 @@ class RedisOutboxStore(OutboxStore):
142
134
  else str(created_raw)
143
135
  )
144
136
  if created_raw
145
- else datetime.now(timezone.utc)
137
+ else datetime.now(UTC)
146
138
  )
147
139
  return OutboxMessage(
148
140
  id=msg_id,
@@ -157,7 +149,7 @@ class RedisOutboxStore(OutboxStore):
157
149
  key = self._msg_key(msg_id)
158
150
  if not self._client.exists(key):
159
151
  return
160
- self._client.hset(key, "processed_at", datetime.now(timezone.utc).isoformat())
152
+ self._client.hset(key, "processed_at", datetime.now(UTC).isoformat())
161
153
 
162
154
  def mark_failed(self, msg_id: int) -> None:
163
155
  key = self._msg_key(msg_id)
@@ -167,7 +159,7 @@ class RedisOutboxStore(OutboxStore):
167
159
  class RedisInboxStore(InboxStore):
168
160
  """Lightweight Redis dedupe store for webhook deliveries."""
169
161
 
170
- def __init__(self, client: "Redis", *, prefix: str = "webhooks:inbox"):
162
+ def __init__(self, client: Redis, *, prefix: str = "webhooks:inbox"):
171
163
  if Redis is None: # pragma: no cover - defensive guard
172
164
  raise RuntimeError("redis-py is required for RedisInboxStore")
173
165
  self._client = client
@@ -191,17 +183,15 @@ def _is_factory(obj: Any) -> TypeGuard[Callable[[], Any]]:
191
183
  return callable(obj) and not isinstance(obj, (str, bytes, bytearray))
192
184
 
193
185
 
194
- def _resolve_value(
195
- value: T_co | _Factory[T_co] | None, default_factory: _Factory[T_co]
196
- ) -> T_co:
186
+ def _resolve_value(value: T_co | _Factory[T_co] | None, default_factory: _Factory[T_co]) -> T_co:
197
187
  if value is None:
198
188
  return default_factory()
199
189
  if _is_factory(value):
200
- return cast(T_co, value())
201
- return cast(T_co, value)
190
+ return cast("T_co", value())
191
+ return cast("T_co", value)
202
192
 
203
193
 
204
- def _build_redis_client(env: Mapping[str, str]) -> "Redis" | None:
194
+ def _build_redis_client(env: Mapping[str, str]) -> Redis | None:
205
195
  if Redis is None:
206
196
  logger.warning(
207
197
  "Redis backend requested but redis-py is not installed; falling back to in-memory stores"
@@ -331,9 +321,7 @@ def add_webhooks(
331
321
  )
332
322
  app.state.webhooks_delivery_handler = handler
333
323
  elif scheduler is not None and schedule_tick:
334
- logger.warning(
335
- "Scheduler provided without queue; skipping outbox tick registration"
336
- )
324
+ logger.warning("Scheduler provided without queue; skipping outbox tick registration")
337
325
 
338
326
 
339
327
  __all__ = ["add_webhooks"]
@@ -75,7 +75,7 @@ def encrypt_secret(plaintext: str) -> str:
75
75
  return plaintext
76
76
 
77
77
  encrypted = fernet.encrypt(plaintext.encode())
78
- return _ENCRYPTED_PREFIX + cast(str, encrypted.decode())
78
+ return _ENCRYPTED_PREFIX + cast("str", encrypted.decode())
79
79
 
80
80
 
81
81
  def decrypt_secret(ciphertext: str) -> str:
@@ -107,7 +107,7 @@ def decrypt_secret(ciphertext: str) -> str:
107
107
  return ciphertext
108
108
 
109
109
  encrypted = ciphertext[len(_ENCRYPTED_PREFIX) :].encode()
110
- return cast(str, fernet.decrypt(encrypted).decode())
110
+ return cast("str", fernet.decrypt(encrypted).decode())
111
111
 
112
112
 
113
113
  def is_encrypted(value: str) -> bool:
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable, Sequence
3
+ from collections.abc import Callable, Sequence
4
4
 
5
5
  from fastapi import HTTPException, Request, status
6
6
 
@@ -21,9 +21,7 @@ def require_signature(
21
21
  try:
22
22
  body = await request.json()
23
23
  except Exception:
24
- raise HTTPException(
25
- status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body"
26
- )
24
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body")
27
25
  secrets = secrets_provider()
28
26
  ok = False
29
27
  if isinstance(secrets, str):
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict
3
+ from typing import Any
4
4
 
5
5
  from fastapi import APIRouter, Depends, HTTPException
6
6
 
@@ -30,7 +30,7 @@ def get_service(
30
30
 
31
31
  @router.post("/subscriptions")
32
32
  def add_subscription(
33
- body: Dict[str, Any],
33
+ body: dict[str, Any],
34
34
  subs: InMemoryWebhookSubscriptions = Depends(get_subs),
35
35
  ):
36
36
  topic = body.get("topic")
@@ -44,7 +44,7 @@ def add_subscription(
44
44
 
45
45
  @router.post("/test-fire")
46
46
  def test_fire(
47
- body: Dict[str, Any],
47
+ body: dict[str, Any],
48
48
  svc: WebhookService = Depends(get_service),
49
49
  ):
50
50
  topic = body.get("topic")
@@ -1,8 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from datetime import datetime, timezone
5
- from typing import Dict, List
4
+ from datetime import UTC, datetime
6
5
  from uuid import uuid4
7
6
 
8
7
  from svc_infra.db.outbox import OutboxStore
@@ -19,7 +18,7 @@ class WebhookSubscription:
19
18
 
20
19
  class InMemoryWebhookSubscriptions:
21
20
  def __init__(self):
22
- self._subs: Dict[str, List[WebhookSubscription]] = {}
21
+ self._subs: dict[str, list[WebhookSubscription]] = {}
23
22
 
24
23
  def add(self, topic: str, url: str, secret: str) -> None:
25
24
  # Upsert semantics per (topic, url): if a subscription already exists
@@ -33,7 +32,7 @@ class InMemoryWebhookSubscriptions:
33
32
  return
34
33
  lst.append(WebhookSubscription(topic, url, secret))
35
34
 
36
- def get_for_topic(self, topic: str) -> List[WebhookSubscription]:
35
+ def get_for_topic(self, topic: str) -> list[WebhookSubscription]:
37
36
  return list(self._subs.get(topic, []))
38
37
 
39
38
 
@@ -42,8 +41,8 @@ class WebhookService:
42
41
  self._outbox = outbox
43
42
  self._subs = subs
44
43
 
45
- def publish(self, topic: str, payload: Dict, *, version: int = 1) -> int:
46
- created_at = datetime.now(timezone.utc).isoformat()
44
+ def publish(self, topic: str, payload: dict, *, version: int = 1) -> int:
45
+ created_at = datetime.now(UTC).isoformat()
47
46
  base_event = {
48
47
  "topic": topic,
49
48
  "payload": payload,
@@ -4,21 +4,21 @@ import hashlib
4
4
  import hmac
5
5
  import json
6
6
  import logging
7
- from typing import Dict, Iterable
7
+ from collections.abc import Iterable
8
8
 
9
9
  logger = logging.getLogger(__name__)
10
10
 
11
11
 
12
- def canonical_body(payload: Dict) -> bytes:
12
+ def canonical_body(payload: dict) -> bytes:
13
13
  return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
14
14
 
15
15
 
16
- def sign(secret: str, payload: Dict) -> str:
16
+ def sign(secret: str, payload: dict) -> str:
17
17
  body = canonical_body(payload)
18
18
  return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
19
19
 
20
20
 
21
- def verify(secret: str, payload: Dict, signature: str) -> bool:
21
+ def verify(secret: str, payload: dict, signature: str) -> bool:
22
22
  expected = sign(secret, payload)
23
23
  try:
24
24
  return hmac.compare_digest(expected, signature)
@@ -27,7 +27,7 @@ def verify(secret: str, payload: Dict, signature: str) -> bool:
27
27
  return False
28
28
 
29
29
 
30
- def verify_any(secrets: Iterable[str], payload: Dict, signature: str) -> bool:
30
+ def verify_any(secrets: Iterable[str], payload: dict, signature: str) -> bool:
31
31
  for s in secrets:
32
32
  if verify(s, payload, signature):
33
33
  return True
@@ -116,10 +116,9 @@ def get_ws_manager(app_or_request: FastAPI | Request) -> ConnectionManager:
116
116
  manager = getattr(app.state, _WS_MANAGER_ATTR, None)
117
117
  if manager is None:
118
118
  raise RuntimeError(
119
- "WebSocket manager not found. "
120
- "Did you forget to call add_websocket_manager(app)?"
119
+ "WebSocket manager not found. Did you forget to call add_websocket_manager(app)?"
121
120
  )
122
- return cast(ConnectionManager, manager)
121
+ return cast("ConnectionManager", manager)
123
122
 
124
123
 
125
124
  def get_ws_manager_dependency(request: Request) -> ConnectionManager:
@@ -18,8 +18,9 @@ from __future__ import annotations
18
18
 
19
19
  import json
20
20
  import logging
21
+ from collections.abc import AsyncIterator
21
22
  from contextlib import asynccontextmanager
22
- from typing import TYPE_CHECKING, Any, AsyncIterator
23
+ from typing import TYPE_CHECKING, Any
23
24
 
24
25
  from websockets.asyncio.client import connect
25
26
  from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
@@ -73,7 +74,7 @@ class WebSocketClient:
73
74
  self._connection: ClientConnection | None = None
74
75
  self._closed = False
75
76
 
76
- async def __aenter__(self) -> "WebSocketClient":
77
+ async def __aenter__(self) -> WebSocketClient:
77
78
  await self.connect()
78
79
  return self
79
80
 
@@ -27,20 +27,14 @@ class WebSocketConfig(BaseSettings):
27
27
  model_config = SettingsConfigDict(env_prefix="WS_")
28
28
 
29
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
- )
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")
36
32
 
37
33
  # Keepalive (ping/pong)
38
34
  ping_interval: float | None = Field(
39
35
  default=20.0, description="Ping interval in seconds (None to disable)"
40
36
  )
41
- ping_timeout: float | None = Field(
42
- default=20.0, description="Pong response timeout in seconds"
43
- )
37
+ ping_timeout: float | None = Field(default=20.0, description="Pong response timeout in seconds")
44
38
 
45
39
  # Message limits
46
40
  max_message_size: int = Field(
@@ -49,18 +43,12 @@ class WebSocketConfig(BaseSettings):
49
43
  max_queue_size: int = Field(default=16, description="Max queued messages")
50
44
 
51
45
  # Reconnection policy
52
- reconnect_enabled: bool = Field(
53
- default=True, description="Enable auto-reconnection"
54
- )
46
+ reconnect_enabled: bool = Field(default=True, description="Enable auto-reconnection")
55
47
  reconnect_max_attempts: int = Field(
56
48
  default=5, description="Max reconnect attempts (0=infinite)"
57
49
  )
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
- )
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")
64
52
  reconnect_jitter: float = Field(default=0.1, description="Jitter factor (0-1)")
65
53
 
66
54
 
@@ -27,8 +27,9 @@ import asyncio
27
27
  import logging
28
28
  import uuid
29
29
  from collections import defaultdict
30
- from datetime import datetime, timezone
31
- from typing import TYPE_CHECKING, Any, Awaitable, Callable
30
+ from collections.abc import Awaitable, Callable
31
+ from datetime import UTC, datetime
32
+ from typing import TYPE_CHECKING, Any
32
33
 
33
34
  from .models import ConnectionInfo
34
35
 
@@ -64,8 +65,8 @@ class ConnectionManager:
64
65
  def __init__(self) -> None:
65
66
  self._lock = asyncio.Lock()
66
67
  # user_id -> list of (connection_id, WebSocket, ConnectionInfo)
67
- self._connections: dict[str, list[tuple[str, WebSocket, ConnectionInfo]]] = (
68
- defaultdict(list)
68
+ self._connections: dict[str, list[tuple[str, WebSocket, ConnectionInfo]]] = defaultdict(
69
+ list
69
70
  )
70
71
  # room -> set of user_ids
71
72
  self._rooms: dict[str, set[str]] = defaultdict(set)
@@ -97,7 +98,7 @@ class ConnectionManager:
97
98
  await websocket.accept()
98
99
 
99
100
  connection_id = str(uuid.uuid4())
100
- now = datetime.now(timezone.utc)
101
+ now = datetime.now(UTC)
101
102
  info = ConnectionInfo(
102
103
  user_id=user_id,
103
104
  connection_id=connection_id,
@@ -121,9 +122,7 @@ class ConnectionManager:
121
122
 
122
123
  return connection_id
123
124
 
124
- async def disconnect(
125
- self, user_id: str, websocket: WebSocket | None = None
126
- ) -> None:
125
+ async def disconnect(self, user_id: str, websocket: WebSocket | None = None) -> None:
127
126
  """
128
127
  Remove connection(s) for a user.
129
128
 
@@ -186,7 +185,7 @@ class ConnectionManager:
186
185
  try:
187
186
  await self._send_message(ws, message)
188
187
  # Update last activity
189
- info.last_activity = datetime.now(timezone.utc)
188
+ info.last_activity = datetime.now(UTC)
190
189
  sent += 1
191
190
  except Exception as e:
192
191
  logger.debug("Failed to send to user %s: %s", user_id, e)
@@ -216,7 +215,7 @@ class ConnectionManager:
216
215
  for uid, ws, info in all_connections:
217
216
  try:
218
217
  await self._send_message(ws, message)
219
- info.last_activity = datetime.now(timezone.utc)
218
+ info.last_activity = datetime.now(UTC)
220
219
  sent += 1
221
220
  except Exception as e:
222
221
  logger.debug("Failed to broadcast to user %s: %s", uid, e)
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.706
3
+ Version: 1.1.0
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
7
7
  Author: Ali Khatami
8
8
  Author-email: aliikhatami94@gmail.com
9
9
  Requires-Python: >=3.11,<4.0
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Framework :: FastAPI
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
@@ -86,13 +86,19 @@ Description-Content-Type: text/markdown
86
86
 
87
87
  # svc-infra
88
88
 
89
- ### Production-ready FastAPI infrastructure in one import
89
+ [![v1.0.0](https://img.shields.io/badge/version-1.0.0-green.svg)](CHANGELOG.md)
90
+ [![CI](https://github.com/nfraxlab/svc-infra/actions/workflows/ci.yml/badge.svg)](https://github.com/nfraxlab/svc-infra/actions/workflows/ci.yml)
91
+ [![PyPI](https://img.shields.io/pypi/v/svc-infra.svg)](https://pypi.org/project/svc-infra/)
92
+ [![Python](https://img.shields.io/pypi/pyversions/svc-infra.svg)](https://pypi.org/project/svc-infra/)
93
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
94
+ [![Downloads](https://img.shields.io/pypi/dm/svc-infra.svg)](https://pypi.org/project/svc-infra/)
95
+ [![codecov](https://codecov.io/gh/nfraxlab/svc-infra/branch/main/graph/badge.svg)](https://codecov.io/gh/nfraxlab/svc-infra)
90
96
 
91
- [![PyPI](https://img.shields.io/pypi/v/svc-infra.svg)](https://pypi.org/project/svc-infra/) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![Downloads](https://static.pepy.tech/badge/svc-infra/month)](https://pepy.tech/project/svc-infra)
97
+ ### Production-ready FastAPI infrastructure in one import
92
98
 
93
99
  **Stop rebuilding auth, billing, webhooks, and background jobs for every project.**
94
100
 
95
- [Documentation](docs/) · [Examples](examples/) · [PyPI](https://pypi.org/project/svc-infra/)
101
+ [Documentation](docs/) · [Examples](examples/) · [PyPI](https://pypi.org/project/svc-infra/) · [Changelog](CHANGELOG.md)
96
102
 
97
103
  </div>
98
104