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,113 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Annotated, Any
5
+
6
+ from fastapi import Depends, HTTPException, Request
7
+
8
+ try: # optional import; auth may not be used by all consumers
9
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity
10
+ except Exception: # pragma: no cover - fallback for minimal builds
11
+ OptionalIdentity = None # type: ignore[misc,assignment]
12
+
13
+
14
+ _tenant_resolver: Callable[..., Any] | None = None
15
+
16
+
17
+ def set_tenant_resolver(
18
+ fn: Callable[..., Any] | None,
19
+ ) -> None:
20
+ """Set or clear a global override hook for tenant resolution.
21
+
22
+ The function receives (request, identity, tenant_header) and should return a tenant id
23
+ string or None to fall back to default logic.
24
+ """
25
+ global _tenant_resolver
26
+ _tenant_resolver = fn
27
+
28
+
29
+ async def _maybe_await(x):
30
+ if callable(getattr(x, "__await__", None)):
31
+ return await x
32
+ return x
33
+
34
+
35
+ async def resolve_tenant_id(
36
+ request: Request,
37
+ tenant_header: str | None = None,
38
+ identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
39
+ ) -> str | None:
40
+ """Resolve tenant id from override, identity, header, or request.state.
41
+
42
+ Order:
43
+ 1) Global override hook (set_tenant_resolver)
44
+ 2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
45
+ 3) X-Tenant-Id header
46
+ 4) request.state.tenant_id
47
+ """
48
+ # read header value if not provided directly (supports direct calls without DI)
49
+ if tenant_header is None:
50
+ try:
51
+ tenant_header = request.headers.get("X-Tenant-Id")
52
+ except Exception:
53
+ tenant_header = None
54
+
55
+ # 1) global override
56
+ if _tenant_resolver is not None:
57
+ try:
58
+ v = _tenant_resolver(request, identity, tenant_header)
59
+ v2 = await _maybe_await(v)
60
+ if v2:
61
+ return str(v2)
62
+ except Exception:
63
+ # fall through to defaults
64
+ pass
65
+
66
+ # 2) from identity
67
+ try:
68
+ if identity and getattr(identity, "user", None) is not None:
69
+ tid = getattr(identity.user, "tenant_id", None)
70
+ if tid:
71
+ return str(tid)
72
+ if identity and getattr(identity, "api_key", None) is not None:
73
+ tid = getattr(identity.api_key, "tenant_id", None)
74
+ if tid:
75
+ return str(tid)
76
+ except Exception:
77
+ pass
78
+
79
+ # 3) from header
80
+ if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
81
+ return tenant_header.strip()
82
+
83
+ # 4) request.state
84
+ try:
85
+ st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
86
+ if st_tid:
87
+ return str(st_tid)
88
+ except Exception:
89
+ pass
90
+
91
+ return None
92
+
93
+
94
+ async def require_tenant_id(
95
+ tenant_id: str | None = Depends(resolve_tenant_id),
96
+ ) -> str:
97
+ if not tenant_id:
98
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
99
+ return tenant_id
100
+
101
+
102
+ # DX aliases
103
+ TenantId = Annotated[str, Depends(require_tenant_id)]
104
+ OptionalTenantId = Annotated[str | None, Depends(resolve_tenant_id)]
105
+
106
+
107
+ __all__ = [
108
+ "TenantId",
109
+ "OptionalTenantId",
110
+ "resolve_tenant_id",
111
+ "require_tenant_id",
112
+ "set_tenant_resolver",
113
+ ]
@@ -0,0 +1,102 @@
1
+ """
2
+ Utilities for capturing routers from add_* functions for versioned routing.
3
+
4
+ This module provides helpers to use integration functions (add_banking, add_payments, etc.)
5
+ under versioned routing without creating separate documentation cards.
6
+
7
+ See: svc-infra/docs/versioned-integrations.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from typing import Any, TypeVar
14
+ from unittest.mock import patch
15
+
16
+ from fastapi import APIRouter, FastAPI
17
+
18
+ __all__ = ["extract_router"]
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ def extract_router(
24
+ add_function: Callable[..., T],
25
+ *,
26
+ prefix: str,
27
+ **kwargs: Any,
28
+ ) -> tuple[APIRouter, T]:
29
+ """
30
+ Capture the router from an add_* function for versioned mounting.
31
+
32
+ This allows you to use integration functions like add_banking(), add_payments(),
33
+ etc. under versioned routing (e.g., /v0/banking) without creating separate
34
+ documentation cards.
35
+
36
+ Args:
37
+ add_function: The add_* function to capture from (e.g., add_banking)
38
+ prefix: URL prefix for the routes (e.g., "/banking")
39
+ **kwargs: Arguments to pass to the add_function
40
+
41
+ Returns:
42
+ Tuple of (router, return_value) where:
43
+ - router: The captured APIRouter with all routes
44
+ - return_value: The original return value from add_function (e.g., provider instance)
45
+
46
+ Example:
47
+ ```python
48
+ # In routers/v0/banking.py
49
+ from svc_infra.api.fastapi.versioned import extract_router
50
+ from fin_infra.banking import add_banking
51
+
52
+ router, banking_provider = extract_router(
53
+ add_banking,
54
+ prefix="/banking",
55
+ provider="plaid",
56
+ cache_ttl=60,
57
+ )
58
+
59
+ # svc-infra auto-discovers 'router' and mounts at /v0/banking
60
+ ```
61
+
62
+ Pattern:
63
+ 1. Creates a mock FastAPI app
64
+ 2. Intercepts include_router to capture the router
65
+ 3. Patches add_prefixed_docs to prevent separate card creation
66
+ 4. Calls the add_function which creates all routes
67
+ 5. Returns the captured router for auto-discovery
68
+
69
+ See Also:
70
+ - docs/versioned-integrations.md: Full pattern documentation
71
+ - api/fastapi/dual/public.py: Similar pattern for dual routers
72
+ """
73
+ # Create mock app to capture router
74
+ mock_app = FastAPI()
75
+ captured_router: APIRouter | None = None
76
+
77
+ def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
78
+ """Intercept include_router to capture instead of mount."""
79
+ nonlocal captured_router
80
+ captured_router = router
81
+
82
+ mock_app.include_router = _capture_router # type: ignore[method-assign]
83
+
84
+ # Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
85
+ def _noop_docs(*args: Any, **kwargs: Any) -> None:
86
+ pass
87
+
88
+ # Call add_function with patches active
89
+ with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
90
+ result = add_function(
91
+ mock_app,
92
+ prefix=prefix,
93
+ **kwargs,
94
+ )
95
+
96
+ if captured_router is None:
97
+ raise RuntimeError(
98
+ f"Failed to capture router from {add_function.__name__}. "
99
+ f"The function may not call app.include_router()."
100
+ )
101
+
102
+ return captured_router, result
svc_infra/app/README.md CHANGED
@@ -14,9 +14,8 @@ This README shows:
14
14
 
15
15
  ```python
16
16
  # main.py (or wherever your app starts)
17
- from svc_infra.logging.logging import setup_logging
17
+ from svc_infra.app.logging import setup_logging, LogLevelOptions
18
18
  from svc_infra.app.env import pick
19
- from svc_infra.logging.logging import LogLevelOptions
20
19
  ```
21
20
 
22
21
  ---
@@ -39,7 +38,8 @@ What you get by default:
39
38
  Set via code:
40
39
 
41
40
  ```python
42
- from svc_infra.logging.logging import LogFormatOptions, LogLevelOptions
41
+ from svc_infra.app.logging.formats import LogFormatOptions
42
+ from svc_infra.app.logging import LogLevelOptions
43
43
 
44
44
  setup_logging(
45
45
  level=LogLevelOptions.INFO, # or "INFO"
@@ -119,7 +119,7 @@ Old (pre-filter) example:
119
119
 
120
120
  ```python
121
121
  from svc_infra.app.env import pick
122
- from svc_infra.logging.logging import setup_logging, LogLevelOptions
122
+ from svc_infra.app.logging import setup_logging, LogLevelOptions
123
123
 
124
124
  setup_logging(
125
125
  level=pick(
@@ -183,7 +183,7 @@ LOG_DROP_PATHS=/metrics,/health,/healthz
183
183
  ## 7) One-liner quickstart
184
184
 
185
185
  ```python
186
- from svc_infra.logging import setup_logging
186
+ from svc_infra.app.logging import setup_logging
187
187
  setup_logging() # done: sensible defaults + filters in prod/test
188
188
  ```
189
189
 
svc_infra/app/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- from .env import pick
1
+ from .env import MissingSecretError, pick, require_secret
2
2
  from .logging import setup_logging
3
3
  from .logging.formats import LoggingConfig, LogLevelOptions
4
4
 
@@ -7,4 +7,6 @@ __all__ = [
7
7
  "LoggingConfig",
8
8
  "LogLevelOptions",
9
9
  "pick",
10
+ "require_secret",
11
+ "MissingSecretError",
10
12
  ]
svc_infra/app/env.py CHANGED
@@ -5,7 +5,7 @@ import warnings
5
5
  from enum import StrEnum
6
6
  from functools import cache
7
7
  from pathlib import Path
8
- from typing import List, NamedTuple, Optional
8
+ from typing import NamedTuple
9
9
 
10
10
  from dotenv import load_dotenv
11
11
 
@@ -39,7 +39,7 @@ SYNONYMS: dict[str, Environment] = {
39
39
  "production": PROD_ENV,
40
40
  }
41
41
 
42
- ALL_ENVIRONMENTS = {e for e in Environment}
42
+ ALL_ENVIRONMENTS = set(Environment)
43
43
 
44
44
 
45
45
  def _normalize(raw: str | None) -> Environment | None:
@@ -132,7 +132,7 @@ def pick(*, prod, nonprod=None, dev=None, test=None, local=None):
132
132
  raise ValueError("pick(): No value found for environment and 'nonprod' was not provided.")
133
133
 
134
134
 
135
- def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
135
+ def find_env_file(start: Path | None = None) -> Path | None:
136
136
  env_file = os.getenv("APP_ENV_FILE") or os.getenv("SVC_INFRA_ENV_FILE")
137
137
  if env_file:
138
138
  p = Path(env_file).expanduser()
@@ -146,7 +146,7 @@ def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
146
146
  return None
147
147
 
148
148
 
149
- def load_env_if_present(path: Optional[Path], *, override: bool = False) -> List[str]:
149
+ def load_env_if_present(path: Path | None, *, override: bool = False) -> list[str]:
150
150
  if not path:
151
151
  return []
152
152
  before = dict(os.environ)
@@ -166,3 +166,69 @@ def prepare_env() -> Path:
166
166
  env_file = find_env_file(start=root)
167
167
  load_env_if_present(env_file, override=False)
168
168
  return root
169
+
170
+
171
+ class MissingSecretError(RuntimeError):
172
+ """Raised when a required secret is not configured in production/staging."""
173
+
174
+ pass
175
+
176
+
177
+ def require_secret(
178
+ value: str | None,
179
+ name: str,
180
+ *,
181
+ dev_default: str | None = None,
182
+ environments: tuple[str, ...] = ("prod", "production", "staging", "test"),
183
+ ) -> str:
184
+ """Require a secret to be set in production environments.
185
+
186
+ In development/local environments, falls back to dev_default if provided.
187
+ In production environments, raises MissingSecretError if not set.
188
+
189
+ Args:
190
+ value: The secret value (may be None or empty)
191
+ name: Name of the secret for error messages (e.g., "SESSION_SECRET")
192
+ dev_default: Default value to use in development (NEVER in production)
193
+ environments: Environments where the secret is required
194
+
195
+ Returns:
196
+ The secret value
197
+
198
+ Raises:
199
+ MissingSecretError: If secret is not set in production environments
200
+
201
+ Example:
202
+ >>> secret = require_secret(
203
+ ... os.getenv("SESSION_SECRET"),
204
+ ... "SESSION_SECRET",
205
+ ... dev_default="dev-only-secret",
206
+ ... )
207
+ """
208
+ if value:
209
+ return value
210
+
211
+ current_env = get_current_environment()
212
+
213
+ # Check if we're in a production-like environment
214
+ raw_env = os.getenv("APP_ENV") or os.getenv("RAILWAY_ENVIRONMENT_NAME") or ""
215
+ is_production_like = (
216
+ current_env == PROD_ENV
217
+ or current_env == TEST_ENV # staging/preview
218
+ or raw_env.lower() in environments
219
+ )
220
+
221
+ if is_production_like:
222
+ raise MissingSecretError(
223
+ f"SECURITY ERROR: {name} must be set in production/staging environments. "
224
+ f"Current environment: {current_env} (raw: {raw_env!r})"
225
+ )
226
+
227
+ # In development, use the dev default if provided
228
+ if dev_default is not None:
229
+ return dev_default
230
+
231
+ raise MissingSecretError(
232
+ f"{name} is not set and no dev_default was provided. "
233
+ "Either set the environment variable or provide a dev_default."
234
+ )
@@ -2,11 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
+ from collections.abc import Sequence
5
6
  from logging.config import dictConfig
6
- from typing import Sequence
7
+ from typing import TYPE_CHECKING, cast
7
8
 
8
9
  from svc_infra.app.env import CURRENT_ENVIRONMENT
9
10
 
11
+ if TYPE_CHECKING:
12
+ from .formats import LogFormatOptions, LogLevelOptions
13
+
10
14
  from .filter import filter_logs_for_paths
11
15
  from .formats import (
12
16
  JsonFormatter,
@@ -27,7 +31,11 @@ def setup_logging(
27
31
  ) -> None:
28
32
  """Configure logging + optional access-log path filtering."""
29
33
  if fmt is not None or level is not None:
30
- LoggingConfig(fmt=fmt, level=level) # pydantic validation
34
+ # Cast to expected Literal types after validation
35
+ LoggingConfig(
36
+ fmt=cast("LogFormatOptions | None", fmt),
37
+ level=cast("LogLevelOptions | None", level),
38
+ ) # pydantic validation
31
39
 
32
40
  if level is None:
33
41
  level = _read_level()
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import Iterable
4
+ from collections.abc import Iterable
5
5
 
6
6
 
7
7
  def _is_metrics_like(record: logging.LogRecord, paths: Iterable[str]) -> bool:
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
+ from collections.abc import Sequence
5
6
  from enum import StrEnum
6
- from typing import Sequence
7
+ from typing import cast
7
8
 
8
9
  from pydantic import BaseModel
9
10
 
@@ -35,7 +36,7 @@ class LoggingConfig(BaseModel):
35
36
  class JsonFormatter(logging.Formatter):
36
37
  """Structured JSON formatter for prod and CI logs."""
37
38
 
38
- def format(self, record: logging.LogRecord) -> str: # type: ignore[override]
39
+ def format(self, record: logging.LogRecord) -> str:
39
40
  import json
40
41
  import os as _os
41
42
  from traceback import format_exception
@@ -50,15 +51,19 @@ class JsonFormatter(logging.Formatter):
50
51
 
51
52
  # Add these two lines:
52
53
  if getattr(record, "trace_id", None):
53
- payload["trace_id"] = record.trace_id
54
+ payload["trace_id"] = record.trace_id # type: ignore[attr-defined]
54
55
  if getattr(record, "span_id", None):
55
- payload["span_id"] = record.span_id
56
+ payload["span_id"] = record.span_id # type: ignore[attr-defined]
56
57
 
57
58
  # Optional correlation id
58
59
  req_id = getattr(record, "request_id", None)
59
60
  if req_id is not None:
60
61
  payload["request_id"] = req_id
61
62
 
63
+ tenant_id = getattr(record, "tenant_id", None)
64
+ if tenant_id is not None:
65
+ payload["tenant_id"] = tenant_id
66
+
62
67
  # Optional HTTP context
63
68
  http_ctx = {
64
69
  k: v
@@ -103,7 +108,10 @@ def _read_level() -> str:
103
108
  return explicit.upper()
104
109
  from svc_infra.app.env import pick
105
110
 
106
- return pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG").upper()
111
+ return cast(
112
+ "str",
113
+ pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG"),
114
+ ).upper()
107
115
 
108
116
 
109
117
  def _read_format() -> str:
svc_infra/app/root.py CHANGED
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import subprocess
5
+ from collections.abc import Iterable
5
6
  from pathlib import Path
6
- from typing import Iterable, Optional
7
7
 
8
8
  DEFAULT_SENTRIES: tuple[str, ...] = (
9
9
  ".git",
@@ -36,7 +36,7 @@ def _is_root_marker(dir_: Path, sentries: Iterable[str]) -> bool:
36
36
  return any((dir_ / name).exists() for name in sentries)
37
37
 
38
38
 
39
- def _git_toplevel(start: Path) -> Optional[Path]:
39
+ def _git_toplevel(start: Path) -> Path | None:
40
40
  try:
41
41
  out = subprocess.check_output(
42
42
  ["git", "rev-parse", "--show-toplevel"],
@@ -50,7 +50,7 @@ def _git_toplevel(start: Path) -> Optional[Path]:
50
50
 
51
51
 
52
52
  def resolve_project_root(
53
- start: Optional[Path] = None,
53
+ start: Path | None = None,
54
54
  *,
55
55
  env_var: str = ENV_VAR,
56
56
  extra_sentries: Iterable[str] = (),
@@ -0,0 +1,40 @@
1
+ """Billing module for usage tracking, metering, and invoicing.
2
+
3
+ Primary API:
4
+ AsyncBillingService - Async billing service
5
+
6
+ Models:
7
+ UsageEvent, UsageAggregate, Invoice, InvoiceLine, Plan, Subscription, etc.
8
+
9
+ Example:
10
+ from svc_infra.billing import AsyncBillingService
11
+
12
+ service = AsyncBillingService(async_session, tenant_id)
13
+ await service.record_usage(metric="api_calls", amount=1, ...)
14
+ """
15
+
16
+ from .async_service import AsyncBillingService
17
+ from .models import (
18
+ Invoice,
19
+ InvoiceLine,
20
+ Plan,
21
+ PlanEntitlement,
22
+ Price,
23
+ Subscription,
24
+ UsageAggregate,
25
+ UsageEvent,
26
+ )
27
+
28
+ __all__ = [
29
+ # Primary API
30
+ "AsyncBillingService",
31
+ # Models
32
+ "UsageEvent",
33
+ "UsageAggregate",
34
+ "Plan",
35
+ "PlanEntitlement",
36
+ "Subscription",
37
+ "Price",
38
+ "Invoice",
39
+ "InvoiceLine",
40
+ ]