svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
svc_infra/cli/__init__.py CHANGED
@@ -4,10 +4,15 @@ import typer
4
4
 
5
5
  from svc_infra.cli.cmds import (
6
6
  _HELP,
7
+ jobs_app,
7
8
  register_alembic,
9
+ register_docs,
10
+ register_dx,
8
11
  register_mongo,
9
12
  register_mongo_scaffold,
10
13
  register_obs,
14
+ register_sdk,
15
+ register_sql_export,
11
16
  register_sql_scaffold,
12
17
  )
13
18
  from svc_infra.cli.foundation.typer_bootstrap import pre_cli
@@ -15,16 +20,35 @@ from svc_infra.cli.foundation.typer_bootstrap import pre_cli
15
20
  app = typer.Typer(no_args_is_help=True, add_completion=False, help=_HELP)
16
21
  pre_cli(app)
17
22
 
18
- # --- sql commands ---
19
- register_alembic(app)
20
- register_sql_scaffold(app)
23
+ # --- sql group ---
24
+ sql_app = typer.Typer(no_args_is_help=True, add_completion=False, help="SQL commands")
25
+ register_alembic(sql_app)
26
+ register_sql_scaffold(sql_app)
27
+ register_sql_export(sql_app)
28
+ app.add_typer(sql_app, name="sql")
21
29
 
22
- # --- nosql commands ---
23
- register_mongo(app)
24
- register_mongo_scaffold(app)
30
+ # --- mongo group ---
31
+ mongo_app = typer.Typer(no_args_is_help=True, add_completion=False, help="MongoDB commands")
32
+ register_mongo(mongo_app)
33
+ register_mongo_scaffold(mongo_app)
34
+ app.add_typer(mongo_app, name="mongo")
25
35
 
26
- # -- observability commands ---
27
- register_obs(app)
36
+ # -- obs group ---
37
+ obs_app = typer.Typer(no_args_is_help=True, add_completion=False, help="Observability commands")
38
+ register_obs(obs_app)
39
+ app.add_typer(obs_app, name="obs")
40
+
41
+ # -- dx commands ---
42
+ register_dx(app)
43
+
44
+ # -- jobs commands ---
45
+ app.add_typer(jobs_app, name="jobs")
46
+
47
+ # -- sdk commands ---
48
+ register_sdk(app)
49
+
50
+ # -- docs commands ---
51
+ register_docs(app)
28
52
 
29
53
 
30
54
  def main():
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -3,16 +3,26 @@ from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
3
3
  register as register_mongo_scaffold,
4
4
  )
5
5
  from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
6
+ from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
6
7
  from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
8
+ from svc_infra.cli.cmds.docs.docs_cmds import register as register_docs
9
+ from svc_infra.cli.cmds.dx import register_dx
10
+ from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
7
11
  from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
12
+ from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
8
13
 
9
14
  from .help import _HELP
10
15
 
11
16
  __all__ = [
12
17
  "register_alembic",
13
18
  "register_sql_scaffold",
19
+ "register_sql_export",
14
20
  "register_mongo",
15
21
  "register_mongo_scaffold",
16
22
  "register_obs",
23
+ "jobs_app",
24
+ "register_sdk",
25
+ "register_dx",
26
+ "register_docs",
17
27
  "_HELP",
18
28
  ]
@@ -188,6 +188,7 @@ def cmd_ping(
188
188
 
189
189
 
190
190
  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)
191
+ # Attach to 'mongo' group app
192
+ app.command("prepare")(cmd_prepare)
193
+ app.command("setup-and-prepare")(cmd_setup_and_prepare)
194
+ app.command("ping")(cmd_ping)
@@ -127,7 +127,7 @@ def register(app: typer.Typer) -> None:
127
127
  • mongo-scaffold-schemas
128
128
  • mongo-scaffold-resources
129
129
  """
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)
130
+ app.command("scaffold")(cmd_scaffold)
131
+ app.command("scaffold-documents")(cmd_scaffold_documents)
132
+ app.command("scaffold-schemas")(cmd_scaffold_schemas)
133
+ app.command("scaffold-resources")(cmd_scaffold_resources)
@@ -1,10 +1,13 @@
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
7
9
 
10
+ from svc_infra.apf_payments.alembic import discover_packages as payments_pkgs
8
11
  from svc_infra.db.sql.core import current as core_current
9
12
  from svc_infra.db.sql.core import downgrade as core_downgrade
10
13
  from svc_infra.db.sql.core import history as core_history
@@ -22,6 +25,23 @@ def apply_database_url(database_url: Optional[str]) -> None:
22
25
  os.environ["SQL_URL"] = database_url
23
26
 
24
27
 
28
+ def _find_pkgs(with_payments, discover_packages) -> List[str]:
29
+ from os import getenv
30
+
31
+ payments_enabled = (
32
+ with_payments
33
+ if with_payments is not None
34
+ else str(getenv("APF_ENABLE_PAYMENTS", "")).lower() in {"1", "true", "yes"}
35
+ )
36
+ final_pkgs = list(discover_packages or [])
37
+ if payments_enabled:
38
+ for p in payments_pkgs():
39
+ if p not in final_pkgs:
40
+ final_pkgs.append(p)
41
+
42
+ return final_pkgs
43
+
44
+
25
45
  def cmd_init(
26
46
  database_url: Optional[str] = typer.Option(
27
47
  None,
@@ -34,14 +54,20 @@ def cmd_init(
34
54
  "If omitted, automatic discovery is used.",
35
55
  ),
36
56
  overwrite: bool = typer.Option(False, help="Overwrite existing files if present."),
57
+ with_payments: bool = typer.Option(
58
+ None,
59
+ help="Include svc-infra payments models when rendering env.py. "
60
+ "Defaults from env APF_ENABLE_PAYMENTS.",
61
+ ),
37
62
  ):
38
63
  """
39
64
  Initialize Alembic scaffold. The env.py variant (async vs. sync) is
40
65
  auto-detected from SQL_URL (if available at init time).
41
66
  """
67
+ final_pkgs = _find_pkgs(with_payments, discover_packages)
42
68
  apply_database_url(database_url)
43
69
  core_init_alembic(
44
- discover_packages=discover_packages,
70
+ discover_packages=final_pkgs or None,
45
71
  overwrite=overwrite,
46
72
  )
47
73
 
@@ -99,7 +125,11 @@ def cmd_current(
99
125
  ):
100
126
  """Display the current revision for each database."""
101
127
  apply_database_url(database_url)
102
- core_current(verbose=verbose)
128
+ result = core_current(verbose=verbose)
129
+ try:
130
+ typer.echo(json.dumps(result))
131
+ except Exception:
132
+ typer.echo(str(result))
103
133
 
104
134
 
105
135
  def cmd_history(
@@ -147,28 +177,104 @@ def cmd_setup_and_migrate(
147
177
  ),
148
178
  initial_message: str = typer.Option("initial schema"),
149
179
  followup_message: str = typer.Option("autogen"),
180
+ # NEW:
181
+ discover_packages: Optional[List[str]] = typer.Option(
182
+ None,
183
+ help="Packages Alembic should import to discover models "
184
+ "(e.g. app.models,svc_infra.apf_payments.models).",
185
+ ),
186
+ with_payments: bool = typer.Option(
187
+ None, # None = read env
188
+ help="Include svc-infra payments models in migrations. "
189
+ "If omitted, falls back to env APF_ENABLE_PAYMENTS=true/1.",
190
+ ),
150
191
  ):
151
192
  """
152
- End-to-end: ensure DB exists, scaffold Alembic, create/upgrade revisions.
193
+ End-to-end: ensure DB exists, scaffold Alembic, create/upgrade, all in one command.
153
194
  Async vs. sync is inferred from SQL_URL.
154
195
  """
155
- apply_database_url(database_url)
156
- core_setup_and_migrate(
196
+ final_pkgs = _find_pkgs(with_payments, discover_packages)
197
+ result = core_setup_and_migrate(
157
198
  overwrite_scaffold=overwrite_scaffold,
158
199
  create_db_if_missing=create_db_if_missing,
159
200
  create_followup_revision=create_followup_revision,
160
201
  initial_message=initial_message,
161
202
  followup_message=followup_message,
203
+ discover_packages=final_pkgs or None,
204
+ database_url=database_url,
162
205
  )
206
+ # Echo a concise JSON result so tests and users can introspect outcome
207
+ try:
208
+ typer.echo(json.dumps(result))
209
+ except Exception:
210
+ # Fallback to plain string if not JSON-serializable for any reason
211
+ typer.echo(str(result))
163
212
 
164
213
 
165
214
  def register(app: typer.Typer) -> None:
166
- app.command("sql-init")(cmd_init)
167
- app.command("sql-revision")(cmd_revision)
168
- app.command("sql-upgrade")(cmd_upgrade)
169
- app.command("sql-downgrade")(cmd_downgrade)
170
- app.command("sql-current")(cmd_current)
171
- app.command("sql-history")(cmd_history)
172
- app.command("sql-stamp")(cmd_stamp)
173
- app.command("sql-merge-heads")(cmd_merge_heads)
174
- app.command("sql-setup-and-migrate")(cmd_setup_and_migrate)
215
+ # Register under the 'sql' group app
216
+ app.command("init")(cmd_init)
217
+ app.command("revision")(cmd_revision)
218
+ app.command("upgrade")(cmd_upgrade)
219
+ # Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
220
+ app.command(
221
+ "downgrade",
222
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
223
+ )(cmd_downgrade)
224
+ app.command("current")(cmd_current)
225
+ app.command("history")(cmd_history)
226
+ app.command("stamp")(cmd_stamp)
227
+ app.command("merge-heads")(cmd_merge_heads)
228
+ app.command("setup-and-migrate")(cmd_setup_and_migrate)
229
+ app.command("seed")(cmd_seed)
230
+
231
+
232
+ def _import_callable(path: str):
233
+ mod_name, _, fn_name = path.partition(":")
234
+ if not mod_name or not fn_name:
235
+ raise typer.BadParameter("Expected format 'module.path:callable'")
236
+ # Back-compat: after moving tests under tests/unit, allow legacy test module
237
+ # dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
238
+ mod = None
239
+ unit_mod = None
240
+ if mod_name.startswith("tests.db."):
241
+ # Try legacy import first (shim module), then unit module fallback
242
+ try:
243
+ mod = import_module(mod_name)
244
+ except ModuleNotFoundError:
245
+ pass
246
+ unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
247
+ try:
248
+ unit_mod = import_module(unit_name)
249
+ except ModuleNotFoundError:
250
+ unit_mod = None
251
+ # If both exist, unify shared state where applicable
252
+ if mod is not None and unit_mod is not None:
253
+ # Example: tests use a global `called` dict; point legacy to unit
254
+ try:
255
+ if hasattr(unit_mod, "called"):
256
+ setattr(mod, "called", getattr(unit_mod, "called"))
257
+ except Exception:
258
+ pass
259
+ # If legacy mod missing but unit exists, use unit
260
+ if mod is None and unit_mod is not None:
261
+ mod = unit_mod
262
+ else:
263
+ mod = import_module(mod_name)
264
+ fn = getattr(mod, fn_name, None)
265
+ if not callable(fn):
266
+ raise typer.BadParameter(f"Callable '{fn_name}' not found in module '{mod_name}'")
267
+ return fn
268
+
269
+
270
+ def cmd_seed(
271
+ target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
272
+ database_url: Optional[str] = typer.Option(
273
+ None,
274
+ help="Database URL; overrides env for this command.",
275
+ ),
276
+ ):
277
+ """Run a user-provided seed function to load fixtures/reference data."""
278
+ apply_database_url(database_url)
279
+ fn = _import_callable(target)
280
+ 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, Optional
9
+
10
+ import typer
11
+ from sqlalchemy import text
12
+
13
+ from svc_infra.db.sql.utils import build_engine
14
+
15
+ try: # SQLAlchemy async extras are optional
16
+ from sqlalchemy.ext.asyncio import AsyncEngine
17
+ except Exception: # pragma: no cover - fallback when async extras unavailable
18
+ AsyncEngine = None # type: ignore[assignment]
19
+
20
+
21
+ def export_tenant(
22
+ table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
23
+ tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
24
+ tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
25
+ output: Optional[Path] = typer.Option(
26
+ None, "--output", help="Output file; defaults to stdout."
27
+ ),
28
+ limit: Optional[int] = typer.Option(None, help="Max rows to export."),
29
+ database_url: Optional[str] = typer.Option(
30
+ None, "--database-url", help="Overrides env SQL_URL for this command."
31
+ ),
32
+ ):
33
+ """Export rows for a tenant from a given SQL table as JSON array."""
34
+ if database_url:
35
+ os.environ["SQL_URL"] = database_url
36
+
37
+ url = os.getenv("SQL_URL")
38
+ if not url:
39
+ typer.echo("SQL_URL is required (or pass --database-url)", err=True)
40
+ raise typer.Exit(code=2)
41
+
42
+ engine = build_engine(url)
43
+ rows: list[dict[str, Any]]
44
+ query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
45
+ if limit and limit > 0:
46
+ query += " LIMIT :limit"
47
+
48
+ params: dict[str, Any] = {"tenant_id": tenant_id}
49
+ if limit and limit > 0:
50
+ params["limit"] = int(limit)
51
+
52
+ stmt = text(query)
53
+
54
+ is_async_engine = AsyncEngine is not None and isinstance(engine, AsyncEngine)
55
+
56
+ if is_async_engine:
57
+ assert AsyncEngine is not None # for type checkers
58
+
59
+ async def _fetch() -> list[dict[str, Any]]:
60
+ async with engine.connect() as conn: # type: ignore[call-arg]
61
+ result = await conn.execute(stmt, params)
62
+ return [dict(row) for row in result.mappings()]
63
+
64
+ rows = asyncio.run(_fetch())
65
+ else:
66
+ with engine.connect() as conn: # type: ignore[attr-defined]
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)
@@ -24,7 +24,8 @@ def cmd_scaffold(
24
24
  "Item", help="Class name for entity/auth (e.g., User, Member, Product)."
25
25
  ),
26
26
  table_name: Optional[str] = typer.Option(
27
- None, help="Optional table name (defaults to plural snake of entity name)."
27
+ None,
28
+ help="Optional table name. For kind=auth, can also be set via AUTH_TABLE_NAME; defaults to plural snake of entity.",
28
29
  ),
29
30
  models_dir: Path = typer.Option(..., help="Directory for models."),
30
31
  schemas_dir: Path = typer.Option(..., help="Directory for schemas."),
@@ -133,6 +134,6 @@ def cmd_scaffold_schemas(
133
134
 
134
135
 
135
136
  def register(app: typer.Typer) -> None:
136
- app.command("sql-scaffold")(cmd_scaffold)
137
- app.command("sql-scaffold-models")(cmd_scaffold_models)
138
- 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,140 @@
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
+ from typing import Dict, List
8
+
9
+ import click
10
+ import typer
11
+ from typer.core import TyperGroup
12
+
13
+ from svc_infra.app.root import resolve_project_root
14
+
15
+
16
+ def _norm(name: str) -> str:
17
+ return name.strip().lower().replace(" ", "-").replace("_", "-")
18
+
19
+
20
+ def _discover_fs_topics(docs_dir: Path) -> Dict[str, Path]:
21
+ topics: Dict[str, Path] = {}
22
+ if docs_dir.exists() and docs_dir.is_dir():
23
+ for p in sorted(docs_dir.glob("*.md")):
24
+ if p.is_file():
25
+ topics[_norm(p.stem)] = p
26
+ return topics
27
+
28
+
29
+ def _discover_pkg_topics() -> Dict[str, Path]:
30
+ """
31
+ Discover docs shipped inside the installed package at svc_infra/docs/*,
32
+ using importlib.resources so this works for wheels, sdists, and zipped wheels.
33
+ """
34
+ topics: Dict[str, Path] = {}
35
+ try:
36
+ docs_root = pkg_files("svc_infra").joinpath("docs")
37
+ # docs_root is a Traversable; it may be inside a zip. Iterate safely.
38
+ for entry in docs_root.iterdir():
39
+ if entry.name.endswith(".md"):
40
+ # materialize to a real tempfile path if needed
41
+ with as_file(entry) as concrete:
42
+ p = Path(concrete)
43
+ if p.exists() and p.is_file():
44
+ topics[_norm(p.stem)] = p
45
+ except Exception:
46
+ # If the package has no docs directory, just return empty.
47
+ pass
48
+ return topics
49
+
50
+
51
+ def _resolve_docs_dir(ctx: click.Context) -> Path | None:
52
+ """
53
+ Optional override precedence:
54
+ 1) SVC_INFRA_DOCS_DIR env var
55
+ 2) *Only when working inside the svc-infra repo itself*: repo-root /docs
56
+ """
57
+ # 1) Env var
58
+ env_dir = os.getenv("SVC_INFRA_DOCS_DIR")
59
+ if env_dir:
60
+ p = Path(env_dir).expanduser()
61
+ if p.exists():
62
+ return p
63
+
64
+ # 2) In-repo convenience (so `svc-infra docs` works inside this repo)
65
+ try:
66
+ root = resolve_project_root()
67
+ proj_docs = root / "docs"
68
+ if proj_docs.exists():
69
+ return proj_docs
70
+ except Exception:
71
+ pass
72
+
73
+ return None
74
+
75
+
76
+ class DocsGroup(TyperGroup):
77
+ def list_commands(self, ctx: click.Context) -> List[str]:
78
+ names: List[str] = list(super().list_commands(ctx) or [])
79
+ dir_to_use = _resolve_docs_dir(ctx)
80
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
81
+ pkg = _discover_pkg_topics()
82
+ names.extend(fs.keys())
83
+ names.extend([k for k in pkg.keys() if k not in fs])
84
+ return sorted(set(names))
85
+
86
+ def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
87
+ cmd = super().get_command(ctx, name)
88
+ if cmd is not None:
89
+ return cmd
90
+
91
+ key = _norm(name)
92
+
93
+ dir_to_use = _resolve_docs_dir(ctx)
94
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
95
+ if key in fs:
96
+ file_path = fs[key]
97
+
98
+ @click.command(name=name)
99
+ def _show_fs() -> None:
100
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
101
+
102
+ return _show_fs
103
+
104
+ pkg = _discover_pkg_topics()
105
+ if key in pkg:
106
+ file_path = pkg[key]
107
+
108
+ @click.command(name=name)
109
+ def _show_pkg() -> None:
110
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
111
+
112
+ return _show_pkg
113
+
114
+ return None
115
+
116
+
117
+ def register(app: typer.Typer) -> None:
118
+ docs_app = typer.Typer(no_args_is_help=True, add_completion=False, cls=DocsGroup)
119
+
120
+ @docs_app.callback(invoke_without_command=True)
121
+ def _docs_options() -> None:
122
+ # No group-level options; dynamic commands and 'show' handle topics.
123
+ return None
124
+
125
+ @docs_app.command("show", help="Show docs for a topic (alternative to dynamic subcommand)")
126
+ def show(topic: str) -> None:
127
+ key = _norm(topic)
128
+ ctx = click.get_current_context()
129
+ dir_to_use = _resolve_docs_dir(ctx)
130
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
131
+ if key in fs:
132
+ typer.echo(fs[key].read_text(encoding="utf-8", errors="replace"))
133
+ return
134
+ pkg = _discover_pkg_topics()
135
+ if key in pkg:
136
+ typer.echo(pkg[key].read_text(encoding="utf-8", errors="replace"))
137
+ return
138
+ raise typer.BadParameter(f"Unknown topic: {topic}")
139
+
140
+ 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,99 @@
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 check_migrations_up_to_date, check_openapi_problem_schema
10
+
11
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
12
+
13
+
14
+ @app.command("openapi")
15
+ def cmd_openapi(path: str = typer.Argument(..., help="Path to OpenAPI JSON")):
16
+ try:
17
+ check_openapi_problem_schema(path=path)
18
+ except Exception as e: # noqa: BLE001
19
+ typer.secho(f"OpenAPI check failed: {e}", fg=typer.colors.RED, err=True)
20
+ raise typer.Exit(2)
21
+ typer.secho("OpenAPI checks passed", fg=typer.colors.GREEN)
22
+
23
+
24
+ @app.command("migrations")
25
+ def cmd_migrations(project_root: str = typer.Option(".", help="Project root")):
26
+ try:
27
+ check_migrations_up_to_date(project_root=project_root)
28
+ except Exception as e: # noqa: BLE001
29
+ typer.secho(f"Migrations check failed: {e}", fg=typer.colors.RED, err=True)
30
+ raise typer.Exit(2)
31
+ typer.secho("Migrations checks passed", fg=typer.colors.GREEN)
32
+
33
+
34
+ @app.command("changelog")
35
+ def cmd_changelog(
36
+ version: str = typer.Argument(..., help="Version (e.g., 0.1.604)"),
37
+ commits_file: str = typer.Option(None, help="Path to JSON lines of commits (sha,subject)"),
38
+ ):
39
+ """Generate a changelog section from commit messages.
40
+
41
+ Expects Conventional Commits style for best grouping; falls back to Other.
42
+ If commits_file is omitted, prints an example format.
43
+ """
44
+ import json
45
+ import sys
46
+
47
+ if not commits_file:
48
+ typer.echo(
49
+ '# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
50
+ err=True,
51
+ )
52
+ raise typer.Exit(2)
53
+ rows = [
54
+ json.loads(line) for line in Path(commits_file).read_text().splitlines() if line.strip()
55
+ ]
56
+ commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
57
+ out = generate_release_section(version=version, commits=commits)
58
+ sys.stdout.write(out)
59
+
60
+
61
+ @app.command("ci")
62
+ def cmd_ci(
63
+ run: bool = typer.Option(False, help="Execute the steps; default just prints a plan"),
64
+ openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
65
+ project_root: str = typer.Option(".", help="Project root for migrations check"),
66
+ ):
67
+ """Print (or run) the CI steps locally to mirror the workflow."""
68
+ steps: list[list[str]] = []
69
+ # Lint, typecheck, tests
70
+ steps.append(["flake8", "--select=E,F"]) # mirrors CI
71
+ steps.append(["mypy", "src"]) # mirrors CI
72
+ if openapi:
73
+ steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
74
+ steps.append(
75
+ [sys.executable, "-m", "svc_infra.cli", "dx", "migrations", "--project-root", project_root]
76
+ )
77
+ steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
78
+
79
+ if not run:
80
+ typer.echo("CI dry-run plan:")
81
+ for cmd in steps:
82
+ typer.echo(" $ " + " ".join(cmd))
83
+ return
84
+
85
+ import subprocess
86
+
87
+ for cmd in steps:
88
+ typer.echo("Running: " + " ".join(cmd))
89
+ res = subprocess.run(cmd)
90
+ if res.returncode != 0:
91
+ raise typer.Exit(res.returncode)
92
+ typer.echo("All CI steps passed")
93
+
94
+
95
+ def main(): # pragma: no cover - CLI entrypoint
96
+ app()
97
+
98
+
99
+ __all__ = ["main", "app"]
@@ -21,4 +21,8 @@ How to run (pick what fits your workflow):
21
21
  Notes:
22
22
  * Make sure you’re in the right virtual environment (or use `pipx`).
23
23
  * You can point `--project-root` at your Alembic root; if omitted we auto-detect.
24
+
25
+ Learn more:
26
+ * Explore available topics: `svc-infra docs --help`
27
+ * Show a topic directly: `svc-infra docs <topic>` or `svc-infra docs show <topic>`
24
28
  """
@@ -0,0 +1 @@
1
+ from __future__ import annotations