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
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
3
+ import builtins
4
+ from collections.abc import Iterable, Sequence
5
+ from datetime import UTC
6
+ from typing import Any, cast
4
7
 
5
8
  try:
6
9
  from bson import ObjectId
@@ -35,7 +38,7 @@ class NoSqlRepository:
35
38
  soft_delete: bool = False,
36
39
  soft_delete_field: str = "deleted_at",
37
40
  soft_delete_flag_field: str | None = None,
38
- immutable_fields: Optional[set[str]] = None,
41
+ immutable_fields: set[str] | None = None,
39
42
  ):
40
43
  self.collection_name = collection_name
41
44
  self.id_field = id_field
@@ -46,7 +49,7 @@ class NoSqlRepository:
46
49
  immutable_fields or {self.id_field, "created_at", "updated_at"}
47
50
  )
48
51
 
49
- def _alive_filter(self) -> Dict[str, Any]:
52
+ def _alive_filter(self) -> dict[str, Any]:
50
53
  """
51
54
  Build a filter that returns 'alive' docs when soft_delete is enabled.
52
55
  - deleted_at is either null or absent
@@ -84,7 +87,7 @@ class NoSqlRepository:
84
87
 
85
88
  return clauses[0] if len(clauses) == 1 else {"$and": clauses}
86
89
 
87
- def _merge_and(self, *filters: Optional[Dict[str, Any]]) -> Dict[str, Any]:
90
+ def _merge_and(self, *filters: dict[str, Any] | None) -> dict[str, Any]:
88
91
  parts = [f for f in filters if f]
89
92
  if not parts:
90
93
  return {}
@@ -104,7 +107,7 @@ class NoSqlRepository:
104
107
  return val
105
108
 
106
109
  @staticmethod
107
- def _public_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
110
+ def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
108
111
  d = dict(doc)
109
112
  if "_id" in d and "id" not in d:
110
113
  _id = d.pop("_id", None)
@@ -120,20 +123,20 @@ class NoSqlRepository:
120
123
  *,
121
124
  limit: int,
122
125
  offset: int,
123
- sort: Optional[List[Tuple[str, int]]] = None,
124
- filter: Optional[Dict[str, Any]] = None,
125
- ) -> List[Dict[str, Any]]:
126
+ sort: builtins.list[tuple[str, int]] | None = None,
127
+ filter: dict[str, Any] | None = None,
128
+ ) -> builtins.list[dict[str, Any]]:
126
129
  filt = self._merge_and(self._alive_filter(), filter)
127
130
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
128
131
  if sort:
129
132
  cursor = cursor.sort(sort)
130
133
  return [self._public_doc(doc) async for doc in cursor]
131
134
 
132
- async def count(self, db, *, filter: Optional[Dict[str, Any]] = None) -> int:
135
+ async def count(self, db, *, filter: dict[str, Any] | None = None) -> int:
133
136
  filt = self._merge_and(self._alive_filter(), filter)
134
- return cast(int, await db[self.collection_name].count_documents(filt or {}))
137
+ return cast("int", await db[self.collection_name].count_documents(filt or {}))
135
138
 
136
- async def get(self, db, id_value: Any) -> Dict | None:
139
+ async def get(self, db, id_value: Any) -> dict | None:
137
140
  id_value = self._normalize_id_value(id_value)
138
141
  filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
139
142
  doc = await db[self.collection_name].find_one(filt)
@@ -141,7 +144,7 @@ class NoSqlRepository:
141
144
  return None
142
145
  return self._public_doc(doc)
143
146
 
144
- async def create(self, db, data: Dict[str, Any]) -> Dict[str, Any]:
147
+ async def create(self, db, data: dict[str, Any]) -> dict[str, Any]:
145
148
  # don't let clients supply soft-delete artifacts on create
146
149
  if self.soft_delete:
147
150
  data.pop(self.soft_delete_field, None)
@@ -150,7 +153,7 @@ class NoSqlRepository:
150
153
  res = await db[self.collection_name].insert_one(data)
151
154
  return self._public_doc({**data, "_id": res.inserted_id})
152
155
 
153
- async def update(self, db, id_value: Any, data: Dict[str, Any]) -> Dict | None:
156
+ async def update(self, db, id_value: Any, data: dict[str, Any]) -> dict | None:
154
157
  for k in list(data.keys()):
155
158
  if k in self.immutable_fields:
156
159
  data.pop(k, None)
@@ -162,19 +165,19 @@ class NoSqlRepository:
162
165
  async def delete(self, db, id_value: Any) -> bool:
163
166
  id_value = self._normalize_id_value(id_value)
164
167
  if self.soft_delete:
165
- set_ops: Dict[str, Any] = {}
168
+ set_ops: dict[str, Any] = {}
166
169
  if self.soft_delete_flag_field:
167
170
  set_ops[self.soft_delete_flag_field] = False
168
- from datetime import datetime, timezone
171
+ from datetime import datetime
169
172
 
170
- set_ops[self.soft_delete_field] = datetime.now(timezone.utc)
173
+ set_ops[self.soft_delete_field] = datetime.now(UTC)
171
174
  res = await db[self.collection_name].update_one(
172
175
  {self.id_field: id_value}, {"$set": set_ops}
173
176
  )
174
- return cast(int, res.modified_count) > 0
177
+ return cast("int", res.modified_count) > 0
175
178
 
176
179
  res = await db[self.collection_name].delete_one({self.id_field: id_value})
177
- return cast(int, res.deleted_count) > 0
180
+ return cast("int", res.deleted_count) > 0
178
181
 
179
182
  async def search(
180
183
  self,
@@ -184,14 +187,12 @@ class NoSqlRepository:
184
187
  fields: Sequence[str],
185
188
  limit: int,
186
189
  offset: int,
187
- sort: Optional[List[Tuple[str, int]]] = None,
188
- ) -> List[Dict[str, Any]]:
190
+ sort: builtins.list[tuple[str, int]] | None = None,
191
+ ) -> builtins.list[dict[str, Any]]:
189
192
  regex = {"$regex": q, "$options": "i"}
190
193
  or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
191
194
  filt = (
192
- self._merge_and(self._alive_filter(), *or_filter)
193
- if or_filter
194
- else self._alive_filter()
195
+ self._merge_and(self._alive_filter(), *or_filter) if or_filter else self._alive_filter()
195
196
  )
196
197
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
197
198
  if sort:
@@ -202,11 +203,9 @@ class NoSqlRepository:
202
203
  regex = {"$regex": q, "$options": "i"}
203
204
  or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
204
205
  filt = self._merge_and(self._alive_filter(), or_filter)
205
- return cast(int, await db[self.collection_name].count_documents(filt or {}))
206
+ return cast("int", await db[self.collection_name].count_documents(filt or {}))
206
207
 
207
- async def exists(self, db, *, where: Iterable[Dict[str, Any]]) -> bool:
208
+ async def exists(self, db, *, where: Iterable[dict[str, Any]]) -> bool:
208
209
  filt = self._merge_and(self._alive_filter(), *list(where))
209
- doc = await db[self.collection_name].find_one(
210
- filt, projection={self.id_field: 1}
211
- )
210
+ doc = await db[self.collection_name].find_one(filt, projection={self.id_field: 1})
212
211
  return doc is not None
@@ -1,15 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable, Iterable, Sequence
3
4
  from dataclasses import dataclass
4
5
  from typing import (
5
- Any,
6
- Callable,
7
- Iterable,
8
- Optional,
9
- Sequence,
10
6
  TYPE_CHECKING,
11
- Type,
12
- Union,
7
+ Any,
13
8
  )
14
9
 
15
10
  if TYPE_CHECKING:
@@ -56,36 +51,36 @@ class NoSqlResource:
56
51
  """
57
52
 
58
53
  # API mounting
59
- collection: Optional[str] = None
54
+ collection: str | None = None
60
55
  prefix: str = ""
61
- document_model: Optional[Type[Any]] = None
56
+ document_model: type[Any] | None = None
62
57
 
63
58
  # optional Pydantic schemas (auto-derived if omitted)
64
- read_schema: Optional[Type[Any]] = None
65
- create_schema: Optional[Type[Any]] = None
66
- update_schema: Optional[Type[Any]] = None
59
+ read_schema: type[Any] | None = None
60
+ create_schema: type[Any] | None = None
61
+ update_schema: type[Any] | None = None
67
62
 
68
63
  # behavior
69
- search_fields: Optional[Sequence[str]] = None
70
- tags: Optional[list[str]] = None
64
+ search_fields: Sequence[str] | None = None
65
+ tags: list[str] | None = None
71
66
  id_field: str = "_id"
72
67
  soft_delete: bool = False
73
68
  soft_delete_field: str = "deleted_at"
74
- soft_delete_flag_field: Optional[str] = None
69
+ soft_delete_flag_field: str | None = None
75
70
 
76
71
  # custom wiring
77
- service_factory: Optional[Callable[[Any], Any]] = None
72
+ service_factory: Callable[[Any], Any] | None = None
78
73
 
79
74
  # generated schema naming and exclusions
80
- read_name: Optional[str] = None
81
- create_name: Optional[str] = None
82
- update_name: Optional[str] = None
75
+ read_name: str | None = None
76
+ create_name: str | None = None
77
+ update_name: str | None = None
83
78
  create_exclude: tuple[str, ...] = ("_id",)
84
79
  read_exclude: tuple[str, ...] = ()
85
80
  update_exclude: tuple[str, ...] = ()
86
81
 
87
82
  # NEW: indexes defined per collection (normalized to IndexModel at prepare time)
88
- indexes: Optional[Iterable[Union[IndexModel, IndexAlias]]] = None
83
+ indexes: Iterable[IndexModel | IndexAlias] | None = None
89
84
 
90
85
  def __post_init__(self):
91
86
  if not self.collection and self.document_model:
@@ -1,18 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Any, Dict, Literal, Optional
4
+ from typing import Any, Literal
5
5
 
6
6
  from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
7
7
  from svc_infra.utils import ensure_init_py, render_template, write
8
8
 
9
- _INIT_CONTENT_PAIRED = (
10
- 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
11
- )
9
+ _INIT_CONTENT_PAIRED = 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
12
10
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
13
11
 
14
12
 
15
- def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> Dict[str, Any]:
13
+ def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> dict[str, Any]:
16
14
  content = _INIT_CONTENT_PAIRED if paired else _INIT_CONTENT_MINIMAL
17
15
  return ensure_init_py(dir_path, overwrite, paired, content)
18
16
 
@@ -29,9 +27,9 @@ def scaffold_core(
29
27
  entity_name: str = "Item",
30
28
  overwrite: bool = False,
31
29
  same_dir: bool = False,
32
- documents_filename: Optional[str] = None,
33
- schemas_filename: Optional[str] = None,
34
- ) -> Dict[str, Any]:
30
+ documents_filename: str | None = None,
31
+ schemas_filename: str | None = None,
32
+ ) -> dict[str, Any]:
35
33
  """Create starter Mongo document model + CRUD schemas."""
36
34
 
37
35
  documents_dir = normalize_dir(documents_dir)
@@ -48,9 +46,7 @@ def scaffold_core(
48
46
  schemas_txt = render_template(
49
47
  tmpl_dir="svc_infra.db.nosql.mongo.templates",
50
48
  name="schemas.py.tmpl",
51
- subs={
52
- "Entity": ent
53
- }, # (only if your schemas.tmpl doesn't need collection_name)
49
+ subs={"Entity": ent}, # (only if your schemas.tmpl doesn't need collection_name)
54
50
  )
55
51
 
56
52
  if same_dir:
@@ -80,8 +76,8 @@ def scaffold_documents_core(
80
76
  dest_dir: Path | str,
81
77
  entity_name: str = "Item",
82
78
  overwrite: bool = False,
83
- documents_filename: Optional[str] = None,
84
- ) -> Dict[str, Any]:
79
+ documents_filename: str | None = None,
80
+ ) -> dict[str, Any]:
85
81
  dest = normalize_dir(dest_dir)
86
82
  ent = pascal(entity_name)
87
83
  coll = plural_snake(ent)
@@ -102,8 +98,8 @@ def scaffold_schemas_core(
102
98
  dest_dir: Path | str,
103
99
  entity_name: str = "Item",
104
100
  overwrite: bool = False,
105
- schemas_filename: Optional[str] = None,
106
- ) -> Dict[str, Any]:
101
+ schemas_filename: str | None = None,
102
+ ) -> dict[str, Any]:
107
103
  dest = normalize_dir(dest_dir)
108
104
  ent = pascal(entity_name)
109
105
  txt = render_template(
@@ -121,9 +117,9 @@ def scaffold_resources_core(
121
117
  *,
122
118
  dest_dir: Path | str,
123
119
  entity_name: str = "Item",
124
- filename: Optional[str] = None, # defaults to "resources.py"
120
+ filename: str | None = None, # defaults to "resources.py"
125
121
  overwrite: bool = False,
126
- ) -> Dict[str, Any]:
122
+ ) -> dict[str, Any]:
127
123
  """
128
124
  Create a starter resources.py with:
129
125
  - empty RESOURCES list
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from .repository import NoSqlRepository
6
7
 
@@ -43,9 +44,7 @@ class NoSqlService:
43
44
  async def search(
44
45
  self, db, *, q: str, fields: Sequence[str], limit: int, offset: int, sort=None
45
46
  ):
46
- return await self.repo.search(
47
- db, q=q, fields=fields, limit=limit, offset=offset, sort=sort
48
- )
47
+ return await self.repo.search(db, q=q, fields=fields, limit=limit, offset=offset, sort=sort)
49
48
 
50
49
  async def count_filtered(self, db, *, q: str, fields: Sequence[str]) -> int:
51
50
  return await self.repo.count_filtered(db, q=q, fields=fields)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from .service import NoSqlService
6
7
 
@@ -11,8 +12,8 @@ class NoSqlServiceWithHooks(NoSqlService):
11
12
  def __init__(
12
13
  self,
13
14
  repo,
14
- pre_create: Optional[PreHook] = None,
15
- pre_update: Optional[PreHook] = None,
15
+ pre_create: PreHook | None = None,
16
+ pre_update: PreHook | None = None,
16
17
  ):
17
18
  super().__init__(repo)
18
19
  self._pre_create = pre_create
@@ -11,9 +11,7 @@ class PyObjectId(ObjectId):
11
11
  """Pydantic v2-compatible ObjectId type."""
12
12
 
13
13
  @classmethod
14
- def __get_pydantic_core_schema__(
15
- cls, _source_type: Any, _handler: GetCoreSchemaHandler
16
- ):
14
+ def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: GetCoreSchemaHandler):
17
15
  def validate(v: Any) -> ObjectId:
18
16
  if isinstance(v, ObjectId):
19
17
  return v
@@ -24,6 +22,4 @@ class PyObjectId(ObjectId):
24
22
  raise ValueError(f"Invalid ObjectId: {v}") from e
25
23
  raise ValueError("ObjectId required")
26
24
 
27
- return core_schema.no_info_after_validator_function(
28
- validate, core_schema.any_schema()
29
- )
25
+ return core_schema.no_info_after_validator_function(validate, core_schema.any_schema())
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Sequence
4
5
  from pathlib import Path
5
- from typing import Optional, Sequence
6
6
 
7
7
  from dotenv import load_dotenv
8
8
 
@@ -27,7 +27,7 @@ def prepare_process_env(project_root: Path | str) -> Path:
27
27
  return root
28
28
 
29
29
 
30
- def _read_secret_from_file(path: str) -> Optional[str]:
30
+ def _read_secret_from_file(path: str) -> str | None:
31
31
  try:
32
32
  p = Path(path)
33
33
  if p.exists():
@@ -40,7 +40,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
40
40
  def get_mongo_url_from_env(
41
41
  required: bool = True,
42
42
  env_vars: Sequence[str] = DEFAULT_MONGO_ENV_VARS,
43
- ) -> Optional[str]:
43
+ ) -> str | None:
44
44
  """
45
45
  Resolve the Mongo connection string with support for:
46
46
  - Primary env vars (DEFAULT_MONGO_ENV_VARS).
@@ -99,7 +99,7 @@ def get_mongo_dbname_from_env(
99
99
  required: bool = False,
100
100
  env_vars: Sequence[str] = DEFAULT_MONGO_DB_ENV_VARS,
101
101
  default: str = "app",
102
- ) -> Optional[str]:
102
+ ) -> str | None:
103
103
  """Return a database name from env; optional (Motor can connect without it)."""
104
104
  load_dotenv(override=False)
105
105
  for key in env_vars:
svc_infra/db/ops.py CHANGED
@@ -25,7 +25,8 @@ from __future__ import annotations
25
25
  import logging
26
26
  import sys
27
27
  import time
28
- from typing import Any, Optional, Sequence, cast
28
+ from collections.abc import Sequence
29
+ from typing import Any, cast
29
30
 
30
31
  from .sql.utils import get_database_url_from_env
31
32
 
@@ -45,7 +46,7 @@ def _flush() -> None:
45
46
  sys.stderr.flush()
46
47
 
47
48
 
48
- def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any:
49
+ def _get_connection(url: str | None = None, connect_timeout: int = 10) -> Any:
49
50
  """
50
51
  Get a psycopg2 connection.
51
52
 
@@ -64,8 +65,7 @@ def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any
64
65
  import psycopg2
65
66
  except ImportError as e:
66
67
  raise ImportError(
67
- "psycopg2 is required for db.ops utilities. "
68
- "Install with: pip install psycopg2-binary"
68
+ "psycopg2 is required for db.ops utilities. Install with: pip install psycopg2-binary"
69
69
  ) from e
70
70
 
71
71
  if url is None:
@@ -76,7 +76,7 @@ def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any
76
76
 
77
77
 
78
78
  def wait_for_database(
79
- url: Optional[str] = None,
79
+ url: str | None = None,
80
80
  timeout: float = DEFAULT_WAIT_TIMEOUT,
81
81
  interval: float = DEFAULT_WAIT_INTERVAL,
82
82
  verbose: bool = True,
@@ -114,9 +114,7 @@ def wait_for_database(
114
114
 
115
115
  if elapsed >= timeout:
116
116
  if verbose:
117
- logger.error(
118
- f"Database not ready after {timeout}s ({attempt} attempts)"
119
- )
117
+ logger.error(f"Database not ready after {timeout}s ({attempt} attempts)")
120
118
  _flush()
121
119
  return False
122
120
 
@@ -130,20 +128,18 @@ def wait_for_database(
130
128
  except Exception as e:
131
129
  if verbose:
132
130
  remaining = timeout - elapsed
133
- logger.debug(
134
- f"Database not ready ({e}), retrying... ({remaining:.0f}s remaining)"
135
- )
131
+ logger.debug(f"Database not ready ({e}), retrying... ({remaining:.0f}s remaining)")
136
132
  _flush()
137
133
  time.sleep(interval)
138
134
 
139
135
 
140
136
  def run_sync_sql(
141
137
  sql: str,
142
- params: Optional[Sequence[Any]] = None,
143
- url: Optional[str] = None,
138
+ params: Sequence[Any] | None = None,
139
+ url: str | None = None,
144
140
  timeout: int = DEFAULT_STATEMENT_TIMEOUT,
145
141
  fetch: bool = False,
146
- ) -> Optional[list[tuple[Any, ...]]]:
142
+ ) -> list[tuple[Any, ...]] | None:
147
143
  """
148
144
  Execute SQL synchronously with a statement timeout.
149
145
 
@@ -188,7 +184,7 @@ def run_sync_sql(
188
184
  cur.execute(sql)
189
185
 
190
186
  if fetch:
191
- return cast(list[tuple[Any, ...]], cur.fetchall())
187
+ return cast("list[tuple[Any, ...]]", cur.fetchall())
192
188
 
193
189
  conn.commit()
194
190
  return None
@@ -198,7 +194,7 @@ def run_sync_sql(
198
194
 
199
195
  def kill_blocking_queries(
200
196
  table_name: str,
201
- url: Optional[str] = None,
197
+ url: str | None = None,
202
198
  timeout: int = DEFAULT_STATEMENT_TIMEOUT,
203
199
  dry_run: bool = False,
204
200
  ) -> list[dict[str, Any]]:
@@ -286,7 +282,7 @@ def kill_blocking_queries(
286
282
 
287
283
  def drop_table_safe(
288
284
  table_name: str,
289
- url: Optional[str] = None,
285
+ url: str | None = None,
290
286
  timeout: int = DEFAULT_STATEMENT_TIMEOUT,
291
287
  kill_blocking: bool = True,
292
288
  if_exists: bool = True,
@@ -353,7 +349,7 @@ def drop_table_safe(
353
349
  def get_database_url(
354
350
  required: bool = True,
355
351
  normalize: bool = True,
356
- ) -> Optional[str]:
352
+ ) -> str | None:
357
353
  """
358
354
  Convenience wrapper for get_database_url_from_env().
359
355
 
svc_infra/db/outbox.py CHANGED
@@ -1,27 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from dataclasses import dataclass, field
4
- from datetime import datetime, timezone
5
- from typing import Any, Dict, Iterable, List, Optional, Protocol
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Protocol
6
7
 
7
8
 
8
9
  @dataclass
9
10
  class OutboxMessage:
10
11
  id: int
11
12
  topic: str
12
- payload: Dict[str, Any]
13
- created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
13
+ payload: dict[str, Any]
14
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
14
15
  attempts: int = 0
15
- processed_at: Optional[datetime] = None
16
+ processed_at: datetime | None = None
16
17
 
17
18
 
18
19
  class OutboxStore(Protocol):
19
- def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
20
+ def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
20
21
  pass
21
22
 
22
- def fetch_next(
23
- self, *, topics: Optional[Iterable[str]] = None
24
- ) -> Optional[OutboxMessage]:
23
+ def fetch_next(self, *, topics: Iterable[str] | None = None) -> OutboxMessage | None:
25
24
  """Return the next undispatched, unprocessed message (FIFO per-topic), or None.
26
25
 
27
26
  Notes:
@@ -42,17 +41,15 @@ class InMemoryOutboxStore:
42
41
 
43
42
  def __init__(self):
44
43
  self._seq = 0
45
- self._messages: List[OutboxMessage] = []
44
+ self._messages: list[OutboxMessage] = []
46
45
 
47
- def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
46
+ def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
48
47
  self._seq += 1
49
48
  msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
50
49
  self._messages.append(msg)
51
50
  return msg
52
51
 
53
- def fetch_next(
54
- self, *, topics: Optional[Iterable[str]] = None
55
- ) -> Optional[OutboxMessage]:
52
+ def fetch_next(self, *, topics: Iterable[str] | None = None) -> OutboxMessage | None:
56
53
  allowed = set(topics) if topics else None
57
54
  for msg in self._messages:
58
55
  if msg.processed_at is not None:
@@ -68,7 +65,7 @@ class InMemoryOutboxStore:
68
65
  def mark_processed(self, msg_id: int) -> None:
69
66
  for msg in self._messages:
70
67
  if msg.id == msg_id:
71
- msg.processed_at = datetime.now(timezone.utc)
68
+ msg.processed_at = datetime.now(UTC)
72
69
  return
73
70
 
74
71
  def mark_failed(self, msg_id: int) -> None:
@@ -92,13 +89,13 @@ class SqlOutboxStore:
92
89
 
93
90
  # Placeholders to outline the API; not implemented here.
94
91
  def enqueue(
95
- self, topic: str, payload: Dict[str, Any]
92
+ self, topic: str, payload: dict[str, Any]
96
93
  ) -> OutboxMessage: # pragma: no cover - skeleton
97
94
  raise NotImplementedError
98
95
 
99
96
  def fetch_next(
100
- self, *, topics: Optional[Iterable[str]] = None
101
- ) -> Optional[OutboxMessage]: # pragma: no cover - skeleton
97
+ self, *, topics: Iterable[str] | None = None
98
+ ) -> OutboxMessage | None: # pragma: no cover - skeleton
102
99
  raise NotImplementedError
103
100
 
104
101
  def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton