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
svc_infra/db/sql/core.py CHANGED
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  import contextlib
4
4
  import io
5
5
  import os
6
+ from collections.abc import Sequence
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Optional, Sequence
9
9
 
10
10
  from alembic import command
11
11
  from alembic.config import Config
@@ -30,7 +30,7 @@ from svc_infra.db.sql.utils import (
30
30
  def init_alembic(
31
31
  *,
32
32
  script_location: str = "migrations",
33
- discover_packages: Optional[Sequence[str]] = None,
33
+ discover_packages: Sequence[str] | None = None,
34
34
  overwrite: bool = False,
35
35
  ) -> Path:
36
36
  """
@@ -140,7 +140,7 @@ def revision(
140
140
  cfg,
141
141
  message=message,
142
142
  autogenerate=autogenerate,
143
- head=head,
143
+ head=head or "head",
144
144
  branch_label=branch_label,
145
145
  version_path=version_path,
146
146
  sql=sql,
@@ -157,7 +157,7 @@ def revision(
157
157
  def upgrade(
158
158
  revision_target: str = "head",
159
159
  *,
160
- database_url: Optional[str] = None,
160
+ database_url: str | None = None,
161
161
  ) -> dict:
162
162
  """
163
163
  Apply migrations forward.
@@ -181,7 +181,7 @@ def upgrade(
181
181
  def downgrade(
182
182
  *,
183
183
  revision_target: str = "-1",
184
- database_url: Optional[str] = None,
184
+ database_url: str | None = None,
185
185
  ) -> dict:
186
186
  """Revert migrations down to the specified revision or relative step.
187
187
 
@@ -203,7 +203,7 @@ def downgrade(
203
203
  def current(
204
204
  verbose: bool = False,
205
205
  *,
206
- database_url: Optional[str] = None,
206
+ database_url: str | None = None,
207
207
  ) -> dict:
208
208
  """Print the current database revision(s)."""
209
209
  root = prepare_env()
@@ -224,7 +224,7 @@ def current(
224
224
  def history(
225
225
  *,
226
226
  verbose: bool = False,
227
- database_url: Optional[str] = None,
227
+ database_url: str | None = None,
228
228
  ) -> dict:
229
229
  """Show the migration history for this project."""
230
230
  root = prepare_env()
@@ -245,7 +245,7 @@ def history(
245
245
  def stamp(
246
246
  *,
247
247
  revision_target: str = "head",
248
- database_url: Optional[str] = None,
248
+ database_url: str | None = None,
249
249
  ) -> dict:
250
250
  """Set the current database revision without running migrations. Useful for marking an existing database as up-to-date."""
251
251
  root = prepare_env()
@@ -262,8 +262,8 @@ def stamp(
262
262
 
263
263
  def merge_heads(
264
264
  *,
265
- message: Optional[str] = None,
266
- database_url: Optional[str] = None,
265
+ message: str | None = None,
266
+ database_url: str | None = None,
267
267
  ) -> dict:
268
268
  """Create a merge revision that joins multiple migration heads."""
269
269
  root = prepare_env()
@@ -309,8 +309,8 @@ def setup_and_migrate(
309
309
  create_followup_revision: bool = True,
310
310
  initial_message: str = "initial schema",
311
311
  followup_message: str = "autogen",
312
- database_url: Optional[str] = None,
313
- discover_packages: Optional[Sequence[str]] = None,
312
+ database_url: str | None = None,
313
+ discover_packages: Sequence[str] | None = None,
314
314
  ) -> dict:
315
315
  """
316
316
  Ensure DB + Alembic are ready and up-to-date.
@@ -319,7 +319,7 @@ def setup_and_migrate(
319
319
  """
320
320
  resolved_url = database_url or get_database_url_from_env(required=True)
321
321
  root = prepare_env()
322
- if create_db_if_missing:
322
+ if create_db_if_missing and resolved_url:
323
323
  ensure_database_exists(resolved_url)
324
324
 
325
325
  mig_dir = init_alembic(
@@ -15,20 +15,19 @@ def _sa_columns(model: type[object]) -> list[Column]:
15
15
  def _py_type(col: Column) -> type:
16
16
  # Prefer SQLAlchemy-provided python_type when available
17
17
  if getattr(col.type, "python_type", None):
18
- return col.type.python_type # type: ignore[no-any-return]
18
+ return col.type.python_type
19
19
 
20
20
  from datetime import date, datetime
21
- from typing import Any as _Any
22
21
  from uuid import UUID
23
22
 
24
23
  from sqlalchemy import JSON, Boolean, Date, DateTime, Integer, String, Text
25
24
 
26
25
  try:
27
26
  from sqlalchemy.dialects.postgresql import JSONB
28
- from sqlalchemy.dialects.postgresql import UUID as PG_UUID # type: ignore
27
+ from sqlalchemy.dialects.postgresql import UUID as PG_UUID
29
28
  except Exception: # pragma: no cover
30
- PG_UUID = None # type: ignore
31
- JSONB = None # type: ignore
29
+ PG_UUID = None # type: ignore[misc,assignment]
30
+ JSONB = None # type: ignore[misc,assignment]
32
31
 
33
32
  t = col.type
34
33
  if PG_UUID is not None and isinstance(t, PG_UUID):
@@ -47,7 +46,7 @@ def _py_type(col: Column) -> type:
47
46
  return dict
48
47
  if JSONB is not None and isinstance(t, JSONB):
49
48
  return dict
50
- return _Any
49
+ return object # fallback type for unknown column types
51
50
 
52
51
 
53
52
  def _exclude_from_create(col: Column) -> bool:
@@ -1,11 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Iterable, Optional, Sequence, Set
3
+ import inspect
4
+ import logging
5
+ from collections.abc import Iterable, Sequence
6
+ from typing import Any, cast
4
7
 
5
8
  from sqlalchemy import Select, String, and_, func, or_, select
6
9
  from sqlalchemy.ext.asyncio import AsyncSession
7
10
  from sqlalchemy.orm import InstrumentedAttribute, class_mapper
8
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _escape_ilike(q: str) -> str:
16
+ """Escape special characters for ILIKE pattern matching.
17
+
18
+ Prevents SQL injection via wildcard characters that could match
19
+ unintended data (e.g., % matches any string, _ matches any char).
20
+ """
21
+ return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
22
+
9
23
 
10
24
  class SqlRepository:
11
25
  """
@@ -20,22 +34,22 @@ class SqlRepository:
20
34
  soft_delete: bool = False,
21
35
  soft_delete_field: str = "deleted_at",
22
36
  soft_delete_flag_field: str | None = None,
23
- immutable_fields: Optional[Set[str]] = None,
37
+ immutable_fields: set[str] | None = None,
24
38
  ):
25
39
  self.model = model
26
40
  self.id_attr = id_attr
27
41
  self.soft_delete = soft_delete
28
42
  self.soft_delete_field = soft_delete_field
29
43
  self.soft_delete_flag_field = soft_delete_flag_field
30
- self.immutable_fields: Set[str] = set(
44
+ self.immutable_fields: set[str] = set(
31
45
  immutable_fields or {"id", "created_at", "updated_at"}
32
46
  )
33
47
 
34
48
  def _model_columns(self) -> set[str]:
35
49
  return {c.key for c in class_mapper(self.model).columns}
36
50
 
37
- def _id_column(self) -> InstrumentedAttribute:
38
- return getattr(self.model, self.id_attr)
51
+ def _id_column(self) -> InstrumentedAttribute[Any]:
52
+ return cast("InstrumentedAttribute[Any]", getattr(self.model, self.id_attr))
39
53
 
40
54
  def _base_select(self) -> Select:
41
55
  stmt = select(self.model)
@@ -55,21 +69,36 @@ class SqlRepository:
55
69
  *,
56
70
  limit: int,
57
71
  offset: int,
58
- order_by: Optional[Sequence[Any]] = None,
72
+ order_by: Sequence[Any] | None = None,
73
+ where: Sequence[Any] | None = None,
59
74
  ) -> Sequence[Any]:
60
- stmt = self._base_select().limit(limit).offset(offset)
75
+ stmt = self._base_select()
76
+ if where:
77
+ stmt = stmt.where(and_(*where))
78
+ stmt = stmt.limit(limit).offset(offset)
61
79
  if order_by:
62
80
  stmt = stmt.order_by(*order_by)
63
- rows = (await session.execute(stmt)).scalars().all()
64
- return rows
81
+ result = (await session.execute(stmt)).scalars().all()
82
+ return list(result)
65
83
 
66
- async def count(self, session: AsyncSession) -> int:
67
- stmt = select(func.count()).select_from(self._base_select().subquery())
68
- return (await session.execute(stmt)).scalar_one()
84
+ async def count(self, session: AsyncSession, *, where: Sequence[Any] | None = None) -> int:
85
+ base = self._base_select()
86
+ if where:
87
+ base = base.where(and_(*where))
88
+ stmt = select(func.count()).select_from(base.subquery())
89
+ return int((await session.execute(stmt)).scalar_one())
69
90
 
70
- async def get(self, session: AsyncSession, id_value: Any) -> Any | None:
91
+ async def get(
92
+ self,
93
+ session: AsyncSession,
94
+ id_value: Any,
95
+ *,
96
+ where: Sequence[Any] | None = None,
97
+ ) -> Any | None:
71
98
  # honors soft-delete if configured
72
99
  stmt = self._base_select().where(self._id_column() == id_value)
100
+ if where:
101
+ stmt = stmt.where(and_(*where))
73
102
  return (await session.execute(stmt)).scalars().first()
74
103
 
75
104
  async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
@@ -78,12 +107,18 @@ class SqlRepository:
78
107
  obj = self.model(**filtered)
79
108
  session.add(obj)
80
109
  await session.flush()
110
+ await session.refresh(obj)
81
111
  return obj
82
112
 
83
113
  async def update(
84
- self, session: AsyncSession, id_value: Any, data: dict[str, Any]
114
+ self,
115
+ session: AsyncSession,
116
+ id_value: Any,
117
+ data: dict[str, Any],
118
+ *,
119
+ where: Sequence[Any] | None = None,
85
120
  ) -> Any | None:
86
- obj = await self.get(session, id_value)
121
+ obj = await self.get(session, id_value, where=where)
87
122
  if not obj:
88
123
  return None
89
124
  valid = self._model_columns()
@@ -91,21 +126,38 @@ class SqlRepository:
91
126
  if k in valid and k not in self.immutable_fields:
92
127
  setattr(obj, k, v)
93
128
  await session.flush()
129
+ await session.refresh(obj)
94
130
  return obj
95
131
 
96
- async def delete(self, session: AsyncSession, id_value: Any) -> bool:
97
- obj = await session.get(self.model, id_value)
132
+ async def delete(
133
+ self,
134
+ session: AsyncSession,
135
+ id_value: Any,
136
+ *,
137
+ where: Sequence[Any] | None = None,
138
+ ) -> bool:
139
+ # Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
140
+ if not where:
141
+ obj = await session.get(self.model, id_value)
142
+ else:
143
+ # Respect soft-delete and optional tenant/extra filters by selecting through base select
144
+ stmt = self._base_select().where(self._id_column() == id_value)
145
+ stmt = stmt.where(and_(*where))
146
+ obj = (await session.execute(stmt)).scalars().first()
98
147
  if not obj:
99
148
  return False
100
149
  if self.soft_delete:
101
150
  # Prefer timestamp, also optionally set flag to False
102
- if hasattr(self.model, self.soft_delete_field):
151
+ # Check attributes on the instance to support test doubles without class-level fields
152
+ if hasattr(obj, self.soft_delete_field):
103
153
  setattr(obj, self.soft_delete_field, func.now())
104
- if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
154
+ if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
105
155
  setattr(obj, self.soft_delete_flag_field, False)
106
156
  await session.flush()
107
157
  return True
108
- session.delete(obj)
158
+ delete_result = session.delete(obj)
159
+ if inspect.isawaitable(delete_result):
160
+ await delete_result
109
161
  await session.flush()
110
162
  return True
111
163
 
@@ -117,19 +169,23 @@ class SqlRepository:
117
169
  fields: Sequence[str],
118
170
  limit: int,
119
171
  offset: int,
120
- order_by: Optional[Sequence[Any]] = None,
172
+ order_by: Sequence[Any] | None = None,
173
+ where: Sequence[Any] | None = None,
121
174
  ) -> Sequence[Any]:
122
- ilike = f"%{q}%"
175
+ ilike = f"%{_escape_ilike(q)}%"
123
176
  conditions = []
124
177
  for f in fields:
125
178
  col = getattr(self.model, f, None)
126
179
  if col is not None:
127
180
  try:
128
181
  conditions.append(col.cast(String).ilike(ilike))
129
- except Exception:
182
+ except Exception as e:
130
183
  # skip columns that cannot be used in ilike even with cast
184
+ logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
131
185
  continue
132
186
  stmt = self._base_select()
187
+ if where:
188
+ stmt = stmt.where(and_(*where))
133
189
  if conditions:
134
190
  stmt = stmt.where(or_(*conditions))
135
191
  stmt = stmt.limit(limit).offset(offset)
@@ -137,17 +193,27 @@ class SqlRepository:
137
193
  stmt = stmt.order_by(*order_by)
138
194
  return (await session.execute(stmt)).scalars().all()
139
195
 
140
- async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
141
- ilike = f"%{q}%"
196
+ async def count_filtered(
197
+ self,
198
+ session: AsyncSession,
199
+ *,
200
+ q: str,
201
+ fields: Sequence[str],
202
+ where: Sequence[Any] | None = None,
203
+ ) -> int:
204
+ ilike = f"%{_escape_ilike(q)}%"
142
205
  conditions = []
143
206
  for f in fields:
144
207
  col = getattr(self.model, f, None)
145
208
  if col is not None:
146
209
  try:
147
210
  conditions.append(col.cast(String).ilike(ilike))
148
- except Exception:
211
+ except Exception as e:
212
+ logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
149
213
  continue
150
214
  stmt = self._base_select()
215
+ if where:
216
+ stmt = stmt.where(and_(*where))
151
217
  if conditions:
152
218
  stmt = stmt.where(or_(*conditions))
153
219
  # SELECT COUNT(*) FROM (<stmt>) as t
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
- from typing import TYPE_CHECKING, Callable, Optional
5
+ from typing import TYPE_CHECKING
5
6
 
6
7
  from svc_infra.db.sql.repository import SqlRepository
7
8
 
@@ -14,23 +15,28 @@ if TYPE_CHECKING:
14
15
  class SqlResource:
15
16
  model: type[object]
16
17
  prefix: str
17
- tags: Optional[list[str]] = None
18
+ tags: list[str] | None = None
18
19
 
19
20
  id_attr: str = "id"
20
21
  soft_delete: bool = False
21
- search_fields: Optional[list[str]] = None
22
- ordering_default: Optional[str] = None
23
- allowed_order_fields: Optional[list[str]] = None
22
+ search_fields: list[str] | None = None
23
+ ordering_default: str | None = None
24
+ allowed_order_fields: list[str] | None = None
24
25
 
25
- read_schema: Optional[type] = None
26
- create_schema: Optional[type] = None
27
- update_schema: Optional[type] = None
26
+ read_schema: type | None = None
27
+ create_schema: type | None = None
28
+ update_schema: type | None = None
28
29
 
29
- read_name: Optional[str] = None
30
- create_name: Optional[str] = None
31
- update_name: Optional[str] = None
30
+ read_name: str | None = None
31
+ create_name: str | None = None
32
+ update_name: str | None = None
32
33
 
33
34
  create_exclude: tuple[str, ...] = ("id",)
34
35
 
35
36
  # Only a type reference; no runtime dependency on FastAPI layer
36
- service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
37
+ service_factory: Callable[[SqlRepository], SqlService] | None = None
38
+
39
+ # Tenancy
40
+ tenant_field: str | None = (
41
+ None # when set, CRUD router will require TenantId and scope by field
42
+ )
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
- from typing import Any, Dict, Literal, Optional
5
+ from typing import Any, Literal
6
6
 
7
7
  from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
8
8
  from svc_infra.utils import ensure_init_py, render_template, write
@@ -13,7 +13,7 @@ _INIT_CONTENT_PAIRED = 'from . import models, schemas\n\n__all__ = ["models", "s
13
13
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
14
14
 
15
15
 
16
- def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> Dict[str, Any]:
16
+ def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> dict[str, Any]:
17
17
  """Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
18
18
  content = _INIT_CONTENT_PAIRED if paired else _INIT_CONTENT_MINIMAL
19
19
  return ensure_init_py(dir_path, overwrite, paired, content)
@@ -31,14 +31,14 @@ def scaffold_core(
31
31
  schemas_dir: Path | str,
32
32
  kind: Kind = "entity",
33
33
  entity_name: str = "Item",
34
- table_name: Optional[str] = None,
34
+ table_name: str | None = None,
35
35
  include_tenant: bool = True,
36
36
  include_soft_delete: bool = False,
37
37
  overwrite: bool = False,
38
38
  same_dir: bool = False,
39
- models_filename: Optional[str] = None,
40
- schemas_filename: Optional[str] = None,
41
- ) -> Dict[str, Any]:
39
+ models_filename: str | None = None,
40
+ schemas_filename: str | None = None,
41
+ ) -> dict[str, Any]:
42
42
  """
43
43
  Create starter model + schema files.
44
44
 
@@ -150,12 +150,12 @@ def scaffold_models_core(
150
150
  dest_dir: Path | str,
151
151
  kind: Kind = "entity",
152
152
  entity_name: str = "Item",
153
- table_name: Optional[str] = None,
153
+ table_name: str | None = None,
154
154
  include_tenant: bool = True,
155
155
  include_soft_delete: bool = False,
156
156
  overwrite: bool = False,
157
- models_filename: Optional[str] = None, # <--- NEW
158
- ) -> Dict[str, Any]:
157
+ models_filename: str | None = None, # <--- NEW
158
+ ) -> dict[str, Any]:
159
159
  """Create only a model file (defaults to <snake(entity)>.py unless models_filename is provided)."""
160
160
  dest = normalize_dir(dest_dir)
161
161
 
@@ -216,8 +216,8 @@ def scaffold_schemas_core(
216
216
  entity_name: str = "Item",
217
217
  include_tenant: bool = True,
218
218
  overwrite: bool = False,
219
- schemas_filename: Optional[str] = None, # <--- NEW
220
- ) -> Dict[str, Any]:
219
+ schemas_filename: str | None = None, # <--- NEW
220
+ ) -> dict[str, Any]:
221
221
  """Create only a schema file (defaults to <snake(entity)>.py unless schemas_filename is provided)."""
222
222
  dest = normalize_dir(dest_dir)
223
223
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from fastapi import HTTPException
6
7
  from sqlalchemy.exc import IntegrityError
@@ -1,4 +1,5 @@
1
- from typing import Any, Callable, Optional
1
+ from collections.abc import Callable
2
+ from typing import Any
2
3
 
3
4
  from svc_infra.db.sql.service import SqlService
4
5
 
@@ -9,8 +10,8 @@ class SqlServiceWithHooks(SqlService):
9
10
  def __init__(
10
11
  self,
11
12
  repo,
12
- pre_create: Optional[PreHook] = None,
13
- pre_update: Optional[PreHook] = None,
13
+ pre_create: PreHook | None = None,
14
+ pre_update: PreHook | None = None,
14
15
  ):
15
16
  super().__init__(repo)
16
17
  self._pre_create = pre_create
@@ -129,62 +129,13 @@ for _ix in make_unique_sql_indexes(
129
129
  # Registered with Table metadata (alembic/autogenerate will pick them up)
130
130
  pass
131
131
 
132
- # ------------------------------ Model: ProviderAccount -------------------------
133
-
134
- class ProviderAccount(ModelBase):
135
- """
136
- Links a local user to an external identity provider account.
137
-
138
- - (provider, provider_account_id) is unique
139
- - Optionally stores tokens for later API calls (refresh_token encrypted at rest)
140
- """
141
- __tablename__ = "provider_accounts"
142
-
143
- id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
144
-
145
- user_id: Mapped[uuid.UUID] = mapped_column(
146
- GUID(), ForeignKey("${auth_table_name}.id", ondelete="CASCADE"), nullable=False
147
- )
148
- user: Mapped["${AuthEntity}"] = relationship(
149
- back_populates="provider_accounts",
150
- lazy="selectin",
151
- )
152
-
153
- provider: Mapped[str] = mapped_column(String(50), nullable=False) # "google"|"github"|"linkedin"|"microsoft"|...
154
- provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False) # sub/oid (OIDC) or id (github/linkedin)
155
-
156
- # Optional token material
157
- access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
158
-
159
- # Store encrypted refresh_token in the same column name for DB compatibility.
160
- _refresh_token: Mapped[Optional[str]] = mapped_column("refresh_token", Text, nullable=True)
161
-
162
- @property
163
- def refresh_token(self) -> Optional[str]:
164
- return _decrypt(self._refresh_token)
165
-
166
- @refresh_token.setter
167
- def refresh_token(self, value: Optional[str]) -> None:
168
- self._refresh_token = _encrypt(value)
169
-
170
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
171
- raw_claims: Mapped[Optional[dict]] = mapped_column(MutableDict.as_mutable(JSON), nullable=True)
172
-
173
- created_at = mapped_column(
174
- DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
175
- )
176
- updated_at = mapped_column(
177
- DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"),
178
- onupdate=text("CURRENT_TIMESTAMP"), nullable=False
179
- )
180
-
181
- __table_args__ = (
182
- UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
183
- Index("ix_provider_accounts_user_id", "user_id"),
184
- )
185
-
186
- def __repr__(self) -> str:
187
- return f"<ProviderAccount provider={self.provider!r} provider_account_id={self.provider_account_id!r} user_id={self.user_id}>"
132
+ # NOTE: ProviderAccount model is imported from svc_infra.security.oauth_models
133
+ # It's an opt-in OAuth model that links users to providers (Google, GitHub, etc.)
134
+ # The relationship 'provider_accounts' is defined above in the ${AuthEntity} class.
135
+ # To enable OAuth in your project:
136
+ # 1. Set ALEMBIC_ENABLE_OAUTH=true in your .env
137
+ # 2. Pass provider_account_model=ProviderAccount to add_auth_users()
138
+ # 3. Import: from svc_infra.security.oauth_models import ProviderAccount
188
139
 
189
140
  # --- Auth service factory ------------------------------------------------------
190
141
 
@@ -6,13 +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
- from sqlalchemy.ext.asyncio import create_async_engine
11
11
 
12
- from svc_infra.db.sql.utils import (
13
- get_database_url_from_env,
14
- _ensure_ssl_default_async as _ensure_ssl_default,
15
- )
12
+ from svc_infra.db.sql.utils import get_database_url_from_env
16
13
 
17
14
  try:
18
15
  from svc_infra.db.sql.types import GUID as _GUID # type: ignore
@@ -105,7 +102,6 @@ def _coerce_to_async(u: URL) -> URL:
105
102
 
106
103
  u = make_url(effective_url)
107
104
  u = _coerce_to_async(u)
108
- u = _ensure_ssl_default(u)
109
105
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
110
106
 
111
107
  # feature flags
@@ -131,15 +127,16 @@ def _collect_metadata() -> list[object]:
131
127
 
132
128
  def _maybe_add(obj: object) -> None:
133
129
  md = getattr(obj, "metadata", None) or obj
134
- if hasattr(md, "tables") and getattr(md, "tables"):
130
+ # Strict check: must be actual MetaData instance
131
+ if isinstance(md, MetaData) and md.tables:
135
132
  found.append(md)
136
133
 
137
134
  def _scan_module_objects(mod: object) -> None:
138
135
  try:
139
136
  for val in vars(mod).values():
140
- md = getattr(val, "metadata", None) or None
141
- if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
142
- found.append(md)
137
+ # Strict check: must be actual MetaData instance
138
+ if isinstance(val, MetaData) and val.tables:
139
+ found.append(val)
143
140
  except Exception:
144
141
  pass
145
142
 
@@ -177,8 +174,16 @@ def _collect_metadata() -> list[object]:
177
174
  if name not in pkgs:
178
175
  pkgs.append(name)
179
176
 
177
+ # Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
180
178
  if "models" not in pkgs:
181
- pkgs.append("models")
179
+ try:
180
+ spec = getattr(importlib, "util", None)
181
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
182
+ if spec.find_spec("models") is not None:
183
+ pkgs.append("models")
184
+ except Exception:
185
+ # Best-effort; if discovery fails, skip adding bare 'models'
186
+ pass
182
187
 
183
188
  def _import_and_collect(modname: str):
184
189
  try:
@@ -221,6 +226,21 @@ def _collect_metadata() -> list[object]:
221
226
  except Exception:
222
227
  _note("ModelBase import", False, traceback.format_exc())
223
228
 
229
+ # Core security models (AuthSession, RefreshToken, etc.)
230
+ try:
231
+ import svc_infra.security.models # noqa: F401
232
+ _note("svc_infra.security.models", True, None)
233
+ except Exception:
234
+ _note("svc_infra.security.models", False, traceback.format_exc())
235
+
236
+ # OAuth models (opt-in via environment variable)
237
+ if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
238
+ try:
239
+ import svc_infra.security.oauth_models # noqa: F401
240
+ _note("svc_infra.security.oauth_models", True, None)
241
+ except Exception:
242
+ _note("svc_infra.security.oauth_models", False, traceback.format_exc())
243
+
224
244
  try:
225
245
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
226
246
  try_autobind_apikey_model(require_env=False)
@@ -352,7 +372,9 @@ def _do_run_migrations(connection):
352
372
 
353
373
  async def run_migrations_online() -> None:
354
374
  url = config.get_main_option("sqlalchemy.url")
355
- engine = create_async_engine(url)
375
+ # Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
376
+ from svc_infra.db.sql.utils import build_engine
377
+ engine = build_engine(url)
356
378
  async with engine.connect() as connection:
357
379
  await connection.run_sync(_do_run_migrations)
358
380
  await engine.dispose()