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
svc_infra/cli/__init__.py CHANGED
@@ -4,10 +4,17 @@ import typer
4
4
 
5
5
  from svc_infra.cli.cmds import (
6
6
  _HELP,
7
+ jobs_app,
7
8
  register_alembic,
9
+ register_db_ops,
10
+ register_docs,
11
+ register_dx,
12
+ register_health,
8
13
  register_mongo,
9
14
  register_mongo_scaffold,
10
15
  register_obs,
16
+ register_sdk,
17
+ register_sql_export,
11
18
  register_sql_scaffold,
12
19
  )
13
20
  from svc_infra.cli.foundation.typer_bootstrap import pre_cli
@@ -15,16 +22,53 @@ from svc_infra.cli.foundation.typer_bootstrap import pre_cli
15
22
  app = typer.Typer(no_args_is_help=True, add_completion=False, help=_HELP)
16
23
  pre_cli(app)
17
24
 
18
- # --- sql commands ---
19
- register_alembic(app)
20
- register_sql_scaffold(app)
25
+ # --- db ops group ---
26
+ db_app = typer.Typer(
27
+ no_args_is_help=True, add_completion=False, help="Database operations"
28
+ )
29
+ register_db_ops(db_app)
30
+ app.add_typer(db_app, name="db")
31
+
32
+ # --- sql group ---
33
+ sql_app = typer.Typer(no_args_is_help=True, add_completion=False, help="SQL commands")
34
+ register_alembic(sql_app)
35
+ register_sql_scaffold(sql_app)
36
+ register_sql_export(sql_app)
37
+ app.add_typer(sql_app, name="sql")
38
+
39
+ # --- mongo group ---
40
+ mongo_app = typer.Typer(
41
+ no_args_is_help=True, add_completion=False, help="MongoDB commands"
42
+ )
43
+ register_mongo(mongo_app)
44
+ register_mongo_scaffold(mongo_app)
45
+ app.add_typer(mongo_app, name="mongo")
46
+
47
+ # --- health group ---
48
+ health_app = typer.Typer(
49
+ no_args_is_help=True, add_completion=False, help="Health checks"
50
+ )
51
+ register_health(health_app)
52
+ app.add_typer(health_app, name="health")
53
+
54
+ # -- obs group ---
55
+ obs_app = typer.Typer(
56
+ no_args_is_help=True, add_completion=False, help="Observability commands"
57
+ )
58
+ register_obs(obs_app)
59
+ app.add_typer(obs_app, name="obs")
60
+
61
+ # -- dx commands ---
62
+ register_dx(app)
63
+
64
+ # -- jobs commands ---
65
+ app.add_typer(jobs_app, name="jobs")
21
66
 
22
- # --- nosql commands ---
23
- register_mongo(app)
24
- register_mongo_scaffold(app)
67
+ # -- sdk commands ---
68
+ register_sdk(app)
25
69
 
26
- # -- observability commands ---
27
- register_obs(app)
70
+ # -- docs commands ---
71
+ register_docs(app)
28
72
 
29
73
 
30
74
  def main():
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -1,18 +1,55 @@
1
- from svc_infra.cli.cmds.db.nosql.mongo.mongo_cmds import register as register_mongo
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ try:
8
+ from svc_infra.cli.cmds.db.nosql.mongo.mongo_cmds import register as register_mongo
9
+ except ModuleNotFoundError as exc:
10
+ _mongo_import_error = exc
11
+
12
+ def register_mongo(app: typer.Typer) -> None: # type: ignore[no-redef]
13
+ def _unavailable() -> Any:
14
+ raise ModuleNotFoundError(
15
+ "MongoDB CLI commands require optional dependencies. Install pymongo and motor "
16
+ "to enable `svc-infra mongo ...` commands."
17
+ ) from _mongo_import_error
18
+
19
+ # Provide a single helpful command instead of failing CLI import.
20
+ app.command("unavailable")(_unavailable)
21
+
22
+
2
23
  from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
3
24
  register as register_mongo_scaffold,
4
25
  )
26
+ from svc_infra.cli.cmds.db.ops_cmds import register as register_db_ops
5
27
  from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
6
- from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
28
+ from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
29
+ from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import (
30
+ register as register_sql_scaffold,
31
+ )
32
+ from svc_infra.cli.cmds.docs.docs_cmds import register as register_docs
33
+ from svc_infra.cli.cmds.dx import register_dx
34
+ from svc_infra.cli.cmds.health.health_cmds import register as register_health
35
+ from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
7
36
  from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
37
+ from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
8
38
 
9
39
  from .help import _HELP
10
40
 
11
41
  __all__ = [
12
42
  "register_alembic",
13
43
  "register_sql_scaffold",
44
+ "register_sql_export",
14
45
  "register_mongo",
15
46
  "register_mongo_scaffold",
47
+ "register_db_ops",
16
48
  "register_obs",
49
+ "jobs_app",
50
+ "register_sdk",
51
+ "register_dx",
52
+ "register_docs",
53
+ "register_health",
17
54
  "_HELP",
18
55
  ]
@@ -172,7 +172,9 @@ def cmd_ping(
172
172
 
173
173
  import asyncio
174
174
 
175
- from svc_infra.db.nosql.mongo.client import acquire_db # local import to avoid side effects
175
+ from svc_infra.db.nosql.mongo.client import (
176
+ acquire_db,
177
+ ) # local import to avoid side effects
176
178
 
177
179
  async def _run():
178
180
  await init_mongo()
@@ -188,6 +190,7 @@ def cmd_ping(
188
190
 
189
191
 
190
192
  def register(app: typer.Typer) -> None:
191
- app.command("mongo-prepare")(cmd_prepare)
192
- app.command("mongo-setup-and-prepare")(cmd_setup_and_prepare)
193
- app.command("mongo-ping")(cmd_ping)
193
+ # Attach to 'mongo' group app
194
+ app.command("prepare")(cmd_prepare)
195
+ app.command("setup-and-prepare")(cmd_setup_and_prepare)
196
+ app.command("ping")(cmd_ping)
@@ -17,7 +17,9 @@ def cmd_scaffold(
17
17
  entity_name: str = typer.Option(
18
18
  "Item", help="Entity class name (e.g., User, Member, Product)."
19
19
  ),
20
- documents_dir: Path = typer.Option(..., help="Directory for Mongo document models."),
20
+ documents_dir: Path = typer.Option(
21
+ ..., help="Directory for Mongo document models."
22
+ ),
21
23
  schemas_dir: Path = typer.Option(..., help="Directory for Pydantic CRUD schemas."),
22
24
  overwrite: bool = typer.Option(False, help="Overwrite existing files."),
23
25
  same_dir: bool = typer.Option(
@@ -127,7 +129,7 @@ def register(app: typer.Typer) -> None:
127
129
  • mongo-scaffold-schemas
128
130
  • mongo-scaffold-resources
129
131
  """
130
- app.command("mongo-scaffold")(cmd_scaffold)
131
- app.command("mongo-scaffold-documents")(cmd_scaffold_documents)
132
- app.command("mongo-scaffold-schemas")(cmd_scaffold_schemas)
133
- app.command("mongo-scaffold-resources")(cmd_scaffold_resources)
132
+ app.command("scaffold")(cmd_scaffold)
133
+ app.command("scaffold-documents")(cmd_scaffold_documents)
134
+ app.command("scaffold-schemas")(cmd_scaffold_schemas)
135
+ app.command("scaffold-resources")(cmd_scaffold_resources)
@@ -0,0 +1,270 @@
1
+ """Database operations CLI commands.
2
+
3
+ Provides CLI commands for database administration:
4
+ - wait: Wait for database to be ready before proceeding
5
+ - kill-queries: Terminate queries blocking a specific table
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import time
13
+ from typing import Optional
14
+
15
+ import typer
16
+
17
+
18
+ def cmd_wait(
19
+ database_url: Optional[str] = typer.Option(
20
+ None,
21
+ "--url",
22
+ "-u",
23
+ help="Database URL; overrides env SQL_URL.",
24
+ ),
25
+ timeout: int = typer.Option(
26
+ 60,
27
+ "--timeout",
28
+ "-t",
29
+ help="Maximum time to wait in seconds.",
30
+ ),
31
+ interval: float = typer.Option(
32
+ 2.0,
33
+ "--interval",
34
+ "-i",
35
+ help="Time between connection attempts in seconds.",
36
+ ),
37
+ quiet: bool = typer.Option(
38
+ False,
39
+ "--quiet",
40
+ "-q",
41
+ help="Suppress progress messages.",
42
+ ),
43
+ ) -> None:
44
+ """
45
+ Wait for database to be ready.
46
+
47
+ Attempts to connect to the database repeatedly until successful
48
+ or timeout is reached. Useful in container startup scripts.
49
+
50
+ Exit codes:
51
+ 0: Database is ready
52
+ 1: Timeout reached, database not ready
53
+ """
54
+ url = database_url or os.getenv("SQL_URL") or os.getenv("DATABASE_URL")
55
+ if not url:
56
+ typer.secho(
57
+ "ERROR: No database URL. Set --url, SQL_URL, or DATABASE_URL.",
58
+ fg=typer.colors.RED,
59
+ )
60
+ raise typer.Exit(1)
61
+
62
+ async def _wait() -> bool:
63
+ """Async wait loop."""
64
+ from svc_infra.health import check_database
65
+
66
+ check = check_database(url)
67
+ deadline = time.monotonic() + timeout
68
+ attempt = 0
69
+
70
+ while time.monotonic() < deadline:
71
+ attempt += 1
72
+ if not quiet:
73
+ typer.echo(f"Attempt {attempt}: Connecting to database...")
74
+
75
+ result = await check()
76
+
77
+ if result.status == "healthy":
78
+ if not quiet:
79
+ typer.secho(
80
+ f"✓ Database ready ({result.latency_ms:.1f}ms)",
81
+ fg=typer.colors.GREEN,
82
+ )
83
+ return True
84
+
85
+ if not quiet:
86
+ msg = result.message or "Connection failed"
87
+ typer.echo(f" → {msg}")
88
+
89
+ remaining = deadline - time.monotonic()
90
+ if remaining > 0:
91
+ await asyncio.sleep(min(interval, remaining))
92
+
93
+ return False
94
+
95
+ success = asyncio.run(_wait())
96
+ if not success:
97
+ typer.secho(
98
+ f"ERROR: Database not ready after {timeout}s",
99
+ fg=typer.colors.RED,
100
+ )
101
+ raise typer.Exit(1)
102
+
103
+
104
+ def cmd_kill_queries(
105
+ table: str = typer.Argument(
106
+ ...,
107
+ help="Table name to find blocking queries for.",
108
+ ),
109
+ database_url: Optional[str] = typer.Option(
110
+ None,
111
+ "--url",
112
+ "-u",
113
+ help="Database URL; overrides env SQL_URL.",
114
+ ),
115
+ dry_run: bool = typer.Option(
116
+ False,
117
+ "--dry-run",
118
+ "-n",
119
+ help="Show queries that would be killed without actually killing them.",
120
+ ),
121
+ force: bool = typer.Option(
122
+ False,
123
+ "--force",
124
+ "-f",
125
+ help="Terminate immediately (pg_terminate_backend) instead of cancel (pg_cancel_backend).",
126
+ ),
127
+ quiet: bool = typer.Option(
128
+ False,
129
+ "--quiet",
130
+ "-q",
131
+ help="Suppress output except errors.",
132
+ ),
133
+ ) -> None:
134
+ """
135
+ Kill queries blocking operations on a table.
136
+
137
+ Finds queries that hold locks on the specified table and attempts
138
+ to cancel or terminate them. Useful when migrations are blocked.
139
+
140
+ By default uses pg_cancel_backend (graceful). Use --force for
141
+ pg_terminate_backend (immediate termination).
142
+
143
+ Examples:
144
+ svc-infra db kill-queries users
145
+ svc-infra db kill-queries users --dry-run
146
+ svc-infra db kill-queries users --force
147
+ """
148
+ url = database_url or os.getenv("SQL_URL") or os.getenv("DATABASE_URL")
149
+ if not url:
150
+ typer.secho(
151
+ "ERROR: No database URL. Set --url, SQL_URL, or DATABASE_URL.",
152
+ fg=typer.colors.RED,
153
+ )
154
+ raise typer.Exit(1)
155
+
156
+ async def _kill_queries() -> int:
157
+ """Find and kill blocking queries. Returns count of killed queries."""
158
+ try:
159
+ import asyncpg
160
+ except ImportError:
161
+ typer.secho(
162
+ "ERROR: asyncpg not installed. Run: pip install asyncpg",
163
+ fg=typer.colors.RED,
164
+ )
165
+ raise typer.Exit(1)
166
+
167
+ # Normalize URL for asyncpg
168
+ db_url = url
169
+ if db_url.startswith("postgres://"):
170
+ db_url = db_url.replace("postgres://", "postgresql://", 1)
171
+ if "+asyncpg" in db_url:
172
+ db_url = db_url.replace("+asyncpg", "")
173
+
174
+ try:
175
+ conn = await asyncpg.connect(db_url)
176
+ except Exception as e:
177
+ typer.secho(
178
+ f"ERROR: Failed to connect to database: {e}",
179
+ fg=typer.colors.RED,
180
+ )
181
+ raise typer.Exit(1)
182
+
183
+ try:
184
+ # Find queries with locks on the table
185
+ # Uses pg_stat_activity joined with pg_locks to find blocking queries
186
+ find_query = """
187
+ SELECT DISTINCT
188
+ a.pid,
189
+ a.usename,
190
+ a.application_name,
191
+ a.state,
192
+ a.query,
193
+ a.query_start,
194
+ l.locktype,
195
+ l.mode
196
+ FROM pg_stat_activity a
197
+ JOIN pg_locks l ON a.pid = l.pid
198
+ WHERE l.relation = $1::regclass
199
+ AND a.pid != pg_backend_pid()
200
+ ORDER BY a.query_start
201
+ """
202
+
203
+ try:
204
+ rows = await conn.fetch(find_query, table)
205
+ except asyncpg.UndefinedTableError:
206
+ typer.secho(
207
+ f"ERROR: Table '{table}' does not exist",
208
+ fg=typer.colors.RED,
209
+ )
210
+ raise typer.Exit(1)
211
+
212
+ if not rows:
213
+ if not quiet:
214
+ typer.echo(f"No active queries found on table '{table}'")
215
+ return 0
216
+
217
+ if not quiet:
218
+ typer.echo(f"Found {len(rows)} query(ies) with locks on '{table}':\n")
219
+ for row in rows:
220
+ query_preview = (row["query"] or "")[:80].replace("\n", " ")
221
+ if len(row["query"] or "") > 80:
222
+ query_preview += "..."
223
+ typer.echo(f" PID {row['pid']}: {query_preview}")
224
+ typer.echo(f" User: {row['usename']}, State: {row['state']}")
225
+ typer.echo(f" Lock: {row['mode']} on {row['locktype']}")
226
+ typer.echo("")
227
+
228
+ if dry_run:
229
+ typer.echo("Dry run - no queries killed.")
230
+ return 0
231
+
232
+ # Kill the queries
233
+ kill_fn = "pg_terminate_backend" if force else "pg_cancel_backend"
234
+ killed = 0
235
+
236
+ for row in rows:
237
+ pid = row["pid"]
238
+ try:
239
+ result = await conn.fetchval(f"SELECT {kill_fn}($1)", pid)
240
+ if result:
241
+ killed += 1
242
+ if not quiet:
243
+ action = "Terminated" if force else "Cancelled"
244
+ typer.secho(f" {action} PID {pid}", fg=typer.colors.GREEN)
245
+ else:
246
+ if not quiet:
247
+ typer.echo(
248
+ f" PID {pid}: already finished or permission denied"
249
+ )
250
+ except Exception as e:
251
+ if not quiet:
252
+ typer.secho(f" PID {pid}: Error - {e}", fg=typer.colors.YELLOW)
253
+
254
+ if not quiet:
255
+ typer.echo(f"\n{killed}/{len(rows)} queries killed.")
256
+ return killed
257
+
258
+ finally:
259
+ await conn.close()
260
+
261
+ count = asyncio.run(_kill_queries())
262
+ if count == 0 and not dry_run:
263
+ # Exit with 0 even if no queries found - that's success
264
+ pass
265
+
266
+
267
+ def register(app: typer.Typer) -> None:
268
+ """Register database operations commands with the CLI app."""
269
+ app.command("wait")(cmd_wait)
270
+ app.command("kill-queries")(cmd_kill_queries)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
5
+ from importlib import import_module
4
6
  from typing import List, Optional
5
7
 
6
8
  import typer
@@ -75,10 +77,16 @@ def cmd_revision(
75
77
  database_url: Optional[str] = typer.Option(
76
78
  None, help="Database URL; overrides env for this command."
77
79
  ),
78
- autogenerate: bool = typer.Option(False, help="Autogenerate migrations by comparing metadata."),
79
- head: Optional[str] = typer.Option("head", help="Set the head to base this revision on."),
80
+ autogenerate: bool = typer.Option(
81
+ False, help="Autogenerate migrations by comparing metadata."
82
+ ),
83
+ head: Optional[str] = typer.Option(
84
+ "head", help="Set the head to base this revision on."
85
+ ),
80
86
  branch_label: Optional[str] = typer.Option(None, help="Branch label."),
81
- version_path: Optional[str] = typer.Option(None, help="Alternative versions/ path."),
87
+ version_path: Optional[str] = typer.Option(
88
+ None, help="Alternative versions/ path."
89
+ ),
82
90
  sql: bool = typer.Option(False, help="Don't generate Python; dump SQL to stdout."),
83
91
  ):
84
92
  """Create a new Alembic revision, either empty or autogenerated."""
@@ -94,7 +102,9 @@ def cmd_revision(
94
102
 
95
103
 
96
104
  def cmd_upgrade(
97
- revision_target: str = typer.Argument("head", help="Target revision (default head)."),
105
+ revision_target: str = typer.Argument(
106
+ "head", help="Target revision (default head)."
107
+ ),
98
108
  database_url: Optional[str] = typer.Option(
99
109
  None, help="Database URL; overrides env for this command."
100
110
  ),
@@ -123,7 +133,11 @@ def cmd_current(
123
133
  ):
124
134
  """Display the current revision for each database."""
125
135
  apply_database_url(database_url)
126
- core_current(verbose=verbose)
136
+ result = core_current(verbose=verbose)
137
+ try:
138
+ typer.echo(json.dumps(result))
139
+ except Exception:
140
+ typer.echo(str(result))
127
141
 
128
142
 
129
143
  def cmd_history(
@@ -152,7 +166,9 @@ def cmd_merge_heads(
152
166
  database_url: Optional[str] = typer.Option(
153
167
  None, help="Database URL; overrides env for this command."
154
168
  ),
155
- message: Optional[str] = typer.Option(None, "-m", "--message", help="Merge revision message."),
169
+ message: Optional[str] = typer.Option(
170
+ None, "-m", "--message", help="Merge revision message."
171
+ ),
156
172
  ):
157
173
  """Create a merge revision for multiple heads."""
158
174
  apply_database_url(database_url)
@@ -164,8 +180,12 @@ def cmd_setup_and_migrate(
164
180
  None,
165
181
  help="Overrides env for this command. Async vs sync is auto-detected from the URL.",
166
182
  ),
167
- overwrite_scaffold: bool = typer.Option(False, help="Overwrite alembic scaffold if present."),
168
- create_db_if_missing: bool = typer.Option(True, help="Create the database/schema if missing."),
183
+ overwrite_scaffold: bool = typer.Option(
184
+ False, help="Overwrite alembic scaffold if present."
185
+ ),
186
+ create_db_if_missing: bool = typer.Option(
187
+ True, help="Create the database/schema if missing."
188
+ ),
169
189
  create_followup_revision: bool = typer.Option(
170
190
  True, help="Create an autogen follow-up revision if revisions already exist."
171
191
  ),
@@ -188,7 +208,7 @@ def cmd_setup_and_migrate(
188
208
  Async vs. sync is inferred from SQL_URL.
189
209
  """
190
210
  final_pkgs = _find_pkgs(with_payments, discover_packages)
191
- core_setup_and_migrate(
211
+ result = core_setup_and_migrate(
192
212
  overwrite_scaffold=overwrite_scaffold,
193
213
  create_db_if_missing=create_db_if_missing,
194
214
  create_followup_revision=create_followup_revision,
@@ -197,15 +217,80 @@ def cmd_setup_and_migrate(
197
217
  discover_packages=final_pkgs or None,
198
218
  database_url=database_url,
199
219
  )
220
+ # Echo a concise JSON result so tests and users can introspect outcome
221
+ try:
222
+ typer.echo(json.dumps(result))
223
+ except Exception:
224
+ # Fallback to plain string if not JSON-serializable for any reason
225
+ typer.echo(str(result))
200
226
 
201
227
 
202
228
  def register(app: typer.Typer) -> None:
203
- app.command("sql-init")(cmd_init)
204
- app.command("sql-revision")(cmd_revision)
205
- app.command("sql-upgrade")(cmd_upgrade)
206
- app.command("sql-downgrade")(cmd_downgrade)
207
- app.command("sql-current")(cmd_current)
208
- app.command("sql-history")(cmd_history)
209
- app.command("sql-stamp")(cmd_stamp)
210
- app.command("sql-merge-heads")(cmd_merge_heads)
211
- app.command("sql-setup-and-migrate")(cmd_setup_and_migrate)
229
+ # Register under the 'sql' group app
230
+ app.command("init")(cmd_init)
231
+ app.command("revision")(cmd_revision)
232
+ app.command("upgrade")(cmd_upgrade)
233
+ # Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
234
+ app.command(
235
+ "downgrade",
236
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
237
+ )(cmd_downgrade)
238
+ app.command("current")(cmd_current)
239
+ app.command("history")(cmd_history)
240
+ app.command("stamp")(cmd_stamp)
241
+ app.command("merge-heads")(cmd_merge_heads)
242
+ app.command("setup-and-migrate")(cmd_setup_and_migrate)
243
+ app.command("seed")(cmd_seed)
244
+
245
+
246
+ def _import_callable(path: str):
247
+ mod_name, _, fn_name = path.partition(":")
248
+ if not mod_name or not fn_name:
249
+ raise typer.BadParameter("Expected format 'module.path:callable'")
250
+ # Back-compat: after moving tests under tests/unit, allow legacy test module
251
+ # dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
252
+ mod = None
253
+ unit_mod = None
254
+ if mod_name.startswith("tests.db."):
255
+ # Try legacy import first (shim module), then unit module fallback
256
+ try:
257
+ mod = import_module(mod_name)
258
+ except ModuleNotFoundError:
259
+ pass
260
+ unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
261
+ try:
262
+ unit_mod = import_module(unit_name)
263
+ except ModuleNotFoundError:
264
+ unit_mod = None
265
+ # If both exist, unify shared state where applicable
266
+ if mod is not None and unit_mod is not None:
267
+ # Example: tests use a global `called` dict; point legacy to unit
268
+ try:
269
+ if hasattr(unit_mod, "called"):
270
+ setattr(mod, "called", getattr(unit_mod, "called"))
271
+ except Exception:
272
+ pass
273
+ # If legacy mod missing but unit exists, use unit
274
+ if mod is None and unit_mod is not None:
275
+ mod = unit_mod
276
+ else:
277
+ mod = import_module(mod_name)
278
+ fn = getattr(mod, fn_name, None)
279
+ if not callable(fn):
280
+ raise typer.BadParameter(
281
+ f"Callable '{fn_name}' not found in module '{mod_name}'"
282
+ )
283
+ return fn
284
+
285
+
286
+ def cmd_seed(
287
+ target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
288
+ database_url: Optional[str] = typer.Option(
289
+ None,
290
+ help="Database URL; overrides env for this command.",
291
+ ),
292
+ ):
293
+ """Run a user-provided seed function to load fixtures/reference data."""
294
+ apply_database_url(database_url)
295
+ fn = _import_callable(target)
296
+ fn()