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
svc_infra/db/sql/uniq.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Iterable, List, Optional, Tuple, Type
3
+ from collections.abc import Iterable
4
+ from typing import Any
4
5
 
5
6
  from sqlalchemy import Index, func
6
7
 
@@ -9,13 +10,13 @@ from svc_infra.db.utils import as_tuple as _as_tuple
9
10
 
10
11
 
11
12
  def make_unique_sql_indexes(
12
- model: Type[Any],
13
+ model: type[Any],
13
14
  *,
14
15
  unique_cs: Iterable[KeySpec] = (),
15
16
  unique_ci: Iterable[KeySpec] = (),
16
- tenant_field: Optional[str] = None,
17
+ tenant_field: str | None = None,
17
18
  name_prefix: str = "uq",
18
- ) -> List[Index]:
19
+ ) -> list[Index]:
19
20
  """Return SQLAlchemy Index objects that enforce uniqueness.
20
21
 
21
22
  - unique_cs: case-sensitive unique specs
@@ -26,12 +27,12 @@ def make_unique_sql_indexes(
26
27
 
27
28
  Declare right after your model class; Alembic or metadata.create_all will pick them up.
28
29
  """
29
- idxs: List[Index] = []
30
+ idxs: list[Index] = []
30
31
 
31
32
  def _col(name: str):
32
33
  return getattr(model, name)
33
34
 
34
- def _to_sa_cols(spec: Tuple[str, ...], *, ci: bool):
35
+ def _to_sa_cols(spec: tuple[str, ...], *, ci: bool):
35
36
  cols = []
36
37
  for cname in spec:
37
38
  c = _col(cname)
@@ -40,7 +41,7 @@ def make_unique_sql_indexes(
40
41
 
41
42
  tenant_col = _col(tenant_field) if tenant_field else None
42
43
 
43
- def _name(ci: bool, spec: Tuple[str, ...], null_bucket: Optional[str] = None):
44
+ def _name(ci: bool, spec: tuple[str, ...], null_bucket: str | None = None):
44
45
  parts = [name_prefix, model.__tablename__]
45
46
  if tenant_field:
46
47
  parts.append(tenant_field)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
3
+ from collections.abc import Callable, Iterable, Sequence
4
+ from typing import Any
4
5
 
5
6
  from fastapi import HTTPException
6
7
  from sqlalchemy import func
@@ -11,14 +12,14 @@ from svc_infra.db.sql.service_with_hooks import SqlServiceWithHooks
11
12
 
12
13
  from .uniq import _as_tuple
13
14
 
14
- ColumnSpec = Union[str, Sequence[str]]
15
+ ColumnSpec = str | Sequence[str]
15
16
 
16
17
 
17
- def _all_present(data: Dict[str, Any], fields: Sequence[str]) -> bool:
18
+ def _all_present(data: dict[str, Any], fields: Sequence[str]) -> bool:
18
19
  return all(f in data for f in fields)
19
20
 
20
21
 
21
- def _nice_label(fields: Sequence[str], data: Dict[str, Any]) -> str:
22
+ def _nice_label(fields: Sequence[str], data: dict[str, Any]) -> str:
22
23
  if len(fields) == 1:
23
24
  f = fields[0]
24
25
  return f"{f}={data.get(f)!r}"
@@ -30,10 +31,10 @@ def dedupe_sql_service(
30
31
  *,
31
32
  unique_cs: Iterable[ColumnSpec] = (),
32
33
  unique_ci: Iterable[ColumnSpec] = (),
33
- tenant_field: Optional[str] = None,
34
- messages: Optional[dict[Tuple[str, ...], str]] = None,
35
- pre_create: Optional[Callable[[dict], dict]] = None,
36
- pre_update: Optional[Callable[[dict], dict]] = None,
34
+ tenant_field: str | None = None,
35
+ messages: dict[tuple[str, ...], str] | None = None,
36
+ pre_create: Callable[[dict], dict] | None = None,
37
+ pre_update: Callable[[dict], dict] | None = None,
37
38
  ):
38
39
  """
39
40
  Build a Service subclass with uniqueness pre-checks:
@@ -46,9 +47,9 @@ def dedupe_sql_service(
46
47
  messages = messages or {}
47
48
 
48
49
  def _build_where(
49
- spec: Tuple[str, ...], data: Dict[str, Any], *, ci: bool, exclude_id: Any | None
50
+ spec: tuple[str, ...], data: dict[str, Any], *, ci: bool, exclude_id: Any | None
50
51
  ):
51
- clauses: List[Any] = []
52
+ clauses: list[Any] = []
52
53
  for col_name in spec:
53
54
  col = getattr(Model, col_name)
54
55
  val = data.get(col_name)
@@ -73,9 +74,7 @@ def dedupe_sql_service(
73
74
 
74
75
  return clauses
75
76
 
76
- async def _precheck(
77
- session, data: Dict[str, Any], *, exclude_id: Any | None
78
- ) -> None:
77
+ async def _precheck(session, data: dict[str, Any], *, exclude_id: Any | None) -> None:
79
78
  # Check CI specs first to catch the broadest conflicts, then CS.
80
79
  for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
81
80
  for spec in spec_list:
@@ -99,9 +98,7 @@ def dedupe_sql_service(
99
98
  return await self.repo.create(session, data)
100
99
  except IntegrityError as e:
101
100
  # Race fallback: let DB constraint be the last line of defense.
102
- raise HTTPException(
103
- status_code=409, detail="Record already exists."
104
- ) from e
101
+ raise HTTPException(status_code=409, detail="Record already exists.") from e
105
102
 
106
103
  async def update(self, session, id_value, data):
107
104
  data = await self.pre_update(data)
@@ -109,8 +106,6 @@ def dedupe_sql_service(
109
106
  try:
110
107
  return await self.repo.update(session, id_value, data)
111
108
  except IntegrityError as e:
112
- raise HTTPException(
113
- status_code=409, detail="Record already exists."
114
- ) from e
109
+ raise HTTPException(status_code=409, detail="Record already exists.") from e
115
110
 
116
111
  return _Svc(repo, pre_create=pre_create, pre_update=pre_update)
svc_infra/db/sql/utils.py CHANGED
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import os
5
+ from collections.abc import Sequence
5
6
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, Optional, Sequence, Set, Union, cast
7
+ from typing import TYPE_CHECKING, Any, cast
7
8
 
8
9
  from alembic.config import Config
9
10
  from dotenv import load_dotenv
@@ -34,7 +35,7 @@ except Exception: # pragma: no cover - optional env
34
35
 
35
36
  def prepare_process_env(
36
37
  project_root: Path | str,
37
- discover_packages: Optional[Sequence[str]] = None,
38
+ discover_packages: Sequence[str] | None = None,
38
39
  ) -> None:
39
40
  """
40
41
  Prepare process environment so Alembic can import the project cleanly.
@@ -60,7 +61,7 @@ def prepare_process_env(
60
61
  os.environ["ALEMBIC_DISCOVER_PACKAGES"] = ",".join(discover_packages)
61
62
 
62
63
 
63
- def _read_secret_from_file(path: str) -> Optional[str]:
64
+ def _read_secret_from_file(path: str) -> str | None:
64
65
  """Return file contents if path exists, else None."""
65
66
  try:
66
67
  p = Path(path)
@@ -71,7 +72,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
71
72
  return None
72
73
 
73
74
 
74
- def _compose_url_from_parts() -> Optional[str]:
75
+ def _compose_url_from_parts() -> str | None:
75
76
  """
76
77
  Compose a SQLAlchemy URL from component env vars.
77
78
  Supports private DNS hostnames and Unix sockets.
@@ -83,9 +84,7 @@ def _compose_url_from_parts() -> Optional[str]:
83
84
  DB_PARAMS (raw query string like 'sslmode=require&connect_timeout=5')
84
85
  """
85
86
  dialect = os.getenv("DB_DIALECT", "").strip() or "postgresql"
86
- driver = os.getenv(
87
- "DB_DRIVER", ""
88
- ).strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
87
+ driver = os.getenv("DB_DRIVER", "").strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
89
88
  host = os.getenv("DB_HOST", "").strip() or None
90
89
  port = os.getenv("DB_PORT", "").strip() or None
91
90
  db = os.getenv("DB_NAME", "").strip() or None
@@ -151,7 +150,7 @@ def get_database_url_from_env(
151
150
  required: bool = True,
152
151
  env_vars: Sequence[str] = DEFAULT_DB_ENV_VARS,
153
152
  normalize: bool = True,
154
- ) -> Optional[str]:
153
+ ) -> str | None:
155
154
  """
156
155
  Resolve the database connection string, with support for:
157
156
  - Primary env vars (in order): DEFAULT_DB_ENV_VARS
@@ -263,7 +262,7 @@ def is_async_url(url: URL | str) -> bool:
263
262
  return bool(ASYNC_DRIVER_HINT.search(dn))
264
263
 
265
264
 
266
- def with_database(url: URL | str, database: Optional[str]) -> URL:
265
+ def with_database(url: URL | str, database: str | None) -> URL:
267
266
  """Return a copy of URL with the database name replaced.
268
267
 
269
268
  Works for most dialects. For SQLite/DuckDB file URLs, `database` is the file path.
@@ -376,17 +375,11 @@ def _ensure_ssl_default(u: URL) -> URL:
376
375
  driver = (u.drivername or "").lower()
377
376
 
378
377
  # If any SSL hint already present, do nothing
379
- if any(
380
- k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")
381
- ):
378
+ if any(k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")):
382
379
  return u
383
380
 
384
381
  # Allow env override; support both common spellings
385
- mode_env = (
386
- os.getenv("DB_SSLMODE_DEFAULT")
387
- or os.getenv("PGSSLMODE")
388
- or os.getenv("PGSSL_MODE")
389
- )
382
+ mode_env = os.getenv("DB_SSLMODE_DEFAULT") or os.getenv("PGSSLMODE") or os.getenv("PGSSL_MODE")
390
383
  mode = (mode_env or "").strip()
391
384
 
392
385
  if "+asyncpg" in driver:
@@ -403,9 +396,7 @@ def _ensure_ssl_default_async(u: URL) -> URL:
403
396
  backend = (u.get_backend_name() or "").lower()
404
397
  if backend in ("postgresql", "postgres"):
405
398
  # asyncpg prefers 'ssl=true' via SQLAlchemy param; if already present, keep it
406
- if any(
407
- k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")
408
- ):
399
+ if any(k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")):
409
400
  return u
410
401
  return u.set(query={**u.query, "ssl": "true"})
411
402
  return u
@@ -420,9 +411,7 @@ def _certifi_ca() -> str | None:
420
411
  return None
421
412
 
422
413
 
423
- def build_engine(
424
- url: URL | str, echo: bool = False
425
- ) -> Union[SyncEngine, AsyncEngineType]:
414
+ def build_engine(url: URL | str, echo: bool = False) -> SyncEngine | AsyncEngineType:
426
415
  u = make_url(url) if isinstance(url, str) else url
427
416
 
428
417
  # Keep your existing PG helpers
@@ -445,9 +434,7 @@ def build_engine(
445
434
  # asyncpg doesn't accept sslmode or ssl=true in query params
446
435
  # Remove these and set ssl='require' in connect_args
447
436
  if "ssl" in u.query or "sslmode" in u.query:
448
- new_query = {
449
- k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")
450
- }
437
+ new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
451
438
  u = u.set(query=new_query)
452
439
  # Set ssl in connect_args - 'require' is safest for hosted databases
453
440
  connect_args["ssl"] = "require"
@@ -461,11 +448,7 @@ def build_engine(
461
448
  import ssl
462
449
 
463
450
  ca = _certifi_ca()
464
- ctx = (
465
- ssl.create_default_context(cafile=ca)
466
- if ca
467
- else ssl.create_default_context()
468
- )
451
+ ctx = ssl.create_default_context(cafile=ca) if ca else ssl.create_default_context()
469
452
  # if your host uses a public CA, verification works;
470
453
  # if not, you can relax verification (not recommended):
471
454
  # ctx.check_hostname = False
@@ -482,9 +465,7 @@ def build_engine(
482
465
  # ----------------- SYNC -----------------
483
466
  u = _coerce_sync_driver(u)
484
467
  if _create_engine is None:
485
- raise RuntimeError(
486
- "SQLAlchemy create_engine is not available in this environment."
487
- )
468
+ raise RuntimeError("SQLAlchemy create_engine is not available in this environment.")
488
469
 
489
470
  dn = (u.drivername or "").lower()
490
471
 
@@ -617,9 +598,7 @@ async def _mysql_create_database_async(url: URL) -> None:
617
598
  engine: AsyncEngineType = build_engine(base_url) # type: ignore[assignment]
618
599
  async with engine.begin() as conn:
619
600
  exists = await conn.scalar(
620
- text(
621
- "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
622
- ),
601
+ text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"),
623
602
  {"name": target_db},
624
603
  )
625
604
  if not exists:
@@ -636,9 +615,7 @@ def _mysql_create_database_sync(url: URL) -> None:
636
615
  engine: SyncEngine = build_engine(base_url) # type: ignore[assignment]
637
616
  with engine.begin() as conn:
638
617
  exists = conn.scalar(
639
- text(
640
- "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
641
- ),
618
+ text("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"),
642
619
  {"name": target_db},
643
620
  )
644
621
  if not exists:
@@ -865,7 +842,7 @@ def ensure_database_exists(url: URL | str) -> None:
865
842
  try:
866
843
  eng = build_engine(u)
867
844
  if is_async_url(u):
868
- async_eng = cast(AsyncEngineType, eng)
845
+ async_eng = cast("AsyncEngineType", eng)
869
846
 
870
847
  async def _ping_and_dispose():
871
848
  async with async_eng.begin() as conn:
@@ -874,7 +851,7 @@ def ensure_database_exists(url: URL | str) -> None:
874
851
 
875
852
  asyncio.run(_ping_and_dispose())
876
853
  else:
877
- sync_eng = cast(SyncEngine, eng)
854
+ sync_eng = cast("SyncEngine", eng)
878
855
  with sync_eng.begin() as conn:
879
856
  conn.execute(text("SELECT 1"))
880
857
  sync_eng.dispose()
@@ -894,7 +871,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
894
871
  return
895
872
  script_location = Path(script_location_str)
896
873
  versions_dir = script_location / "versions"
897
- local_ids: Set[str] = set()
874
+ local_ids: set[str] = set()
898
875
  if versions_dir.exists():
899
876
  for p in versions_dir.glob("*.py"):
900
877
  try:
@@ -912,7 +889,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
912
889
  if is_async_url(url_obj):
913
890
 
914
891
  async def _run() -> None:
915
- eng = cast(AsyncEngineType, build_engine(url_obj))
892
+ eng = cast("AsyncEngineType", build_engine(url_obj))
916
893
  try:
917
894
  async with eng.begin() as conn:
918
895
  # Do sync-y inspector / SQL via run_sync
@@ -927,9 +904,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
927
904
  )
928
905
  missing = any((ver not in local_ids) for (ver,) in rows)
929
906
  if missing:
930
- sync_conn.execute(
931
- text("DROP TABLE IF EXISTS alembic_version")
932
- )
907
+ sync_conn.execute(text("DROP TABLE IF EXISTS alembic_version"))
933
908
 
934
909
  await conn.run_sync(_check_and_maybe_drop)
935
910
  finally:
@@ -937,17 +912,13 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
937
912
 
938
913
  asyncio.run(_run())
939
914
  else:
940
- eng = cast(SyncEngine, build_engine(url_obj))
915
+ eng = cast("SyncEngine", build_engine(url_obj))
941
916
  try:
942
917
  with eng.begin() as c:
943
918
  insp = inspect(c)
944
919
  if not insp.has_table("alembic_version"):
945
920
  return
946
- rows = list(
947
- c.execute(
948
- text("SELECT version_num FROM alembic_version")
949
- ).fetchall()
950
- )
921
+ rows = list(c.execute(text("SELECT version_num FROM alembic_version")).fetchall())
951
922
  missing = any((ver not in local_ids) for (ver,) in rows)
952
923
  if missing:
953
924
  c.execute(text("DROP TABLE IF EXISTS alembic_version"))
svc_infra/db/utils.py CHANGED
@@ -1,10 +1,10 @@
1
+ from collections.abc import Sequence
1
2
  from pathlib import Path
2
- from typing import Sequence, Tuple, Union
3
3
 
4
- KeySpec = Union[str, Sequence[str]]
4
+ KeySpec = str | Sequence[str]
5
5
 
6
6
 
7
- def as_tuple(spec: KeySpec) -> Tuple[str, ...]:
7
+ def as_tuple(spec: KeySpec) -> tuple[str, ...]:
8
8
  return (spec,) if isinstance(spec, str) else tuple(spec)
9
9
 
10
10
 
@@ -33,7 +33,6 @@ from __future__ import annotations
33
33
  import os
34
34
  from enum import StrEnum
35
35
  from functools import cache
36
- from typing import Optional
37
36
 
38
37
 
39
38
  class Platform(StrEnum):
@@ -98,9 +97,7 @@ PLATFORM_SIGNATURES: dict[Platform, tuple[str, ...]] = {
98
97
  Platform.AZURE_APP_SERVICE: ("WEBSITE_SITE_NAME", "WEBSITE_INSTANCE_ID"),
99
98
  # Generic container/orchestration (check last)
100
99
  Platform.KUBERNETES: ("KUBERNETES_SERVICE_HOST", "KUBERNETES_PORT"),
101
- Platform.DOCKER: (
102
- "DOCKER_CONTAINER",
103
- ), # User must set this; no reliable auto-detect
100
+ Platform.DOCKER: ("DOCKER_CONTAINER",), # User must set this; no reliable auto-detect
104
101
  }
105
102
 
106
103
  # Container detection paths (Linux-specific)
@@ -126,7 +123,7 @@ def _is_in_container() -> bool:
126
123
 
127
124
  # Check cgroup (Linux)
128
125
  try:
129
- with open("/proc/1/cgroup", "r") as f:
126
+ with open("/proc/1/cgroup") as f:
130
127
  cgroup = f.read()
131
128
  if "docker" in cgroup or "kubepods" in cgroup or "containerd" in cgroup:
132
129
  return True
@@ -169,9 +166,7 @@ def get_platform() -> Platform:
169
166
 
170
167
 
171
168
  # Platform category groupings
172
- AWS_PLATFORMS = frozenset(
173
- {Platform.AWS_ECS, Platform.AWS_LAMBDA, Platform.AWS_BEANSTALK}
174
- )
169
+ AWS_PLATFORMS = frozenset({Platform.AWS_ECS, Platform.AWS_LAMBDA, Platform.AWS_BEANSTALK})
175
170
  GCP_PLATFORMS = frozenset({Platform.CLOUD_RUN, Platform.APP_ENGINE, Platform.GCE})
176
171
  AZURE_PLATFORMS = frozenset(
177
172
  {
@@ -180,9 +175,7 @@ AZURE_PLATFORMS = frozenset(
180
175
  Platform.AZURE_APP_SERVICE,
181
176
  }
182
177
  )
183
- PAAS_PLATFORMS = frozenset(
184
- {Platform.RAILWAY, Platform.RENDER, Platform.FLY, Platform.HEROKU}
185
- )
178
+ PAAS_PLATFORMS = frozenset({Platform.RAILWAY, Platform.RENDER, Platform.FLY, Platform.HEROKU})
186
179
 
187
180
 
188
181
  def is_aws() -> bool:
@@ -292,7 +285,7 @@ def get_database_url(
292
285
  *,
293
286
  prefer_private: bool = True,
294
287
  normalize: bool = True,
295
- ) -> Optional[str]:
288
+ ) -> str | None:
296
289
  """
297
290
  Get database URL with platform-aware resolution.
298
291
 
@@ -336,7 +329,7 @@ def get_database_url(
336
329
  return None
337
330
 
338
331
 
339
- def get_redis_url(*, prefer_private: bool = True) -> Optional[str]:
332
+ def get_redis_url(*, prefer_private: bool = True) -> str | None:
340
333
  """
341
334
  Get Redis URL with platform-aware resolution.
342
335
 
@@ -388,7 +381,7 @@ def get_service_url(
388
381
  *,
389
382
  default_port: int = 8000,
390
383
  scheme: str = "http",
391
- ) -> Optional[str]:
384
+ ) -> str | None:
392
385
  """
393
386
  Get URL for an internal service by name.
394
387
 
@@ -426,7 +419,7 @@ def get_service_url(
426
419
  return None
427
420
 
428
421
 
429
- def get_public_url() -> Optional[str]:
422
+ def get_public_url() -> str | None:
430
423
  """
431
424
  Get the public URL of this service.
432
425
 
@@ -20,7 +20,7 @@ Quick Start:
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
- from typing import TYPE_CHECKING, Optional, cast
23
+ from typing import TYPE_CHECKING, cast
24
24
 
25
25
  from fastapi import HTTPException, Request, Response
26
26
 
@@ -34,7 +34,7 @@ if TYPE_CHECKING:
34
34
  from .ease import DocumentManager
35
35
 
36
36
 
37
- def get_documents_manager(app: "FastAPI") -> "DocumentManager":
37
+ def get_documents_manager(app: FastAPI) -> DocumentManager:
38
38
  """
39
39
  Dependency to get document manager from app state.
40
40
 
@@ -49,17 +49,16 @@ def get_documents_manager(app: "FastAPI") -> "DocumentManager":
49
49
  """
50
50
  if not hasattr(app.state, "documents"):
51
51
  raise RuntimeError("Documents not configured. Call add_documents(app) first.")
52
- from .ease import DocumentManager
53
52
 
54
- return cast(DocumentManager, app.state.documents)
53
+ return cast("DocumentManager", app.state.documents)
55
54
 
56
55
 
57
56
  def add_documents(
58
- app: "FastAPI",
59
- storage_backend: Optional["StorageBackend"] = None,
57
+ app: FastAPI,
58
+ storage_backend: StorageBackend | None = None,
60
59
  prefix: str = "/documents",
61
- tags: Optional[list[str]] = None,
62
- ) -> "DocumentManager":
60
+ tags: list[str] | None = None,
61
+ ) -> DocumentManager:
63
62
  """
64
63
  Add document management endpoints to FastAPI app.
65
64
 
@@ -27,7 +27,7 @@ Quick Start:
27
27
 
28
28
  from __future__ import annotations
29
29
 
30
- from typing import TYPE_CHECKING, Dict, List, Optional
30
+ from typing import TYPE_CHECKING
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from svc_infra.storage.base import StorageBackend
@@ -65,7 +65,7 @@ class DocumentManager:
65
65
  >>> manager.delete(doc.id)
66
66
  """
67
67
 
68
- def __init__(self, storage: "StorageBackend"):
68
+ def __init__(self, storage: StorageBackend):
69
69
  """
70
70
  Initialize document manager.
71
71
 
@@ -79,9 +79,9 @@ class DocumentManager:
79
79
  user_id: str,
80
80
  file: bytes,
81
81
  filename: str,
82
- metadata: Optional[Dict] = None,
83
- content_type: Optional[str] = None,
84
- ) -> "Document":
82
+ metadata: dict | None = None,
83
+ content_type: str | None = None,
84
+ ) -> Document:
85
85
  """
86
86
  Upload a document.
87
87
 
@@ -133,7 +133,7 @@ class DocumentManager:
133
133
 
134
134
  return await download_document(self.storage, document_id)
135
135
 
136
- def get(self, document_id: str) -> Optional["Document"]:
136
+ def get(self, document_id: str) -> Document | None:
137
137
  """
138
138
  Get document metadata by ID.
139
139
 
@@ -174,7 +174,7 @@ class DocumentManager:
174
174
  user_id: str,
175
175
  limit: int = 100,
176
176
  offset: int = 0,
177
- ) -> List["Document"]:
177
+ ) -> list[Document]:
178
178
  """
179
179
  List user's documents.
180
180
 
@@ -198,7 +198,7 @@ class DocumentManager:
198
198
  return list_documents(user_id, limit, offset)
199
199
 
200
200
 
201
- def easy_documents(storage: Optional["StorageBackend"] = None) -> DocumentManager:
201
+ def easy_documents(storage: StorageBackend | None = None) -> DocumentManager:
202
202
  """
203
203
  Create a document manager with auto-configured storage.
204
204
 
@@ -22,7 +22,7 @@ Quick Start:
22
22
  from __future__ import annotations
23
23
 
24
24
  from datetime import datetime
25
- from typing import Any, Dict, Optional
25
+ from typing import Any
26
26
 
27
27
  from pydantic import BaseModel, ConfigDict, Field
28
28
 
@@ -105,10 +105,10 @@ class Document(BaseModel):
105
105
  )
106
106
  storage_path: str = Field(..., description="Storage backend path/key")
107
107
  content_type: str = Field(..., description="MIME type (e.g., application/pdf)")
108
- checksum: Optional[str] = Field(
108
+ checksum: str | None = Field(
109
109
  None, description="File checksum for integrity validation (e.g., sha256:...)"
110
110
  )
111
- metadata: Dict[str, Any] = Field(
111
+ metadata: dict[str, Any] = Field(
112
112
  default_factory=dict,
113
113
  description="Flexible metadata for custom fields (category, tags, dates, etc.)",
114
114
  )
@@ -26,7 +26,7 @@ import hashlib
26
26
  import mimetypes
27
27
  import uuid
28
28
  from datetime import datetime
29
- from typing import TYPE_CHECKING, Dict, List, Optional
29
+ from typing import TYPE_CHECKING
30
30
 
31
31
  if TYPE_CHECKING:
32
32
  from svc_infra.storage.base import StorageBackend
@@ -35,17 +35,17 @@ if TYPE_CHECKING:
35
35
 
36
36
  # In-memory metadata storage (production: use SQL database)
37
37
  # This is a temporary solution until SQL integration is complete
38
- _documents_metadata: Dict[str, "Document"] = {}
38
+ _documents_metadata: dict[str, Document] = {}
39
39
 
40
40
 
41
41
  async def upload_document(
42
- storage: "StorageBackend",
42
+ storage: StorageBackend,
43
43
  user_id: str,
44
44
  file: bytes,
45
45
  filename: str,
46
- metadata: Optional[Dict] = None,
47
- content_type: Optional[str] = None,
48
- ) -> "Document":
46
+ metadata: dict | None = None,
47
+ content_type: str | None = None,
48
+ ) -> Document:
49
49
  """
50
50
  Upload a document with file content to storage backend.
51
51
 
@@ -105,9 +105,7 @@ async def upload_document(
105
105
  checksum = f"sha256:{hashlib.sha256(file).hexdigest()}"
106
106
 
107
107
  # Upload file to storage backend
108
- await storage.put(
109
- storage_path, file, content_type=content_type, metadata=metadata or {}
110
- )
108
+ await storage.put(storage_path, file, content_type=content_type, metadata=metadata or {})
111
109
 
112
110
  # Create document metadata
113
111
  doc = Document(
@@ -128,7 +126,7 @@ async def upload_document(
128
126
  return doc
129
127
 
130
128
 
131
- def get_document(document_id: str) -> Optional["Document"]:
129
+ def get_document(document_id: str) -> Document | None:
132
130
  """
133
131
  Get document metadata by ID.
134
132
 
@@ -146,7 +144,7 @@ def get_document(document_id: str) -> Optional["Document"]:
146
144
  return _documents_metadata.get(document_id)
147
145
 
148
146
 
149
- async def download_document(storage: "StorageBackend", document_id: str) -> bytes:
147
+ async def download_document(storage: StorageBackend, document_id: str) -> bytes:
150
148
  """
151
149
  Download document file content from storage.
152
150
 
@@ -176,7 +174,7 @@ async def download_document(storage: "StorageBackend", document_id: str) -> byte
176
174
  return await storage.get(doc.storage_path)
177
175
 
178
176
 
179
- async def delete_document(storage: "StorageBackend", document_id: str) -> bool:
177
+ async def delete_document(storage: StorageBackend, document_id: str) -> bool:
180
178
  """
181
179
  Delete document and its file content.
182
180
 
@@ -212,7 +210,7 @@ def list_documents(
212
210
  user_id: str,
213
211
  limit: int = 100,
214
212
  offset: int = 0,
215
- ) -> List["Document"]:
213
+ ) -> list[Document]:
216
214
  """
217
215
  List user's documents with pagination.
218
216