svc-infra 0.1.589__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 (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -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 +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,116 @@
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: # noqa: BLE001
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: # noqa: BLE001
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(
41
+ None, help="Path to JSON lines of commits (sha,subject)"
42
+ ),
43
+ ):
44
+ """Generate a changelog section from commit messages.
45
+
46
+ Expects Conventional Commits style for best grouping; falls back to Other.
47
+ If commits_file is omitted, prints an example format.
48
+ """
49
+ import json
50
+ import sys
51
+
52
+ if not commits_file:
53
+ typer.echo(
54
+ '# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
55
+ err=True,
56
+ )
57
+ raise typer.Exit(2)
58
+ rows = [
59
+ json.loads(line)
60
+ for line in Path(commits_file).read_text().splitlines()
61
+ if line.strip()
62
+ ]
63
+ commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
64
+ out = generate_release_section(version=version, commits=commits)
65
+ sys.stdout.write(out)
66
+
67
+
68
+ @app.command("ci")
69
+ def cmd_ci(
70
+ run: bool = typer.Option(
71
+ False, help="Execute the steps; default just prints a plan"
72
+ ),
73
+ openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
74
+ project_root: str = typer.Option(".", help="Project root for migrations check"),
75
+ ):
76
+ """Print (or run) the CI steps locally to mirror the workflow."""
77
+ steps: list[list[str]] = []
78
+ # Lint, typecheck, tests
79
+ steps.append(["flake8", "--select=E,F"]) # mirrors CI
80
+ steps.append(["mypy", "src"]) # mirrors CI
81
+ if openapi:
82
+ steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
83
+ steps.append(
84
+ [
85
+ sys.executable,
86
+ "-m",
87
+ "svc_infra.cli",
88
+ "dx",
89
+ "migrations",
90
+ "--project-root",
91
+ project_root,
92
+ ]
93
+ )
94
+ steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
95
+
96
+ if not run:
97
+ typer.echo("CI dry-run plan:")
98
+ for cmd in steps:
99
+ typer.echo(" $ " + " ".join(cmd))
100
+ return
101
+
102
+ import subprocess
103
+
104
+ for cmd in steps:
105
+ typer.echo("Running: " + " ".join(cmd))
106
+ res = subprocess.run(cmd)
107
+ if res.returncode != 0:
108
+ raise typer.Exit(res.returncode)
109
+ typer.echo("All CI steps passed")
110
+
111
+
112
+ def main(): # pragma: no cover - CLI entrypoint
113
+ app()
114
+
115
+
116
+ __all__ = ["main", "app"]
@@ -0,0 +1,179 @@
1
+ """Health check CLI commands.
2
+
3
+ Provides CLI commands for health checking:
4
+ - check: Check health of a URL endpoint
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+
12
+ import typer
13
+
14
+
15
+ def cmd_check(
16
+ url: str = typer.Argument(
17
+ ...,
18
+ help="URL of the health endpoint to check.",
19
+ ),
20
+ timeout: float = typer.Option(
21
+ 10.0,
22
+ "--timeout",
23
+ "-t",
24
+ help="Request timeout in seconds.",
25
+ ),
26
+ json_output: bool = typer.Option(
27
+ False,
28
+ "--json",
29
+ "-j",
30
+ help="Output as JSON.",
31
+ ),
32
+ verbose: bool = typer.Option(
33
+ False,
34
+ "--verbose",
35
+ "-v",
36
+ help="Show detailed response.",
37
+ ),
38
+ ) -> None:
39
+ """
40
+ Check health of a URL endpoint.
41
+
42
+ Fetches the URL and reports the health status based on HTTP response.
43
+ Expects the endpoint to return 200 for healthy status.
44
+
45
+ Examples:
46
+ svc-infra health check http://localhost:8000/health
47
+ svc-infra health check http://api:8080/ready --timeout 5
48
+ svc-infra health check http://localhost:8000/health --json
49
+
50
+ Exit codes:
51
+ 0: Healthy (HTTP 2xx)
52
+ 1: Unhealthy or unreachable
53
+ """
54
+
55
+ async def _check() -> dict:
56
+ """Perform the health check and return result."""
57
+ from svc_infra.health import check_url
58
+
59
+ # Create the check function with the given URL
60
+ check_fn = check_url(url, timeout=timeout)
61
+
62
+ # Run the check
63
+ result = await check_fn()
64
+
65
+ return result.to_dict()
66
+
67
+ result = asyncio.run(_check())
68
+
69
+ if json_output:
70
+ typer.echo(json.dumps(result, indent=2))
71
+ else:
72
+ status = result["status"]
73
+ latency = result["latency_ms"]
74
+
75
+ if status == "healthy":
76
+ typer.secho(f"✓ {url}", fg=typer.colors.GREEN)
77
+ typer.echo(f" Status: {status} ({latency:.1f}ms)")
78
+ else:
79
+ typer.secho(f"✗ {url}", fg=typer.colors.RED)
80
+ typer.echo(f" Status: {status}")
81
+ if result.get("message"):
82
+ typer.echo(f" Message: {result['message']}")
83
+
84
+ if verbose and result.get("details"):
85
+ typer.echo(" Details:")
86
+ for key, value in result["details"].items():
87
+ typer.echo(f" {key}: {value}")
88
+
89
+ # Exit with error code if unhealthy
90
+ if result["status"] != "healthy":
91
+ raise typer.Exit(1)
92
+
93
+
94
+ def cmd_wait(
95
+ url: str = typer.Argument(
96
+ ...,
97
+ help="URL of the health endpoint to wait for.",
98
+ ),
99
+ timeout: int = typer.Option(
100
+ 60,
101
+ "--timeout",
102
+ "-t",
103
+ help="Maximum time to wait in seconds.",
104
+ ),
105
+ interval: float = typer.Option(
106
+ 2.0,
107
+ "--interval",
108
+ "-i",
109
+ help="Time between checks in seconds.",
110
+ ),
111
+ quiet: bool = typer.Option(
112
+ False,
113
+ "--quiet",
114
+ "-q",
115
+ help="Suppress progress messages.",
116
+ ),
117
+ ) -> None:
118
+ """
119
+ Wait for a health endpoint to become healthy.
120
+
121
+ Repeatedly checks the URL until it returns a healthy response
122
+ or timeout is reached.
123
+
124
+ Examples:
125
+ svc-infra health wait http://localhost:8000/health
126
+ svc-infra health wait http://api:8080/ready --timeout 120
127
+
128
+ Exit codes:
129
+ 0: Endpoint became healthy
130
+ 1: Timeout reached, endpoint not healthy
131
+ """
132
+ import time
133
+
134
+ async def _wait() -> bool:
135
+ """Wait loop."""
136
+ from svc_infra.health import check_url
137
+
138
+ check_fn = check_url(url, timeout=5.0)
139
+ deadline = time.monotonic() + timeout
140
+ attempt = 0
141
+
142
+ while time.monotonic() < deadline:
143
+ attempt += 1
144
+ if not quiet:
145
+ typer.echo(f"Attempt {attempt}: Checking {url}...")
146
+
147
+ result = await check_fn()
148
+
149
+ if result.status == "healthy":
150
+ if not quiet:
151
+ typer.secho(
152
+ f"✓ Healthy ({result.latency_ms:.1f}ms)",
153
+ fg=typer.colors.GREEN,
154
+ )
155
+ return True
156
+
157
+ if not quiet:
158
+ msg = result.message or "Unhealthy"
159
+ typer.echo(f" → {msg}")
160
+
161
+ remaining = deadline - time.monotonic()
162
+ if remaining > 0:
163
+ await asyncio.sleep(min(interval, remaining))
164
+
165
+ return False
166
+
167
+ success = asyncio.run(_wait())
168
+ if not success:
169
+ typer.secho(
170
+ f"ERROR: Endpoint not healthy after {timeout}s",
171
+ fg=typer.colors.RED,
172
+ )
173
+ raise typer.Exit(1)
174
+
175
+
176
+ def register(app: typer.Typer) -> None:
177
+ """Register health check commands with the CLI app."""
178
+ app.command("check")(cmd_check)
179
+ app.command("wait")(cmd_wait)
@@ -0,0 +1,8 @@
1
+ """Health check CLI commands module.
2
+
3
+ Re-exports from health/__init__.py for backward compatibility.
4
+ """
5
+
6
+ from svc_infra.cli.cmds.health import register
7
+
8
+ __all__ = ["register"]
@@ -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
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from svc_infra.jobs.easy import easy_jobs
9
+ from svc_infra.jobs.loader import schedule_from_env
10
+ from svc_infra.jobs.worker import process_one
11
+
12
+ app = typer.Typer(help="Background jobs and scheduler commands")
13
+
14
+
15
+ @app.command("run")
16
+ def run(
17
+ poll_interval: float = typer.Option(
18
+ 0.5, help="Sleep seconds between loops when idle"
19
+ ),
20
+ max_loops: Optional[int] = typer.Option(
21
+ None, help="Max loops before exit (for tests)"
22
+ ),
23
+ ):
24
+ """Run scheduler ticks and process jobs in a simple loop."""
25
+
26
+ queue, scheduler = easy_jobs()
27
+ # load schedule from env JSON if provided
28
+ schedule_from_env(scheduler)
29
+
30
+ async def _loop():
31
+ loops = 0
32
+ while True:
33
+ await scheduler.tick()
34
+ processed = await process_one(queue, _noop_handler)
35
+ if not processed:
36
+ # idle
37
+ await asyncio.sleep(poll_interval)
38
+ if max_loops is not None:
39
+ loops += 1
40
+ if loops >= max_loops:
41
+ break
42
+
43
+ async def _noop_handler(job):
44
+ # Default handler does nothing; users should write their own runners
45
+ return None
46
+
47
+ asyncio.run(_loop())
@@ -4,18 +4,22 @@ import os
4
4
  import socket
5
5
  import subprocess
6
6
  from pathlib import Path
7
+ from typing import Any, Callable
7
8
  from urllib.parse import urlparse
8
9
 
9
10
  import typer
10
11
 
12
+ from svc_infra.obs.cloud_dash import push_dashboards_from_pkg
13
+ from svc_infra.utils import render_template, write
14
+
11
15
  # --- NEW: load .env automatically (best-effort) ---
16
+ load_dotenv: Callable[..., Any] | None
12
17
  try:
13
- from dotenv import load_dotenv # type: ignore
18
+ from dotenv import load_dotenv as _real_load_dotenv
14
19
  except Exception: # pragma: no cover
15
20
  load_dotenv = None
16
-
17
- from svc_infra.obs.cloud_dash import push_dashboards_from_pkg
18
- from svc_infra.utils import render_template, write
21
+ else:
22
+ load_dotenv = _real_load_dotenv
19
23
 
20
24
 
21
25
  def _run(cmd: list[str], *, env: dict | None = None):
@@ -25,7 +29,9 @@ def _run(cmd: list[str], *, env: dict | None = None):
25
29
  def _emit_local_stack(root: Path, metrics_url: str):
26
30
  write(
27
31
  root / "docker-compose.yml",
28
- render_template("svc_infra.obs.providers.grafana.templates", "docker-compose.yml.tmpl", {}),
32
+ render_template(
33
+ "svc_infra.obs.providers.grafana.templates", "docker-compose.yml.tmpl", {}
34
+ ),
29
35
  )
30
36
  p = urlparse(metrics_url)
31
37
  prom_yml = render_template(
@@ -102,7 +108,7 @@ def up():
102
108
  - Else → Local mode (Grafana + Prometheus).
103
109
  """
104
110
  # NEW: load .env once, best-effort, without crashing if package missing
105
- if load_dotenv:
111
+ if load_dotenv is not None:
106
112
  try:
107
113
  load_dotenv(dotenv_path=Path(".env"), override=False)
108
114
  except Exception:
@@ -110,7 +116,9 @@ def up():
110
116
 
111
117
  root = Path(".obs")
112
118
  root.mkdir(exist_ok=True)
113
- metrics_url = os.getenv("SVC_INFRA_METRICS_URL", "http://host.docker.internal:8000/metrics")
119
+ metrics_url = os.getenv(
120
+ "SVC_INFRA_METRICS_URL", "http://host.docker.internal:8000/metrics"
121
+ )
114
122
 
115
123
  cloud_url = os.getenv("GRAFANA_CLOUD_URL", "").strip()
116
124
  cloud_token = os.getenv("GRAFANA_CLOUD_TOKEN", "").strip()
@@ -131,7 +139,14 @@ def up():
131
139
  ):
132
140
  _emit_local_agent(root, metrics_url)
133
141
  _run(
134
- ["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "up", "-d"],
142
+ [
143
+ "docker",
144
+ "compose",
145
+ "-f",
146
+ str(root / "docker-compose.cloud.yml"),
147
+ "up",
148
+ "-d",
149
+ ],
135
150
  env=os.environ.copy(),
136
151
  )
137
152
  typer.echo("[cloud] local Grafana Agent started (pushing metrics to Cloud)")
@@ -146,7 +161,10 @@ def up():
146
161
  env["GRAFANA_PORT"] = str(local_graf)
147
162
  env["PROM_PORT"] = str(local_prom)
148
163
  _emit_local_stack(root, metrics_url)
149
- _run(["docker", "compose", "-f", str(root / "docker-compose.yml"), "up", "-d"], env=env)
164
+ _run(
165
+ ["docker", "compose", "-f", str(root / "docker-compose.yml"), "up", "-d"],
166
+ env=env,
167
+ )
150
168
  typer.echo(f"Local Grafana → http://localhost:{local_graf} (admin/admin)")
151
169
  typer.echo(f"Local Prometheus → http://localhost:{local_prom}")
152
170
 
@@ -155,11 +173,13 @@ def down():
155
173
  root = Path(".obs")
156
174
  if (root / "docker-compose.yml").exists():
157
175
  subprocess.run(
158
- ["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"], check=False
176
+ ["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"],
177
+ check=False,
159
178
  )
160
179
  if (root / "docker-compose.cloud.yml").exists():
161
180
  subprocess.run(
162
- ["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "down"], check=False
181
+ ["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "down"],
182
+ check=False,
163
183
  )
164
184
  typer.echo("Stopped local obs services.")
165
185
 
@@ -171,7 +191,7 @@ def scaffold(target: str = typer.Option(..., help="compose|railway|k8s|fly")):
171
191
  out.mkdir(parents=True, exist_ok=True)
172
192
 
173
193
  base = files("svc_infra.obs.templates.sidecars").joinpath(target)
174
- for p in base.rglob("*"):
194
+ for p in base.rglob("*"): # type: ignore[attr-defined]
175
195
  if p.is_file():
176
196
  rel = p.relative_to(base)
177
197
  dst = out / rel
@@ -182,6 +202,7 @@ def scaffold(target: str = typer.Option(..., help="compose|railway|k8s|fly")):
182
202
 
183
203
 
184
204
  def register(app: typer.Typer) -> None:
185
- app.command("obs-up")(up)
186
- app.command("obs-down")(down)
187
- app.command("obs-scaffold")(scaffold)
205
+ # Attach to 'obs' group app
206
+ app.command("up")(up)
207
+ app.command("down")(down)
208
+ app.command("scaffold")(scaffold)
File without changes
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(
8
+ no_args_is_help=True, add_completion=False, help="Generate SDKs from OpenAPI."
9
+ )
10
+
11
+
12
+ def _echo(cmd: list[str]):
13
+ typer.echo("$ " + " ".join(cmd))
14
+
15
+
16
+ def _parse_bool(val: str | bool | None, default: bool = True) -> bool:
17
+ if isinstance(val, bool):
18
+ return val
19
+ if val is None:
20
+ return default
21
+ s = str(val).strip().lower()
22
+ if s in {"1", "true", "yes", "y"}:
23
+ return True
24
+ if s in {"0", "false", "no", "n"}:
25
+ return False
26
+ return default
27
+
28
+
29
+ @app.command("ts")
30
+ def sdk_ts(
31
+ openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
32
+ outdir: str = typer.Option("sdk-ts", help="Output directory"),
33
+ dry_run: str = typer.Option(
34
+ "true", help="Print commands instead of running (true/false)"
35
+ ),
36
+ ):
37
+ """Generate a TypeScript SDK (openapi-typescript-codegen as default)."""
38
+ cmd = [
39
+ "npx",
40
+ "openapi-typescript-codegen",
41
+ "--input",
42
+ openapi,
43
+ "--output",
44
+ outdir,
45
+ ]
46
+ if _parse_bool(dry_run, True):
47
+ _echo(cmd)
48
+ return
49
+ subprocess.check_call(cmd)
50
+ typer.secho(f"TS SDK generated → {outdir}", fg=typer.colors.GREEN)
51
+
52
+
53
+ @app.command("py")
54
+ def sdk_py(
55
+ openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
56
+ outdir: str = typer.Option("sdk-py", help="Output directory"),
57
+ package_name: str = typer.Option("client_sdk", help="Python package name"),
58
+ dry_run: str = typer.Option(
59
+ "true", help="Print commands instead of running (true/false)"
60
+ ),
61
+ ):
62
+ """Generate a Python SDK via openapi-generator-cli with "python" generator."""
63
+ cmd = [
64
+ "npx",
65
+ "-y",
66
+ "@openapitools/openapi-generator-cli",
67
+ "generate",
68
+ "-i",
69
+ openapi,
70
+ "-g",
71
+ "python",
72
+ "-o",
73
+ outdir,
74
+ "--additional-properties",
75
+ f"packageName={package_name}",
76
+ ]
77
+ if _parse_bool(dry_run, True):
78
+ _echo(cmd)
79
+ return
80
+ subprocess.check_call(cmd)
81
+ typer.secho(f"Python SDK generated → {outdir}", fg=typer.colors.GREEN)
82
+
83
+
84
+ @app.command("postman")
85
+ def sdk_postman(
86
+ openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
87
+ out: str = typer.Option(
88
+ "postman_collection.json", help="Output Postman collection"
89
+ ),
90
+ dry_run: str = typer.Option(
91
+ "true", help="Print commands instead of running (true/false)"
92
+ ),
93
+ ):
94
+ """Convert OpenAPI to a Postman collection via openapi-to-postmanv2."""
95
+ cmd = [
96
+ "npx",
97
+ "-y",
98
+ "openapi-to-postmanv2",
99
+ "-s",
100
+ openapi,
101
+ "-o",
102
+ out,
103
+ ]
104
+ if _parse_bool(dry_run, True):
105
+ _echo(cmd)
106
+ return
107
+ subprocess.check_call(cmd)
108
+ typer.secho(f"Postman collection generated → {out}", fg=typer.colors.GREEN)
109
+
110
+
111
+ def register(root: typer.Typer):
112
+ root.add_typer(app, name="sdk")
@@ -25,7 +25,9 @@ def candidate_cmds(root: Path, prog: str, argv: List[str]) -> List[List[str]]:
25
25
  cmds.append([prog, *argv])
26
26
 
27
27
  py = shutil.which("python3") or shutil.which("python") or "python"
28
- module = prog.replace("-", "_") + ".cli_shim" # e.g., svc-infra -> svc_infra.cli_shim
28
+ module = (
29
+ prog.replace("-", "_") + ".cli_shim"
30
+ ) # e.g., svc-infra -> svc_infra.cli_shim
29
31
  cmds.append([py, "-m", module, *argv])
30
32
 
31
33
  return cmds
@@ -54,4 +56,6 @@ async def run_from_root(root: Path, prog: str, argv: List[str]) -> str:
54
56
  except Exception as e:
55
57
  last_exc = e
56
58
  continue
57
- raise RuntimeError(f"All runners failed in {root} for: {prog} {' '.join(argv)}") from last_exc
59
+ raise RuntimeError(
60
+ f"All runners failed in {root} for: {prog} {' '.join(argv)}"
61
+ ) from last_exc
svc_infra/data/add.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Callable, Iterable, Optional
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from svc_infra.cli.cmds.db.sql.alembic_cmds import cmd_setup_and_migrate
9
+
10
+
11
+ def add_data_lifecycle(
12
+ app: FastAPI,
13
+ *,
14
+ auto_migrate: bool = True,
15
+ database_url: str | None = None,
16
+ discover_packages: Optional[list[str]] = None,
17
+ with_payments: bool | None = None,
18
+ on_load_fixtures: Optional[Callable[[], None]] = None,
19
+ retention_jobs: Optional[Iterable[Callable[[], None]]] = None,
20
+ erasure_job: Optional[Callable[[str], None]] = None,
21
+ ) -> None:
22
+ """
23
+ Wire data lifecycle conveniences:
24
+
25
+ - auto_migrate: run end-to-end Alembic setup-and-migrate on startup (idempotent).
26
+ - on_load_fixtures: optional callback to load reference/fixture data once at startup.
27
+ - retention_jobs: optional list of callables to register purge tasks (scheduler integration is external).
28
+ - erasure_job: optional callable to trigger a GDPR erasure workflow for a given principal ID.
29
+
30
+ This helper is intentionally minimal: it coordinates existing building blocks
31
+ and offers extension points. Jobs should be scheduled using svc_infra.jobs helpers.
32
+ """
33
+
34
+ async def _run_lifecycle() -> None:
35
+ # Startup
36
+ if auto_migrate:
37
+ cmd_setup_and_migrate(
38
+ database_url=database_url,
39
+ overwrite_scaffold=False,
40
+ create_db_if_missing=True,
41
+ create_followup_revision=True,
42
+ initial_message="initial schema",
43
+ followup_message="autogen",
44
+ discover_packages=discover_packages,
45
+ with_payments=with_payments if with_payments is not None else False,
46
+ )
47
+ if on_load_fixtures:
48
+ res = on_load_fixtures()
49
+ if inspect.isawaitable(res):
50
+ await res
51
+
52
+ app.add_event_handler("startup", _run_lifecycle)
53
+
54
+ # Store optional jobs on app.state for external schedulers to discover/register.
55
+ if retention_jobs is not None:
56
+ app.state.data_retention_jobs = list(retention_jobs)
57
+ if erasure_job is not None:
58
+ app.state.data_erasure_job = erasure_job
59
+
60
+
61
+ __all__ = ["add_data_lifecycle"]