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
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
- from typing import List, Optional
5
+ from importlib import import_module
5
6
 
6
7
  import typer
7
8
 
@@ -17,13 +18,13 @@ from svc_infra.db.sql.core import stamp as core_stamp
17
18
  from svc_infra.db.sql.core import upgrade as core_upgrade
18
19
 
19
20
 
20
- def apply_database_url(database_url: Optional[str]) -> None:
21
+ def apply_database_url(database_url: str | None) -> None:
21
22
  """If provided, set SQL_URL for the current process."""
22
23
  if database_url:
23
24
  os.environ["SQL_URL"] = database_url
24
25
 
25
26
 
26
- def _find_pkgs(with_payments, discover_packages) -> List[str]:
27
+ def _find_pkgs(with_payments, discover_packages) -> list[str]:
27
28
  from os import getenv
28
29
 
29
30
  payments_enabled = (
@@ -41,12 +42,12 @@ def _find_pkgs(with_payments, discover_packages) -> List[str]:
41
42
 
42
43
 
43
44
  def cmd_init(
44
- database_url: Optional[str] = typer.Option(
45
+ database_url: str | None = typer.Option(
45
46
  None,
46
47
  help="Database URL; overrides env SQL_URL for this command. "
47
48
  "Async vs sync is auto-detected from the URL.",
48
49
  ),
49
- discover_packages: Optional[List[str]] = typer.Option(
50
+ discover_packages: list[str] | None = typer.Option(
50
51
  None,
51
52
  help="Packages to search for SQLAlchemy metadata; may pass multiple. "
52
53
  "If omitted, automatic discovery is used.",
@@ -72,13 +73,13 @@ def cmd_init(
72
73
 
73
74
  def cmd_revision(
74
75
  message: str = typer.Option(..., "-m", "--message", help="Revision message."),
75
- database_url: Optional[str] = typer.Option(
76
+ database_url: str | None = typer.Option(
76
77
  None, help="Database URL; overrides env for this command."
77
78
  ),
78
79
  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
- branch_label: Optional[str] = typer.Option(None, help="Branch label."),
81
- version_path: Optional[str] = typer.Option(None, help="Alternative versions/ path."),
80
+ head: str | None = typer.Option("head", help="Set the head to base this revision on."),
81
+ branch_label: str | None = typer.Option(None, help="Branch label."),
82
+ version_path: str | None = typer.Option(None, help="Alternative versions/ path."),
82
83
  sql: bool = typer.Option(False, help="Don't generate Python; dump SQL to stdout."),
83
84
  ):
84
85
  """Create a new Alembic revision, either empty or autogenerated."""
@@ -95,7 +96,7 @@ def cmd_revision(
95
96
 
96
97
  def cmd_upgrade(
97
98
  revision_target: str = typer.Argument("head", help="Target revision (default head)."),
98
- database_url: Optional[str] = typer.Option(
99
+ database_url: str | None = typer.Option(
99
100
  None, help="Database URL; overrides env for this command."
100
101
  ),
101
102
  ):
@@ -106,7 +107,7 @@ def cmd_upgrade(
106
107
 
107
108
  def cmd_downgrade(
108
109
  revision_target: str = typer.Argument("-1", help="Target revision (default -1)."),
109
- database_url: Optional[str] = typer.Option(
110
+ database_url: str | None = typer.Option(
110
111
  None, help="Database URL; overrides env for this command."
111
112
  ),
112
113
  ):
@@ -116,18 +117,22 @@ def cmd_downgrade(
116
117
 
117
118
 
118
119
  def cmd_current(
119
- database_url: Optional[str] = typer.Option(
120
+ database_url: str | None = typer.Option(
120
121
  None, help="Database URL; overrides env for this command."
121
122
  ),
122
123
  verbose: bool = typer.Option(False, help="Verbose output."),
123
124
  ):
124
125
  """Display the current revision for each database."""
125
126
  apply_database_url(database_url)
126
- core_current(verbose=verbose)
127
+ result = core_current(verbose=verbose)
128
+ try:
129
+ typer.echo(json.dumps(result))
130
+ except Exception:
131
+ typer.echo(str(result))
127
132
 
128
133
 
129
134
  def cmd_history(
130
- database_url: Optional[str] = typer.Option(
135
+ database_url: str | None = typer.Option(
131
136
  None, help="Database URL; overrides env for this command."
132
137
  ),
133
138
  verbose: bool = typer.Option(False, help="Verbose output."),
@@ -139,7 +144,7 @@ def cmd_history(
139
144
 
140
145
  def cmd_stamp(
141
146
  revision_target: str = typer.Argument("head"),
142
- database_url: Optional[str] = typer.Option(
147
+ database_url: str | None = typer.Option(
143
148
  None, help="Database URL; overrides env for this command."
144
149
  ),
145
150
  ):
@@ -149,10 +154,10 @@ def cmd_stamp(
149
154
 
150
155
 
151
156
  def cmd_merge_heads(
152
- database_url: Optional[str] = typer.Option(
157
+ database_url: str | None = typer.Option(
153
158
  None, help="Database URL; overrides env for this command."
154
159
  ),
155
- message: Optional[str] = typer.Option(None, "-m", "--message", help="Merge revision message."),
160
+ message: str | None = typer.Option(None, "-m", "--message", help="Merge revision message."),
156
161
  ):
157
162
  """Create a merge revision for multiple heads."""
158
163
  apply_database_url(database_url)
@@ -160,7 +165,7 @@ def cmd_merge_heads(
160
165
 
161
166
 
162
167
  def cmd_setup_and_migrate(
163
- database_url: Optional[str] = typer.Option(
168
+ database_url: str | None = typer.Option(
164
169
  None,
165
170
  help="Overrides env for this command. Async vs sync is auto-detected from the URL.",
166
171
  ),
@@ -172,7 +177,7 @@ def cmd_setup_and_migrate(
172
177
  initial_message: str = typer.Option("initial schema"),
173
178
  followup_message: str = typer.Option("autogen"),
174
179
  # NEW:
175
- discover_packages: Optional[List[str]] = typer.Option(
180
+ discover_packages: list[str] | None = typer.Option(
176
181
  None,
177
182
  help="Packages Alembic should import to discover models "
178
183
  "(e.g. app.models,svc_infra.apf_payments.models).",
@@ -188,7 +193,7 @@ def cmd_setup_and_migrate(
188
193
  Async vs. sync is inferred from SQL_URL.
189
194
  """
190
195
  final_pkgs = _find_pkgs(with_payments, discover_packages)
191
- core_setup_and_migrate(
196
+ result = core_setup_and_migrate(
192
197
  overwrite_scaffold=overwrite_scaffold,
193
198
  create_db_if_missing=create_db_if_missing,
194
199
  create_followup_revision=create_followup_revision,
@@ -197,15 +202,78 @@ def cmd_setup_and_migrate(
197
202
  discover_packages=final_pkgs or None,
198
203
  database_url=database_url,
199
204
  )
205
+ # Echo a concise JSON result so tests and users can introspect outcome
206
+ try:
207
+ typer.echo(json.dumps(result))
208
+ except Exception:
209
+ # Fallback to plain string if not JSON-serializable for any reason
210
+ typer.echo(str(result))
200
211
 
201
212
 
202
213
  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)
214
+ # Register under the 'sql' group app
215
+ app.command("init")(cmd_init)
216
+ app.command("revision")(cmd_revision)
217
+ app.command("upgrade")(cmd_upgrade)
218
+ # Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
219
+ app.command(
220
+ "downgrade",
221
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
222
+ )(cmd_downgrade)
223
+ app.command("current")(cmd_current)
224
+ app.command("history")(cmd_history)
225
+ app.command("stamp")(cmd_stamp)
226
+ app.command("merge-heads")(cmd_merge_heads)
227
+ app.command("setup-and-migrate")(cmd_setup_and_migrate)
228
+ app.command("seed")(cmd_seed)
229
+
230
+
231
+ def _import_callable(path: str):
232
+ mod_name, _, fn_name = path.partition(":")
233
+ if not mod_name or not fn_name:
234
+ raise typer.BadParameter("Expected format 'module.path:callable'")
235
+ # Back-compat: after moving tests under tests/unit, allow legacy test module
236
+ # dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
237
+ mod = None
238
+ unit_mod = None
239
+ if mod_name.startswith("tests.db."):
240
+ # Try legacy import first (shim module), then unit module fallback
241
+ try:
242
+ mod = import_module(mod_name)
243
+ except ModuleNotFoundError:
244
+ pass
245
+ unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
246
+ try:
247
+ unit_mod = import_module(unit_name)
248
+ except ModuleNotFoundError:
249
+ unit_mod = None
250
+ # If both exist, unify shared state where applicable
251
+ if mod is not None and unit_mod is not None:
252
+ # Example: tests use a global `called` dict; point legacy to unit
253
+ try:
254
+ if hasattr(unit_mod, "called"):
255
+ mod.called = unit_mod.called # type: ignore[attr-defined]
256
+ except Exception:
257
+ pass
258
+ # If legacy mod missing but unit exists, use unit
259
+ if mod is None and unit_mod is not None:
260
+ mod = unit_mod
261
+ else:
262
+ mod = import_module(mod_name)
263
+ fn = getattr(mod, fn_name, None)
264
+ if not callable(fn):
265
+ raise typer.BadParameter(f"Callable '{fn_name}' not found in module '{mod_name}'")
266
+ return fn
267
+
268
+
269
+ def cmd_seed(
270
+ target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
271
+ database_url: str | None = typer.Option(
272
+ None,
273
+ help="Database URL; overrides env for this command.",
274
+ ),
275
+ ):
276
+ """Run a user-provided seed function to load fixtures/reference data."""
277
+ apply_database_url(database_url)
278
+ fn = _import_callable(target)
279
+ fn()
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import typer
11
+ from sqlalchemy import text
12
+ from sqlalchemy.engine import Engine
13
+
14
+ from svc_infra.db.sql.utils import build_engine
15
+
16
+ try: # SQLAlchemy async extras are optional
17
+ import sqlalchemy.ext.asyncio as sa_async
18
+ except Exception: # pragma: no cover - fallback when async extras unavailable
19
+ sa_async = None # type: ignore[assignment]
20
+
21
+
22
+ def export_tenant(
23
+ table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
24
+ tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
25
+ tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
26
+ output: Path | None = typer.Option(None, "--output", help="Output file; defaults to stdout."),
27
+ limit: int | None = typer.Option(None, help="Max rows to export."),
28
+ database_url: str | None = typer.Option(
29
+ None, "--database-url", help="Overrides env SQL_URL for this command."
30
+ ),
31
+ ):
32
+ """Export rows for a tenant from a given SQL table as JSON array."""
33
+ if database_url:
34
+ os.environ["SQL_URL"] = database_url
35
+
36
+ url = os.getenv("SQL_URL")
37
+ if not url:
38
+ typer.echo("SQL_URL is required (or pass --database-url)", err=True)
39
+ raise typer.Exit(code=2)
40
+
41
+ engine = build_engine(url)
42
+ rows: list[dict[str, Any]]
43
+ query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
44
+ if limit and limit > 0:
45
+ query += " LIMIT :limit"
46
+
47
+ params: dict[str, Any] = {"tenant_id": tenant_id}
48
+ if limit and limit > 0:
49
+ params["limit"] = int(limit)
50
+
51
+ stmt = text(query)
52
+
53
+ is_async_engine = sa_async is not None and isinstance(engine, sa_async.AsyncEngine)
54
+
55
+ if is_async_engine:
56
+ async_engine = cast("Any", engine)
57
+
58
+ async def _fetch() -> list[dict[str, Any]]:
59
+ async with async_engine.connect() as conn:
60
+ result = await conn.execute(stmt, params)
61
+ return [dict(row) for row in result.mappings()]
62
+
63
+ rows = asyncio.run(_fetch())
64
+ else:
65
+ sync_engine = cast("Engine", engine)
66
+ with sync_engine.connect() as conn:
67
+ result = conn.execute(stmt, params)
68
+ rows = [dict(row) for row in result.mappings()]
69
+
70
+ data = json.dumps(rows, indent=2)
71
+ if output:
72
+ output.write_text(data)
73
+ typer.echo(str(output))
74
+ else:
75
+ sys.stdout.write(data)
76
+
77
+
78
+ def register(app_root: typer.Typer) -> None:
79
+ # Attach directly to the provided 'sql' group app
80
+ app_root.command("export-tenant")(export_tenant)
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Optional, cast
4
+ from typing import cast
5
5
 
6
6
  import click
7
7
  import typer
@@ -23,7 +23,7 @@ def cmd_scaffold(
23
23
  entity_name: str = typer.Option(
24
24
  "Item", help="Class name for entity/auth (e.g., User, Member, Product)."
25
25
  ),
26
- table_name: Optional[str] = typer.Option(
26
+ table_name: str | None = typer.Option(
27
27
  None,
28
28
  help="Optional table name. For kind=auth, can also be set via AUTH_TABLE_NAME; defaults to plural snake of entity.",
29
29
  ),
@@ -35,10 +35,10 @@ def cmd_scaffold(
35
35
  "--same-dir/--no-same-dir",
36
36
  help="Put models & schemas into the same dir.",
37
37
  ),
38
- models_filename: Optional[str] = typer.Option(
38
+ models_filename: str | None = typer.Option(
39
39
  None, help="Custom filename for models (separate-dir mode)."
40
40
  ),
41
- schemas_filename: Optional[str] = typer.Option(
41
+ schemas_filename: str | None = typer.Option(
42
42
  None, help="Custom filename for schemas (separate-dir mode)."
43
43
  ),
44
44
  ):
@@ -50,7 +50,7 @@ def cmd_scaffold(
50
50
  res = scaffold_core(
51
51
  models_dir=models_dir,
52
52
  schemas_dir=schemas_dir,
53
- kind=cast(Kind, kind.lower()),
53
+ kind=cast("Kind", kind.lower()),
54
54
  entity_name=entity_name,
55
55
  table_name=table_name,
56
56
  overwrite=overwrite,
@@ -70,13 +70,13 @@ def cmd_scaffold_models(
70
70
  click_type=click.Choice(["entity", "auth"], case_sensitive=False),
71
71
  ),
72
72
  entity_name: str = typer.Option("Item", "--entity-name"),
73
- table_name: Optional[str] = typer.Option(None, "--table-name"),
73
+ table_name: str | None = typer.Option(None, "--table-name"),
74
74
  include_tenant: bool = typer.Option(True, "--include-tenant/--no-include-tenant"),
75
75
  include_soft_delete: bool = typer.Option(
76
76
  False, "--include-soft-delete/--no-include-soft-delete"
77
77
  ),
78
78
  overwrite: bool = typer.Option(False, "--overwrite/--no-overwrite"),
79
- models_filename: Optional[str] = typer.Option(
79
+ models_filename: str | None = typer.Option(
80
80
  None,
81
81
  "--models-filename",
82
82
  help="Filename to write (e.g. project_models.py). Defaults to <snake(entity)>.py",
@@ -89,7 +89,7 @@ def cmd_scaffold_models(
89
89
  """
90
90
  res = scaffold_models_core(
91
91
  dest_dir=dest_dir,
92
- kind=cast(Kind, kind.lower()),
92
+ kind=cast("Kind", kind.lower()),
93
93
  entity_name=entity_name,
94
94
  table_name=table_name,
95
95
  include_tenant=include_tenant,
@@ -111,7 +111,7 @@ def cmd_scaffold_schemas(
111
111
  entity_name: str = typer.Option("Item", "--entity-name"),
112
112
  include_tenant: bool = typer.Option(True, "--include-tenant/--no-include-tenant"),
113
113
  overwrite: bool = typer.Option(False, "--overwrite/--no-overwrite"),
114
- schemas_filename: Optional[str] = typer.Option(
114
+ schemas_filename: str | None = typer.Option(
115
115
  None,
116
116
  "--schemas-filename",
117
117
  help="Filename to write (e.g. project_schemas.py). Defaults to <snake(entity)>.py",
@@ -124,7 +124,7 @@ def cmd_scaffold_schemas(
124
124
  """
125
125
  res = scaffold_schemas_core(
126
126
  dest_dir=dest_dir,
127
- kind=cast(Kind, kind.lower()),
127
+ kind=cast("Kind", kind.lower()),
128
128
  entity_name=entity_name,
129
129
  include_tenant=include_tenant,
130
130
  overwrite=overwrite,
@@ -134,6 +134,6 @@ def cmd_scaffold_schemas(
134
134
 
135
135
 
136
136
  def register(app: typer.Typer) -> None:
137
- app.command("sql-scaffold")(cmd_scaffold)
138
- app.command("sql-scaffold-models")(cmd_scaffold_models)
139
- app.command("sql-scaffold-schemas")(cmd_scaffold_schemas)
137
+ app.command("scaffold")(cmd_scaffold)
138
+ app.command("scaffold-models")(cmd_scaffold_models)
139
+ app.command("scaffold-schemas")(cmd_scaffold_schemas)
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from importlib.resources import as_file
5
+ from importlib.resources import files as pkg_files
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import typer
10
+ from typer.core import TyperGroup
11
+
12
+ from svc_infra.app.root import resolve_project_root
13
+
14
+
15
+ def _norm(name: str) -> str:
16
+ return name.strip().lower().replace(" ", "-").replace("_", "-")
17
+
18
+
19
+ def _discover_fs_topics(docs_dir: Path) -> dict[str, Path]:
20
+ topics: dict[str, Path] = {}
21
+ if docs_dir.exists() and docs_dir.is_dir():
22
+ for p in sorted(docs_dir.glob("*.md")):
23
+ if p.is_file():
24
+ topics[_norm(p.stem)] = p
25
+ return topics
26
+
27
+
28
+ def _discover_pkg_topics() -> dict[str, Path]:
29
+ """
30
+ Discover docs shipped inside the installed package at svc_infra/docs/*,
31
+ using importlib.resources so this works for wheels, sdists, and zipped wheels.
32
+ """
33
+ topics: dict[str, Path] = {}
34
+ try:
35
+ docs_root = pkg_files("svc_infra").joinpath("docs")
36
+ # docs_root is a Traversable; it may be inside a zip. Iterate safely.
37
+ for entry in docs_root.iterdir():
38
+ if entry.name.endswith(".md"):
39
+ # materialize to a real tempfile path if needed
40
+ with as_file(entry) as concrete:
41
+ p = Path(concrete)
42
+ if p.exists() and p.is_file():
43
+ topics[_norm(p.stem)] = p
44
+ except Exception:
45
+ # If the package has no docs directory, just return empty.
46
+ pass
47
+ return topics
48
+
49
+
50
+ def _resolve_docs_dir(ctx: click.Context) -> Path | None:
51
+ """
52
+ Optional override precedence:
53
+ 1) SVC_INFRA_DOCS_DIR env var
54
+ 2) *Only when working inside the svc-infra repo itself*: repo-root /docs
55
+ """
56
+ # 1) Env var
57
+ env_dir = os.getenv("SVC_INFRA_DOCS_DIR")
58
+ if env_dir:
59
+ p = Path(env_dir).expanduser()
60
+ if p.exists():
61
+ return p
62
+
63
+ # 2) In-repo convenience (so `svc-infra docs` works inside this repo)
64
+ try:
65
+ root = resolve_project_root()
66
+ proj_docs = root / "docs"
67
+ if proj_docs.exists():
68
+ return proj_docs
69
+ except Exception:
70
+ pass
71
+
72
+ return None
73
+
74
+
75
+ class DocsGroup(TyperGroup):
76
+ def list_commands(self, ctx: click.Context) -> list[str]:
77
+ names: list[str] = list(super().list_commands(ctx) or [])
78
+ dir_to_use = _resolve_docs_dir(ctx)
79
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
80
+ pkg = _discover_pkg_topics()
81
+ names.extend(fs.keys())
82
+ names.extend([k for k in pkg.keys() if k not in fs])
83
+ return sorted(set(names))
84
+
85
+ def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
86
+ cmd = super().get_command(ctx, name)
87
+ if cmd is not None:
88
+ return cmd
89
+
90
+ key = _norm(name)
91
+
92
+ dir_to_use = _resolve_docs_dir(ctx)
93
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
94
+ if key in fs:
95
+ file_path = fs[key]
96
+
97
+ @click.command(name=name)
98
+ def _show_fs() -> None:
99
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
100
+
101
+ return _show_fs
102
+
103
+ pkg = _discover_pkg_topics()
104
+ if key in pkg:
105
+ file_path = pkg[key]
106
+
107
+ @click.command(name=name)
108
+ def _show_pkg() -> None:
109
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
110
+
111
+ return _show_pkg
112
+
113
+ return None
114
+
115
+
116
+ def register(app: typer.Typer) -> None:
117
+ docs_app = typer.Typer(no_args_is_help=True, add_completion=False, cls=DocsGroup)
118
+
119
+ @docs_app.callback(invoke_without_command=True)
120
+ def _docs_options() -> None:
121
+ # No group-level options; dynamic commands and 'show' handle topics.
122
+ return None
123
+
124
+ @docs_app.command("show", help="Show docs for a topic (alternative to dynamic subcommand)")
125
+ def show(topic: str) -> None:
126
+ key = _norm(topic)
127
+ ctx = click.get_current_context()
128
+ dir_to_use = _resolve_docs_dir(ctx)
129
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
130
+ if key in fs:
131
+ typer.echo(fs[key].read_text(encoding="utf-8", errors="replace"))
132
+ return
133
+ pkg = _discover_pkg_topics()
134
+ if key in pkg:
135
+ typer.echo(pkg[key].read_text(encoding="utf-8", errors="replace"))
136
+ return
137
+ raise typer.BadParameter(f"Unknown topic: {topic}")
138
+
139
+ app.add_typer(docs_app, name="docs")
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .dx_cmds import app as dx_app
6
+
7
+
8
+ def register_dx(root: typer.Typer) -> None:
9
+ root.add_typer(dx_app, name="dx")
10
+
11
+
12
+ __all__ = ["register_dx"]
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from svc_infra.dx.changelog import Commit, generate_release_section
9
+ from svc_infra.dx.checks import (
10
+ check_migrations_up_to_date,
11
+ check_openapi_problem_schema,
12
+ )
13
+
14
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
15
+
16
+
17
+ @app.command("openapi")
18
+ def cmd_openapi(path: str = typer.Argument(..., help="Path to OpenAPI JSON")):
19
+ try:
20
+ check_openapi_problem_schema(path=path)
21
+ except Exception as e:
22
+ typer.secho(f"OpenAPI check failed: {e}", fg=typer.colors.RED, err=True)
23
+ raise typer.Exit(2)
24
+ typer.secho("OpenAPI checks passed", fg=typer.colors.GREEN)
25
+
26
+
27
+ @app.command("migrations")
28
+ def cmd_migrations(project_root: str = typer.Option(".", help="Project root")):
29
+ try:
30
+ check_migrations_up_to_date(project_root=project_root)
31
+ except Exception as e:
32
+ typer.secho(f"Migrations check failed: {e}", fg=typer.colors.RED, err=True)
33
+ raise typer.Exit(2)
34
+ typer.secho("Migrations checks passed", fg=typer.colors.GREEN)
35
+
36
+
37
+ @app.command("changelog")
38
+ def cmd_changelog(
39
+ version: str = typer.Argument(..., help="Version (e.g., 0.1.604)"),
40
+ commits_file: str = typer.Option(None, help="Path to JSON lines of commits (sha,subject)"),
41
+ ):
42
+ """Generate a changelog section from commit messages.
43
+
44
+ Expects Conventional Commits style for best grouping; falls back to Other.
45
+ If commits_file is omitted, prints an example format.
46
+ """
47
+ import json
48
+ import sys
49
+
50
+ if not commits_file:
51
+ typer.echo(
52
+ '# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
53
+ err=True,
54
+ )
55
+ raise typer.Exit(2)
56
+ rows = [
57
+ json.loads(line) for line in Path(commits_file).read_text().splitlines() if line.strip()
58
+ ]
59
+ commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
60
+ out = generate_release_section(version=version, commits=commits)
61
+ sys.stdout.write(out)
62
+
63
+
64
+ @app.command("ci")
65
+ def cmd_ci(
66
+ run: bool = typer.Option(False, help="Execute the steps; default just prints a plan"),
67
+ openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
68
+ project_root: str = typer.Option(".", help="Project root for migrations check"),
69
+ ):
70
+ """Print (or run) the CI steps locally to mirror the workflow."""
71
+ steps: list[list[str]] = []
72
+ # Lint, typecheck, tests
73
+ steps.append(["flake8", "--select=E,F"]) # mirrors CI
74
+ steps.append(["mypy", "src"]) # mirrors CI
75
+ if openapi:
76
+ steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
77
+ steps.append(
78
+ [
79
+ sys.executable,
80
+ "-m",
81
+ "svc_infra.cli",
82
+ "dx",
83
+ "migrations",
84
+ "--project-root",
85
+ project_root,
86
+ ]
87
+ )
88
+ steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
89
+
90
+ if not run:
91
+ typer.echo("CI dry-run plan:")
92
+ for cmd in steps:
93
+ typer.echo(" $ " + " ".join(cmd))
94
+ return
95
+
96
+ import subprocess
97
+
98
+ for cmd in steps:
99
+ typer.echo("Running: " + " ".join(cmd))
100
+ res = subprocess.run(cmd)
101
+ if res.returncode != 0:
102
+ raise typer.Exit(res.returncode)
103
+ typer.echo("All CI steps passed")
104
+
105
+
106
+ def main(): # pragma: no cover - CLI entrypoint
107
+ app()
108
+
109
+
110
+ __all__ = ["main", "app"]