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,101 @@
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 typing import Any, Callable, TypeVar
13
+ from unittest.mock import patch
14
+
15
+ from fastapi import APIRouter, FastAPI
16
+
17
+ __all__ = ["extract_router"]
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def extract_router(
23
+ add_function: Callable[..., T],
24
+ *,
25
+ prefix: str,
26
+ **kwargs: Any,
27
+ ) -> tuple[APIRouter, T]:
28
+ """
29
+ Capture the router from an add_* function for versioned mounting.
30
+
31
+ This allows you to use integration functions like add_banking(), add_payments(),
32
+ etc. under versioned routing (e.g., /v0/banking) without creating separate
33
+ documentation cards.
34
+
35
+ Args:
36
+ add_function: The add_* function to capture from (e.g., add_banking)
37
+ prefix: URL prefix for the routes (e.g., "/banking")
38
+ **kwargs: Arguments to pass to the add_function
39
+
40
+ Returns:
41
+ Tuple of (router, return_value) where:
42
+ - router: The captured APIRouter with all routes
43
+ - return_value: The original return value from add_function (e.g., provider instance)
44
+
45
+ Example:
46
+ ```python
47
+ # In routers/v0/banking.py
48
+ from svc_infra.api.fastapi.versioned import extract_router
49
+ from fin_infra.banking import add_banking
50
+
51
+ router, banking_provider = extract_router(
52
+ add_banking,
53
+ prefix="/banking",
54
+ provider="plaid",
55
+ cache_ttl=60,
56
+ )
57
+
58
+ # svc-infra auto-discovers 'router' and mounts at /v0/banking
59
+ ```
60
+
61
+ Pattern:
62
+ 1. Creates a mock FastAPI app
63
+ 2. Intercepts include_router to capture the router
64
+ 3. Patches add_prefixed_docs to prevent separate card creation
65
+ 4. Calls the add_function which creates all routes
66
+ 5. Returns the captured router for auto-discovery
67
+
68
+ See Also:
69
+ - docs/versioned-integrations.md: Full pattern documentation
70
+ - api/fastapi/dual/public.py: Similar pattern for dual routers
71
+ """
72
+ # Create mock app to capture router
73
+ mock_app = FastAPI()
74
+ captured_router: APIRouter | None = None
75
+
76
+ def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
77
+ """Intercept include_router to capture instead of mount."""
78
+ nonlocal captured_router
79
+ captured_router = router
80
+
81
+ setattr(mock_app, "include_router", _capture_router)
82
+
83
+ # Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
84
+ def _noop_docs(*args: Any, **kwargs: Any) -> None:
85
+ pass
86
+
87
+ # Call add_function with patches active
88
+ with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
89
+ result = add_function(
90
+ mock_app,
91
+ prefix=prefix,
92
+ **kwargs,
93
+ )
94
+
95
+ if captured_router is None:
96
+ raise RuntimeError(
97
+ f"Failed to capture router from {add_function.__name__}. "
98
+ f"The function may not call app.include_router()."
99
+ )
100
+
101
+ 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
@@ -129,7 +129,9 @@ def pick(*, prod, nonprod=None, dev=None, test=None, local=None):
129
129
  return local
130
130
  if nonprod is not None:
131
131
  return nonprod
132
- raise ValueError("pick(): No value found for environment and 'nonprod' was not provided.")
132
+ raise ValueError(
133
+ "pick(): No value found for environment and 'nonprod' was not provided."
134
+ )
133
135
 
134
136
 
135
137
  def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
@@ -166,3 +168,69 @@ def prepare_env() -> Path:
166
168
  env_file = find_env_file(start=root)
167
169
  load_env_if_present(env_file, override=False)
168
170
  return root
171
+
172
+
173
+ class MissingSecretError(RuntimeError):
174
+ """Raised when a required secret is not configured in production/staging."""
175
+
176
+ pass
177
+
178
+
179
+ def require_secret(
180
+ value: str | None,
181
+ name: str,
182
+ *,
183
+ dev_default: str | None = None,
184
+ environments: tuple[str, ...] = ("prod", "production", "staging", "test"),
185
+ ) -> str:
186
+ """Require a secret to be set in production environments.
187
+
188
+ In development/local environments, falls back to dev_default if provided.
189
+ In production environments, raises MissingSecretError if not set.
190
+
191
+ Args:
192
+ value: The secret value (may be None or empty)
193
+ name: Name of the secret for error messages (e.g., "SESSION_SECRET")
194
+ dev_default: Default value to use in development (NEVER in production)
195
+ environments: Environments where the secret is required
196
+
197
+ Returns:
198
+ The secret value
199
+
200
+ Raises:
201
+ MissingSecretError: If secret is not set in production environments
202
+
203
+ Example:
204
+ >>> secret = require_secret(
205
+ ... os.getenv("SESSION_SECRET"),
206
+ ... "SESSION_SECRET",
207
+ ... dev_default="dev-only-secret",
208
+ ... )
209
+ """
210
+ if value:
211
+ return value
212
+
213
+ current_env = get_current_environment()
214
+
215
+ # Check if we're in a production-like environment
216
+ raw_env = os.getenv("APP_ENV") or os.getenv("RAILWAY_ENVIRONMENT_NAME") or ""
217
+ is_production_like = (
218
+ current_env == PROD_ENV
219
+ or current_env == TEST_ENV # staging/preview
220
+ or raw_env.lower() in environments
221
+ )
222
+
223
+ if is_production_like:
224
+ raise MissingSecretError(
225
+ f"SECURITY ERROR: {name} must be set in production/staging environments. "
226
+ f"Current environment: {current_env} (raw: {raw_env!r})"
227
+ )
228
+
229
+ # In development, use the dev default if provided
230
+ if dev_default is not None:
231
+ return dev_default
232
+
233
+ raise MissingSecretError(
234
+ f"{name} is not set and no dev_default was provided. "
235
+ "Either set the environment variable or provide a dev_default."
236
+ )
@@ -3,10 +3,13 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  from logging.config import dictConfig
6
- from typing import Sequence
6
+ from typing import TYPE_CHECKING, Sequence, cast
7
7
 
8
8
  from svc_infra.app.env import CURRENT_ENVIRONMENT
9
9
 
10
+ if TYPE_CHECKING:
11
+ from .formats import LogFormatOptions, LogLevelOptions
12
+
10
13
  from .filter import filter_logs_for_paths
11
14
  from .formats import (
12
15
  JsonFormatter,
@@ -27,7 +30,11 @@ def setup_logging(
27
30
  ) -> None:
28
31
  """Configure logging + optional access-log path filtering."""
29
32
  if fmt is not None or level is not None:
30
- LoggingConfig(fmt=fmt, level=level) # pydantic validation
33
+ # Cast to expected Literal types after validation
34
+ LoggingConfig(
35
+ fmt=cast("LogFormatOptions | None", fmt),
36
+ level=cast("LogLevelOptions | None", level),
37
+ ) # pydantic validation
31
38
 
32
39
  if level is None:
33
40
  level = _read_level()
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  from enum import StrEnum
6
- from typing import Sequence
6
+ from typing import Sequence, cast
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
@@ -35,7 +35,7 @@ class LoggingConfig(BaseModel):
35
35
  class JsonFormatter(logging.Formatter):
36
36
  """Structured JSON formatter for prod and CI logs."""
37
37
 
38
- def format(self, record: logging.LogRecord) -> str: # type: ignore[override]
38
+ def format(self, record: logging.LogRecord) -> str:
39
39
  import json
40
40
  import os as _os
41
41
  from traceback import format_exception
@@ -50,15 +50,19 @@ class JsonFormatter(logging.Formatter):
50
50
 
51
51
  # Add these two lines:
52
52
  if getattr(record, "trace_id", None):
53
- payload["trace_id"] = record.trace_id
53
+ payload["trace_id"] = record.trace_id # type: ignore[attr-defined]
54
54
  if getattr(record, "span_id", None):
55
- payload["span_id"] = record.span_id
55
+ payload["span_id"] = record.span_id # type: ignore[attr-defined]
56
56
 
57
57
  # Optional correlation id
58
58
  req_id = getattr(record, "request_id", None)
59
59
  if req_id is not None:
60
60
  payload["request_id"] = req_id
61
61
 
62
+ tenant_id = getattr(record, "tenant_id", None)
63
+ if tenant_id is not None:
64
+ payload["tenant_id"] = tenant_id
65
+
62
66
  # Optional HTTP context
63
67
  http_ctx = {
64
68
  k: v
@@ -103,7 +107,10 @@ def _read_level() -> str:
103
107
  return explicit.upper()
104
108
  from svc_infra.app.env import pick
105
109
 
106
- return pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG").upper()
110
+ return cast(
111
+ str,
112
+ pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG"),
113
+ ).upper()
107
114
 
108
115
 
109
116
  def _read_format() -> str:
@@ -0,0 +1,23 @@
1
+ from .models import (
2
+ Invoice,
3
+ InvoiceLine,
4
+ Plan,
5
+ PlanEntitlement,
6
+ Price,
7
+ Subscription,
8
+ UsageAggregate,
9
+ UsageEvent,
10
+ )
11
+ from .service import BillingService
12
+
13
+ __all__ = [
14
+ "UsageEvent",
15
+ "UsageAggregate",
16
+ "Plan",
17
+ "PlanEntitlement",
18
+ "Subscription",
19
+ "Price",
20
+ "Invoice",
21
+ "InvoiceLine",
22
+ "BillingService",
23
+ ]
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional, Sequence
6
+
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
11
+
12
+
13
+ class AsyncBillingService:
14
+ def __init__(self, session: AsyncSession, tenant_id: str):
15
+ self.session = session
16
+ self.tenant_id = tenant_id
17
+
18
+ async def record_usage(
19
+ self,
20
+ *,
21
+ metric: str,
22
+ amount: int,
23
+ at: datetime,
24
+ idempotency_key: str,
25
+ metadata: dict | None,
26
+ ) -> str:
27
+ if at.tzinfo is None:
28
+ at = at.replace(tzinfo=timezone.utc)
29
+ evt = UsageEvent(
30
+ id=str(uuid.uuid4()),
31
+ tenant_id=self.tenant_id,
32
+ metric=metric,
33
+ amount=amount,
34
+ at_ts=at,
35
+ idempotency_key=idempotency_key,
36
+ metadata_json=metadata or {},
37
+ )
38
+ self.session.add(evt)
39
+ await self.session.flush()
40
+ return evt.id
41
+
42
+ async def aggregate_daily(self, *, metric: str, day_start: datetime) -> int:
43
+ day_start = day_start.replace(
44
+ hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
45
+ )
46
+ next_day = day_start + timedelta(days=1)
47
+ total = 0
48
+ rows: Sequence[UsageEvent] = (
49
+ (
50
+ await self.session.execute(
51
+ select(UsageEvent).where(
52
+ UsageEvent.tenant_id == self.tenant_id,
53
+ UsageEvent.metric == metric,
54
+ UsageEvent.at_ts >= day_start,
55
+ UsageEvent.at_ts < next_day,
56
+ )
57
+ )
58
+ )
59
+ .scalars()
60
+ .all()
61
+ )
62
+ for r in rows:
63
+ total += int(r.amount)
64
+
65
+ agg = (
66
+ await self.session.execute(
67
+ select(UsageAggregate).where(
68
+ UsageAggregate.tenant_id == self.tenant_id,
69
+ UsageAggregate.metric == metric,
70
+ UsageAggregate.period_start == day_start,
71
+ UsageAggregate.granularity == "day",
72
+ )
73
+ )
74
+ ).scalar_one_or_none()
75
+ if agg:
76
+ agg.total = total
77
+ else:
78
+ self.session.add(
79
+ UsageAggregate(
80
+ id=str(uuid.uuid4()),
81
+ tenant_id=self.tenant_id,
82
+ metric=metric,
83
+ period_start=day_start,
84
+ granularity="day",
85
+ total=total,
86
+ )
87
+ )
88
+ return total
89
+
90
+ async def list_daily_aggregates(
91
+ self, *, metric: str, date_from: Optional[datetime], date_to: Optional[datetime]
92
+ ) -> list[UsageAggregate]:
93
+ q = select(UsageAggregate).where(
94
+ UsageAggregate.tenant_id == self.tenant_id,
95
+ UsageAggregate.metric == metric,
96
+ UsageAggregate.granularity == "day",
97
+ )
98
+ if date_from is not None:
99
+ q = q.where(UsageAggregate.period_start >= date_from)
100
+ if date_to is not None:
101
+ q = q.where(UsageAggregate.period_start < date_to)
102
+ rows = list((await self.session.execute(q)).scalars().all())
103
+ return rows
104
+
105
+ async def generate_monthly_invoice(
106
+ self, *, period_start: datetime, period_end: datetime, currency: str
107
+ ) -> str:
108
+ total = 0
109
+ aggs: Sequence[UsageAggregate] = (
110
+ (
111
+ await self.session.execute(
112
+ select(UsageAggregate).where(
113
+ UsageAggregate.tenant_id == self.tenant_id,
114
+ UsageAggregate.period_start >= period_start,
115
+ UsageAggregate.period_start < period_end,
116
+ UsageAggregate.granularity == "day",
117
+ )
118
+ )
119
+ )
120
+ .scalars()
121
+ .all()
122
+ )
123
+ for r in aggs:
124
+ total += int(r.total)
125
+
126
+ inv = Invoice(
127
+ id=str(uuid.uuid4()),
128
+ tenant_id=self.tenant_id,
129
+ period_start=period_start,
130
+ period_end=period_end,
131
+ status="created",
132
+ total_amount=total,
133
+ currency=currency,
134
+ )
135
+ self.session.add(inv)
136
+ await self.session.flush()
137
+
138
+ line = InvoiceLine(
139
+ id=str(uuid.uuid4()),
140
+ invoice_id=inv.id,
141
+ price_id=None,
142
+ metric=None,
143
+ quantity=1,
144
+ amount=total,
145
+ )
146
+ self.session.add(line)
147
+ return inv.id