svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
3
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
4
4
 
5
- from bson import ObjectId
5
+ try:
6
+ from bson import ObjectId
7
+
8
+ _HAS_BSON = True
9
+ except ModuleNotFoundError:
10
+ # `bson` is provided by the optional `pymongo` dependency.
11
+ # Keep imports working for non-mongo users/tests; runtime Mongo usage still
12
+ # requires installing pymongo.
13
+ _HAS_BSON = False
14
+
15
+ class ObjectId: # type: ignore[no-redef]
16
+ pass
6
17
 
7
18
 
8
19
  class NoSqlRepository:
@@ -78,11 +89,13 @@ class NoSqlRepository:
78
89
  if not parts:
79
90
  return {}
80
91
  if len(parts) == 1:
81
- return parts[0] # type: ignore[return-value]
92
+ return parts[0]
82
93
  return {"$and": parts}
83
94
 
84
95
  def _normalize_id_value(self, val: Any) -> Any:
85
96
  """If we use Mongo’s _id and a string is passed, coerce to ObjectId when possible."""
97
+ if not _HAS_BSON:
98
+ return val
86
99
  if self.id_field == "_id" and isinstance(val, str):
87
100
  try:
88
101
  return ObjectId(val)
@@ -91,13 +104,14 @@ class NoSqlRepository:
91
104
  return val
92
105
 
93
106
  @staticmethod
94
- def _public_doc(doc: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
95
- if not doc:
96
- return doc
107
+ def _public_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
97
108
  d = dict(doc)
98
109
  if "_id" in d and "id" not in d:
99
110
  _id = d.pop("_id", None)
100
- d["id"] = str(_id) if isinstance(_id, ObjectId) else _id
111
+ if _HAS_BSON and isinstance(_id, ObjectId):
112
+ d["id"] = str(_id)
113
+ else:
114
+ d["id"] = _id
101
115
  return d
102
116
 
103
117
  async def list(
@@ -108,7 +122,7 @@ class NoSqlRepository:
108
122
  offset: int,
109
123
  sort: Optional[List[Tuple[str, int]]] = None,
110
124
  filter: Optional[Dict[str, Any]] = None,
111
- ) -> List[Dict]:
125
+ ) -> List[Dict[str, Any]]:
112
126
  filt = self._merge_and(self._alive_filter(), filter)
113
127
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
114
128
  if sort:
@@ -117,12 +131,14 @@ class NoSqlRepository:
117
131
 
118
132
  async def count(self, db, *, filter: Optional[Dict[str, Any]] = None) -> int:
119
133
  filt = self._merge_and(self._alive_filter(), filter)
120
- return await db[self.collection_name].count_documents(filt or {})
134
+ return cast(int, await db[self.collection_name].count_documents(filt or {}))
121
135
 
122
136
  async def get(self, db, id_value: Any) -> Dict | None:
123
137
  id_value = self._normalize_id_value(id_value)
124
138
  filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
125
139
  doc = await db[self.collection_name].find_one(filt)
140
+ if doc is None:
141
+ return None
126
142
  return self._public_doc(doc)
127
143
 
128
144
  async def create(self, db, data: Dict[str, Any]) -> Dict[str, Any]:
@@ -155,10 +171,10 @@ class NoSqlRepository:
155
171
  res = await db[self.collection_name].update_one(
156
172
  {self.id_field: id_value}, {"$set": set_ops}
157
173
  )
158
- return res.modified_count > 0
174
+ return cast(int, res.modified_count) > 0
159
175
 
160
176
  res = await db[self.collection_name].delete_one({self.id_field: id_value})
161
- return res.deleted_count > 0
177
+ return cast(int, res.deleted_count) > 0
162
178
 
163
179
  async def search(
164
180
  self,
@@ -169,11 +185,13 @@ class NoSqlRepository:
169
185
  limit: int,
170
186
  offset: int,
171
187
  sort: Optional[List[Tuple[str, int]]] = None,
172
- ) -> List[Dict]:
188
+ ) -> List[Dict[str, Any]]:
173
189
  regex = {"$regex": q, "$options": "i"}
174
190
  or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
175
191
  filt = (
176
- self._merge_and(self._alive_filter(), *or_filter) if or_filter else self._alive_filter()
192
+ self._merge_and(self._alive_filter(), *or_filter)
193
+ if or_filter
194
+ else self._alive_filter()
177
195
  )
178
196
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
179
197
  if sort:
@@ -184,9 +202,11 @@ class NoSqlRepository:
184
202
  regex = {"$regex": q, "$options": "i"}
185
203
  or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
186
204
  filt = self._merge_and(self._alive_filter(), or_filter)
187
- return await db[self.collection_name].count_documents(filt or {})
205
+ return cast(int, await db[self.collection_name].count_documents(filt or {}))
188
206
 
189
207
  async def exists(self, db, *, where: Iterable[Dict[str, Any]]) -> bool:
190
208
  filt = self._merge_and(self._alive_filter(), *list(where))
191
- doc = await db[self.collection_name].find_one(filt, projection={self.id_field: 1})
209
+ doc = await db[self.collection_name].find_one(
210
+ filt, projection={self.id_field: 1}
211
+ )
192
212
  return doc is not None
@@ -1,9 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Any, Callable, Iterable, Optional, Sequence, Type, Union
5
-
6
- from pymongo import IndexModel
4
+ from typing import (
5
+ Any,
6
+ Callable,
7
+ Iterable,
8
+ Optional,
9
+ Sequence,
10
+ TYPE_CHECKING,
11
+ Type,
12
+ Union,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from pymongo import IndexModel
17
+ else:
18
+ try:
19
+ from pymongo import IndexModel
20
+ except ModuleNotFoundError:
21
+ # Minimal runtime stub so importing svc_infra works without optional Mongo deps.
22
+ class IndexModel: # type: ignore[no-redef]
23
+ pass
7
24
 
8
25
 
9
26
  def _snake(name: str) -> str:
@@ -6,7 +6,9 @@ from typing import Any, Dict, Literal, Optional
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 = 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
9
+ _INIT_CONTENT_PAIRED = (
10
+ 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
11
+ )
10
12
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
11
13
 
12
14
 
@@ -46,7 +48,9 @@ def scaffold_core(
46
48
  schemas_txt = render_template(
47
49
  tmpl_dir="svc_infra.db.nosql.mongo.templates",
48
50
  name="schemas.py.tmpl",
49
- subs={"Entity": ent}, # (only if your schemas.tmpl doesn't need collection_name)
51
+ subs={
52
+ "Entity": ent
53
+ }, # (only if your schemas.tmpl doesn't need collection_name)
50
54
  )
51
55
 
52
56
  if same_dir:
@@ -103,7 +107,9 @@ def scaffold_schemas_core(
103
107
  dest = normalize_dir(dest_dir)
104
108
  ent = pascal(entity_name)
105
109
  txt = render_template(
106
- tmpl_dir="svc_infra.db.nosql.mongo.templates", name="schemas.py.tmpl", subs={"Entity": ent}
110
+ tmpl_dir="svc_infra.db.nosql.mongo.templates",
111
+ name="schemas.py.tmpl",
112
+ subs={"Entity": ent},
107
113
  )
108
114
  filename = schemas_filename or f"{snake(entity_name)}.py"
109
115
  res = write(dest / filename, txt, overwrite)
@@ -43,7 +43,9 @@ class NoSqlService:
43
43
  async def search(
44
44
  self, db, *, q: str, fields: Sequence[str], limit: int, offset: int, sort=None
45
45
  ):
46
- return await self.repo.search(db, q=q, fields=fields, limit=limit, offset=offset, sort=sort)
46
+ return await self.repo.search(
47
+ db, q=q, fields=fields, limit=limit, offset=offset, sort=sort
48
+ )
47
49
 
48
50
  async def count_filtered(self, db, *, q: str, fields: Sequence[str]) -> int:
49
51
  return await self.repo.count_filtered(db, q=q, fields=fields)
@@ -11,7 +11,9 @@ class PyObjectId(ObjectId):
11
11
  """Pydantic v2-compatible ObjectId type."""
12
12
 
13
13
  @classmethod
14
- def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: GetCoreSchemaHandler):
14
+ def __get_pydantic_core_schema__(
15
+ cls, _source_type: Any, _handler: GetCoreSchemaHandler
16
+ ):
15
17
  def validate(v: Any) -> ObjectId:
16
18
  if isinstance(v, ObjectId):
17
19
  return v
@@ -22,4 +24,6 @@ class PyObjectId(ObjectId):
22
24
  raise ValueError(f"Invalid ObjectId: {v}") from e
23
25
  raise ValueError("ObjectId required")
24
26
 
25
- return core_schema.no_info_after_validator_function(validate, core_schema.any_schema())
27
+ return core_schema.no_info_after_validator_function(
28
+ validate, core_schema.any_schema()
29
+ )
svc_infra/db/ops.py ADDED
@@ -0,0 +1,384 @@
1
+ """Database operations utilities for one-off administrative tasks.
2
+
3
+ This module provides synchronous database utilities for operations that
4
+ don't fit the normal async SQLAlchemy workflow, such as:
5
+ - Waiting for database readiness at startup
6
+ - Executing maintenance SQL
7
+ - Dropping tables with lock handling
8
+ - Terminating blocking queries
9
+
10
+ These utilities use psycopg2 directly for maximum reliability in
11
+ edge cases where the ORM might not be available or appropriate.
12
+
13
+ Example:
14
+ >>> from svc_infra.db.ops import wait_for_database, run_sync_sql
15
+ >>>
16
+ >>> # Wait for database before app starts
17
+ >>> wait_for_database(timeout=30)
18
+ >>>
19
+ >>> # Run maintenance query
20
+ >>> run_sync_sql("VACUUM ANALYZE my_table")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import sys
27
+ import time
28
+ from typing import Any, Optional, Sequence, cast
29
+
30
+ from .sql.utils import get_database_url_from_env
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Timeout for individual database operations (seconds)
35
+ DEFAULT_STATEMENT_TIMEOUT = 30
36
+
37
+ # Default wait-for-database settings
38
+ DEFAULT_WAIT_TIMEOUT = 30
39
+ DEFAULT_WAIT_INTERVAL = 1.0
40
+
41
+
42
+ def _flush() -> None:
43
+ """Force flush stdout/stderr for containerized log visibility."""
44
+ sys.stdout.flush()
45
+ sys.stderr.flush()
46
+
47
+
48
+ def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any:
49
+ """
50
+ Get a psycopg2 connection.
51
+
52
+ Args:
53
+ url: Database URL. If None, resolved from environment.
54
+ connect_timeout: Connection timeout in seconds.
55
+
56
+ Returns:
57
+ psycopg2 connection object
58
+
59
+ Raises:
60
+ ImportError: If psycopg2 is not installed
61
+ RuntimeError: If no database URL is available
62
+ """
63
+ try:
64
+ import psycopg2
65
+ except ImportError as e:
66
+ raise ImportError(
67
+ "psycopg2 is required for db.ops utilities. "
68
+ "Install with: pip install psycopg2-binary"
69
+ ) from e
70
+
71
+ if url is None:
72
+ url = get_database_url_from_env(required=True)
73
+
74
+ # Add connect_timeout to connection options
75
+ return psycopg2.connect(url, connect_timeout=connect_timeout)
76
+
77
+
78
+ def wait_for_database(
79
+ url: Optional[str] = None,
80
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
81
+ interval: float = DEFAULT_WAIT_INTERVAL,
82
+ verbose: bool = True,
83
+ ) -> bool:
84
+ """
85
+ Wait for database to be ready, with retries.
86
+
87
+ Useful for container startup scripts where the database may not
88
+ be immediately available.
89
+
90
+ Args:
91
+ url: Database URL. If None, resolved from environment.
92
+ timeout: Maximum time to wait in seconds (default: 30)
93
+ interval: Time between retry attempts in seconds (default: 1.0)
94
+ verbose: If True, log progress messages
95
+
96
+ Returns:
97
+ True if database is ready, False if timeout reached
98
+
99
+ Example:
100
+ >>> # In container startup script
101
+ >>> if not wait_for_database(timeout=60):
102
+ ... sys.exit(1)
103
+ >>> # Database is ready, continue with app startup
104
+ """
105
+ if url is None:
106
+ url = get_database_url_from_env(required=True)
107
+
108
+ start = time.monotonic()
109
+ attempt = 0
110
+
111
+ while True:
112
+ attempt += 1
113
+ elapsed = time.monotonic() - start
114
+
115
+ if elapsed >= timeout:
116
+ if verbose:
117
+ logger.error(
118
+ f"Database not ready after {timeout}s ({attempt} attempts)"
119
+ )
120
+ _flush()
121
+ return False
122
+
123
+ try:
124
+ conn = _get_connection(url, connect_timeout=min(5, int(timeout - elapsed)))
125
+ conn.close()
126
+ if verbose:
127
+ logger.info(f"Database ready after {elapsed:.1f}s ({attempt} attempts)")
128
+ _flush()
129
+ return True
130
+ except Exception as e:
131
+ if verbose:
132
+ remaining = timeout - elapsed
133
+ logger.debug(
134
+ f"Database not ready ({e}), retrying... ({remaining:.0f}s remaining)"
135
+ )
136
+ _flush()
137
+ time.sleep(interval)
138
+
139
+
140
+ def run_sync_sql(
141
+ sql: str,
142
+ params: Optional[Sequence[Any]] = None,
143
+ url: Optional[str] = None,
144
+ timeout: int = DEFAULT_STATEMENT_TIMEOUT,
145
+ fetch: bool = False,
146
+ ) -> Optional[list[tuple[Any, ...]]]:
147
+ """
148
+ Execute SQL synchronously with a statement timeout.
149
+
150
+ This is useful for one-off administrative queries that don't fit
151
+ the normal async SQLAlchemy workflow.
152
+
153
+ Args:
154
+ sql: SQL statement to execute
155
+ params: Optional parameters for parameterized queries
156
+ url: Database URL. If None, resolved from environment.
157
+ timeout: Statement timeout in seconds (default: 30)
158
+ fetch: If True, return fetched rows; if False, return None
159
+
160
+ Returns:
161
+ List of tuples if fetch=True, otherwise None
162
+
163
+ Raises:
164
+ psycopg2.Error: On database errors
165
+ TimeoutError: If statement exceeds timeout
166
+
167
+ Example:
168
+ >>> # Run a maintenance query
169
+ >>> run_sync_sql("VACUUM ANALYZE users")
170
+ >>>
171
+ >>> # Fetch data with timeout
172
+ >>> rows = run_sync_sql(
173
+ ... "SELECT id, name FROM users WHERE active = %s",
174
+ ... params=(True,),
175
+ ... fetch=True,
176
+ ... timeout=10
177
+ ... )
178
+ """
179
+ conn = _get_connection(url)
180
+ try:
181
+ with conn.cursor() as cur:
182
+ # Set statement timeout (PostgreSQL-specific)
183
+ cur.execute(f"SET statement_timeout = '{timeout}s'")
184
+
185
+ if params:
186
+ cur.execute(sql, params)
187
+ else:
188
+ cur.execute(sql)
189
+
190
+ if fetch:
191
+ return cast(list[tuple[Any, ...]], cur.fetchall())
192
+
193
+ conn.commit()
194
+ return None
195
+ finally:
196
+ conn.close()
197
+
198
+
199
+ def kill_blocking_queries(
200
+ table_name: str,
201
+ url: Optional[str] = None,
202
+ timeout: int = DEFAULT_STATEMENT_TIMEOUT,
203
+ dry_run: bool = False,
204
+ ) -> list[dict[str, Any]]:
205
+ """
206
+ Terminate queries blocking operations on a specific table.
207
+
208
+ This is useful before DROP TABLE or ALTER TABLE operations that
209
+ might be blocked by long-running queries or idle transactions.
210
+
211
+ Args:
212
+ table_name: Name of the table (can include schema as 'schema.table')
213
+ url: Database URL. If None, resolved from environment.
214
+ timeout: Statement timeout in seconds (default: 30)
215
+ dry_run: If True, only report blocking queries without terminating
216
+
217
+ Returns:
218
+ List of dicts with info about terminated (or found) queries:
219
+ [{"pid": 123, "query": "SELECT...", "state": "active", "terminated": True}]
220
+
221
+ Example:
222
+ >>> # Check what would be terminated
223
+ >>> blocking = kill_blocking_queries("embeddings", dry_run=True)
224
+ >>> print(f"Found {len(blocking)} blocking queries")
225
+ >>>
226
+ >>> # Actually terminate them
227
+ >>> kill_blocking_queries("embeddings")
228
+ """
229
+ # Query to find blocking queries on a table
230
+ find_blocking_sql = """
231
+ SELECT pid, state, query, age(clock_timestamp(), query_start) as duration
232
+ FROM pg_stat_activity
233
+ WHERE pid != pg_backend_pid()
234
+ AND state != 'idle'
235
+ AND (
236
+ query ILIKE %s
237
+ OR query ILIKE %s
238
+ OR query ILIKE %s
239
+ )
240
+ ORDER BY query_start;
241
+ """
242
+
243
+ # Patterns to match queries involving the table
244
+ patterns = (
245
+ f"%{table_name}%",
246
+ f"%{table_name.split('.')[-1]}%", # Just table name without schema
247
+ f"%{table_name.replace('.', '%')}%", # Handle schema.table pattern
248
+ )
249
+
250
+ conn = _get_connection(url)
251
+ terminated: list[dict[str, Any]] = []
252
+
253
+ try:
254
+ with conn.cursor() as cur:
255
+ cur.execute(f"SET statement_timeout = '{timeout}s'")
256
+ cur.execute(find_blocking_sql, patterns)
257
+ rows = cur.fetchall()
258
+
259
+ for pid, state, query, duration in rows:
260
+ info = {
261
+ "pid": pid,
262
+ "state": state,
263
+ "query": query[:200] + "..." if len(query) > 200 else query,
264
+ "duration": str(duration),
265
+ "terminated": False,
266
+ }
267
+
268
+ if not dry_run:
269
+ try:
270
+ cur.execute("SELECT pg_terminate_backend(%s)", (pid,))
271
+ info["terminated"] = True
272
+ logger.info(f"Terminated query PID {pid}: {query[:100]}...")
273
+ except Exception as e:
274
+ logger.warning(f"Failed to terminate PID {pid}: {e}")
275
+ info["error"] = str(e)
276
+
277
+ terminated.append(info)
278
+
279
+ conn.commit()
280
+ finally:
281
+ conn.close()
282
+
283
+ _flush()
284
+ return terminated
285
+
286
+
287
+ def drop_table_safe(
288
+ table_name: str,
289
+ url: Optional[str] = None,
290
+ timeout: int = DEFAULT_STATEMENT_TIMEOUT,
291
+ kill_blocking: bool = True,
292
+ if_exists: bool = True,
293
+ cascade: bool = False,
294
+ ) -> bool:
295
+ """
296
+ Drop a table safely with lock handling.
297
+
298
+ Handles common issues with DROP TABLE:
299
+ - Terminates blocking queries first (optional)
300
+ - Uses statement timeout to avoid hanging
301
+ - Handles 'table does not exist' gracefully
302
+
303
+ Args:
304
+ table_name: Name of table to drop (can include schema)
305
+ url: Database URL. If None, resolved from environment.
306
+ timeout: Statement timeout in seconds (default: 30)
307
+ kill_blocking: If True, terminate blocking queries first (default: True)
308
+ if_exists: If True, don't error if table doesn't exist (default: True)
309
+ cascade: If True, drop dependent objects (default: False)
310
+
311
+ Returns:
312
+ True if table was dropped (or didn't exist), False on error
313
+
314
+ Example:
315
+ >>> # Drop table, killing any blocking queries first
316
+ >>> drop_table_safe("embeddings", cascade=True)
317
+ True
318
+ >>>
319
+ >>> # Safe to call even if table doesn't exist
320
+ >>> drop_table_safe("nonexistent_table")
321
+ True
322
+ """
323
+ if url is None:
324
+ url = get_database_url_from_env(required=True)
325
+
326
+ # Kill blocking queries first if requested
327
+ if kill_blocking:
328
+ blocked = kill_blocking_queries(table_name, url=url, timeout=timeout)
329
+ if blocked:
330
+ logger.info(f"Terminated {len(blocked)} blocking queries before DROP")
331
+ # Brief pause to let connections clean up
332
+ time.sleep(0.5)
333
+
334
+ # Build DROP statement
335
+ drop_sql = "DROP TABLE"
336
+ if if_exists:
337
+ drop_sql += " IF EXISTS"
338
+ drop_sql += f" {table_name}"
339
+ if cascade:
340
+ drop_sql += " CASCADE"
341
+
342
+ try:
343
+ run_sync_sql(drop_sql, url=url, timeout=timeout)
344
+ logger.info(f"Dropped table: {table_name}")
345
+ _flush()
346
+ return True
347
+ except Exception as e:
348
+ logger.error(f"Failed to drop table {table_name}: {e}")
349
+ _flush()
350
+ return False
351
+
352
+
353
+ def get_database_url(
354
+ required: bool = True,
355
+ normalize: bool = True,
356
+ ) -> Optional[str]:
357
+ """
358
+ Convenience wrapper for get_database_url_from_env().
359
+
360
+ This is the recommended way to get the database URL, as it
361
+ handles all common environment variable names and normalizations.
362
+
363
+ Args:
364
+ required: If True, raise RuntimeError when no URL is found
365
+ normalize: If True, convert postgres:// to postgresql://
366
+
367
+ Returns:
368
+ Database URL string, or None if not found and not required
369
+
370
+ Example:
371
+ >>> url = get_database_url()
372
+ >>> print(url)
373
+ 'postgresql://user:pass@host:5432/db'
374
+ """
375
+ return get_database_url_from_env(required=required, normalize=normalize)
376
+
377
+
378
+ __all__ = [
379
+ "wait_for_database",
380
+ "run_sync_sql",
381
+ "kill_blocking_queries",
382
+ "drop_table_safe",
383
+ "get_database_url",
384
+ ]