svc-infra 0.1.595__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Optional, cast
9
+
10
+ import typer
11
+ from sqlalchemy import text
12
+ from sqlalchemy.engine import Engine
13
+
14
+ from svc_infra.db.sql.utils import build_engine
15
+
16
+ try: # SQLAlchemy async extras are optional
17
+ import sqlalchemy.ext.asyncio as sa_async
18
+ except Exception: # pragma: no cover - fallback when async extras unavailable
19
+ sa_async = None # type: ignore[assignment]
20
+
21
+
22
+ def export_tenant(
23
+ table: str = typer.Argument(
24
+ ..., help="Qualified table name to export (e.g., public.items)"
25
+ ),
26
+ tenant_id: str = typer.Option(
27
+ ..., "--tenant-id", help="Tenant id value to filter by."
28
+ ),
29
+ tenant_field: str = typer.Option(
30
+ "tenant_id", help="Column name for tenant id filter."
31
+ ),
32
+ output: Optional[Path] = typer.Option(
33
+ None, "--output", help="Output file; defaults to stdout."
34
+ ),
35
+ limit: Optional[int] = typer.Option(None, help="Max rows to export."),
36
+ database_url: Optional[str] = typer.Option(
37
+ None, "--database-url", help="Overrides env SQL_URL for this command."
38
+ ),
39
+ ):
40
+ """Export rows for a tenant from a given SQL table as JSON array."""
41
+ if database_url:
42
+ os.environ["SQL_URL"] = database_url
43
+
44
+ url = os.getenv("SQL_URL")
45
+ if not url:
46
+ typer.echo("SQL_URL is required (or pass --database-url)", err=True)
47
+ raise typer.Exit(code=2)
48
+
49
+ engine = build_engine(url)
50
+ rows: list[dict[str, Any]]
51
+ query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
52
+ if limit and limit > 0:
53
+ query += " LIMIT :limit"
54
+
55
+ params: dict[str, Any] = {"tenant_id": tenant_id}
56
+ if limit and limit > 0:
57
+ params["limit"] = int(limit)
58
+
59
+ stmt = text(query)
60
+
61
+ is_async_engine = sa_async is not None and isinstance(engine, sa_async.AsyncEngine)
62
+
63
+ if is_async_engine:
64
+ async_engine = cast(Any, engine)
65
+
66
+ async def _fetch() -> list[dict[str, Any]]:
67
+ async with async_engine.connect() as conn:
68
+ result = await conn.execute(stmt, params)
69
+ return [dict(row) for row in result.mappings()]
70
+
71
+ rows = asyncio.run(_fetch())
72
+ else:
73
+ sync_engine = cast(Engine, engine)
74
+ with sync_engine.connect() as conn:
75
+ result = conn.execute(stmt, params)
76
+ rows = [dict(row) for row in result.mappings()]
77
+
78
+ data = json.dumps(rows, indent=2)
79
+ if output:
80
+ output.write_text(data)
81
+ typer.echo(str(output))
82
+ else:
83
+ sys.stdout.write(data)
84
+
85
+
86
+ def register(app_root: typer.Typer) -> None:
87
+ # Attach directly to the provided 'sql' group app
88
+ app_root.command("export-tenant")(export_tenant)
@@ -134,6 +134,6 @@ def cmd_scaffold_schemas(
134
134
 
135
135
 
136
136
  def register(app: typer.Typer) -> None:
137
- app.command("sql-scaffold")(cmd_scaffold)
138
- app.command("sql-scaffold-models")(cmd_scaffold_models)
139
- app.command("sql-scaffold-schemas")(cmd_scaffold_schemas)
137
+ app.command("scaffold")(cmd_scaffold)
138
+ app.command("scaffold-models")(cmd_scaffold_models)
139
+ app.command("scaffold-schemas")(cmd_scaffold_schemas)
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from importlib.resources import as_file
5
+ from importlib.resources import files as pkg_files
6
+ from pathlib import Path
7
+ from typing import Dict, List
8
+
9
+ import click
10
+ import typer
11
+ from typer.core import TyperGroup
12
+
13
+ from svc_infra.app.root import resolve_project_root
14
+
15
+
16
+ def _norm(name: str) -> str:
17
+ return name.strip().lower().replace(" ", "-").replace("_", "-")
18
+
19
+
20
+ def _discover_fs_topics(docs_dir: Path) -> Dict[str, Path]:
21
+ topics: Dict[str, Path] = {}
22
+ if docs_dir.exists() and docs_dir.is_dir():
23
+ for p in sorted(docs_dir.glob("*.md")):
24
+ if p.is_file():
25
+ topics[_norm(p.stem)] = p
26
+ return topics
27
+
28
+
29
+ def _discover_pkg_topics() -> Dict[str, Path]:
30
+ """
31
+ Discover docs shipped inside the installed package at svc_infra/docs/*,
32
+ using importlib.resources so this works for wheels, sdists, and zipped wheels.
33
+ """
34
+ topics: Dict[str, Path] = {}
35
+ try:
36
+ docs_root = pkg_files("svc_infra").joinpath("docs")
37
+ # docs_root is a Traversable; it may be inside a zip. Iterate safely.
38
+ for entry in docs_root.iterdir():
39
+ if entry.name.endswith(".md"):
40
+ # materialize to a real tempfile path if needed
41
+ with as_file(entry) as concrete:
42
+ p = Path(concrete)
43
+ if p.exists() and p.is_file():
44
+ topics[_norm(p.stem)] = p
45
+ except Exception:
46
+ # If the package has no docs directory, just return empty.
47
+ pass
48
+ return topics
49
+
50
+
51
+ def _resolve_docs_dir(ctx: click.Context) -> Path | None:
52
+ """
53
+ Optional override precedence:
54
+ 1) SVC_INFRA_DOCS_DIR env var
55
+ 2) *Only when working inside the svc-infra repo itself*: repo-root /docs
56
+ """
57
+ # 1) Env var
58
+ env_dir = os.getenv("SVC_INFRA_DOCS_DIR")
59
+ if env_dir:
60
+ p = Path(env_dir).expanduser()
61
+ if p.exists():
62
+ return p
63
+
64
+ # 2) In-repo convenience (so `svc-infra docs` works inside this repo)
65
+ try:
66
+ root = resolve_project_root()
67
+ proj_docs = root / "docs"
68
+ if proj_docs.exists():
69
+ return proj_docs
70
+ except Exception:
71
+ pass
72
+
73
+ return None
74
+
75
+
76
+ class DocsGroup(TyperGroup):
77
+ def list_commands(self, ctx: click.Context) -> List[str]:
78
+ names: List[str] = list(super().list_commands(ctx) or [])
79
+ dir_to_use = _resolve_docs_dir(ctx)
80
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
81
+ pkg = _discover_pkg_topics()
82
+ names.extend(fs.keys())
83
+ names.extend([k for k in pkg.keys() if k not in fs])
84
+ return sorted(set(names))
85
+
86
+ def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
87
+ cmd = super().get_command(ctx, name)
88
+ if cmd is not None:
89
+ return cmd
90
+
91
+ key = _norm(name)
92
+
93
+ dir_to_use = _resolve_docs_dir(ctx)
94
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
95
+ if key in fs:
96
+ file_path = fs[key]
97
+
98
+ @click.command(name=name)
99
+ def _show_fs() -> None:
100
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
101
+
102
+ return _show_fs
103
+
104
+ pkg = _discover_pkg_topics()
105
+ if key in pkg:
106
+ file_path = pkg[key]
107
+
108
+ @click.command(name=name)
109
+ def _show_pkg() -> None:
110
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
111
+
112
+ return _show_pkg
113
+
114
+ return None
115
+
116
+
117
+ def register(app: typer.Typer) -> None:
118
+ docs_app = typer.Typer(no_args_is_help=True, add_completion=False, cls=DocsGroup)
119
+
120
+ @docs_app.callback(invoke_without_command=True)
121
+ def _docs_options() -> None:
122
+ # No group-level options; dynamic commands and 'show' handle topics.
123
+ return None
124
+
125
+ @docs_app.command(
126
+ "show", help="Show docs for a topic (alternative to dynamic subcommand)"
127
+ )
128
+ def show(topic: str) -> None:
129
+ key = _norm(topic)
130
+ ctx = click.get_current_context()
131
+ dir_to_use = _resolve_docs_dir(ctx)
132
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
133
+ if key in fs:
134
+ typer.echo(fs[key].read_text(encoding="utf-8", errors="replace"))
135
+ return
136
+ pkg = _discover_pkg_topics()
137
+ if key in pkg:
138
+ typer.echo(pkg[key].read_text(encoding="utf-8", errors="replace"))
139
+ return
140
+ raise typer.BadParameter(f"Unknown topic: {topic}")
141
+
142
+ app.add_typer(docs_app, name="docs")
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from .dx_cmds import app as dx_app
6
+
7
+
8
+ def register_dx(root: typer.Typer) -> None:
9
+ root.add_typer(dx_app, name="dx")
10
+
11
+
12
+ __all__ = ["register_dx"]
@@ -0,0 +1,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())