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,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from dataclasses import dataclass
4
- from typing import Iterable, List, Optional
5
5
 
6
6
  FAVICON_DATA_URI = (
7
7
  "data:image/svg+xml,"
@@ -14,9 +14,9 @@ FAVICON_DATA_URI = (
14
14
 
15
15
  @dataclass(frozen=True)
16
16
  class DocTargets:
17
- swagger: Optional[str] = None
18
- redoc: Optional[str] = None
19
- openapi_json: Optional[str] = None
17
+ swagger: str | None = None
18
+ redoc: str | None = None
19
+ openapi_json: str | None = None
20
20
 
21
21
 
22
22
  @dataclass(frozen=True)
@@ -31,7 +31,7 @@ def _btn(label: str, href: str) -> str:
31
31
 
32
32
  def _card(spec: CardSpec) -> str:
33
33
  tag = "/" if spec.tag.strip("/") == "" else f"/{spec.tag.strip('/')}"
34
- links: List[str] = []
34
+ links: list[str] = []
35
35
  if spec.docs.swagger:
36
36
  links.append(_btn("Swagger", spec.docs.swagger))
37
37
  if spec.docs.redoc:
@@ -50,9 +50,7 @@ def _card(spec: CardSpec) -> str:
50
50
  """.strip()
51
51
 
52
52
 
53
- def render_index_html(
54
- *, service_name: str, release: str, cards: Iterable[CardSpec]
55
- ) -> str:
53
+ def render_index_html(*, service_name: str, release: str, cards: Iterable[CardSpec]) -> str:
56
54
  grid = "\n".join(_card(c) for c in cards)
57
55
  return f"""
58
56
  <!doctype html>
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
- from typing import Dict, Iterable, List, Optional, Set, Tuple
4
+ from collections.abc import Iterable
5
5
 
6
6
  from fastapi import FastAPI
7
7
  from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
@@ -10,15 +10,15 @@ from fastapi.responses import HTMLResponse
10
10
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, Environment
11
11
 
12
12
  # (prefix, swagger_path, redoc_path, openapi_path, title)
13
- DOC_SCOPES: List[Tuple[str, str, str, str, str]] = []
13
+ DOC_SCOPES: list[tuple[str, str, str, str, str]] = []
14
14
 
15
15
  _HTTP_METHODS = {"get", "put", "post", "delete", "patch", "options", "head", "trace"}
16
16
 
17
17
 
18
18
  def _path_included(
19
19
  path: str,
20
- include_prefixes: Optional[Iterable[str]] = None,
21
- exclude_prefixes: Optional[Iterable[str]] = None,
20
+ include_prefixes: Iterable[str] | None = None,
21
+ exclude_prefixes: Iterable[str] | None = None,
22
22
  ) -> bool:
23
23
  def _match(pfx: str) -> bool:
24
24
  pfx = pfx.rstrip("/") or "/"
@@ -31,7 +31,7 @@ def _path_included(
31
31
  return True
32
32
 
33
33
 
34
- def _collect_refs(obj, refset: Set[Tuple[str, str]]):
34
+ def _collect_refs(obj, refset: set[tuple[str, str]]):
35
35
  if isinstance(obj, dict):
36
36
  for k, v in obj.items():
37
37
  if k == "$ref" and isinstance(v, str) and v.startswith("#/components/"):
@@ -46,8 +46,8 @@ def _collect_refs(obj, refset: Set[Tuple[str, str]]):
46
46
 
47
47
 
48
48
  def _close_over_component_refs(
49
- full_components: Dict, initial: Set[Tuple[str, str]]
50
- ) -> Set[Tuple[str, str]]:
49
+ full_components: dict, initial: set[tuple[str, str]]
50
+ ) -> set[tuple[str, str]]:
51
51
  to_visit = list(initial)
52
52
  seen = set(initial)
53
53
  while to_visit:
@@ -55,7 +55,7 @@ def _close_over_component_refs(
55
55
  comp = (full_components or {}).get(section, {}).get(name)
56
56
  if not isinstance(comp, dict):
57
57
  continue
58
- nested: Set[Tuple[str, str]] = set()
58
+ nested: set[tuple[str, str]] = set()
59
59
  _collect_refs(comp, nested)
60
60
  for ref in nested:
61
61
  if ref not in seen:
@@ -65,11 +65,11 @@ def _close_over_component_refs(
65
65
 
66
66
 
67
67
  def _prune_to_paths(
68
- full_schema: Dict,
69
- keep_paths: Dict[str, dict],
70
- title_suffix: Optional[str],
71
- server_prefix: Optional[str] = None,
72
- ) -> Dict:
68
+ full_schema: dict,
69
+ keep_paths: dict[str, dict],
70
+ title_suffix: str | None,
71
+ server_prefix: str | None = None,
72
+ ) -> dict:
73
73
  schema = copy.deepcopy(full_schema)
74
74
  schema["paths"] = keep_paths
75
75
 
@@ -77,9 +77,9 @@ def _prune_to_paths(
77
77
  if server_prefix is not None:
78
78
  schema["servers"] = [{"url": server_prefix}]
79
79
 
80
- used_tags: Set[str] = set()
81
- direct_refs: Set[Tuple[str, str]] = set()
82
- used_security_schemes: Set[str] = set()
80
+ used_tags: set[str] = set()
81
+ direct_refs: set[tuple[str, str]] = set()
82
+ used_security_schemes: set[str] = set()
83
83
 
84
84
  for path_item in keep_paths.values():
85
85
  for method, op in path_item.items():
@@ -95,7 +95,7 @@ def _prune_to_paths(
95
95
  comps = schema.get("components") or {}
96
96
  all_refs = _close_over_component_refs(comps, direct_refs)
97
97
 
98
- pruned_components: Dict[str, Dict] = {}
98
+ pruned_components: dict[str, dict] = {}
99
99
  if isinstance(comps, dict):
100
100
  for section, items in comps.items():
101
101
  keep_names = {name for (sec, name) in all_refs if sec == section}
@@ -110,9 +110,7 @@ def _prune_to_paths(
110
110
 
111
111
  if "tags" in schema and isinstance(schema["tags"], list):
112
112
  schema["tags"] = [
113
- t
114
- for t in schema["tags"]
115
- if isinstance(t, dict) and t.get("name") in used_tags
113
+ t for t in schema["tags"] if isinstance(t, dict) and t.get("name") in used_tags
116
114
  ]
117
115
 
118
116
  info = dict(schema.get("info") or {})
@@ -123,17 +121,15 @@ def _prune_to_paths(
123
121
 
124
122
 
125
123
  def _build_filtered_schema(
126
- full_schema: Dict,
124
+ full_schema: dict,
127
125
  *,
128
- include_prefixes: Optional[List[str]] = None,
129
- exclude_prefixes: Optional[List[str]] = None,
130
- title_suffix: Optional[str] = None,
131
- ) -> Dict:
126
+ include_prefixes: list[str] | None = None,
127
+ exclude_prefixes: list[str] | None = None,
128
+ title_suffix: str | None = None,
129
+ ) -> dict:
132
130
  paths = full_schema.get("paths", {}) or {}
133
131
  keep_paths = {
134
- p: v
135
- for p, v in paths.items()
136
- if _path_included(p, include_prefixes, exclude_prefixes)
132
+ p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
137
133
  }
138
134
 
139
135
  # Determine the server prefix for scoped docs
@@ -154,9 +150,7 @@ def _build_filtered_schema(
154
150
  stripped_paths[path] = spec
155
151
  keep_paths = stripped_paths
156
152
 
157
- return _prune_to_paths(
158
- full_schema, keep_paths, title_suffix, server_prefix=server_prefix
159
- )
153
+ return _prune_to_paths(full_schema, keep_paths, title_suffix, server_prefix=server_prefix)
160
154
 
161
155
 
162
156
  def _ensure_original_openapi_saved(app: FastAPI) -> None:
@@ -164,12 +158,12 @@ def _ensure_original_openapi_saved(app: FastAPI) -> None:
164
158
  app.state._scoped_original_openapi = app.openapi
165
159
 
166
160
 
167
- def _get_full_schema_from_original(app: FastAPI) -> Dict:
161
+ def _get_full_schema_from_original(app: FastAPI) -> dict:
168
162
  _ensure_original_openapi_saved(app)
169
163
  return copy.deepcopy(app.state._scoped_original_openapi())
170
164
 
171
165
 
172
- def _install_root_filter(app: FastAPI, exclude_prefixes: List[str]) -> None:
166
+ def _install_root_filter(app: FastAPI, exclude_prefixes: list[str]) -> None:
173
167
  _ensure_original_openapi_saved(app)
174
168
  app.state._scoped_root_exclusions = sorted(set(exclude_prefixes))
175
169
 
@@ -179,10 +173,10 @@ def _install_root_filter(app: FastAPI, exclude_prefixes: List[str]) -> None:
179
173
  full_schema, exclude_prefixes=app.state._scoped_root_exclusions
180
174
  )
181
175
 
182
- setattr(app, "openapi", root_filtered_openapi)
176
+ app.openapi = root_filtered_openapi # type: ignore[method-assign]
183
177
 
184
178
 
185
- def _current_registered_scopes() -> List[str]:
179
+ def _current_registered_scopes() -> list[str]:
186
180
  return [scope for (scope, *_rest) in DOC_SCOPES]
187
181
 
188
182
 
@@ -193,8 +187,8 @@ def _ensure_root_excludes_registered_scopes(app: FastAPI) -> None:
193
187
 
194
188
 
195
189
  def _normalize_envs(
196
- envs: Optional[Iterable[Environment | str]],
197
- ) -> Optional[set[Environment]]:
190
+ envs: Iterable[Environment | str] | None,
191
+ ) -> set[Environment] | None:
198
192
  if envs is None:
199
193
  return None
200
194
  out: set[Environment] = set()
@@ -209,7 +203,7 @@ def add_prefixed_docs(
209
203
  prefix: str,
210
204
  title: str,
211
205
  auto_exclude_from_root: bool = True,
212
- visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
206
+ visible_envs: Iterable[Environment | str] | None = (LOCAL_ENV, DEV_ENV),
213
207
  ) -> None:
214
208
  scope = prefix.rstrip("/") or "/"
215
209
 
@@ -233,7 +227,7 @@ def add_prefixed_docs(
233
227
  redoc_path = f"{scope}/redoc"
234
228
 
235
229
  _ensure_original_openapi_saved(app)
236
- _scope_cache: Dict | None = None
230
+ _scope_cache: dict | None = None
237
231
 
238
232
  def _scoped_schema():
239
233
  nonlocal _scope_cache
@@ -260,7 +254,5 @@ def add_prefixed_docs(
260
254
  DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
261
255
 
262
256
 
263
- def replace_root_openapi_with_exclusions(
264
- app: FastAPI, *, exclude_prefixes: List[str]
265
- ) -> None:
257
+ def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: list[str]) -> None:
266
258
  _install_root_filter(app, exclude_prefixes)
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable
3
+ from collections.abc import Callable
4
4
 
5
5
  from fastapi import APIRouter
6
6
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from ..auth.security import (
6
7
  AllowIdentity,
@@ -21,7 +22,7 @@ from ..openapi.responses import (
21
22
  from .router import DualAPIRouter
22
23
 
23
24
 
24
- def _merge(base: Optional[Sequence[Any]], extra: Optional[Sequence[Any]]) -> list[Any]:
25
+ def _merge(base: Sequence[Any] | None, extra: Sequence[Any] | None) -> list[Any]:
25
26
  out: list[Any] = []
26
27
  if base:
27
28
  out.extend(base)
@@ -32,7 +33,7 @@ def _merge(base: Optional[Sequence[Any]], extra: Optional[Sequence[Any]]) -> lis
32
33
 
33
34
  # PUBLIC (but attach OptionalIdentity for convenience)
34
35
  def optional_identity_router(
35
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
36
+ *, dependencies: Sequence[Any] | None = None, **kwargs: Any
36
37
  ) -> DualAPIRouter:
37
38
  r = DualAPIRouter(dependencies=_merge([AllowIdentity], dependencies), **kwargs)
38
39
  apply_default_security(r, default_security=[]) # public looking in docs
@@ -41,9 +42,7 @@ def optional_identity_router(
41
42
 
42
43
 
43
44
  # PROTECTED: any auth (JWT/cookie OR API key)
44
- def protected_router(
45
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
46
- ) -> DualAPIRouter:
45
+ def protected_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
47
46
  r = DualAPIRouter(dependencies=_merge([RequireIdentity], dependencies), **kwargs)
48
47
  apply_default_security(
49
48
  r,
@@ -58,9 +57,7 @@ def protected_router(
58
57
 
59
58
 
60
59
  # USER-ONLY (no API-key-only access)
61
- def user_router(
62
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
63
- ) -> DualAPIRouter:
60
+ def user_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
64
61
  r = DualAPIRouter(dependencies=_merge([RequireUser()], dependencies), **kwargs)
65
62
  apply_default_security(
66
63
  r, default_security=[{"OAuth2PasswordBearer": []}, {"SessionCookie": []}]
@@ -70,9 +67,7 @@ def user_router(
70
67
 
71
68
 
72
69
  # SERVICE-ONLY (API key required)
73
- def service_router(
74
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
75
- ) -> DualAPIRouter:
70
+ def service_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
76
71
  r = DualAPIRouter(dependencies=_merge([RequireService()], dependencies), **kwargs)
77
72
  apply_default_security(r, default_security=[{"APIKeyHeader": []}])
78
73
  apply_default_responses(r, DEFAULT_SERVICE)
@@ -111,7 +106,7 @@ def roles_router(*roles: str, role_resolver=None, **kwargs):
111
106
 
112
107
 
113
108
  def ws_protected_router(
114
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
109
+ *, dependencies: Sequence[Any] | None = None, **kwargs: Any
115
110
  ) -> DualAPIRouter:
116
111
  """
117
112
  Protected WebSocket router - requires valid JWT token.
@@ -141,7 +136,7 @@ def ws_protected_router(
141
136
 
142
137
 
143
138
  def ws_optional_router(
144
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
139
+ *, dependencies: Sequence[Any] | None = None, **kwargs: Any
145
140
  ) -> DualAPIRouter:
146
141
  """
147
142
  Optional auth WebSocket router - allows anonymous connections.
@@ -163,9 +158,7 @@ def ws_optional_router(
163
158
  return r
164
159
 
165
160
 
166
- def ws_user_router(
167
- *, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
168
- ) -> DualAPIRouter:
161
+ def ws_user_router(*, dependencies: Sequence[Any] | None = None, **kwargs: Any) -> DualAPIRouter:
169
162
  """
170
163
  User-only WebSocket router - requires valid user JWT (no API key).
171
164
 
@@ -189,7 +182,7 @@ def ws_user_router(
189
182
 
190
183
 
191
184
  def ws_scopes_router(
192
- *scopes: str, dependencies: Optional[Sequence[Any]] = None, **kwargs: Any
185
+ *scopes: str, dependencies: Sequence[Any] | None = None, **kwargs: Any
193
186
  ) -> DualAPIRouter:
194
187
  """
195
188
  Scope-gated WebSocket router - requires valid JWT with specific scopes.
@@ -207,9 +200,7 @@ def ws_scopes_router(
207
200
  ...
208
201
  """
209
202
  r = DualAPIRouter(
210
- dependencies=_merge(
211
- [RequireWSIdentity, RequireWSScopes(*scopes)], dependencies
212
- ),
203
+ dependencies=_merge([RequireWSIdentity, RequireWSScopes(*scopes)], dependencies),
213
204
  **kwargs,
214
205
  )
215
206
  apply_default_security(
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Type
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from fastapi import APIRouter
6
7
  from fastapi.params import Depends
@@ -44,7 +45,7 @@ class DualAPIRouter(APIRouter):
44
45
  **kwargs,
45
46
  )
46
47
  # only add the "/" twin for *safe* methods
47
- if set(m.upper() for m in methods) <= safe_methods:
48
+ if {m.upper() for m in methods} <= safe_methods:
48
49
  self.add_api_route(
49
50
  "/", func, methods=methods, include_in_schema=False, **kwargs
50
51
  )
@@ -59,15 +60,13 @@ class DualAPIRouter(APIRouter):
59
60
  **kwargs,
60
61
  )
61
62
  if alt != primary:
62
- self.add_api_route(
63
- alt, func, methods=methods, include_in_schema=False, **kwargs
64
- )
63
+ self.add_api_route(alt, func, methods=methods, include_in_schema=False, **kwargs)
65
64
  return func
66
65
 
67
66
  return decorator
68
67
 
69
68
  def add_api_route(self, path, endpoint, **kwargs):
70
- methods = set((kwargs.get("methods") or []))
69
+ methods = set(kwargs.get("methods") or [])
71
70
  for r in self.routes:
72
71
  if getattr(r, "path", None) == path and methods & (
73
72
  getattr(r, "methods", set()) or set()
@@ -78,45 +77,31 @@ class DualAPIRouter(APIRouter):
78
77
  # ---------- HTTP method shorthands ----------
79
78
 
80
79
  def get(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
81
- return self._dual_decorator(
82
- path, ["GET"], show_in_schema=show_in_schema, **kwargs
83
- )
80
+ return self._dual_decorator(path, ["GET"], show_in_schema=show_in_schema, **kwargs)
84
81
 
85
82
  def post(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
86
- return self._dual_decorator(
87
- path, ["POST"], show_in_schema=show_in_schema, **kwargs
88
- )
83
+ return self._dual_decorator(path, ["POST"], show_in_schema=show_in_schema, **kwargs)
89
84
 
90
85
  def patch(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
91
- return self._dual_decorator(
92
- path, ["PATCH"], show_in_schema=show_in_schema, **kwargs
93
- )
86
+ return self._dual_decorator(path, ["PATCH"], show_in_schema=show_in_schema, **kwargs)
94
87
 
95
88
  def delete(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
96
- return self._dual_decorator(
97
- path, ["DELETE"], show_in_schema=show_in_schema, **kwargs
98
- )
89
+ return self._dual_decorator(path, ["DELETE"], show_in_schema=show_in_schema, **kwargs)
99
90
 
100
91
  def put(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
101
- return self._dual_decorator(
102
- path, ["PUT"], show_in_schema=show_in_schema, **kwargs
103
- )
92
+ return self._dual_decorator(path, ["PUT"], show_in_schema=show_in_schema, **kwargs)
104
93
 
105
94
  def options(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
106
- return self._dual_decorator(
107
- path, ["OPTIONS"], show_in_schema=show_in_schema, **kwargs
108
- )
95
+ return self._dual_decorator(path, ["OPTIONS"], show_in_schema=show_in_schema, **kwargs)
109
96
 
110
97
  def head(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
111
- return self._dual_decorator(
112
- path, ["HEAD"], show_in_schema=show_in_schema, **kwargs
113
- )
98
+ return self._dual_decorator(path, ["HEAD"], show_in_schema=show_in_schema, **kwargs)
114
99
 
115
100
  def list(
116
101
  self,
117
102
  path: str,
118
103
  *,
119
- model: Type[BaseModel],
104
+ model: type[BaseModel],
120
105
  envelope: bool = False,
121
106
  cursor: bool = True,
122
107
  page: bool = True,
@@ -155,9 +140,7 @@ class DualAPIRouter(APIRouter):
155
140
  kwargs["response_model"] = kwargs.get("response_model") or response_model
156
141
 
157
142
  # we still want the dual-registration behavior
158
- return self._dual_decorator(
159
- path, ["GET"], show_in_schema=show_in_schema, **kwargs
160
- )
143
+ return self._dual_decorator(path, ["GET"], show_in_schema=show_in_schema, **kwargs)
161
144
 
162
145
  # ---------- WebSocket ----------
163
146
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from typing import Iterable, Sequence
4
+ from collections.abc import Iterable, Sequence
5
5
 
6
6
  from fastapi import FastAPI
7
7
  from pydantic import BaseModel, Field
@@ -64,7 +64,7 @@ class EasyAppOptions(BaseModel):
64
64
  observability: ObservabilityOptions = ObservabilityOptions()
65
65
 
66
66
  @classmethod
67
- def from_env(cls) -> "EasyAppOptions":
67
+ def from_env(cls) -> EasyAppOptions:
68
68
  """
69
69
  Build options from environment variables:
70
70
 
@@ -88,7 +88,7 @@ class EasyAppOptions(BaseModel):
88
88
  ),
89
89
  )
90
90
 
91
- def merged_with(self, override: "EasyAppOptions | None") -> "EasyAppOptions":
91
+ def merged_with(self, override: EasyAppOptions | None) -> EasyAppOptions:
92
92
  """
93
93
  Merge two option sets. Non-None fields in `override` win.
94
94
  (For iterables, if override provides a non-None value, it wins entirely.)
@@ -104,13 +104,9 @@ class EasyAppOptions(BaseModel):
104
104
  else self.logging.enable
105
105
  ),
106
106
  level=(
107
- override.logging.level
108
- if override.logging.level is not None
109
- else self.logging.level
107
+ override.logging.level if override.logging.level is not None else self.logging.level
110
108
  ),
111
- fmt=override.logging.fmt
112
- if override.logging.fmt is not None
113
- else self.logging.fmt,
109
+ fmt=override.logging.fmt if override.logging.fmt is not None else self.logging.fmt,
114
110
  )
115
111
 
116
112
  # observability
@@ -152,8 +148,33 @@ def easy_service_api(
152
148
  public_cors_origins: list[str] | str | None = None,
153
149
  root_public_base_url: str | None = None,
154
150
  root_include_api_key: bool | None = None,
151
+ skip_paths: list[str] | None = None,
155
152
  **fastapi_kwargs, # Forward all other FastAPI kwargs
156
153
  ) -> FastAPI:
154
+ """
155
+ Create a FastAPI application with standard service configuration.
156
+
157
+ Args:
158
+ name: Service name for OpenAPI docs and logging.
159
+ release: Version string for the service.
160
+ versions: List of (tag, routers_package, public_base_url) tuples for API versioning.
161
+ root_routers: Router module(s) to mount at root level.
162
+ public_cors_origins: Origins to allow for CORS.
163
+ root_public_base_url: Public base URL for root-level routes.
164
+ root_include_api_key: Whether to include API key auth for root routes.
165
+ skip_paths: Path prefixes to exclude from timeout/rate-limit middleware.
166
+ Uses prefix matching: "/v1/chat" matches "/v1/chat" and "/v1/chat/stream"
167
+ but not "/api/v1/chat". Falls back to SKIP_MIDDLEWARE_PATHS env var.
168
+ **fastapi_kwargs: Additional kwargs passed to FastAPI constructor.
169
+
170
+ Returns:
171
+ Configured FastAPI application.
172
+ """
173
+ # Env fallback for skip_paths
174
+ effective_skip = (
175
+ skip_paths if skip_paths is not None else _env_csv_paths("SKIP_MIDDLEWARE_PATHS")
176
+ )
177
+
157
178
  service = ServiceInfo(name=name, release=release)
158
179
  specs = [
159
180
  APIVersionSpec(tag=str(tag), routers_package=pkg, public_base_url=base)
@@ -166,6 +187,7 @@ def easy_service_api(
166
187
  public_cors_origins=public_cors_origins,
167
188
  root_public_base_url=root_public_base_url,
168
189
  root_include_api_key=root_include_api_key,
190
+ skip_paths=effective_skip,
169
191
  **fastapi_kwargs, # Forward to setup_service_api
170
192
  )
171
193
 
@@ -179,26 +201,47 @@ def easy_service_app(
179
201
  public_cors_origins: list[str] | str | None = None,
180
202
  root_public_base_url: str | None = None,
181
203
  root_include_api_key: bool | None = None,
204
+ skip_paths: list[str] | None = None,
182
205
  options: EasyAppOptions | None = None,
183
206
  enable_logging: bool | None = None,
184
207
  enable_observability: bool | None = None,
185
208
  **fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
186
209
  ) -> FastAPI:
187
210
  """
188
- One-call bootstrap with env + options + flags:
189
-
190
- Precedence (strongest → weakest):
211
+ One-call bootstrap with env + options + flags.
212
+
213
+ Args:
214
+ name: Service name for OpenAPI docs and logging.
215
+ release: Version string for the service.
216
+ versions: List of (tag, routers_package, public_base_url) tuples for API versioning.
217
+ root_routers: Router module(s) to mount at root level.
218
+ public_cors_origins: Origins to allow for CORS.
219
+ root_public_base_url: Public base URL for root-level routes.
220
+ root_include_api_key: Whether to include API key auth for root routes.
221
+ skip_paths: Path prefixes to exclude from timeout/rate-limit middleware.
222
+ Uses prefix matching: "/v1/chat" matches "/v1/chat" and "/v1/chat/stream"
223
+ but not "/api/v1/chat". Falls back to SKIP_MIDDLEWARE_PATHS env var.
224
+ options: EasyAppOptions for logging/observability configuration.
225
+ enable_logging: Override to enable/disable logging.
226
+ enable_observability: Override to enable/disable observability.
227
+ **fastapi_kwargs: Additional kwargs passed to FastAPI constructor.
228
+
229
+ Precedence (strongest → weakest):
191
230
  1) enable_logging / enable_observability args
192
231
  2) `options=` object (per-field)
193
232
  3) `EasyAppOptions.from_env()`
194
233
 
195
- Env recognized:
234
+ Env recognized:
196
235
  ENABLE_LOGGING=true|false
197
236
  ENABLE_OBS=true|false
198
237
  LOG_LEVEL=DEBUG|INFO|...
199
238
  LOG_FORMAT=json|plain
200
239
  METRICS_PATH=/metrics
201
240
  OBS_SKIP_PATHS=/metrics,/health,/internal
241
+ SKIP_MIDDLEWARE_PATHS=/v1/chat,/v1/stream (for timeout/rate-limit skip)
242
+
243
+ Returns:
244
+ Configured FastAPI application with logging and observability.
202
245
  """
203
246
  # 0) Start from env
204
247
  env_opts = EasyAppOptions.from_env()
@@ -233,6 +276,7 @@ def easy_service_app(
233
276
  public_cors_origins=public_cors_origins,
234
277
  root_public_base_url=root_public_base_url,
235
278
  root_include_api_key=root_include_api_key,
279
+ skip_paths=skip_paths,
236
280
  **fastapi_kwargs, # Forward FastAPI kwargs (lifespan, etc.)
237
281
  )
238
282
 
@@ -1,4 +1,4 @@
1
- from datetime import datetime, timezone
1
+ from datetime import UTC, datetime
2
2
  from email.utils import format_datetime, parsedate_to_datetime
3
3
  from hashlib import sha256
4
4
 
@@ -16,13 +16,11 @@ def set_conditional_headers(
16
16
  resp.headers["ETag"] = etag
17
17
  if last_modified:
18
18
  if last_modified.tzinfo is None:
19
- last_modified = last_modified.replace(tzinfo=timezone.utc)
19
+ last_modified = last_modified.replace(tzinfo=UTC)
20
20
  resp.headers["Last-Modified"] = format_datetime(last_modified)
21
21
 
22
22
 
23
- def maybe_not_modified(
24
- request: Request, etag: str | None, last_modified: datetime | None
25
- ) -> bool:
23
+ def maybe_not_modified(request: Request, etag: str | None, last_modified: datetime | None) -> bool:
26
24
  inm = request.headers.get("If-None-Match")
27
25
  ims = request.headers.get("If-Modified-Since")
28
26
  etag_ok = etag and inm and etag in [t.strip() for t in inm.split(",")]
@@ -31,9 +31,7 @@ class CatchAllExceptionMiddleware:
31
31
 
32
32
  if response_started:
33
33
  try:
34
- await send(
35
- {"type": "http.response.body", "body": b"", "more_body": False}
36
- )
34
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
37
35
  except Exception:
38
36
  pass
39
37
  else:
@@ -54,6 +52,4 @@ class CatchAllExceptionMiddleware:
54
52
  "headers": [(b"content-type", PROBLEM_MT.encode("ascii"))],
55
53
  }
56
54
  )
57
- await send(
58
- {"type": "http.response.body", "body": body, "more_body": False}
59
- )
55
+ await send({"type": "http.response.body", "body": body, "more_body": False})
@@ -1,6 +1,3 @@
1
- from typing import Optional
2
-
3
-
4
1
  class FastApiException(Exception):
5
2
  """
6
3
  Application error that should be rendered as Problem Details.
@@ -9,7 +6,7 @@ class FastApiException(Exception):
9
6
  def __init__(
10
7
  self,
11
8
  title: str,
12
- detail: Optional[str] = None,
9
+ detail: str | None = None,
13
10
  status_code: int = 400,
14
11
  *,
15
12
  code: str | None = None,