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
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Generic, Iterable, List, Optional, Sequence, TypeVar
3
+ from collections.abc import Iterable, Sequence
4
+ from typing import Any, Generic, TypeVar
4
5
 
5
6
  from fastapi import Query
6
7
  from pydantic import BaseModel
@@ -21,39 +22,37 @@ def dep_limit_offset(
21
22
 
22
23
 
23
24
  class OrderParams(BaseModel):
24
- order_by: Optional[str] = None
25
+ order_by: str | None = None
25
26
 
26
27
 
27
28
  def dep_order(
28
- order_by: Optional[str] = Query(
29
- None, description="Comma-separated fields; '-' for DESC"
30
- ),
29
+ order_by: str | None = Query(None, description="Comma-separated fields; '-' for DESC"),
31
30
  ) -> OrderParams:
32
31
  return OrderParams(order_by=order_by)
33
32
 
34
33
 
35
34
  class SearchParams(BaseModel):
36
- q: Optional[str] = None
37
- fields: Optional[str] = None
35
+ q: str | None = None
36
+ fields: str | None = None
38
37
 
39
38
 
40
39
  def dep_search(
41
- q: Optional[str] = Query(None, description="Search query"),
42
- fields: Optional[str] = Query(None, description="Comma-separated list of fields"),
40
+ q: str | None = Query(None, description="Search query"),
41
+ fields: str | None = Query(None, description="Comma-separated list of fields"),
43
42
  ) -> SearchParams:
44
43
  return SearchParams(q=q, fields=fields)
45
44
 
46
45
 
47
46
  class Page(BaseModel, Generic[T]):
48
47
  total: int
49
- items: List[T]
48
+ items: list[T]
50
49
  limit: int
51
50
  offset: int
52
51
 
53
52
  @classmethod
54
53
  def from_items(
55
54
  cls, *, total: int, items: Sequence[T] | Iterable[T], limit: int, offset: int
56
- ) -> "Page[T]":
55
+ ) -> Page[T]:
57
56
  return cls(total=total, items=list(items), limit=limit, offset=offset)
58
57
 
59
58
 
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Sequence
4
5
  from contextlib import asynccontextmanager
5
- from typing import Sequence
6
6
 
7
7
  from bson import ObjectId
8
8
  from fastapi import FastAPI
@@ -30,9 +30,7 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
30
30
  expected = get_mongo_dbname_from_env(required=False)
31
31
  db = await acquire_db()
32
32
  if expected and db.name != expected:
33
- raise RuntimeError(
34
- f"Connected to Mongo DB '{db.name}', expected '{expected}'."
35
- )
33
+ raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
36
34
  yield
37
35
  finally:
38
36
  await close_mongo()
@@ -49,9 +47,7 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
49
47
  expected = get_mongo_dbname_from_env(required=False)
50
48
  db = await acquire_db()
51
49
  if expected and db.name != expected:
52
- raise RuntimeError(
53
- f"Connected to Mongo DB '{db.name}', expected '{expected}'."
54
- )
50
+ raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
55
51
  try:
56
52
  yield
57
53
  finally:
@@ -65,9 +61,7 @@ def add_mongo_health(
65
61
  ) -> None:
66
62
  if include_in_schema is None:
67
63
  include_in_schema = CURRENT_ENVIRONMENT == LOCAL_ENV
68
- app.include_router(
69
- make_mongo_health_router(prefix=prefix, include_in_schema=include_in_schema)
70
- )
64
+ app.include_router(make_mongo_health_router(prefix=prefix, include_in_schema=include_in_schema))
71
65
 
72
66
 
73
67
  def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
@@ -79,11 +73,7 @@ def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> Non
79
73
  soft_delete_field=resource.soft_delete_field,
80
74
  soft_delete_flag_field=resource.soft_delete_flag_field,
81
75
  )
82
- svc = (
83
- resource.service_factory(repo)
84
- if resource.service_factory
85
- else NoSqlService(repo)
86
- )
76
+ svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
87
77
 
88
78
  if resource.read_schema and resource.create_schema and resource.update_schema:
89
79
  Read, Create, Update = (
@@ -1,4 +1,5 @@
1
- from typing import Annotated, Any, Optional, Sequence, Type, cast
1
+ from collections.abc import Sequence
2
+ from typing import Annotated, Any, cast
2
3
 
3
4
  from fastapi import APIRouter, Body, Depends, HTTPException
4
5
 
@@ -27,7 +28,7 @@ DBDep = Annotated[AsyncIOMotorDatabase, Depends(acquire_db)]
27
28
 
28
29
 
29
30
  def _parse_sort(
30
- order_spec: Optional[str], allowed_order_fields: Optional[list[str]]
31
+ order_spec: str | None, allowed_order_fields: list[str] | None
31
32
  ) -> list[tuple[str, int]]:
32
33
  if not order_spec:
33
34
  return []
@@ -43,18 +44,18 @@ def _parse_sort(
43
44
  def make_crud_router_plus_mongo(
44
45
  *,
45
46
  service: NoSqlService,
46
- read_schema: Type[Any],
47
- create_schema: Type[Any],
48
- update_schema: Type[Any],
47
+ read_schema: type[Any],
48
+ create_schema: type[Any],
49
+ update_schema: type[Any],
49
50
  prefix: str,
50
51
  tags: list[str] | None = None,
51
- search_fields: Optional[Sequence[str]] = None,
52
- default_ordering: Optional[str] = None,
53
- allowed_order_fields: Optional[list[str]] = None,
52
+ search_fields: Sequence[str] | None = None,
53
+ default_ordering: str | None = None,
54
+ allowed_order_fields: list[str] | None = None,
54
55
  mount_under_db_prefix: bool = True,
55
56
  ) -> APIRouter:
56
- read_model = cast(Any, read_schema)
57
- page_model = cast(Any, Page[read_schema]) # type: ignore[valid-type]
57
+ read_model = cast("Any", read_schema)
58
+ page_model = cast("Any", Page[read_schema]) # type: ignore[valid-type]
58
59
 
59
60
  router_prefix = ("/_mongo" + prefix) if mount_under_db_prefix else prefix
60
61
  router = public_router(
@@ -89,9 +90,7 @@ def make_crud_router_plus_mongo(
89
90
  else:
90
91
  items = await service.list(db, limit=lp.limit, offset=lp.offset, sort=sort)
91
92
  total = await service.count(db)
92
- return Page[Any].from_items(
93
- total=total, items=items, limit=lp.limit, offset=lp.offset
94
- )
93
+ return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
95
94
 
96
95
  # GET by id
97
96
  @router.get(
@@ -113,7 +112,7 @@ def make_crud_router_plus_mongo(
113
112
  description=f"Create item in {prefix} collection",
114
113
  )
115
114
  async def create_item(db: DBDep, payload: create_schema = Body(...)): # type: ignore[valid-type]
116
- data = cast(Any, payload).model_dump(exclude_unset=True)
115
+ data = cast("Any", payload).model_dump(exclude_unset=True)
117
116
  return await service.create(db, data)
118
117
 
119
118
  # UPDATE
@@ -127,7 +126,7 @@ def make_crud_router_plus_mongo(
127
126
  item_id: Any,
128
127
  payload: update_schema = Body(...), # type: ignore[valid-type]
129
128
  ):
130
- data = cast(Any, payload).model_dump(exclude_unset=True)
129
+ data = cast("Any", payload).model_dump(exclude_unset=True)
131
130
  row = await service.update(db, item_id, data)
132
131
  if not row:
133
132
  raise HTTPException(404, "Not found")
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Sequence
4
5
  from contextlib import asynccontextmanager
5
- from typing import Optional, Sequence
6
6
 
7
7
  from fastapi import FastAPI
8
8
 
@@ -17,9 +17,7 @@ from .session import dispose_session, initialize_session
17
17
 
18
18
  def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
19
19
  for r in resources:
20
- repo = SqlRepository(
21
- model=r.model, id_attr=r.id_attr, soft_delete=r.soft_delete
22
- )
20
+ repo = SqlRepository(model=r.model, id_attr=r.id_attr, soft_delete=r.soft_delete)
23
21
 
24
22
  if r.service_factory:
25
23
  svc = r.service_factory(repo)
@@ -73,9 +71,7 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
73
71
  app.include_router(router)
74
72
 
75
73
 
76
- def add_sql_db(
77
- app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_URL"
78
- ) -> None:
74
+ def add_sql_db(app: FastAPI, *, url: str | None = None, dsn_env: str = "SQL_URL") -> None:
79
75
  """Configure DB lifecycle for the app (either explicit URL or from env).
80
76
 
81
77
  This preserves any existing lifespan context (like user-defined lifespans)
@@ -106,9 +102,7 @@ def add_sql_db(
106
102
  async def lifespan_from_env(_app: FastAPI):
107
103
  env_url = os.getenv(dsn_env)
108
104
  if not env_url:
109
- raise RuntimeError(
110
- f"Missing environment variable {dsn_env} for database URL"
111
- )
105
+ raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
112
106
  initialize_session(env_url)
113
107
  try:
114
108
  if existing_lifespan is not None:
@@ -125,16 +119,14 @@ def add_sql_db(
125
119
  def add_sql_health(
126
120
  app: FastAPI, *, prefix: str = "/_sql/health", include_in_schema: bool = False
127
121
  ) -> None:
128
- app.include_router(
129
- _make_db_health_router(prefix=prefix, include_in_schema=include_in_schema)
130
- )
122
+ app.include_router(_make_db_health_router(prefix=prefix, include_in_schema=include_in_schema))
131
123
 
132
124
 
133
125
  def setup_sql(
134
126
  app: FastAPI,
135
127
  resources: Sequence[SqlResource],
136
128
  *,
137
- url: Optional[str] = None,
129
+ url: str | None = None,
138
130
  dsn_env: str = "SQL_URL",
139
131
  include_health: bool = True,
140
132
  health_prefix: str = "/_sql/health",
@@ -1,4 +1,5 @@
1
- from typing import Annotated, Any, Callable, Optional, Sequence, Type, TypeVar, cast
1
+ from collections.abc import Callable, Sequence
2
+ from typing import Annotated, Any, TypeVar, cast
2
3
 
3
4
  from fastapi import APIRouter, Body, Depends, HTTPException
4
5
  from pydantic import BaseModel
@@ -29,14 +30,14 @@ def make_crud_router_plus_sql(
29
30
  *,
30
31
  model: type[Any],
31
32
  service: SqlService,
32
- read_schema: Type[ReadModel],
33
- create_schema: Type[CreateModel],
34
- update_schema: Type[UpdateModel],
33
+ read_schema: type[ReadModel],
34
+ create_schema: type[CreateModel],
35
+ update_schema: type[UpdateModel],
35
36
  prefix: str,
36
37
  tags: list[str] | None = None,
37
- search_fields: Optional[Sequence[str]] = None,
38
- default_ordering: Optional[str] = None,
39
- allowed_order_fields: Optional[list[str]] = None,
38
+ search_fields: Sequence[str] | None = None,
39
+ default_ordering: str | None = None,
40
+ allowed_order_fields: list[str] | None = None,
40
41
  mount_under_db_prefix: bool = True,
41
42
  ) -> APIRouter:
42
43
  router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
@@ -58,7 +59,7 @@ def make_crud_router_plus_sql(
58
59
  return v
59
60
  return v
60
61
 
61
- def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
62
+ def _parse_ordering_to_fields(order_spec: str | None) -> list[str]:
62
63
  if not order_spec:
63
64
  return []
64
65
  pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
@@ -102,13 +103,9 @@ def make_crud_router_plus_sql(
102
103
  )
103
104
  total = await service.count_filtered(session, q=sp.q, fields=fields)
104
105
  else:
105
- items = await service.list(
106
- session, limit=lp.limit, offset=lp.offset, order_by=order_by
107
- )
106
+ items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
108
107
  total = await service.count(session)
109
- return Page[Any].from_items(
110
- total=total, items=items, limit=lp.limit, offset=lp.offset
111
- )
108
+ return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
112
109
 
113
110
  # -------- GET by id --------
114
111
  @router.get(
@@ -134,7 +131,7 @@ def make_crud_router_plus_sql(
134
131
  payload: create_schema = Body(...), # type: ignore[valid-type]
135
132
  ):
136
133
  if isinstance(payload, BaseModel):
137
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
134
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
138
135
  elif isinstance(payload, dict):
139
136
  data = payload
140
137
  else:
@@ -153,7 +150,7 @@ def make_crud_router_plus_sql(
153
150
  payload: update_schema = Body(...), # type: ignore[valid-type]
154
151
  ):
155
152
  if isinstance(payload, BaseModel):
156
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
153
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
157
154
  elif isinstance(payload, dict):
158
155
  data = payload
159
156
  else:
@@ -181,18 +178,16 @@ def make_crud_router_plus_sql(
181
178
  def make_tenant_crud_router_plus_sql(
182
179
  *,
183
180
  model: type[Any],
184
- service_factory: Callable[
185
- [], Any
186
- ], # factory that returns a SqlService (will be wrapped)
187
- read_schema: Type[ReadModel],
188
- create_schema: Type[CreateModel],
189
- update_schema: Type[UpdateModel],
181
+ service_factory: Callable[[], Any], # factory that returns a SqlService (will be wrapped)
182
+ read_schema: type[ReadModel],
183
+ create_schema: type[CreateModel],
184
+ update_schema: type[UpdateModel],
190
185
  prefix: str,
191
186
  tenant_field: str = "tenant_id",
192
187
  tags: list[str] | None = None,
193
- search_fields: Optional[Sequence[str]] = None,
194
- default_ordering: Optional[str] = None,
195
- allowed_order_fields: Optional[list[str]] = None,
188
+ search_fields: Sequence[str] | None = None,
189
+ default_ordering: str | None = None,
190
+ allowed_order_fields: list[str] | None = None,
196
191
  mount_under_db_prefix: bool = True,
197
192
  ) -> APIRouter:
198
193
  """Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
@@ -206,9 +201,7 @@ def make_tenant_crud_router_plus_sql(
206
201
  # Evaluate the base service once to preserve in-memory state across requests in tests/local.
207
202
  # Consumers may pass either an instance or a zero-arg factory function.
208
203
  try:
209
- _base_instance = (
210
- service_factory() if callable(service_factory) else service_factory
211
- )
204
+ _base_instance = service_factory() if callable(service_factory) else service_factory
212
205
  except TypeError:
213
206
  # If the callable requires args, assume it's already an instance
214
207
  _base_instance = service_factory
@@ -224,7 +217,7 @@ def make_tenant_crud_router_plus_sql(
224
217
  return v
225
218
  return v
226
219
 
227
- def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
220
+ def _parse_ordering_to_fields(order_spec: str | None) -> list[str]:
228
221
  if not order_spec:
229
222
  return []
230
223
  pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
@@ -239,9 +232,7 @@ def make_tenant_crud_router_plus_sql(
239
232
  # create per-request service with tenant scoping
240
233
  async def _svc(session: SqlSessionDep, tenant_id: TenantId):
241
234
  repo_or_service = getattr(_base_instance, "repo", _base_instance)
242
- svc: Any = TenantSqlService(
243
- repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field
244
- )
235
+ svc: Any = TenantSqlService(repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field)
245
236
  return svc
246
237
 
247
238
  @router.get("", response_model=Page[read_schema]) # type: ignore[valid-type]
@@ -272,13 +263,9 @@ def make_tenant_crud_router_plus_sql(
272
263
  )
273
264
  total = await svc.count_filtered(session, q=sp.q, fields=fields)
274
265
  else:
275
- items = await svc.list(
276
- session, limit=lp.limit, offset=lp.offset, order_by=order_by
277
- )
266
+ items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
278
267
  total = await svc.count(session)
279
- return Page[Any].from_items(
280
- total=total, items=items, limit=lp.limit, offset=lp.offset
281
- )
268
+ return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
282
269
 
283
270
  @router.get("/{item_id}", response_model=read_schema)
284
271
  async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
@@ -296,7 +283,7 @@ def make_tenant_crud_router_plus_sql(
296
283
  ):
297
284
  svc = await _svc(session, tenant_id)
298
285
  if isinstance(payload, BaseModel):
299
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
286
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
300
287
  elif isinstance(payload, dict):
301
288
  data = payload
302
289
  else:
@@ -312,7 +299,7 @@ def make_tenant_crud_router_plus_sql(
312
299
  ):
313
300
  svc = await _svc(session, tenant_id)
314
301
  if isinstance(payload, BaseModel):
315
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
302
+ data = cast("BaseModel", payload).model_dump(exclude_unset=True)
316
303
  elif isinstance(payload, dict):
317
304
  data = payload
318
305
  else:
@@ -14,9 +14,7 @@ def _make_db_health_router(
14
14
  include_in_schema: bool = False,
15
15
  ) -> APIRouter:
16
16
  """Internal factory for the DB health router."""
17
- router = public_router(
18
- prefix=prefix, tags=["health"], include_in_schema=include_in_schema
19
- )
17
+ router = public_router(prefix=prefix, tags=["health"], include_in_schema=include_in_schema)
20
18
 
21
19
  @router.get("", status_code=status.HTTP_200_OK)
22
20
  async def db_health(session: SqlSessionDep) -> Response:
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
- from typing import Annotated, AsyncIterator, Tuple
5
+ from collections.abc import AsyncIterator
6
+ from typing import Annotated
6
7
 
7
8
  from fastapi import Depends
8
9
  from sqlalchemy import text
@@ -23,7 +24,7 @@ _SessionLocal: async_sessionmaker[AsyncSession] | None = None
23
24
 
24
25
  def _init_engine_and_session(
25
26
  url: str,
26
- ) -> Tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
27
+ ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
27
28
  async_url = _coerce_to_async_url(url)
28
29
  if async_url != url:
29
30
  logger.info(
@@ -63,9 +64,7 @@ async def get_session() -> AsyncIterator[AsyncSession]:
63
64
  if ms > 0:
64
65
  try:
65
66
  # SET LOCAL applies for the duration of the current transaction only
66
- await session.execute(
67
- text("SET LOCAL statement_timeout = :ms"), {"ms": ms}
68
- )
67
+ await session.execute(text("SET LOCAL statement_timeout = :ms"), {"ms": ms})
69
68
  except Exception:
70
69
  # Non-PG dialects (e.g., SQLite) will error; ignore silently
71
70
  pass
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, AsyncIterator, Callable, Tuple
3
+ from collections.abc import AsyncIterator, Callable
4
+ from typing import Any
4
5
  from uuid import UUID
5
6
 
6
7
  from fastapi import Depends
@@ -30,7 +31,7 @@ def get_fastapi_users(
30
31
  user_schema_update: Any,
31
32
  *,
32
33
  public_auth_prefix: str = "/auth",
33
- ) -> Tuple[
34
+ ) -> tuple[
34
35
  FastAPIUsers,
35
36
  AuthenticationBackend,
36
37
  DualAPIRouter,
@@ -51,9 +52,7 @@ def get_fastapi_users(
51
52
 
52
53
  async def on_after_register(self, user: Any, request=None):
53
54
  st = get_auth_settings()
54
- if CURRENT_ENVIRONMENT in (DEV_ENV, LOCAL_ENV) and bool(
55
- st.auto_verify_in_dev
56
- ):
55
+ if CURRENT_ENVIRONMENT in (DEV_ENV, LOCAL_ENV) and bool(st.auto_verify_in_dev):
57
56
  await self.user_db.update(user, {"is_verified": True})
58
57
  return
59
58
  await self.request_verify(user, request)
@@ -62,10 +61,10 @@ def get_fastapi_users(
62
61
  verify_url = f"{public_auth_prefix}/verify?token={token}"
63
62
  sender = get_sender()
64
63
  sender.send(
65
- to=getattr(user, "email"),
64
+ to=user.email,
66
65
  subject="Verify your account",
67
66
  html_body=f"""
68
- <p>Hi {getattr(user, 'full_name', '') or 'there'},</p>
67
+ <p>Hi {getattr(user, "full_name", "") or "there"},</p>
69
68
  <p>Click to verify your account:</p>
70
69
  <p><a href="{verify_url}">{verify_url}</a></p>
71
70
  """,
@@ -75,7 +74,7 @@ def get_fastapi_users(
75
74
  reset_url = f"{public_auth_prefix}/reset-password?token={token}"
76
75
  sender = get_sender()
77
76
  sender.send(
78
- to=getattr(user, "email"),
77
+ to=user.email,
79
78
  subject="Reset your password",
80
79
  html_body=f"""
81
80
  <p>We received a request to reset your password.</p>
@@ -116,9 +115,7 @@ def get_fastapi_users(
116
115
  old_secrets=old,
117
116
  token_audience=audience,
118
117
  )
119
- return JWTStrategy(
120
- secret=secret, lifetime_seconds=lifetime, token_audience=audience
121
- )
118
+ return JWTStrategy(secret=secret, lifetime_seconds=lifetime, token_audience=audience)
122
119
 
123
120
  bearer_transport = BearerTransport(tokenUrl=auth_login_path)
124
121
  auth_backend = AuthenticationBackend(
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import time
5
- from typing import Callable, Optional
5
+ from collections.abc import Callable
6
6
 
7
7
  from fastapi import HTTPException
8
8
  from starlette.requests import Request
@@ -30,9 +30,7 @@ class RateLimiter:
30
30
  limit: int,
31
31
  window: int = 60,
32
32
  key_fn: Callable = lambda r: "global",
33
- limit_resolver: Optional[
34
- Callable[[Request, Optional[str]], Optional[int]]
35
- ] = None,
33
+ limit_resolver: Callable[[Request, str | None], int | None] | None = None,
36
34
  scope_by_tenant: bool = False,
37
35
  store: RateLimitStore | None = None,
38
36
  ):
@@ -65,7 +63,7 @@ class RateLimiter:
65
63
  except Exception:
66
64
  eff_limit = self.limit
67
65
 
68
- count, store_limit, reset = self.store.incr(str(key), self.window)
66
+ count, _store_limit, reset = self.store.incr(str(key), self.window)
69
67
  if count > eff_limit:
70
68
  retry = max(0, reset - int(time.time()))
71
69
  try:
@@ -87,7 +85,7 @@ def rate_limiter(
87
85
  limit: int,
88
86
  window: int = 60,
89
87
  key_fn: Callable = lambda r: "global",
90
- limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
88
+ limit_resolver: Callable[[Request, str | None], int | None] | None = None,
91
89
  scope_by_tenant: bool = False,
92
90
  store: RateLimitStore | None = None,
93
91
  ):
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from collections.abc import Callable
4
5
  from pathlib import Path
5
- from typing import Callable, Optional
6
6
 
7
7
  from fastapi import FastAPI, Request
8
8
  from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
@@ -18,7 +18,7 @@ def add_docs(
18
18
  redoc_url: str = "/redoc",
19
19
  swagger_url: str = "/docs",
20
20
  openapi_url: str = "/openapi.json",
21
- export_openapi_to: Optional[str] = None,
21
+ export_openapi_to: str | None = None,
22
22
  # Landing page options
23
23
  landing_url: str = "/",
24
24
  include_landing: bool = True,
@@ -29,15 +29,13 @@ def add_docs(
29
29
  """
30
30
 
31
31
  # OpenAPI JSON route
32
- async def openapi_handler() -> JSONResponse: # noqa: ANN201
32
+ async def openapi_handler() -> JSONResponse:
33
33
  return JSONResponse(app.openapi())
34
34
 
35
- app.add_api_route(
36
- openapi_url, openapi_handler, methods=["GET"], include_in_schema=False
37
- )
35
+ app.add_api_route(openapi_url, openapi_handler, methods=["GET"], include_in_schema=False)
38
36
 
39
37
  # Swagger UI route
40
- async def swagger_ui(request: Request) -> HTMLResponse: # noqa: ANN201
38
+ async def swagger_ui(request: Request) -> HTMLResponse:
41
39
  resp = get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
42
40
  theme = request.query_params.get("theme")
43
41
  if theme == "dark":
@@ -47,7 +45,7 @@ def add_docs(
47
45
  app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
48
46
 
49
47
  # Redoc route
50
- async def redoc_ui(request: Request) -> HTMLResponse: # noqa: ANN201
48
+ async def redoc_ui(request: Request) -> HTMLResponse:
51
49
  resp = get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
52
50
  theme = request.query_params.get("theme")
53
51
  if theme == "dark":
@@ -80,15 +78,13 @@ def add_docs(
80
78
  if landing_path in existing_paths:
81
79
  landing_path = "/_docs"
82
80
 
83
- async def _landing() -> HTMLResponse: # noqa: ANN201
81
+ async def _landing() -> HTMLResponse:
84
82
  cards: list[CardSpec] = []
85
83
  # Root docs card using the provided paths
86
84
  cards.append(
87
85
  CardSpec(
88
86
  tag="",
89
- docs=DocTargets(
90
- swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url
91
- ),
87
+ docs=DocTargets(swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url),
92
88
  )
93
89
  )
94
90
  # Scoped docs (if any were registered via add_prefixed_docs)
@@ -96,9 +92,7 @@ def add_docs(
96
92
  cards.append(
97
93
  CardSpec(
98
94
  tag=scope.strip("/"),
99
- docs=DocTargets(
100
- swagger=swagger, redoc=redoc, openapi_json=openapi_json
101
- ),
95
+ docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
102
96
  )
103
97
  )
104
98
  html = render_index_html(
@@ -106,9 +100,7 @@ def add_docs(
106
100
  )
107
101
  return HTMLResponse(html)
108
102
 
109
- app.add_api_route(
110
- landing_path, _landing, methods=["GET"], include_in_schema=False
111
- )
103
+ app.add_api_route(landing_path, _landing, methods=["GET"], include_in_schema=False)
112
104
 
113
105
 
114
106
  def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
@@ -130,9 +122,7 @@ def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
130
122
  body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
131
123
  # add class to body to allow stronger selectors
132
124
  body = body.replace("<body>", '<body class="dark">', 1)
133
- return HTMLResponse(
134
- content=body, status_code=resp.status_code, headers=dict(resp.headers)
135
- )
125
+ return HTMLResponse(content=body, status_code=resp.status_code, headers=dict(resp.headers))
136
126
 
137
127
 
138
128
  _DARK_CSS = """
@@ -147,7 +137,7 @@ a { color: #62aef7; }
147
137
  def add_sdk_generation_stub(
148
138
  app: FastAPI,
149
139
  *,
150
- on_generate: Optional[Callable[[], None]] = None,
140
+ on_generate: Callable[[], None] | None = None,
151
141
  openapi_path: str = "/openapi.json",
152
142
  ) -> None:
153
143
  """Hook to add an SDK generation stub.
@@ -163,7 +153,7 @@ def add_sdk_generation_stub(
163
153
  router = public_router(prefix="/_docs", include_in_schema=False)
164
154
 
165
155
  @router.post("/generate-sdk")
166
- async def _generate() -> dict: # noqa: ANN201
156
+ async def _generate() -> dict:
167
157
  on_generate()
168
158
  return {"status": "ok"}
169
159