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

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

Potentially problematic release.


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

Files changed (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -73,7 +73,9 @@ def dedupe_sql_service(
73
73
 
74
74
  return clauses
75
75
 
76
- async def _precheck(session, data: Dict[str, Any], *, exclude_id: Any | None) -> None:
76
+ async def _precheck(
77
+ session, data: Dict[str, Any], *, exclude_id: Any | None
78
+ ) -> None:
77
79
  # Check CI specs first to catch the broadest conflicts, then CS.
78
80
  for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
79
81
  for spec in spec_list:
@@ -97,7 +99,9 @@ def dedupe_sql_service(
97
99
  return await self.repo.create(session, data)
98
100
  except IntegrityError as e:
99
101
  # Race fallback: let DB constraint be the last line of defense.
100
- raise HTTPException(status_code=409, detail="Record already exists.") from e
102
+ raise HTTPException(
103
+ status_code=409, detail="Record already exists."
104
+ ) from e
101
105
 
102
106
  async def update(self, session, id_value, data):
103
107
  data = await self.pre_update(data)
@@ -105,6 +109,8 @@ def dedupe_sql_service(
105
109
  try:
106
110
  return await self.repo.update(session, id_value, data)
107
111
  except IntegrityError as e:
108
- raise HTTPException(status_code=409, detail="Record already exists.") from e
112
+ raise HTTPException(
113
+ status_code=409, detail="Record already exists."
114
+ ) from e
109
115
 
110
116
  return _Svc(repo, pre_create=pre_create, pre_update=pre_update)
svc_infra/db/sql/utils.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import os
5
5
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, Optional, Sequence, Set, Union
6
+ from typing import TYPE_CHECKING, Any, Optional, Sequence, Set, Union, cast
7
7
 
8
8
  from alembic.config import Config
9
9
  from dotenv import load_dotenv
@@ -17,19 +17,19 @@ if TYPE_CHECKING:
17
17
  from sqlalchemy.engine import Engine as SyncEngine
18
18
  from sqlalchemy.ext.asyncio import AsyncEngine as AsyncEngineType
19
19
  else:
20
- SyncEngine = Any # type: ignore
21
- AsyncEngineType = Any # type: ignore
20
+ SyncEngine = Any # type: ignore[assignment]
21
+ AsyncEngineType = Any # type: ignore[assignment]
22
22
 
23
23
  try:
24
24
  # 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
25
+ from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
26
26
  except Exception: # pragma: no cover - optional dep
27
- _create_async_engine = None # type: ignore
27
+ _create_async_engine = None # type: ignore[assignment]
28
28
 
29
29
  try:
30
- from sqlalchemy import create_engine as _create_engine # type: ignore
30
+ from sqlalchemy import create_engine as _create_engine
31
31
  except Exception: # pragma: no cover - optional env
32
- _create_engine = None # type: ignore
32
+ _create_engine = None # type: ignore[assignment]
33
33
 
34
34
 
35
35
  def prepare_process_env(
@@ -83,7 +83,9 @@ def _compose_url_from_parts() -> Optional[str]:
83
83
  DB_PARAMS (raw query string like 'sslmode=require&connect_timeout=5')
84
84
  """
85
85
  dialect = os.getenv("DB_DIALECT", "").strip() or "postgresql"
86
- driver = os.getenv("DB_DRIVER", "").strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
86
+ driver = os.getenv(
87
+ "DB_DRIVER", ""
88
+ ).strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
87
89
  host = os.getenv("DB_HOST", "").strip() or None
88
90
  port = os.getenv("DB_PORT", "").strip() or None
89
91
  db = os.getenv("DB_NAME", "").strip() or None
@@ -124,18 +126,62 @@ def _compose_url_from_parts() -> Optional[str]:
124
126
  # ---------- Environment helpers ----------
125
127
 
126
128
 
129
+ def _normalize_database_url(url: str) -> str:
130
+ """
131
+ Normalize database URL for SQLAlchemy compatibility.
132
+
133
+ Handles:
134
+ - postgres:// → postgresql:// (Heroku/Railway legacy format)
135
+ - Strips whitespace
136
+
137
+ Args:
138
+ url: Raw database URL string
139
+
140
+ Returns:
141
+ Normalized URL suitable for SQLAlchemy
142
+ """
143
+ url = url.strip()
144
+ # Heroku and Railway historically use 'postgres://' which SQLAlchemy doesn't accept
145
+ if url.startswith("postgres://"):
146
+ url = "postgresql://" + url[len("postgres://") :]
147
+ return url
148
+
149
+
127
150
  def get_database_url_from_env(
128
151
  required: bool = True,
129
152
  env_vars: Sequence[str] = DEFAULT_DB_ENV_VARS,
153
+ normalize: bool = True,
130
154
  ) -> Optional[str]:
131
155
  """
132
156
  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).
157
+ - Primary env vars (in order): DEFAULT_DB_ENV_VARS
158
+ (SQL_URL, DB_URL, DATABASE_URL, DATABASE_URL_PRIVATE, PRIVATE_SQL_URL)
134
159
  - Secret file pointers: <NAME>_FILE (reads file contents).
135
160
  - Well-known locations: SQL_URL_FILE, /run/secrets/database_url.
136
161
  - 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.
162
+
163
+ When a value is found, it is also written back into os.environ["SQL_URL"]
164
+ for downstream code.
165
+
166
+ Args:
167
+ required: If True, raise RuntimeError when no URL is found
168
+ env_vars: Sequence of environment variable names to check
169
+ normalize: If True, convert postgres:// to postgresql:// (default: True)
170
+
171
+ Returns:
172
+ Database URL string, or None if not found and not required
173
+
174
+ Raises:
175
+ RuntimeError: If required=True and no URL is found
138
176
  """
177
+
178
+ def _finalize(url: str) -> str:
179
+ """Normalize and cache the URL."""
180
+ if normalize:
181
+ url = _normalize_database_url(url)
182
+ os.environ["SQL_URL"] = url
183
+ return url
184
+
139
185
  # Load .env without clobbering existing process env
140
186
  load_dotenv(override=False)
141
187
 
@@ -150,10 +196,8 @@ def get_database_url_from_env(
150
196
  if os.path.isabs(s) and Path(s).exists():
151
197
  file_val = _read_secret_from_file(s)
152
198
  if file_val:
153
- os.environ["SQL_URL"] = file_val
154
- return file_val
155
- os.environ["SQL_URL"] = s
156
- return s
199
+ return _finalize(file_val)
200
+ return _finalize(s)
157
201
 
158
202
  # Companion NAME_FILE secret path (e.g., SQL_URL_FILE)
159
203
  file_key = f"{key}_FILE"
@@ -161,32 +205,28 @@ def get_database_url_from_env(
161
205
  if file_path:
162
206
  file_val = _read_secret_from_file(file_path)
163
207
  if file_val:
164
- os.environ["SQL_URL"] = file_val
165
- return file_val
208
+ return _finalize(file_val)
166
209
 
167
210
  # 2) Conventional secret envs
168
211
  file_path = os.getenv("SQL_URL_FILE")
169
212
  if file_path:
170
213
  file_val = _read_secret_from_file(file_path)
171
214
  if file_val:
172
- os.environ["SQL_URL"] = file_val
173
- return file_val
215
+ return _finalize(file_val)
174
216
 
175
217
  # 3) Docker/K8s default secret mount
176
218
  file_val = _read_secret_from_file("/run/secrets/database_url")
177
219
  if file_val:
178
- os.environ["SQL_URL"] = file_val
179
- return file_val
220
+ return _finalize(file_val)
180
221
 
181
222
  # 4) Compose from parts (DB_DIALECT/DB_DRIVER/DB_HOST/.../DB_PARAMS)
182
223
  composed = _compose_url_from_parts()
183
224
  if composed:
184
- os.environ["SQL_URL"] = composed
185
- return composed
225
+ return _finalize(composed)
186
226
 
187
227
  if required:
188
228
  raise RuntimeError(
189
- "Database URL not set. Set SQL_URL (or PRIVATE_SQL_URL / DB_URL), "
229
+ "Database URL not set. Set SQL_URL, DATABASE_URL, or DATABASE_URL_PRIVATE, "
190
230
  "or provide DB_* parts (DB_HOST, DB_NAME, etc.), or a *_FILE secret."
191
231
  )
192
232
  return None
@@ -196,10 +236,17 @@ def _ensure_timeout_default(u: URL) -> URL:
196
236
  """
197
237
  Ensure a conservative connection timeout is present for libpq-based drivers.
198
238
  For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
239
+ For asyncpg, timeout is set via connect_args (not query string).
199
240
  """
200
241
  backend = (u.get_backend_name() or "").lower()
201
242
  if backend not in ("postgresql", "postgres"):
202
243
  return u
244
+
245
+ # asyncpg doesn't support connect_timeout in query string - use connect_args instead
246
+ dn = (u.drivername or "").lower()
247
+ if "+asyncpg" in dn:
248
+ return u
249
+
203
250
  if "connect_timeout" in u.query:
204
251
  return u
205
252
  # Default 10s unless overridden
@@ -329,17 +376,22 @@ def _ensure_ssl_default(u: URL) -> URL:
329
376
  driver = (u.drivername or "").lower()
330
377
 
331
378
  # If any SSL hint already present, do nothing
332
- if any(k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")):
379
+ if any(
380
+ k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")
381
+ ):
333
382
  return u
334
383
 
335
384
  # Allow env override; support both common spellings
336
- mode_env = os.getenv("DB_SSLMODE_DEFAULT") or os.getenv("PGSSLMODE") or os.getenv("PGSSL_MODE")
385
+ mode_env = (
386
+ os.getenv("DB_SSLMODE_DEFAULT")
387
+ or os.getenv("PGSSLMODE")
388
+ or os.getenv("PGSSL_MODE")
389
+ )
337
390
  mode = (mode_env or "").strip()
338
391
 
339
392
  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"})
393
+ # asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
394
+ # Do not add ssl parameter to URL query for asyncpg
343
395
  return u
344
396
  else:
345
397
  # libpq-based drivers: use sslmode (default 'require' for hosted PG)
@@ -351,7 +403,9 @@ def _ensure_ssl_default_async(u: URL) -> URL:
351
403
  backend = (u.get_backend_name() or "").lower()
352
404
  if backend in ("postgresql", "postgres"):
353
405
  # asyncpg prefers 'ssl=true' via SQLAlchemy param; if already present, keep it
354
- if any(k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")):
406
+ if any(
407
+ k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")
408
+ ):
355
409
  return u
356
410
  return u.set(query={**u.query, "ssl": "true"})
357
411
  return u
@@ -366,7 +420,9 @@ def _certifi_ca() -> str | None:
366
420
  return None
367
421
 
368
422
 
369
- def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncEngineType]:
423
+ def build_engine(
424
+ url: URL | str, echo: bool = False
425
+ ) -> Union[SyncEngine, AsyncEngineType]:
370
426
  u = make_url(url) if isinstance(url, str) else url
371
427
 
372
428
  # Keep your existing PG helpers
@@ -382,10 +438,20 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
382
438
  "Async driver URL provided but SQLAlchemy async extras are not available."
383
439
  )
384
440
 
385
- # asyncpg: honor connection timeout
441
+ # asyncpg: honor connection timeout only (NOT connect_timeout)
386
442
  if "+asyncpg" in (u.drivername or ""):
387
443
  connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
388
444
 
445
+ # asyncpg doesn't accept sslmode or ssl=true in query params
446
+ # Remove these and set ssl='require' in connect_args
447
+ if "ssl" in u.query or "sslmode" in u.query:
448
+ new_query = {
449
+ k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")
450
+ }
451
+ u = u.set(query=new_query)
452
+ # Set ssl in connect_args - 'require' is safest for hosted databases
453
+ connect_args["ssl"] = "require"
454
+
389
455
  # NEW: aiomysql SSL default
390
456
  if "+aiomysql" in (u.drivername or "") and not any(
391
457
  k in u.query for k in ("ssl", "ssl_ca", "sslmode")
@@ -395,7 +461,11 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
395
461
  import ssl
396
462
 
397
463
  ca = _certifi_ca()
398
- ctx = ssl.create_default_context(cafile=ca) if ca else ssl.create_default_context()
464
+ ctx = (
465
+ ssl.create_default_context(cafile=ca)
466
+ if ca
467
+ else ssl.create_default_context()
468
+ )
399
469
  # if your host uses a public CA, verification works;
400
470
  # if not, you can relax verification (not recommended):
401
471
  # ctx.check_hostname = False
@@ -404,15 +474,17 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
404
474
  except Exception:
405
475
  connect_args["ssl"] = True # minimal hint to enable TLS
406
476
 
407
- kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
477
+ async_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
408
478
  if connect_args:
409
- kwargs["connect_args"] = connect_args
410
- return _create_async_engine(u, **kwargs)
479
+ async_engine_kwargs["connect_args"] = connect_args
480
+ return _create_async_engine(u, **async_engine_kwargs)
411
481
 
412
482
  # ----------------- SYNC -----------------
413
483
  u = _coerce_sync_driver(u)
414
484
  if _create_engine is None:
415
- raise RuntimeError("SQLAlchemy create_engine is not available in this environment.")
485
+ raise RuntimeError(
486
+ "SQLAlchemy create_engine is not available in this environment."
487
+ )
416
488
 
417
489
  dn = (u.drivername or "").lower()
418
490
 
@@ -435,10 +507,10 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
435
507
  # Optional: if your provider requires it, you can also add:
436
508
  # connect_args.setdefault("client_flag", 0)
437
509
 
438
- kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
510
+ sync_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
439
511
  if connect_args:
440
- kwargs["connect_args"] = connect_args
441
- return _create_engine(u, **kwargs)
512
+ sync_engine_kwargs["connect_args"] = connect_args
513
+ return _create_engine(u, **sync_engine_kwargs)
442
514
 
443
515
 
444
516
  # ---------- Identifier quoting helpers ----------
@@ -485,7 +557,7 @@ async def _pg_create_database_async(url: URL) -> None:
485
557
  )
486
558
  if not exists:
487
559
  quoted = _pg_quote_ident(target_db)
488
- await conn.execution_options(isolation_level="AUTOCOMMIT").execute(
560
+ await conn.execution_options(isolation_level="AUTOCOMMIT").execute( # type: ignore[attr-defined]
489
561
  text(f'CREATE DATABASE "{quoted}"')
490
562
  )
491
563
  except DBAPIError as e:
@@ -545,7 +617,9 @@ async def _mysql_create_database_async(url: URL) -> None:
545
617
  engine: AsyncEngineType = build_engine(base_url) # type: ignore[assignment]
546
618
  async with engine.begin() as conn:
547
619
  exists = await conn.scalar(
548
- text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"),
620
+ text(
621
+ "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
622
+ ),
549
623
  {"name": target_db},
550
624
  )
551
625
  if not exists:
@@ -562,7 +636,9 @@ def _mysql_create_database_sync(url: URL) -> None:
562
636
  engine: SyncEngine = build_engine(base_url) # type: ignore[assignment]
563
637
  with engine.begin() as conn:
564
638
  exists = conn.scalar(
565
- text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"),
639
+ text(
640
+ "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
641
+ ),
566
642
  {"name": target_db},
567
643
  )
568
644
  if not exists:
@@ -789,17 +865,19 @@ def ensure_database_exists(url: URL | str) -> None:
789
865
  try:
790
866
  eng = build_engine(u)
791
867
  if is_async_url(u):
868
+ async_eng = cast(AsyncEngineType, eng)
792
869
 
793
870
  async def _ping_and_dispose():
794
- async with eng.begin() as conn: # type: ignore[call-arg]
871
+ async with async_eng.begin() as conn:
795
872
  await conn.execute(text("SELECT 1"))
796
- await eng.dispose() # type: ignore[attr-defined]
873
+ await async_eng.dispose()
797
874
 
798
875
  asyncio.run(_ping_and_dispose())
799
876
  else:
800
- with eng.begin() as conn: # type: ignore[call-arg]
877
+ sync_eng = cast(SyncEngine, eng)
878
+ with sync_eng.begin() as conn:
801
879
  conn.execute(text("SELECT 1"))
802
- eng.dispose() # type: ignore[attr-defined]
880
+ sync_eng.dispose()
803
881
  except OperationalError as exc: # pragma: no cover (depends on env)
804
882
  raise RuntimeError(f"Failed to connect to database: {exc}") from exc
805
883
 
@@ -811,7 +889,10 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
811
889
  return
812
890
 
813
891
  # Gather local revision ids from versions/
814
- script_location = Path(cfg.get_main_option("script_location"))
892
+ script_location_str = cfg.get_main_option("script_location")
893
+ if not script_location_str:
894
+ return
895
+ script_location = Path(script_location_str)
815
896
  versions_dir = script_location / "versions"
816
897
  local_ids: Set[str] = set()
817
898
  if versions_dir.exists():
@@ -831,7 +912,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
831
912
  if is_async_url(url_obj):
832
913
 
833
914
  async def _run() -> None:
834
- eng = build_engine(url_obj) # AsyncEngine
915
+ eng = cast(AsyncEngineType, build_engine(url_obj))
835
916
  try:
836
917
  async with eng.begin() as conn:
837
918
  # Do sync-y inspector / SQL via run_sync
@@ -846,7 +927,9 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
846
927
  )
847
928
  missing = any((ver not in local_ids) for (ver,) in rows)
848
929
  if missing:
849
- sync_conn.execute(text("DROP TABLE IF EXISTS alembic_version"))
930
+ sync_conn.execute(
931
+ text("DROP TABLE IF EXISTS alembic_version")
932
+ )
850
933
 
851
934
  await conn.run_sync(_check_and_maybe_drop)
852
935
  finally:
@@ -854,13 +937,17 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
854
937
 
855
938
  asyncio.run(_run())
856
939
  else:
857
- eng = build_engine(url_obj) # sync Engine
940
+ eng = cast(SyncEngine, build_engine(url_obj))
858
941
  try:
859
942
  with eng.begin() as c:
860
943
  insp = inspect(c)
861
944
  if not insp.has_table("alembic_version"):
862
945
  return
863
- rows = list(c.execute(text("SELECT version_num FROM alembic_version")).fetchall())
946
+ rows = list(
947
+ c.execute(
948
+ text("SELECT version_num FROM alembic_version")
949
+ ).fetchall()
950
+ )
864
951
  missing = any((ver not in local_ids) for (ver,) in rows)
865
952
  if missing:
866
953
  c.execute(text("DROP TABLE IF EXISTS alembic_version"))
@@ -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)