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
svc_infra/cache/tags.py CHANGED
@@ -28,50 +28,25 @@ async def invalidate_tags(*tags: str) -> int:
28
28
  if not tags:
29
29
  return 0
30
30
 
31
- count = 0
31
+ # Preserve order while de-duplicating.
32
+ tags_to_delete = list(dict.fromkeys(tags))
32
33
 
33
- # Strategy 1: Modern cashews invalidate with tags parameter
34
+ # Cashews supports explicit tag deletion via delete_tags().
34
35
  try:
35
- result = await _cache.invalidate(tags=list(tags))
36
- return int(result) if isinstance(result, int) else len(tags)
37
- except (TypeError, AttributeError):
38
- pass
36
+ if hasattr(_cache, "delete_tags"):
37
+ await _cache.delete_tags(*tags_to_delete)
38
+ return len(tags_to_delete)
39
39
  except Exception as e:
40
- logger.warning(f"Modern tag invalidation failed: {e}")
41
-
42
- # Strategy 2: Legacy cashews invalidate with positional args
43
- try:
44
- result = await _cache.invalidate(*tags)
45
- return int(result) if isinstance(result, int) else len(tags)
46
- except (TypeError, AttributeError):
47
- pass
48
- except Exception as e:
49
- logger.warning(f"Legacy tag invalidation failed: {e}")
50
-
51
- # Strategy 3: Individual tag methods
52
- for tag in tags:
53
- for method_name in ("delete_tag", "invalidate_tag", "tag_invalidate"):
54
- if hasattr(_cache, method_name):
55
- try:
56
- method = getattr(_cache, method_name)
57
- result = await method(tag)
58
- count += int(result) if isinstance(result, int) else 1
59
- break
60
- except Exception as e:
61
- logger.debug(f"Tag method {method_name} failed for tag {tag}: {e}")
62
- continue
63
- else:
64
- # Strategy 4: Pattern matching fallback
65
- for method_name in ("delete_match", "invalidate_match", "invalidate"):
66
- if hasattr(_cache, method_name):
67
- try:
68
- method = getattr(_cache, method_name)
69
- pattern = f"*{tag}*"
70
- result = await method(pattern)
71
- count += int(result) if isinstance(result, int) else 1
72
- break
73
- except Exception as e:
74
- logger.debug(f"Pattern method {method_name} failed for tag {tag}: {e}")
75
- continue
76
-
77
- return count
40
+ logger.warning(f"Cache tag invalidation failed: {e}")
41
+
42
+ # Fallback: attempt private per-tag deletion when available.
43
+ deleted = 0
44
+ for tag in tags_to_delete:
45
+ try:
46
+ if hasattr(_cache, "_delete_tag"):
47
+ await _cache._delete_tag(tag)
48
+ deleted += 1
49
+ except Exception as e:
50
+ logger.debug(f"Tag deletion failed for tag {tag}: {e}")
51
+
52
+ return deleted
svc_infra/cache/ttl.py CHANGED
@@ -6,7 +6,6 @@ via environment variables with sensible defaults.
6
6
  """
7
7
 
8
8
  import os
9
- from typing import Optional
10
9
 
11
10
 
12
11
  def _get_env_int(key: str, default: int) -> int:
@@ -36,7 +35,7 @@ TTL_SHORT: int = _get_env_int("CACHE_TTL_SHORT", 30) # 30 seconds
36
35
  TTL_LONG: int = _get_env_int("CACHE_TTL_LONG", 3600) # 1 hour
37
36
 
38
37
 
39
- def get_ttl(duration_type: str) -> Optional[int]:
38
+ def get_ttl(duration_type: str) -> int | None:
40
39
  """
41
40
  Get TTL value by duration type name.
42
41
 
@@ -60,7 +59,7 @@ def get_ttl(duration_type: str) -> Optional[int]:
60
59
  return ttl_map.get(duration_type.lower())
61
60
 
62
61
 
63
- def validate_ttl(ttl: Optional[int]) -> int:
62
+ def validate_ttl(ttl: int | None) -> int:
64
63
  """
65
64
  Validate and normalize a TTL value.
66
65
 
svc_infra/cache/utils.py CHANGED
@@ -8,7 +8,8 @@ hashing complex objects, and formatting key templates.
8
8
  import hashlib
9
9
  import json
10
10
  import logging
11
- from typing import Any, Iterable, Union
11
+ from collections.abc import Iterable
12
+ from typing import Any
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
@@ -42,7 +43,7 @@ def stable_hash(*args: Any, **kwargs: Any) -> str:
42
43
  return hashlib.sha1(raw.encode("utf-8")).hexdigest()
43
44
 
44
45
 
45
- def join_key(parts: Iterable[Union[str, int, None]]) -> str:
46
+ def join_key(parts: Iterable[str | int | None]) -> str:
46
47
  """
47
48
  Join key parts into a cache key, filtering out empty values.
48
49
 
@@ -98,7 +99,7 @@ def format_tuple_key(key_tuple: tuple[str, ...], **kwargs) -> str:
98
99
  raise ValueError(f"Failed to format key template: {e}") from e
99
100
 
100
101
 
101
- def normalize_cache_key(key: Union[str, tuple[str, ...]], **kwargs) -> str:
102
+ def normalize_cache_key(key: str | tuple[str, ...], **kwargs) -> str:
102
103
  """
103
104
  Normalize a cache key from various input formats.
104
105
 
svc_infra/cli/__init__.py CHANGED
@@ -4,10 +4,17 @@ import typer
4
4
 
5
5
  from svc_infra.cli.cmds import (
6
6
  _HELP,
7
+ jobs_app,
7
8
  register_alembic,
9
+ register_db_ops,
10
+ register_docs,
11
+ register_dx,
12
+ register_health,
8
13
  register_mongo,
9
14
  register_mongo_scaffold,
10
15
  register_obs,
16
+ register_sdk,
17
+ register_sql_export,
11
18
  register_sql_scaffold,
12
19
  )
13
20
  from svc_infra.cli.foundation.typer_bootstrap import pre_cli
@@ -15,16 +22,45 @@ from svc_infra.cli.foundation.typer_bootstrap import pre_cli
15
22
  app = typer.Typer(no_args_is_help=True, add_completion=False, help=_HELP)
16
23
  pre_cli(app)
17
24
 
18
- # --- sql commands ---
19
- register_alembic(app)
20
- register_sql_scaffold(app)
25
+ # --- db ops group ---
26
+ db_app = typer.Typer(no_args_is_help=True, add_completion=False, help="Database operations")
27
+ register_db_ops(db_app)
28
+ app.add_typer(db_app, name="db")
21
29
 
22
- # --- nosql commands ---
23
- register_mongo(app)
24
- register_mongo_scaffold(app)
30
+ # --- sql group ---
31
+ sql_app = typer.Typer(no_args_is_help=True, add_completion=False, help="SQL commands")
32
+ register_alembic(sql_app)
33
+ register_sql_scaffold(sql_app)
34
+ register_sql_export(sql_app)
35
+ app.add_typer(sql_app, name="sql")
25
36
 
26
- # -- observability commands ---
27
- register_obs(app)
37
+ # --- mongo group ---
38
+ mongo_app = typer.Typer(no_args_is_help=True, add_completion=False, help="MongoDB commands")
39
+ register_mongo(mongo_app)
40
+ register_mongo_scaffold(mongo_app)
41
+ app.add_typer(mongo_app, name="mongo")
42
+
43
+ # --- health group ---
44
+ health_app = typer.Typer(no_args_is_help=True, add_completion=False, help="Health checks")
45
+ register_health(health_app)
46
+ app.add_typer(health_app, name="health")
47
+
48
+ # -- obs group ---
49
+ obs_app = typer.Typer(no_args_is_help=True, add_completion=False, help="Observability commands")
50
+ register_obs(obs_app)
51
+ app.add_typer(obs_app, name="obs")
52
+
53
+ # -- dx commands ---
54
+ register_dx(app)
55
+
56
+ # -- jobs commands ---
57
+ app.add_typer(jobs_app, name="jobs")
58
+
59
+ # -- sdk commands ---
60
+ register_sdk(app)
61
+
62
+ # -- docs commands ---
63
+ register_docs(app)
28
64
 
29
65
 
30
66
  def main():
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -1,18 +1,55 @@
1
- from svc_infra.cli.cmds.db.nosql.mongo.mongo_cmds import register as register_mongo
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ try:
8
+ from svc_infra.cli.cmds.db.nosql.mongo.mongo_cmds import register as register_mongo
9
+ except ModuleNotFoundError as exc:
10
+ _mongo_import_error = exc
11
+
12
+ def register_mongo(app: typer.Typer) -> None: # type: ignore[no-redef]
13
+ def _unavailable() -> Any:
14
+ raise ModuleNotFoundError(
15
+ "MongoDB CLI commands require optional dependencies. Install pymongo and motor "
16
+ "to enable `svc-infra mongo ...` commands."
17
+ ) from _mongo_import_error
18
+
19
+ # Provide a single helpful command instead of failing CLI import.
20
+ app.command("unavailable")(_unavailable)
21
+
22
+
2
23
  from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
3
24
  register as register_mongo_scaffold,
4
25
  )
26
+ from svc_infra.cli.cmds.db.ops_cmds import register as register_db_ops
5
27
  from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
6
- from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
28
+ from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
29
+ from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import (
30
+ register as register_sql_scaffold,
31
+ )
32
+ from svc_infra.cli.cmds.docs.docs_cmds import register as register_docs
33
+ from svc_infra.cli.cmds.dx import register_dx
34
+ from svc_infra.cli.cmds.health.health_cmds import register as register_health
35
+ from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
7
36
  from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
37
+ from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
8
38
 
9
39
  from .help import _HELP
10
40
 
11
41
  __all__ = [
12
42
  "register_alembic",
13
43
  "register_sql_scaffold",
44
+ "register_sql_export",
14
45
  "register_mongo",
15
46
  "register_mongo_scaffold",
47
+ "register_db_ops",
16
48
  "register_obs",
49
+ "jobs_app",
50
+ "register_sdk",
51
+ "register_dx",
52
+ "register_docs",
53
+ "register_health",
17
54
  "_HELP",
18
55
  ]
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import importlib
4
4
  import os
5
- from typing import Any, Optional, Sequence
5
+ from collections.abc import Sequence
6
+ from typing import Any
6
7
 
7
8
  import typer
8
9
 
@@ -17,7 +18,7 @@ from svc_infra.db.nosql.utils import prepare_process_env
17
18
  # -------------------- helpers --------------------
18
19
 
19
20
 
20
- def _apply_mongo_env(mongo_url: Optional[str], mongo_db: Optional[str]) -> None:
21
+ def _apply_mongo_env(mongo_url: str | None, mongo_db: str | None) -> None:
21
22
  """If provided, set MONGO_URL / MONGO_DB for the current process."""
22
23
  if mongo_url:
23
24
  os.environ["MONGO_URL"] = mongo_url
@@ -63,13 +64,13 @@ def cmd_prepare(
63
64
  "--resources",
64
65
  help="Dotted path to NoSqlResource(s). e.g. 'app.db.mongo:RESOURCES'",
65
66
  ),
66
- mongo_url: Optional[str] = typer.Option(
67
+ mongo_url: str | None = typer.Option(
67
68
  None, "--mongo-url", help="Overrides env MONGO_URL for this command."
68
69
  ),
69
- mongo_db: Optional[str] = typer.Option(
70
+ mongo_db: str | None = typer.Option(
70
71
  None, "--mongo-db", help="Overrides env MONGO_DB for this command."
71
72
  ),
72
- service_id: Optional[str] = typer.Option(
73
+ service_id: str | None = typer.Option(
73
74
  None,
74
75
  "--service-id",
75
76
  help="Stable ID for this service/app. Defaults to top-level module name.",
@@ -125,13 +126,13 @@ def cmd_setup_and_prepare(
125
126
  "--resources",
126
127
  help="Dotted path to NoSqlResource(s). e.g. 'app.db.mongo:RESOURCES'",
127
128
  ),
128
- mongo_url: Optional[str] = typer.Option(
129
+ mongo_url: str | None = typer.Option(
129
130
  None, "--mongo-url", help="Overrides env MONGO_URL for this command."
130
131
  ),
131
- mongo_db: Optional[str] = typer.Option(
132
+ mongo_db: str | None = typer.Option(
132
133
  None, "--mongo-db", help="Overrides env MONGO_DB for this command."
133
134
  ),
134
- service_id: Optional[str] = typer.Option(
135
+ service_id: str | None = typer.Option(
135
136
  None,
136
137
  "--service-id",
137
138
  help="Stable ID for this service/app. Defaults to top-level module name.",
@@ -157,10 +158,10 @@ def cmd_setup_and_prepare(
157
158
 
158
159
 
159
160
  def cmd_ping(
160
- mongo_url: Optional[str] = typer.Option(
161
+ mongo_url: str | None = typer.Option(
161
162
  None, "--mongo-url", help="Overrides env MONGO_URL for this command."
162
163
  ),
163
- mongo_db: Optional[str] = typer.Option(
164
+ mongo_db: str | None = typer.Option(
164
165
  None, "--mongo-db", help="Overrides env MONGO_DB for this command."
165
166
  ),
166
167
  ):
@@ -172,7 +173,9 @@ def cmd_ping(
172
173
 
173
174
  import asyncio
174
175
 
175
- from svc_infra.db.nosql.mongo.client import acquire_db # local import to avoid side effects
176
+ from svc_infra.db.nosql.mongo.client import (
177
+ acquire_db,
178
+ ) # local import to avoid side effects
176
179
 
177
180
  async def _run():
178
181
  await init_mongo()
@@ -188,6 +191,7 @@ def cmd_ping(
188
191
 
189
192
 
190
193
  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)
194
+ # Attach to 'mongo' group app
195
+ app.command("prepare")(cmd_prepare)
196
+ app.command("setup-and-prepare")(cmd_setup_and_prepare)
197
+ app.command("ping")(cmd_ping)
@@ -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
 
@@ -25,10 +24,10 @@ def cmd_scaffold(
25
24
  "--same-dir/--no-same-dir",
26
25
  help="Put documents & schemas into the same directory.",
27
26
  ),
28
- documents_filename: Optional[str] = typer.Option(
27
+ documents_filename: str | None = typer.Option(
29
28
  None, help="Custom filename for documents (separate-dir mode)."
30
29
  ),
31
- schemas_filename: Optional[str] = typer.Option(
30
+ schemas_filename: str | None = typer.Option(
32
31
  None, help="Custom filename for schemas (separate-dir mode)."
33
32
  ),
34
33
  ):
@@ -53,7 +52,7 @@ def cmd_scaffold_documents(
53
52
  dest_dir: Path = typer.Option(..., "--dest-dir", resolve_path=True),
54
53
  entity_name: str = typer.Option("Item", "--entity-name"),
55
54
  overwrite: bool = typer.Option(False, "--overwrite/--no-overwrite"),
56
- documents_filename: Optional[str] = typer.Option(
55
+ documents_filename: str | None = typer.Option(
57
56
  None,
58
57
  "--documents-filename",
59
58
  help="Filename to write (e.g. product_doc.py). Defaults to <snake(entity)>.py",
@@ -73,7 +72,7 @@ def cmd_scaffold_schemas(
73
72
  dest_dir: Path = typer.Option(..., "--dest-dir", resolve_path=True),
74
73
  entity_name: str = typer.Option("Item", "--entity-name"),
75
74
  overwrite: bool = typer.Option(False, "--overwrite/--no-overwrite"),
76
- schemas_filename: Optional[str] = typer.Option(
75
+ schemas_filename: str | None = typer.Option(
77
76
  None,
78
77
  "--schemas-filename",
79
78
  help="Filename to write (e.g. product_schemas.py). Defaults to <snake(entity)>.py",
@@ -96,7 +95,7 @@ def cmd_scaffold_resources(
96
95
  "--entity-name",
97
96
  help="Used only to prefill example placeholders.",
98
97
  ),
99
- filename: Optional[str] = typer.Option(
98
+ filename: str | None = typer.Option(
100
99
  None,
101
100
  "--filename",
102
101
  help='Output filename (default: "resources.py")',
@@ -127,7 +126,7 @@ def register(app: typer.Typer) -> None:
127
126
  • mongo-scaffold-schemas
128
127
  • mongo-scaffold-resources
129
128
  """
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)
129
+ app.command("scaffold")(cmd_scaffold)
130
+ app.command("scaffold-documents")(cmd_scaffold_documents)
131
+ app.command("scaffold-schemas")(cmd_scaffold_schemas)
132
+ app.command("scaffold-resources")(cmd_scaffold_resources)
@@ -0,0 +1,267 @@
1
+ """Database operations CLI commands.
2
+
3
+ Provides CLI commands for database administration:
4
+ - wait: Wait for database to be ready before proceeding
5
+ - kill-queries: Terminate queries blocking a specific table
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import time
13
+
14
+ import typer
15
+
16
+
17
+ def cmd_wait(
18
+ database_url: str | None = typer.Option(
19
+ None,
20
+ "--url",
21
+ "-u",
22
+ help="Database URL; overrides env SQL_URL.",
23
+ ),
24
+ timeout: int = typer.Option(
25
+ 60,
26
+ "--timeout",
27
+ "-t",
28
+ help="Maximum time to wait in seconds.",
29
+ ),
30
+ interval: float = typer.Option(
31
+ 2.0,
32
+ "--interval",
33
+ "-i",
34
+ help="Time between connection attempts in seconds.",
35
+ ),
36
+ quiet: bool = typer.Option(
37
+ False,
38
+ "--quiet",
39
+ "-q",
40
+ help="Suppress progress messages.",
41
+ ),
42
+ ) -> None:
43
+ """
44
+ Wait for database to be ready.
45
+
46
+ Attempts to connect to the database repeatedly until successful
47
+ or timeout is reached. Useful in container startup scripts.
48
+
49
+ Exit codes:
50
+ 0: Database is ready
51
+ 1: Timeout reached, database not ready
52
+ """
53
+ url = database_url or os.getenv("SQL_URL") or os.getenv("DATABASE_URL")
54
+ if not url:
55
+ typer.secho(
56
+ "ERROR: No database URL. Set --url, SQL_URL, or DATABASE_URL.",
57
+ fg=typer.colors.RED,
58
+ )
59
+ raise typer.Exit(1)
60
+
61
+ async def _wait() -> bool:
62
+ """Async wait loop."""
63
+ from svc_infra.health import check_database
64
+
65
+ check = check_database(url)
66
+ deadline = time.monotonic() + timeout
67
+ attempt = 0
68
+
69
+ while time.monotonic() < deadline:
70
+ attempt += 1
71
+ if not quiet:
72
+ typer.echo(f"Attempt {attempt}: Connecting to database...")
73
+
74
+ result = await check()
75
+
76
+ if result.status == "healthy":
77
+ if not quiet:
78
+ typer.secho(
79
+ f"✓ Database ready ({result.latency_ms:.1f}ms)",
80
+ fg=typer.colors.GREEN,
81
+ )
82
+ return True
83
+
84
+ if not quiet:
85
+ msg = result.message or "Connection failed"
86
+ typer.echo(f" → {msg}")
87
+
88
+ remaining = deadline - time.monotonic()
89
+ if remaining > 0:
90
+ await asyncio.sleep(min(interval, remaining))
91
+
92
+ return False
93
+
94
+ success = asyncio.run(_wait())
95
+ if not success:
96
+ typer.secho(
97
+ f"ERROR: Database not ready after {timeout}s",
98
+ fg=typer.colors.RED,
99
+ )
100
+ raise typer.Exit(1)
101
+
102
+
103
+ def cmd_kill_queries(
104
+ table: str = typer.Argument(
105
+ ...,
106
+ help="Table name to find blocking queries for.",
107
+ ),
108
+ database_url: str | None = typer.Option(
109
+ None,
110
+ "--url",
111
+ "-u",
112
+ help="Database URL; overrides env SQL_URL.",
113
+ ),
114
+ dry_run: bool = typer.Option(
115
+ False,
116
+ "--dry-run",
117
+ "-n",
118
+ help="Show queries that would be killed without actually killing them.",
119
+ ),
120
+ force: bool = typer.Option(
121
+ False,
122
+ "--force",
123
+ "-f",
124
+ help="Terminate immediately (pg_terminate_backend) instead of cancel (pg_cancel_backend).",
125
+ ),
126
+ quiet: bool = typer.Option(
127
+ False,
128
+ "--quiet",
129
+ "-q",
130
+ help="Suppress output except errors.",
131
+ ),
132
+ ) -> None:
133
+ """
134
+ Kill queries blocking operations on a table.
135
+
136
+ Finds queries that hold locks on the specified table and attempts
137
+ to cancel or terminate them. Useful when migrations are blocked.
138
+
139
+ By default uses pg_cancel_backend (graceful). Use --force for
140
+ pg_terminate_backend (immediate termination).
141
+
142
+ Examples:
143
+ svc-infra db kill-queries users
144
+ svc-infra db kill-queries users --dry-run
145
+ svc-infra db kill-queries users --force
146
+ """
147
+ url = database_url or os.getenv("SQL_URL") or os.getenv("DATABASE_URL")
148
+ if not url:
149
+ typer.secho(
150
+ "ERROR: No database URL. Set --url, SQL_URL, or DATABASE_URL.",
151
+ fg=typer.colors.RED,
152
+ )
153
+ raise typer.Exit(1)
154
+
155
+ async def _kill_queries() -> int:
156
+ """Find and kill blocking queries. Returns count of killed queries."""
157
+ try:
158
+ import asyncpg
159
+ except ImportError:
160
+ typer.secho(
161
+ "ERROR: asyncpg not installed. Run: pip install asyncpg",
162
+ fg=typer.colors.RED,
163
+ )
164
+ raise typer.Exit(1)
165
+
166
+ # Normalize URL for asyncpg
167
+ db_url = url
168
+ if db_url.startswith("postgres://"):
169
+ db_url = db_url.replace("postgres://", "postgresql://", 1)
170
+ if "+asyncpg" in db_url:
171
+ db_url = db_url.replace("+asyncpg", "")
172
+
173
+ try:
174
+ conn = await asyncpg.connect(db_url)
175
+ except Exception as e:
176
+ typer.secho(
177
+ f"ERROR: Failed to connect to database: {e}",
178
+ fg=typer.colors.RED,
179
+ )
180
+ raise typer.Exit(1)
181
+
182
+ try:
183
+ # Find queries with locks on the table
184
+ # Uses pg_stat_activity joined with pg_locks to find blocking queries
185
+ find_query = """
186
+ SELECT DISTINCT
187
+ a.pid,
188
+ a.usename,
189
+ a.application_name,
190
+ a.state,
191
+ a.query,
192
+ a.query_start,
193
+ l.locktype,
194
+ l.mode
195
+ FROM pg_stat_activity a
196
+ JOIN pg_locks l ON a.pid = l.pid
197
+ WHERE l.relation = $1::regclass
198
+ AND a.pid != pg_backend_pid()
199
+ ORDER BY a.query_start
200
+ """
201
+
202
+ try:
203
+ rows = await conn.fetch(find_query, table)
204
+ except asyncpg.UndefinedTableError:
205
+ typer.secho(
206
+ f"ERROR: Table '{table}' does not exist",
207
+ fg=typer.colors.RED,
208
+ )
209
+ raise typer.Exit(1)
210
+
211
+ if not rows:
212
+ if not quiet:
213
+ typer.echo(f"No active queries found on table '{table}'")
214
+ return 0
215
+
216
+ if not quiet:
217
+ typer.echo(f"Found {len(rows)} query(ies) with locks on '{table}':\n")
218
+ for row in rows:
219
+ query_preview = (row["query"] or "")[:80].replace("\n", " ")
220
+ if len(row["query"] or "") > 80:
221
+ query_preview += "..."
222
+ typer.echo(f" PID {row['pid']}: {query_preview}")
223
+ typer.echo(f" User: {row['usename']}, State: {row['state']}")
224
+ typer.echo(f" Lock: {row['mode']} on {row['locktype']}")
225
+ typer.echo("")
226
+
227
+ if dry_run:
228
+ typer.echo("Dry run - no queries killed.")
229
+ return 0
230
+
231
+ # Kill the queries
232
+ kill_fn = "pg_terminate_backend" if force else "pg_cancel_backend"
233
+ killed = 0
234
+
235
+ for row in rows:
236
+ pid = row["pid"]
237
+ try:
238
+ result = await conn.fetchval(f"SELECT {kill_fn}($1)", pid)
239
+ if result:
240
+ killed += 1
241
+ if not quiet:
242
+ action = "Terminated" if force else "Cancelled"
243
+ typer.secho(f" {action} PID {pid}", fg=typer.colors.GREEN)
244
+ else:
245
+ if not quiet:
246
+ typer.echo(f" PID {pid}: already finished or permission denied")
247
+ except Exception as e:
248
+ if not quiet:
249
+ typer.secho(f" PID {pid}: Error - {e}", fg=typer.colors.YELLOW)
250
+
251
+ if not quiet:
252
+ typer.echo(f"\n{killed}/{len(rows)} queries killed.")
253
+ return killed
254
+
255
+ finally:
256
+ await conn.close()
257
+
258
+ count = asyncio.run(_kill_queries())
259
+ if count == 0 and not dry_run:
260
+ # Exit with 0 even if no queries found - that's success
261
+ pass
262
+
263
+
264
+ def register(app: typer.Typer) -> None:
265
+ """Register database operations commands with the CLI app."""
266
+ app.command("wait")(cmd_wait)
267
+ app.command("kill-queries")(cmd_kill_queries)