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
@@ -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,42 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import typer
6
+
7
+ from svc_infra.jobs.easy import easy_jobs
8
+ from svc_infra.jobs.loader import schedule_from_env
9
+ from svc_infra.jobs.worker import process_one
10
+
11
+ app = typer.Typer(help="Background jobs and scheduler commands")
12
+
13
+
14
+ @app.command("run")
15
+ def run(
16
+ poll_interval: float = typer.Option(0.5, help="Sleep seconds between loops when idle"),
17
+ max_loops: int | None = typer.Option(None, help="Max loops before exit (for tests)"),
18
+ ):
19
+ """Run scheduler ticks and process jobs in a simple loop."""
20
+
21
+ queue, scheduler = easy_jobs()
22
+ # load schedule from env JSON if provided
23
+ schedule_from_env(scheduler)
24
+
25
+ async def _loop():
26
+ loops = 0
27
+ while True:
28
+ await scheduler.tick()
29
+ processed = await process_one(queue, _noop_handler)
30
+ if not processed:
31
+ # idle
32
+ await asyncio.sleep(poll_interval)
33
+ if max_loops is not None:
34
+ loops += 1
35
+ if loops >= max_loops:
36
+ break
37
+
38
+ async def _noop_handler(job):
39
+ # Default handler does nothing; users should write their own runners
40
+ return None
41
+
42
+ asyncio.run(_loop())
@@ -3,19 +3,24 @@ from __future__ import annotations
3
3
  import os
4
4
  import socket
5
5
  import subprocess
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
8
+ from typing import Any
7
9
  from urllib.parse import urlparse
8
10
 
9
11
  import typer
10
12
 
13
+ from svc_infra.obs.cloud_dash import push_dashboards_from_pkg
14
+ from svc_infra.utils import render_template, write
15
+
11
16
  # --- NEW: load .env automatically (best-effort) ---
17
+ load_dotenv: Callable[..., Any] | None
12
18
  try:
13
- from dotenv import load_dotenv # type: ignore
19
+ from dotenv import load_dotenv as _real_load_dotenv
14
20
  except Exception: # pragma: no cover
15
21
  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
22
+ else:
23
+ load_dotenv = _real_load_dotenv
19
24
 
20
25
 
21
26
  def _run(cmd: list[str], *, env: dict | None = None):
@@ -102,7 +107,7 @@ def up():
102
107
  - Else → Local mode (Grafana + Prometheus).
103
108
  """
104
109
  # NEW: load .env once, best-effort, without crashing if package missing
105
- if load_dotenv:
110
+ if load_dotenv is not None:
106
111
  try:
107
112
  load_dotenv(dotenv_path=Path(".env"), override=False)
108
113
  except Exception:
@@ -131,7 +136,14 @@ def up():
131
136
  ):
132
137
  _emit_local_agent(root, metrics_url)
133
138
  _run(
134
- ["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "up", "-d"],
139
+ [
140
+ "docker",
141
+ "compose",
142
+ "-f",
143
+ str(root / "docker-compose.cloud.yml"),
144
+ "up",
145
+ "-d",
146
+ ],
135
147
  env=os.environ.copy(),
136
148
  )
137
149
  typer.echo("[cloud] local Grafana Agent started (pushing metrics to Cloud)")
@@ -146,7 +158,10 @@ def up():
146
158
  env["GRAFANA_PORT"] = str(local_graf)
147
159
  env["PROM_PORT"] = str(local_prom)
148
160
  _emit_local_stack(root, metrics_url)
149
- _run(["docker", "compose", "-f", str(root / "docker-compose.yml"), "up", "-d"], env=env)
161
+ _run(
162
+ ["docker", "compose", "-f", str(root / "docker-compose.yml"), "up", "-d"],
163
+ env=env,
164
+ )
150
165
  typer.echo(f"Local Grafana → http://localhost:{local_graf} (admin/admin)")
151
166
  typer.echo(f"Local Prometheus → http://localhost:{local_prom}")
152
167
 
@@ -155,11 +170,13 @@ def down():
155
170
  root = Path(".obs")
156
171
  if (root / "docker-compose.yml").exists():
157
172
  subprocess.run(
158
- ["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"], check=False
173
+ ["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"],
174
+ check=False,
159
175
  )
160
176
  if (root / "docker-compose.cloud.yml").exists():
161
177
  subprocess.run(
162
- ["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "down"], check=False
178
+ ["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "down"],
179
+ check=False,
163
180
  )
164
181
  typer.echo("Stopped local obs services.")
165
182
 
@@ -171,7 +188,7 @@ def scaffold(target: str = typer.Option(..., help="compose|railway|k8s|fly")):
171
188
  out.mkdir(parents=True, exist_ok=True)
172
189
 
173
190
  base = files("svc_infra.obs.templates.sidecars").joinpath(target)
174
- for p in base.rglob("*"):
191
+ for p in base.rglob("*"): # type: ignore[attr-defined]
175
192
  if p.is_file():
176
193
  rel = p.relative_to(base)
177
194
  dst = out / rel
@@ -182,6 +199,7 @@ def scaffold(target: str = typer.Option(..., help="compose|railway|k8s|fly")):
182
199
 
183
200
 
184
201
  def register(app: typer.Typer) -> None:
185
- app.command("obs-up")(up)
186
- app.command("obs-down")(down)
187
- app.command("obs-scaffold")(scaffold)
202
+ # Attach to 'obs' group app
203
+ app.command("up")(up)
204
+ app.command("down")(down)
205
+ app.command("scaffold")(scaffold)
File without changes
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="Generate SDKs from OpenAPI.")
8
+
9
+
10
+ def _echo(cmd: list[str]):
11
+ typer.echo("$ " + " ".join(cmd))
12
+
13
+
14
+ def _parse_bool(val: str | bool | None, default: bool = True) -> bool:
15
+ if isinstance(val, bool):
16
+ return val
17
+ if val is None:
18
+ return default
19
+ s = str(val).strip().lower()
20
+ if s in {"1", "true", "yes", "y"}:
21
+ return True
22
+ if s in {"0", "false", "no", "n"}:
23
+ return False
24
+ return default
25
+
26
+
27
+ @app.command("ts")
28
+ def sdk_ts(
29
+ openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
30
+ outdir: str = typer.Option("sdk-ts", help="Output directory"),
31
+ dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
32
+ ):
33
+ """Generate a TypeScript SDK (openapi-typescript-codegen as default)."""
34
+ cmd = [
35
+ "npx",
36
+ "openapi-typescript-codegen",
37
+ "--input",
38
+ openapi,
39
+ "--output",
40
+ outdir,
41
+ ]
42
+ if _parse_bool(dry_run, True):
43
+ _echo(cmd)
44
+ return
45
+ subprocess.check_call(cmd)
46
+ typer.secho(f"TS SDK generated → {outdir}", fg=typer.colors.GREEN)
47
+
48
+
49
+ @app.command("py")
50
+ def sdk_py(
51
+ openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
52
+ outdir: str = typer.Option("sdk-py", help="Output directory"),
53
+ package_name: str = typer.Option("client_sdk", help="Python package name"),
54
+ dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
55
+ ):
56
+ """Generate a Python SDK via openapi-generator-cli with "python" generator."""
57
+ cmd = [
58
+ "npx",
59
+ "-y",
60
+ "@openapitools/openapi-generator-cli",
61
+ "generate",
62
+ "-i",
63
+ openapi,
64
+ "-g",
65
+ "python",
66
+ "-o",
67
+ outdir,
68
+ "--additional-properties",
69
+ f"packageName={package_name}",
70
+ ]
71
+ if _parse_bool(dry_run, True):
72
+ _echo(cmd)
73
+ return
74
+ subprocess.check_call(cmd)
75
+ typer.secho(f"Python SDK generated → {outdir}", fg=typer.colors.GREEN)
76
+
77
+
78
+ @app.command("postman")
79
+ def sdk_postman(
80
+ openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
81
+ out: str = typer.Option("postman_collection.json", help="Output Postman collection"),
82
+ dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
83
+ ):
84
+ """Convert OpenAPI to a Postman collection via openapi-to-postmanv2."""
85
+ cmd = [
86
+ "npx",
87
+ "-y",
88
+ "openapi-to-postmanv2",
89
+ "-s",
90
+ openapi,
91
+ "-o",
92
+ out,
93
+ ]
94
+ if _parse_bool(dry_run, True):
95
+ _echo(cmd)
96
+ return
97
+ subprocess.check_call(cmd)
98
+ typer.secho(f"Postman collection generated → {out}", fg=typer.colors.GREEN)
99
+
100
+
101
+ def register(root: typer.Typer):
102
+ root.add_typer(app, name="sdk")
@@ -3,21 +3,20 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import shutil
5
5
  from pathlib import Path
6
- from typing import List, Optional
7
6
 
8
7
 
9
8
  def _has_poetry(root: Path) -> bool:
10
9
  return (root / "pyproject.toml").exists() and bool(shutil.which("poetry"))
11
10
 
12
11
 
13
- def candidate_cmds(root: Path, prog: str, argv: List[str]) -> List[List[str]]:
12
+ def candidate_cmds(root: Path, prog: str, argv: list[str]) -> list[list[str]]:
14
13
  """
15
14
  Return argv lists to try in order:
16
15
  1) poetry run <prog> ...
17
16
  2) <prog> ...
18
17
  3) python -m <module> ...
19
18
  """
20
- cmds: List[List[str]] = []
19
+ cmds: list[list[str]] = []
21
20
  if _has_poetry(root):
22
21
  cmds.append(["poetry", "run", prog, *argv])
23
22
 
@@ -31,12 +30,12 @@ def candidate_cmds(root: Path, prog: str, argv: List[str]) -> List[List[str]]:
31
30
  return cmds
32
31
 
33
32
 
34
- async def run_from_root(root: Path, prog: str, argv: List[str]) -> str:
33
+ async def run_from_root(root: Path, prog: str, argv: list[str]) -> str:
35
34
  """
36
35
  cd to project root and run the first working candidate command.
37
36
  Returns captured stdout+stderr text; raises on total failure.
38
37
  """
39
- last_exc: Optional[BaseException] = None
38
+ last_exc: BaseException | None = None
40
39
  for cmd in candidate_cmds(root, prog, argv):
41
40
  try:
42
41
  proc = await asyncio.create_subprocess_exec(
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -12,7 +11,7 @@ from svc_infra.app.root import resolve_project_root
12
11
  def pre_cli(app: typer.Typer) -> None:
13
12
  @app.callback()
14
13
  def _bootstrap(
15
- env_file: Optional[Path] = typer.Option(
14
+ env_file: Path | None = typer.Option(
16
15
  None,
17
16
  "--env-file",
18
17
  dir_okay=False,
@@ -0,0 +1,83 @@
1
+ """Data lifecycle module for backup verification, retention, and GDPR erasure.
2
+
3
+ This module provides data lifecycle management primitives:
4
+
5
+ - **add_data_lifecycle**: FastAPI integration for auto-migration and fixtures
6
+ - **Backup**: Backup health verification utilities
7
+ - **Retention**: Data retention policies and purge execution
8
+ - **Erasure**: GDPR-compliant data erasure workflows
9
+ - **Fixtures**: Fixture loading with run-once semantics
10
+
11
+ Example:
12
+ from fastapi import FastAPI
13
+ from svc_infra.data import add_data_lifecycle, make_on_load_fixtures
14
+
15
+ app = FastAPI()
16
+
17
+ # Enable auto-migration and fixture loading
18
+ add_data_lifecycle(
19
+ app,
20
+ auto_migrate=True,
21
+ on_load_fixtures=make_on_load_fixtures(load_seed_data),
22
+ )
23
+
24
+ # Define retention policies
25
+ from svc_infra.data import RetentionPolicy, run_retention_purge
26
+
27
+ policies = [
28
+ RetentionPolicy(name="old_logs", model=AuditLog, older_than_days=90),
29
+ RetentionPolicy(name="expired_tokens", model=RefreshToken, older_than_days=30),
30
+ ]
31
+
32
+ # Run in a scheduled job
33
+ affected = await run_retention_purge(session, policies)
34
+
35
+ # GDPR erasure
36
+ from svc_infra.data import ErasurePlan, ErasureStep, run_erasure
37
+
38
+ plan = ErasurePlan(steps=[
39
+ ErasureStep(name="anonymize_user", run=anonymize_user_data),
40
+ ErasureStep(name="delete_logs", run=delete_user_logs),
41
+ ])
42
+ await run_erasure(session, principal_id="user_123", plan=plan)
43
+
44
+ See Also:
45
+ - docs/data-lifecycle.md for detailed documentation
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ # FastAPI integration
51
+ from .add import add_data_lifecycle
52
+
53
+ # Backup verification
54
+ from .backup import BackupHealthReport, make_backup_verification_job, verify_backups
55
+
56
+ # GDPR erasure
57
+ from .erasure import ErasurePlan, ErasureStep, run_erasure
58
+
59
+ # Fixture loading
60
+ from .fixtures import make_on_load_fixtures, run_fixtures
61
+
62
+ # Retention policies
63
+ from .retention import RetentionPolicy, purge_policy, run_retention_purge
64
+
65
+ __all__ = [
66
+ # FastAPI integration
67
+ "add_data_lifecycle",
68
+ # Backup
69
+ "BackupHealthReport",
70
+ "verify_backups",
71
+ "make_backup_verification_job",
72
+ # Retention
73
+ "RetentionPolicy",
74
+ "purge_policy",
75
+ "run_retention_purge",
76
+ # Erasure
77
+ "ErasureStep",
78
+ "ErasurePlan",
79
+ "run_erasure",
80
+ # Fixtures
81
+ "run_fixtures",
82
+ "make_on_load_fixtures",
83
+ ]
svc_infra/data/add.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable, Iterable
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: list[str] | None = None,
17
+ with_payments: bool | None = None,
18
+ on_load_fixtures: Callable[[], None] | None = None,
19
+ retention_jobs: Iterable[Callable[[], None]] | None = None,
20
+ erasure_job: Callable[[str], None] | 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"]