svc-infra 0.1.595__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 (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -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 +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -6,11 +6,10 @@ from typing import List, Tuple
6
6
  import sys, pathlib, importlib, pkgutil, traceback
7
7
 
8
8
  from alembic import context
9
+ from sqlalchemy import MetaData
9
10
  from sqlalchemy.engine import make_url, URL
10
11
 
11
12
  from svc_infra.db.sql.utils import (
12
- _coerce_sync_driver,
13
- _ensure_ssl_default,
14
13
  get_database_url_from_env,
15
14
  build_engine,
16
15
  )
@@ -103,7 +102,6 @@ if not effective_url:
103
102
 
104
103
  u = make_url(effective_url)
105
104
  u = _coerce_sync_driver(u)
106
- u = _ensure_ssl_default(u)
107
105
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
108
106
 
109
107
 
@@ -142,14 +140,16 @@ def _collect_metadata() -> list[object]:
142
140
 
143
141
  def _maybe_add(obj: object) -> None:
144
142
  md = getattr(obj, "metadata", None) or obj
145
- if hasattr(md, "tables") and getattr(md, "tables"):
143
+ # Strict check: must be actual MetaData instance
144
+ if isinstance(md, MetaData) and md.tables:
146
145
  found.append(md)
147
146
 
148
147
  def _scan_module_objects(mod: object) -> None:
149
148
  try:
150
149
  for val in vars(mod).values():
151
150
  md = getattr(val, "metadata", None) or None
152
- if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
151
+ # Only add if it's a SQLAlchemy MetaData object (has tables dict, not a callable/generator)
152
+ if md is not None and hasattr(md, "tables") and isinstance(getattr(md, "tables", None), dict):
153
153
  found.append(md)
154
154
  except Exception:
155
155
  pass
@@ -191,9 +191,16 @@ def _collect_metadata() -> list[object]:
191
191
  if name not in pkgs:
192
192
  pkgs.append(name)
193
193
 
194
- # Always also try a bare 'models'
194
+ # Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
195
195
  if "models" not in pkgs:
196
- pkgs.append("models")
196
+ try:
197
+ spec = getattr(importlib, "util", None)
198
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
199
+ if spec.find_spec("models") is not None:
200
+ pkgs.append("models")
201
+ except Exception:
202
+ # If discovery fails, skip adding bare 'models'
203
+ pass
197
204
 
198
205
  def _import_and_collect(modname: str):
199
206
  try:
@@ -239,6 +246,21 @@ def _collect_metadata() -> list[object]:
239
246
  except Exception:
240
247
  _note("ModelBase import", False, traceback.format_exc())
241
248
 
249
+ # Core security models (AuthSession, RefreshToken, etc.)
250
+ try:
251
+ import svc_infra.security.models # noqa: F401
252
+ _note("svc_infra.security.models", True, None)
253
+ except Exception:
254
+ _note("svc_infra.security.models", False, traceback.format_exc())
255
+
256
+ # OAuth models (opt-in via environment variable)
257
+ if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
258
+ try:
259
+ import svc_infra.security.oauth_models # noqa: F401
260
+ _note("svc_infra.security.oauth_models", True, None)
261
+ except Exception:
262
+ _note("svc_infra.security.oauth_models", False, traceback.format_exc())
263
+
242
264
  # Optional: autobind API key model
243
265
  try:
244
266
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from .service import SqlService
9
+
10
+
11
+ class TenantSqlService(SqlService):
12
+ """
13
+ SQL service wrapper that automatically scopes operations to a tenant.
14
+
15
+ - Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
16
+ - On create, if the model has the tenant field and it's not set in data, injects tenant_id.
17
+ """
18
+
19
+ def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
20
+ super().__init__(repo)
21
+ self.tenant_id = tenant_id
22
+ self.tenant_field = tenant_field
23
+
24
+ def _where(self) -> Sequence[Any]:
25
+ model = self.repo.model
26
+ col = getattr(model, self.tenant_field, None)
27
+ if col is None:
28
+ return []
29
+ return [col == self.tenant_id]
30
+
31
+ async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
32
+ return await self.repo.list(
33
+ session, limit=limit, offset=offset, order_by=order_by, where=self._where()
34
+ )
35
+
36
+ async def count(self, session: AsyncSession) -> int:
37
+ return await self.repo.count(session, where=self._where())
38
+
39
+ async def get(self, session: AsyncSession, id_value: Any):
40
+ return await self.repo.get(session, id_value, where=self._where())
41
+
42
+ async def create(self, session: AsyncSession, data: dict[str, Any]):
43
+ data = await self.pre_create(data)
44
+ # inject tenant_id if model supports it and value missing
45
+ if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
46
+ data[self.tenant_field] = self.tenant_id
47
+ return await self.repo.create(session, data)
48
+
49
+ async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
50
+ data = await self.pre_update(data)
51
+ return await self.repo.update(session, id_value, data, where=self._where())
52
+
53
+ async def delete(self, session: AsyncSession, id_value: Any) -> bool:
54
+ return await self.repo.delete(session, id_value, where=self._where())
55
+
56
+ async def search(
57
+ self,
58
+ session: AsyncSession,
59
+ *,
60
+ q: str,
61
+ fields: Sequence[str],
62
+ limit: int,
63
+ offset: int,
64
+ order_by=None,
65
+ ):
66
+ return await self.repo.search(
67
+ session,
68
+ q=q,
69
+ fields=fields,
70
+ limit=limit,
71
+ offset=offset,
72
+ order_by=order_by,
73
+ where=self._where(),
74
+ )
75
+
76
+ async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
77
+ return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
78
+
79
+
80
+ __all__ = ["TenantSqlService"]
svc_infra/db/sql/uniq.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Iterable, List, Optional, Tuple, Type
3
+ from collections.abc import Iterable
4
+ from typing import Any
4
5
 
5
6
  from sqlalchemy import Index, func
6
7
 
@@ -9,13 +10,13 @@ from svc_infra.db.utils import as_tuple as _as_tuple
9
10
 
10
11
 
11
12
  def make_unique_sql_indexes(
12
- model: Type[Any],
13
+ model: type[Any],
13
14
  *,
14
15
  unique_cs: Iterable[KeySpec] = (),
15
16
  unique_ci: Iterable[KeySpec] = (),
16
- tenant_field: Optional[str] = None,
17
+ tenant_field: str | None = None,
17
18
  name_prefix: str = "uq",
18
- ) -> List[Index]:
19
+ ) -> list[Index]:
19
20
  """Return SQLAlchemy Index objects that enforce uniqueness.
20
21
 
21
22
  - unique_cs: case-sensitive unique specs
@@ -26,12 +27,12 @@ def make_unique_sql_indexes(
26
27
 
27
28
  Declare right after your model class; Alembic or metadata.create_all will pick them up.
28
29
  """
29
- idxs: List[Index] = []
30
+ idxs: list[Index] = []
30
31
 
31
32
  def _col(name: str):
32
33
  return getattr(model, name)
33
34
 
34
- def _to_sa_cols(spec: Tuple[str, ...], *, ci: bool):
35
+ def _to_sa_cols(spec: tuple[str, ...], *, ci: bool):
35
36
  cols = []
36
37
  for cname in spec:
37
38
  c = _col(cname)
@@ -40,7 +41,7 @@ def make_unique_sql_indexes(
40
41
 
41
42
  tenant_col = _col(tenant_field) if tenant_field else None
42
43
 
43
- def _name(ci: bool, spec: Tuple[str, ...], null_bucket: Optional[str] = None):
44
+ def _name(ci: bool, spec: tuple[str, ...], null_bucket: str | None = None):
44
45
  parts = [name_prefix, model.__tablename__]
45
46
  if tenant_field:
46
47
  parts.append(tenant_field)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
3
+ from collections.abc import Callable, Iterable, Sequence
4
+ from typing import Any
4
5
 
5
6
  from fastapi import HTTPException
6
7
  from sqlalchemy import func
@@ -11,14 +12,14 @@ from svc_infra.db.sql.service_with_hooks import SqlServiceWithHooks
11
12
 
12
13
  from .uniq import _as_tuple
13
14
 
14
- ColumnSpec = Union[str, Sequence[str]]
15
+ ColumnSpec = str | Sequence[str]
15
16
 
16
17
 
17
- def _all_present(data: Dict[str, Any], fields: Sequence[str]) -> bool:
18
+ def _all_present(data: dict[str, Any], fields: Sequence[str]) -> bool:
18
19
  return all(f in data for f in fields)
19
20
 
20
21
 
21
- def _nice_label(fields: Sequence[str], data: Dict[str, Any]) -> str:
22
+ def _nice_label(fields: Sequence[str], data: dict[str, Any]) -> str:
22
23
  if len(fields) == 1:
23
24
  f = fields[0]
24
25
  return f"{f}={data.get(f)!r}"
@@ -30,10 +31,10 @@ def dedupe_sql_service(
30
31
  *,
31
32
  unique_cs: Iterable[ColumnSpec] = (),
32
33
  unique_ci: Iterable[ColumnSpec] = (),
33
- tenant_field: Optional[str] = None,
34
- messages: Optional[dict[Tuple[str, ...], str]] = None,
35
- pre_create: Optional[Callable[[dict], dict]] = None,
36
- pre_update: Optional[Callable[[dict], dict]] = None,
34
+ tenant_field: str | None = None,
35
+ messages: dict[tuple[str, ...], str] | None = None,
36
+ pre_create: Callable[[dict], dict] | None = None,
37
+ pre_update: Callable[[dict], dict] | None = None,
37
38
  ):
38
39
  """
39
40
  Build a Service subclass with uniqueness pre-checks:
@@ -46,9 +47,9 @@ def dedupe_sql_service(
46
47
  messages = messages or {}
47
48
 
48
49
  def _build_where(
49
- spec: Tuple[str, ...], data: Dict[str, Any], *, ci: bool, exclude_id: Any | None
50
+ spec: tuple[str, ...], data: dict[str, Any], *, ci: bool, exclude_id: Any | None
50
51
  ):
51
- clauses: List[Any] = []
52
+ clauses: list[Any] = []
52
53
  for col_name in spec:
53
54
  col = getattr(Model, col_name)
54
55
  val = data.get(col_name)
@@ -73,7 +74,7 @@ def dedupe_sql_service(
73
74
 
74
75
  return clauses
75
76
 
76
- async def _precheck(session, data: Dict[str, Any], *, exclude_id: Any | None) -> None:
77
+ async def _precheck(session, data: dict[str, Any], *, exclude_id: Any | None) -> None:
77
78
  # Check CI specs first to catch the broadest conflicts, then CS.
78
79
  for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
79
80
  for spec in spec_list:
svc_infra/db/sql/utils.py CHANGED
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import os
5
+ from collections.abc import Sequence
5
6
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, Optional, Sequence, Set, Union
7
+ from typing import TYPE_CHECKING, Any, cast
7
8
 
8
9
  from alembic.config import Config
9
10
  from dotenv import load_dotenv
@@ -17,24 +18,24 @@ if TYPE_CHECKING:
17
18
  from sqlalchemy.engine import Engine as SyncEngine
18
19
  from sqlalchemy.ext.asyncio import AsyncEngine as AsyncEngineType
19
20
  else:
20
- SyncEngine = Any # type: ignore
21
- AsyncEngineType = Any # type: ignore
21
+ SyncEngine = Any # type: ignore[assignment]
22
+ AsyncEngineType = Any # type: ignore[assignment]
22
23
 
23
24
  try:
24
25
  # Runtime import (may be missing if async extras aren’t installed)
25
- from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine # type: ignore
26
+ from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
26
27
  except Exception: # pragma: no cover - optional dep
27
- _create_async_engine = None # type: ignore
28
+ _create_async_engine = None # type: ignore[assignment]
28
29
 
29
30
  try:
30
- from sqlalchemy import create_engine as _create_engine # type: ignore
31
+ from sqlalchemy import create_engine as _create_engine
31
32
  except Exception: # pragma: no cover - optional env
32
- _create_engine = None # type: ignore
33
+ _create_engine = None # type: ignore[assignment]
33
34
 
34
35
 
35
36
  def prepare_process_env(
36
37
  project_root: Path | str,
37
- discover_packages: Optional[Sequence[str]] = None,
38
+ discover_packages: Sequence[str] | None = None,
38
39
  ) -> None:
39
40
  """
40
41
  Prepare process environment so Alembic can import the project cleanly.
@@ -60,7 +61,7 @@ def prepare_process_env(
60
61
  os.environ["ALEMBIC_DISCOVER_PACKAGES"] = ",".join(discover_packages)
61
62
 
62
63
 
63
- def _read_secret_from_file(path: str) -> Optional[str]:
64
+ def _read_secret_from_file(path: str) -> str | None:
64
65
  """Return file contents if path exists, else None."""
65
66
  try:
66
67
  p = Path(path)
@@ -71,7 +72,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
71
72
  return None
72
73
 
73
74
 
74
- def _compose_url_from_parts() -> Optional[str]:
75
+ def _compose_url_from_parts() -> str | None:
75
76
  """
76
77
  Compose a SQLAlchemy URL from component env vars.
77
78
  Supports private DNS hostnames and Unix sockets.
@@ -124,18 +125,62 @@ def _compose_url_from_parts() -> Optional[str]:
124
125
  # ---------- Environment helpers ----------
125
126
 
126
127
 
128
+ def _normalize_database_url(url: str) -> str:
129
+ """
130
+ Normalize database URL for SQLAlchemy compatibility.
131
+
132
+ Handles:
133
+ - postgres:// → postgresql:// (Heroku/Railway legacy format)
134
+ - Strips whitespace
135
+
136
+ Args:
137
+ url: Raw database URL string
138
+
139
+ Returns:
140
+ Normalized URL suitable for SQLAlchemy
141
+ """
142
+ url = url.strip()
143
+ # Heroku and Railway historically use 'postgres://' which SQLAlchemy doesn't accept
144
+ if url.startswith("postgres://"):
145
+ url = "postgresql://" + url[len("postgres://") :]
146
+ return url
147
+
148
+
127
149
  def get_database_url_from_env(
128
150
  required: bool = True,
129
151
  env_vars: Sequence[str] = DEFAULT_DB_ENV_VARS,
130
- ) -> Optional[str]:
152
+ normalize: bool = True,
153
+ ) -> str | None:
131
154
  """
132
155
  Resolve the database connection string, with support for:
133
- - Primary env vars (in order): DEFAULT_DB_ENV_VARS (e.g. SQL_URL, PRIVATE_SQL_URL, DB_URL).
156
+ - Primary env vars (in order): DEFAULT_DB_ENV_VARS
157
+ (SQL_URL, DB_URL, DATABASE_URL, DATABASE_URL_PRIVATE, PRIVATE_SQL_URL)
134
158
  - Secret file pointers: <NAME>_FILE (reads file contents).
135
159
  - Well-known locations: SQL_URL_FILE, /run/secrets/database_url.
136
160
  - Composed from parts: DB_* (host, port, name, user, password, params).
137
- When a value is found, it is also written back into os.environ["SQL_URL"] for downstream code.
161
+
162
+ When a value is found, it is also written back into os.environ["SQL_URL"]
163
+ for downstream code.
164
+
165
+ Args:
166
+ required: If True, raise RuntimeError when no URL is found
167
+ env_vars: Sequence of environment variable names to check
168
+ normalize: If True, convert postgres:// to postgresql:// (default: True)
169
+
170
+ Returns:
171
+ Database URL string, or None if not found and not required
172
+
173
+ Raises:
174
+ RuntimeError: If required=True and no URL is found
138
175
  """
176
+
177
+ def _finalize(url: str) -> str:
178
+ """Normalize and cache the URL."""
179
+ if normalize:
180
+ url = _normalize_database_url(url)
181
+ os.environ["SQL_URL"] = url
182
+ return url
183
+
139
184
  # Load .env without clobbering existing process env
140
185
  load_dotenv(override=False)
141
186
 
@@ -150,10 +195,8 @@ def get_database_url_from_env(
150
195
  if os.path.isabs(s) and Path(s).exists():
151
196
  file_val = _read_secret_from_file(s)
152
197
  if file_val:
153
- os.environ["SQL_URL"] = file_val
154
- return file_val
155
- os.environ["SQL_URL"] = s
156
- return s
198
+ return _finalize(file_val)
199
+ return _finalize(s)
157
200
 
158
201
  # Companion NAME_FILE secret path (e.g., SQL_URL_FILE)
159
202
  file_key = f"{key}_FILE"
@@ -161,32 +204,28 @@ def get_database_url_from_env(
161
204
  if file_path:
162
205
  file_val = _read_secret_from_file(file_path)
163
206
  if file_val:
164
- os.environ["SQL_URL"] = file_val
165
- return file_val
207
+ return _finalize(file_val)
166
208
 
167
209
  # 2) Conventional secret envs
168
210
  file_path = os.getenv("SQL_URL_FILE")
169
211
  if file_path:
170
212
  file_val = _read_secret_from_file(file_path)
171
213
  if file_val:
172
- os.environ["SQL_URL"] = file_val
173
- return file_val
214
+ return _finalize(file_val)
174
215
 
175
216
  # 3) Docker/K8s default secret mount
176
217
  file_val = _read_secret_from_file("/run/secrets/database_url")
177
218
  if file_val:
178
- os.environ["SQL_URL"] = file_val
179
- return file_val
219
+ return _finalize(file_val)
180
220
 
181
221
  # 4) Compose from parts (DB_DIALECT/DB_DRIVER/DB_HOST/.../DB_PARAMS)
182
222
  composed = _compose_url_from_parts()
183
223
  if composed:
184
- os.environ["SQL_URL"] = composed
185
- return composed
224
+ return _finalize(composed)
186
225
 
187
226
  if required:
188
227
  raise RuntimeError(
189
- "Database URL not set. Set SQL_URL (or PRIVATE_SQL_URL / DB_URL), "
228
+ "Database URL not set. Set SQL_URL, DATABASE_URL, or DATABASE_URL_PRIVATE, "
190
229
  "or provide DB_* parts (DB_HOST, DB_NAME, etc.), or a *_FILE secret."
191
230
  )
192
231
  return None
@@ -196,10 +235,17 @@ def _ensure_timeout_default(u: URL) -> URL:
196
235
  """
197
236
  Ensure a conservative connection timeout is present for libpq-based drivers.
198
237
  For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
238
+ For asyncpg, timeout is set via connect_args (not query string).
199
239
  """
200
240
  backend = (u.get_backend_name() or "").lower()
201
241
  if backend not in ("postgresql", "postgres"):
202
242
  return u
243
+
244
+ # asyncpg doesn't support connect_timeout in query string - use connect_args instead
245
+ dn = (u.drivername or "").lower()
246
+ if "+asyncpg" in dn:
247
+ return u
248
+
203
249
  if "connect_timeout" in u.query:
204
250
  return u
205
251
  # Default 10s unless overridden
@@ -216,7 +262,7 @@ def is_async_url(url: URL | str) -> bool:
216
262
  return bool(ASYNC_DRIVER_HINT.search(dn))
217
263
 
218
264
 
219
- def with_database(url: URL | str, database: Optional[str]) -> URL:
265
+ def with_database(url: URL | str, database: str | None) -> URL:
220
266
  """Return a copy of URL with the database name replaced.
221
267
 
222
268
  Works for most dialects. For SQLite/DuckDB file URLs, `database` is the file path.
@@ -337,9 +383,8 @@ def _ensure_ssl_default(u: URL) -> URL:
337
383
  mode = (mode_env or "").strip()
338
384
 
339
385
  if "+asyncpg" in driver:
340
- # asyncpg: use 'ssl=true' (SQLAlchemy forwards to asyncpg)
341
- if "ssl" not in u.query:
342
- return u.set(query={**u.query, "ssl": "true"})
386
+ # asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
387
+ # Do not add ssl parameter to URL query for asyncpg
343
388
  return u
344
389
  else:
345
390
  # libpq-based drivers: use sslmode (default 'require' for hosted PG)
@@ -366,7 +411,7 @@ def _certifi_ca() -> str | None:
366
411
  return None
367
412
 
368
413
 
369
- def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncEngineType]:
414
+ def build_engine(url: URL | str, echo: bool = False) -> SyncEngine | AsyncEngineType:
370
415
  u = make_url(url) if isinstance(url, str) else url
371
416
 
372
417
  # Keep your existing PG helpers
@@ -382,10 +427,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
382
427
  "Async driver URL provided but SQLAlchemy async extras are not available."
383
428
  )
384
429
 
385
- # asyncpg: honor connection timeout
430
+ # asyncpg: honor connection timeout only (NOT connect_timeout)
386
431
  if "+asyncpg" in (u.drivername or ""):
387
432
  connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
388
433
 
434
+ # asyncpg doesn't accept sslmode or ssl=true in query params
435
+ # Remove these and set ssl='require' in connect_args
436
+ if "ssl" in u.query or "sslmode" in u.query:
437
+ new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
438
+ u = u.set(query=new_query)
439
+ # Set ssl in connect_args - 'require' is safest for hosted databases
440
+ connect_args["ssl"] = "require"
441
+
389
442
  # NEW: aiomysql SSL default
390
443
  if "+aiomysql" in (u.drivername or "") and not any(
391
444
  k in u.query for k in ("ssl", "ssl_ca", "sslmode")
@@ -404,10 +457,10 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
404
457
  except Exception:
405
458
  connect_args["ssl"] = True # minimal hint to enable TLS
406
459
 
407
- kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
460
+ async_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
408
461
  if connect_args:
409
- kwargs["connect_args"] = connect_args
410
- return _create_async_engine(u, **kwargs)
462
+ async_engine_kwargs["connect_args"] = connect_args
463
+ return _create_async_engine(u, **async_engine_kwargs)
411
464
 
412
465
  # ----------------- SYNC -----------------
413
466
  u = _coerce_sync_driver(u)
@@ -435,10 +488,10 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
435
488
  # Optional: if your provider requires it, you can also add:
436
489
  # connect_args.setdefault("client_flag", 0)
437
490
 
438
- kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
491
+ sync_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
439
492
  if connect_args:
440
- kwargs["connect_args"] = connect_args
441
- return _create_engine(u, **kwargs)
493
+ sync_engine_kwargs["connect_args"] = connect_args
494
+ return _create_engine(u, **sync_engine_kwargs)
442
495
 
443
496
 
444
497
  # ---------- Identifier quoting helpers ----------
@@ -485,7 +538,7 @@ async def _pg_create_database_async(url: URL) -> None:
485
538
  )
486
539
  if not exists:
487
540
  quoted = _pg_quote_ident(target_db)
488
- await conn.execution_options(isolation_level="AUTOCOMMIT").execute(
541
+ await conn.execution_options(isolation_level="AUTOCOMMIT").execute( # type: ignore[attr-defined]
489
542
  text(f'CREATE DATABASE "{quoted}"')
490
543
  )
491
544
  except DBAPIError as e:
@@ -789,17 +842,19 @@ def ensure_database_exists(url: URL | str) -> None:
789
842
  try:
790
843
  eng = build_engine(u)
791
844
  if is_async_url(u):
845
+ async_eng = cast("AsyncEngineType", eng)
792
846
 
793
847
  async def _ping_and_dispose():
794
- async with eng.begin() as conn: # type: ignore[call-arg]
848
+ async with async_eng.begin() as conn:
795
849
  await conn.execute(text("SELECT 1"))
796
- await eng.dispose() # type: ignore[attr-defined]
850
+ await async_eng.dispose()
797
851
 
798
852
  asyncio.run(_ping_and_dispose())
799
853
  else:
800
- with eng.begin() as conn: # type: ignore[call-arg]
854
+ sync_eng = cast("SyncEngine", eng)
855
+ with sync_eng.begin() as conn:
801
856
  conn.execute(text("SELECT 1"))
802
- eng.dispose() # type: ignore[attr-defined]
857
+ sync_eng.dispose()
803
858
  except OperationalError as exc: # pragma: no cover (depends on env)
804
859
  raise RuntimeError(f"Failed to connect to database: {exc}") from exc
805
860
 
@@ -811,9 +866,12 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
811
866
  return
812
867
 
813
868
  # Gather local revision ids from versions/
814
- script_location = Path(cfg.get_main_option("script_location"))
869
+ script_location_str = cfg.get_main_option("script_location")
870
+ if not script_location_str:
871
+ return
872
+ script_location = Path(script_location_str)
815
873
  versions_dir = script_location / "versions"
816
- local_ids: Set[str] = set()
874
+ local_ids: set[str] = set()
817
875
  if versions_dir.exists():
818
876
  for p in versions_dir.glob("*.py"):
819
877
  try:
@@ -831,7 +889,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
831
889
  if is_async_url(url_obj):
832
890
 
833
891
  async def _run() -> None:
834
- eng = build_engine(url_obj) # AsyncEngine
892
+ eng = cast("AsyncEngineType", build_engine(url_obj))
835
893
  try:
836
894
  async with eng.begin() as conn:
837
895
  # Do sync-y inspector / SQL via run_sync
@@ -854,7 +912,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
854
912
 
855
913
  asyncio.run(_run())
856
914
  else:
857
- eng = build_engine(url_obj) # sync Engine
915
+ eng = cast("SyncEngine", build_engine(url_obj))
858
916
  try:
859
917
  with eng.begin() as c:
860
918
  insp = inspect(c)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import Integer
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+
7
+ class Versioned:
8
+ """Mixin for optimistic locking with integer version.
9
+
10
+ - Initialize version=1 on insert (via default=1)
11
+ - Bump version in app code before commit to detect mismatches.
12
+ """
13
+
14
+ version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
svc_infra/db/utils.py CHANGED
@@ -1,10 +1,10 @@
1
+ from collections.abc import Sequence
1
2
  from pathlib import Path
2
- from typing import Sequence, Tuple, Union
3
3
 
4
- KeySpec = Union[str, Sequence[str]]
4
+ KeySpec = str | Sequence[str]
5
5
 
6
6
 
7
- def as_tuple(spec: KeySpec) -> Tuple[str, ...]:
7
+ def as_tuple(spec: KeySpec) -> tuple[str, ...]:
8
8
  return (spec,) if isinstance(spec, str) else tuple(spec)
9
9
 
10
10