svc-infra 0.1.706__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 (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  from collections import defaultdict
6
- from typing import Iterable, Sequence
6
+ from collections.abc import Iterable, Sequence
7
7
 
8
8
  from fastapi import FastAPI
9
9
  from fastapi.middleware.cors import CORSMiddleware
@@ -85,9 +85,7 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
85
85
  if isinstance(public_cors_origins, list):
86
86
  param_origins = [o.strip() for o in public_cors_origins if o and o.strip()]
87
87
  elif isinstance(public_cors_origins, str):
88
- param_origins = [
89
- o.strip() for o in public_cors_origins.split(",") if o and o.strip()
90
- ]
88
+ param_origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
91
89
  else:
92
90
  param_origins = []
93
91
 
@@ -106,7 +104,7 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
106
104
  if not origins:
107
105
  return
108
106
 
109
- cors_kwargs = dict(allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
107
+ cors_kwargs = {"allow_credentials": True, "allow_methods": ["*"], "allow_headers": ["*"]}
110
108
 
111
109
  # Check for "*" (allow all) first
112
110
  if "*" in origins:
@@ -176,9 +174,7 @@ def _dump_or_none(model):
176
174
  def _build_child_app(
177
175
  service: ServiceInfo, spec: APIVersionSpec, skip_paths: list[str] | None = None
178
176
  ) -> FastAPI:
179
- title = (
180
- f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
181
- )
177
+ title = f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
182
178
  child = FastAPI(
183
179
  title=title,
184
180
  version=service.release,
@@ -192,9 +188,7 @@ def _build_child_app(
192
188
  _setup_middlewares(child, skip_paths=skip_paths)
193
189
 
194
190
  # ---- OpenAPI pipeline (DRY!) ----
195
- include_api_key = (
196
- bool(spec.include_api_key) if spec.include_api_key is not None else False
197
- )
191
+ include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
198
192
  tag_str = str(spec.tag).strip("/")
199
193
  mount_path = f"/{tag_str}"
200
194
  server_url = (
@@ -272,9 +266,7 @@ def _build_parent_app(
272
266
  )
273
267
  # app-provided root routers
274
268
  for pkg in _coerce_list(root_routers):
275
- register_all_routers(
276
- parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT
277
- )
269
+ register_all_routers(parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT)
278
270
 
279
271
  return parent
280
272
 
@@ -294,8 +286,8 @@ class RouteLoggerMiddleware:
294
286
  path = scope.get("path", "")
295
287
  method = scope.get("method", "")
296
288
 
297
- # Skip specified paths
298
- if any(skip in path for skip in self.skip_paths):
289
+ # Skip specified paths using prefix matching
290
+ if any(path.startswith(skip) for skip in self.skip_paths):
299
291
  await self.app(scope, receive, send)
300
292
  return
301
293
 
@@ -303,15 +295,11 @@ class RouteLoggerMiddleware:
303
295
  async def send_wrapper(message):
304
296
  if message["type"] == "http.response.start":
305
297
  route = scope.get("route")
306
- route_path = getattr(route, "path_format", None) or getattr(
307
- route, "path", None
308
- )
298
+ route_path = getattr(route, "path_format", None) or getattr(route, "path", None)
309
299
  if route_path:
310
300
  root_path = scope.get("root_path", "") or ""
311
301
  headers = list(message.get("headers", []))
312
- headers.append(
313
- (b"x-handled-by", f"{method} {root_path}{route_path}".encode())
314
- )
302
+ headers.append((b"x-handled-by", f"{method} {root_path}{route_path}".encode()))
315
303
  message = {**message, "headers": headers}
316
304
  await send(message)
317
305
 
@@ -367,9 +355,7 @@ def setup_service_api(
367
355
  cards.append(
368
356
  CardSpec(
369
357
  tag="",
370
- docs=DocTargets(
371
- swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"
372
- ),
358
+ docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
373
359
  )
374
360
  )
375
361
 
@@ -393,15 +379,11 @@ def setup_service_api(
393
379
  cards.append(
394
380
  CardSpec(
395
381
  tag=scope.strip("/"),
396
- docs=DocTargets(
397
- swagger=swagger, redoc=redoc, openapi_json=openapi_json
398
- ),
382
+ docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
399
383
  )
400
384
  )
401
385
 
402
- html = render_index_html(
403
- service_name=service.name, release=service.release, cards=cards
404
- )
386
+ html = render_index_html(service_name=service.name, release=service.release, cards=cards)
405
387
  return HTMLResponse(html)
406
388
 
407
389
  return parent
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from fastapi import FastAPI
6
7
 
7
8
  from .context import set_tenant_resolver
8
9
 
9
10
 
10
- def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
11
+ def add_tenancy(app: FastAPI, *, resolver: Callable[..., Any] | None = None) -> None:
11
12
  """Wire tenancy resolver for the application.
12
13
 
13
14
  Provide a resolver(request, identity, header) -> Optional[str] to override
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Annotated, Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Annotated, Any
4
5
 
5
6
  from fastapi import Depends, HTTPException, Request
6
7
 
@@ -10,11 +11,11 @@ except Exception: # pragma: no cover - fallback for minimal builds
10
11
  OptionalIdentity = None # type: ignore[misc,assignment]
11
12
 
12
13
 
13
- _tenant_resolver: Optional[Callable[..., Any]] = None
14
+ _tenant_resolver: Callable[..., Any] | None = None
14
15
 
15
16
 
16
17
  def set_tenant_resolver(
17
- fn: Optional[Callable[..., Any]],
18
+ fn: Callable[..., Any] | None,
18
19
  ) -> None:
19
20
  """Set or clear a global override hook for tenant resolution.
20
21
 
@@ -33,9 +34,9 @@ async def _maybe_await(x):
33
34
 
34
35
  async def resolve_tenant_id(
35
36
  request: Request,
36
- tenant_header: Optional[str] = None,
37
+ tenant_header: str | None = None,
37
38
  identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
38
- ) -> Optional[str]:
39
+ ) -> str | None:
39
40
  """Resolve tenant id from override, identity, header, or request.state.
40
41
 
41
42
  Order:
@@ -91,7 +92,7 @@ async def resolve_tenant_id(
91
92
 
92
93
 
93
94
  async def require_tenant_id(
94
- tenant_id: Optional[str] = Depends(resolve_tenant_id),
95
+ tenant_id: str | None = Depends(resolve_tenant_id),
95
96
  ) -> str:
96
97
  if not tenant_id:
97
98
  raise HTTPException(status_code=400, detail="tenant_context_missing")
@@ -100,7 +101,7 @@ async def require_tenant_id(
100
101
 
101
102
  # DX aliases
102
103
  TenantId = Annotated[str, Depends(require_tenant_id)]
103
- OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
104
+ OptionalTenantId = Annotated[str | None, Depends(resolve_tenant_id)]
104
105
 
105
106
 
106
107
  __all__ = [
@@ -9,7 +9,8 @@ See: svc-infra/docs/versioned-integrations.md
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Any, Callable, TypeVar
12
+ from collections.abc import Callable
13
+ from typing import Any, TypeVar
13
14
  from unittest.mock import patch
14
15
 
15
16
  from fastapi import APIRouter, FastAPI
@@ -78,7 +79,7 @@ def extract_router(
78
79
  nonlocal captured_router
79
80
  captured_router = router
80
81
 
81
- setattr(mock_app, "include_router", _capture_router)
82
+ mock_app.include_router = _capture_router # type: ignore[method-assign]
82
83
 
83
84
  # Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
84
85
  def _noop_docs(*args: Any, **kwargs: Any) -> None:
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:
@@ -129,12 +129,10 @@ 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(
133
- "pick(): No value found for environment and 'nonprod' was not provided."
134
- )
132
+ raise ValueError("pick(): No value found for environment and 'nonprod' was not provided.")
135
133
 
136
134
 
137
- def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
135
+ def find_env_file(start: Path | None = None) -> Path | None:
138
136
  env_file = os.getenv("APP_ENV_FILE") or os.getenv("SVC_INFRA_ENV_FILE")
139
137
  if env_file:
140
138
  p = Path(env_file).expanduser()
@@ -148,7 +146,7 @@ def find_env_file(start: Optional[Path] = None) -> Optional[Path]:
148
146
  return None
149
147
 
150
148
 
151
- 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]:
152
150
  if not path:
153
151
  return []
154
152
  before = dict(os.environ)
@@ -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 logging.config import dictConfig
6
- from typing import TYPE_CHECKING, Sequence, cast
7
+ from typing import TYPE_CHECKING, cast
7
8
 
8
9
  from svc_infra.app.env import CURRENT_ENVIRONMENT
9
10
 
@@ -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, cast
7
+ from typing import cast
7
8
 
8
9
  from pydantic import BaseModel
9
10
 
@@ -108,7 +109,7 @@ def _read_level() -> str:
108
109
  from svc_infra.app.env import pick
109
110
 
110
111
  return cast(
111
- str,
112
+ "str",
112
113
  pick(prod="INFO", nonprod="DEBUG", dev="DEBUG", test="DEBUG", local="DEBUG"),
113
114
  ).upper()
114
115
 
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] = (),
@@ -1,3 +1,19 @@
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
1
17
  from .models import (
2
18
  Invoice,
3
19
  InvoiceLine,
@@ -8,9 +24,11 @@ from .models import (
8
24
  UsageAggregate,
9
25
  UsageEvent,
10
26
  )
11
- from .service import BillingService
12
27
 
13
28
  __all__ = [
29
+ # Primary API
30
+ "AsyncBillingService",
31
+ # Models
14
32
  "UsageEvent",
15
33
  "UsageAggregate",
16
34
  "Plan",
@@ -19,5 +37,4 @@ __all__ = [
19
37
  "Price",
20
38
  "Invoice",
21
39
  "InvoiceLine",
22
- "BillingService",
23
40
  ]
@@ -1,8 +1,30 @@
1
+ """Async Billing Service - Primary billing API.
2
+
3
+ This is the recommended billing service for all new code. It provides
4
+ full async/await support for usage tracking, aggregation, and invoicing.
5
+
6
+ Usage:
7
+ from svc_infra.billing import AsyncBillingService
8
+
9
+ async with async_session_maker() as session:
10
+ service = AsyncBillingService(session, tenant_id="tenant_123")
11
+ await service.record_usage(
12
+ metric="api_calls",
13
+ amount=1,
14
+ at=datetime.now(timezone.utc),
15
+ idempotency_key="unique-key",
16
+ metadata={"endpoint": "/api/v1/users"},
17
+ )
18
+
19
+ See also:
20
+ - models: Invoice, UsageEvent, UsageAggregate, etc.
21
+ """
22
+
1
23
  from __future__ import annotations
2
24
 
3
25
  import uuid
4
- from datetime import datetime, timedelta, timezone
5
- from typing import Optional, Sequence
26
+ from collections.abc import Sequence
27
+ from datetime import UTC, datetime, timedelta
6
28
 
7
29
  from sqlalchemy import select
8
30
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,7 +47,7 @@ class AsyncBillingService:
25
47
  metadata: dict | None,
26
48
  ) -> str:
27
49
  if at.tzinfo is None:
28
- at = at.replace(tzinfo=timezone.utc)
50
+ at = at.replace(tzinfo=UTC)
29
51
  evt = UsageEvent(
30
52
  id=str(uuid.uuid4()),
31
53
  tenant_id=self.tenant_id,
@@ -40,9 +62,7 @@ class AsyncBillingService:
40
62
  return evt.id
41
63
 
42
64
  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
- )
65
+ day_start = day_start.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC)
46
66
  next_day = day_start + timedelta(days=1)
47
67
  total = 0
48
68
  rows: Sequence[UsageEvent] = (
@@ -88,7 +108,7 @@ class AsyncBillingService:
88
108
  return total
89
109
 
90
110
  async def list_daily_aggregates(
91
- self, *, metric: str, date_from: Optional[datetime], date_to: Optional[datetime]
111
+ self, *, metric: str, date_from: datetime | None, date_to: datetime | None
92
112
  ) -> list[UsageAggregate]:
93
113
  q = select(UsageAggregate).where(
94
114
  UsageAggregate.tenant_id == self.tenant_id,
svc_infra/billing/jobs.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- from datetime import datetime, timezone
5
- from typing import Any, Awaitable, Callable, Dict, Optional
4
+ from collections.abc import Awaitable, Callable
5
+ from datetime import UTC, datetime
6
+ from typing import Any
6
7
 
7
8
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
9
 
@@ -23,7 +24,7 @@ async def job_aggregate_daily(
23
24
  """
24
25
  svc = AsyncBillingService(session=session, tenant_id=tenant_id)
25
26
  if day_start.tzinfo is None:
26
- day_start = day_start.replace(tzinfo=timezone.utc)
27
+ day_start = day_start.replace(tzinfo=UTC)
27
28
  await svc.aggregate_daily(metric=metric, day_start=day_start)
28
29
 
29
30
 
@@ -41,9 +42,9 @@ async def job_generate_monthly_invoice(
41
42
  """
42
43
  svc = AsyncBillingService(session=session, tenant_id=tenant_id)
43
44
  if period_start.tzinfo is None:
44
- period_start = period_start.replace(tzinfo=timezone.utc)
45
+ period_start = period_start.replace(tzinfo=UTC)
45
46
  if period_end.tzinfo is None:
46
- period_end = period_end.replace(tzinfo=timezone.utc)
47
+ period_end = period_end.replace(tzinfo=UTC)
47
48
  return await svc.generate_monthly_invoice(
48
49
  period_start=period_start, period_end=period_end, currency=currency
49
50
  )
@@ -66,7 +67,7 @@ def enqueue_aggregate_daily(
66
67
  payload = {
67
68
  "tenant_id": tenant_id,
68
69
  "metric": metric,
69
- "day_start": day_start.astimezone(timezone.utc).isoformat(),
70
+ "day_start": day_start.astimezone(UTC).isoformat(),
70
71
  }
71
72
  queue.enqueue(BILLING_AGGREGATE_JOB, payload, delay_seconds=delay_seconds)
72
73
 
@@ -82,8 +83,8 @@ def enqueue_generate_monthly_invoice(
82
83
  ) -> None:
83
84
  payload = {
84
85
  "tenant_id": tenant_id,
85
- "period_start": period_start.astimezone(timezone.utc).isoformat(),
86
- "period_end": period_end.astimezone(timezone.utc).isoformat(),
86
+ "period_start": period_start.astimezone(UTC).isoformat(),
87
+ "period_end": period_end.astimezone(UTC).isoformat(),
87
88
  "currency": currency,
88
89
  }
89
90
  queue.enqueue(BILLING_INVOICE_JOB, payload, delay_seconds=delay_seconds)
@@ -94,7 +95,7 @@ def make_daily_aggregate_tick(
94
95
  *,
95
96
  tenant_id: str,
96
97
  metric: str,
97
- when: Optional[datetime] = None,
98
+ when: datetime | None = None,
98
99
  ):
99
100
  """Return an async function that enqueues a daily aggregate job.
100
101
 
@@ -103,18 +104,16 @@ def make_daily_aggregate_tick(
103
104
  """
104
105
 
105
106
  async def _tick():
106
- ts = (when or datetime.now(timezone.utc)).astimezone(timezone.utc)
107
+ ts = (when or datetime.now(UTC)).astimezone(UTC)
107
108
  day_start = ts.replace(hour=0, minute=0, second=0, microsecond=0)
108
- enqueue_aggregate_daily(
109
- queue, tenant_id=tenant_id, metric=metric, day_start=day_start
110
- )
109
+ enqueue_aggregate_daily(queue, tenant_id=tenant_id, metric=metric, day_start=day_start)
111
110
 
112
111
  return _tick
113
112
 
114
113
 
115
114
  def make_billing_job_handler(
116
115
  *,
117
- session_factory: "async_sessionmaker[AsyncSession]",
116
+ session_factory: async_sessionmaker[AsyncSession],
118
117
  webhooks: WebhookService,
119
118
  ) -> Callable[[Job], Awaitable[None]]:
120
119
  """Create a worker handler that processes billing jobs and emits webhooks.
@@ -139,7 +138,7 @@ def make_billing_job_handler(
139
138
 
140
139
  async def _handler(job: Job) -> None:
141
140
  name = job.name
142
- data: Dict[str, Any] = job.payload or {}
141
+ data: dict[str, Any] = job.payload or {}
143
142
  if name == BILLING_AGGREGATE_JOB:
144
143
  tenant_id = str(data.get("tenant_id"))
145
144
  metric = str(data.get("metric"))
@@ -156,7 +155,7 @@ def make_billing_job_handler(
156
155
  {
157
156
  "tenant_id": tenant_id,
158
157
  "metric": metric,
159
- "day_start": day_start.astimezone(timezone.utc).isoformat(),
158
+ "day_start": day_start.astimezone(UTC).isoformat(),
160
159
  "total": int(total),
161
160
  },
162
161
  )
@@ -166,12 +165,7 @@ def make_billing_job_handler(
166
165
  period_start_raw = data.get("period_start")
167
166
  period_end_raw = data.get("period_end")
168
167
  currency = str(data.get("currency"))
169
- if (
170
- not tenant_id
171
- or not period_start_raw
172
- or not period_end_raw
173
- or not currency
174
- ):
168
+ if not tenant_id or not period_start_raw or not period_end_raw or not currency:
175
169
  return
176
170
  period_start = datetime.fromisoformat(str(period_start_raw))
177
171
  period_end = datetime.fromisoformat(str(period_end_raw))
@@ -186,8 +180,8 @@ def make_billing_job_handler(
186
180
  {
187
181
  "tenant_id": tenant_id,
188
182
  "invoice_id": invoice_id,
189
- "period_start": period_start.astimezone(timezone.utc).isoformat(),
190
- "period_end": period_end.astimezone(timezone.utc).isoformat(),
183
+ "period_start": period_start.astimezone(UTC).isoformat(),
184
+ "period_end": period_end.astimezone(UTC).isoformat(),
191
185
  "currency": currency,
192
186
  },
193
187
  )
@@ -218,20 +212,16 @@ def add_billing_jobs(
218
212
 
219
213
  async def _tick_fn(tid=tenant_id, m=metric):
220
214
  # Enqueue for the current UTC day
221
- now = datetime.now(timezone.utc)
215
+ now = datetime.now(UTC)
222
216
  day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
223
- enqueue_aggregate_daily(
224
- queue, tenant_id=tid, metric=m, day_start=day_start
225
- )
217
+ enqueue_aggregate_daily(queue, tenant_id=tid, metric=m, day_start=day_start)
226
218
 
227
- scheduler.add_task(
228
- f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn
229
- )
219
+ scheduler.add_task(f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn)
230
220
  elif name == "invoice":
231
221
  tenant_id = j["tenant_id"]
232
222
  currency = j["currency"]
233
- pstart = datetime.fromisoformat(j["period_start"]).astimezone(timezone.utc)
234
- pend = datetime.fromisoformat(j["period_end"]).astimezone(timezone.utc)
223
+ pstart = datetime.fromisoformat(j["period_start"]).astimezone(UTC)
224
+ pend = datetime.fromisoformat(j["period_end"]).astimezone(UTC)
235
225
 
236
226
  async def _tick_inv(tid=tenant_id, cs=currency, ps=pstart, pe=pend):
237
227
  enqueue_generate_monthly_invoice(