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
@@ -8,8 +8,8 @@ Import this module only when enable_oauth=True is passed to add_auth_users.
8
8
  from __future__ import annotations
9
9
 
10
10
  import uuid
11
- from datetime import datetime, timezone
12
- from typing import TYPE_CHECKING, Optional
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
13
13
 
14
14
  from sqlalchemy import (
15
15
  JSON,
@@ -44,13 +44,13 @@ class ProviderAccount(ModelBase):
44
44
  )
45
45
  provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
46
46
  provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
47
- access_token: Mapped[Optional[str]] = mapped_column(Text)
48
- refresh_token: Mapped[Optional[str]] = mapped_column(Text)
49
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
50
- raw_claims: Mapped[Optional[dict]] = mapped_column(JSON)
47
+ access_token: Mapped[str | None] = mapped_column(Text)
48
+ refresh_token: Mapped[str | None] = mapped_column(Text)
49
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
50
+ raw_claims: Mapped[dict | None] = mapped_column(JSON)
51
51
 
52
52
  # Bidirectional relationship to User model
53
- user: Mapped["User"] = relationship(back_populates="provider_accounts")
53
+ user: Mapped[User] = relationship(back_populates="provider_accounts")
54
54
 
55
55
  created_at = mapped_column(
56
56
  DateTime(timezone=True),
@@ -60,7 +60,7 @@ class ProviderAccount(ModelBase):
60
60
  updated_at = mapped_column(
61
61
  DateTime(timezone=True),
62
62
  server_default=text("CURRENT_TIMESTAMP"),
63
- onupdate=lambda: datetime.now(timezone.utc),
63
+ onupdate=lambda: datetime.now(UTC),
64
64
  nullable=False,
65
65
  )
66
66
 
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import uuid
5
- from datetime import datetime, timedelta, timezone
6
- from typing import Any, Optional
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Any
7
7
 
8
8
  try:
9
9
  from sqlalchemy import select
@@ -29,7 +29,7 @@ async def issue_invitation(
29
29
  org_id: uuid.UUID,
30
30
  email: str,
31
31
  role: str,
32
- created_by: Optional[uuid.UUID] = None,
32
+ created_by: uuid.UUID | None = None,
33
33
  ttl_hours: int = 72,
34
34
  ) -> tuple[str, OrganizationInvitation]:
35
35
  """Create a new invitation; revoke any existing active invites for the same email+org."""
@@ -50,7 +50,7 @@ async def issue_invitation(
50
50
  .scalars()
51
51
  .all()
52
52
  )
53
- now = datetime.now(timezone.utc)
53
+ now = datetime.now(UTC)
54
54
  for r in rows:
55
55
  r.revoked_at = now
56
56
  except Exception: # pragma: no cover
@@ -58,8 +58,8 @@ async def issue_invitation(
58
58
  else:
59
59
  # FakeDB path: revoke in-memory invites
60
60
  if hasattr(db, "added"):
61
- now = datetime.now(timezone.utc)
62
- for r in list(getattr(db, "added")):
61
+ now = datetime.now(UTC)
62
+ for r in list(db.added):
63
63
  if (
64
64
  isinstance(r, OrganizationInvitation)
65
65
  and r.org_id == org_id
@@ -75,9 +75,9 @@ async def issue_invitation(
75
75
  email=email.lower().strip(),
76
76
  role=role,
77
77
  token_hash=_hash_token(raw),
78
- expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
78
+ expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
79
79
  created_by=created_by,
80
- last_sent_at=datetime.now(timezone.utc),
80
+ last_sent_at=datetime.now(UTC),
81
81
  resend_count=0,
82
82
  )
83
83
  if hasattr(db, "add"):
@@ -90,7 +90,7 @@ async def issue_invitation(
90
90
  async def resend_invitation(db: Any, *, invitation: OrganizationInvitation) -> str:
91
91
  raw = _new_token()
92
92
  invitation.token_hash = _hash_token(raw)
93
- invitation.last_sent_at = datetime.now(timezone.utc)
93
+ invitation.last_sent_at = datetime.now(UTC)
94
94
  invitation.resend_count = (invitation.resend_count or 0) + 1
95
95
  if hasattr(db, "flush"):
96
96
  await db.flush()
@@ -103,7 +103,7 @@ async def accept_invitation(
103
103
  invitation: OrganizationInvitation,
104
104
  user_id: uuid.UUID,
105
105
  ) -> OrganizationMembership:
106
- now = datetime.now(timezone.utc)
106
+ now = datetime.now(UTC)
107
107
  if invitation.revoked_at or invitation.used_at:
108
108
  raise ValueError("invitation_unusable")
109
109
  if invitation.expires_at and invitation.expires_at < now:
@@ -113,9 +113,7 @@ async def accept_invitation(
113
113
  invitation.used_at = now
114
114
 
115
115
  # create membership (upsert-like enforced by DB unique constraint)
116
- mem = OrganizationMembership(
117
- org_id=invitation.org_id, user_id=user_id, role=invitation.role
118
- )
116
+ mem = OrganizationMembership(org_id=invitation.org_id, user_id=user_id, role=invitation.role)
119
117
  if hasattr(db, "add"):
120
118
  db.add(mem)
121
119
  if hasattr(db, "flush"):
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ from collections.abc import Callable, Iterable
4
5
  from dataclasses import dataclass
5
- from typing import Callable, Iterable, Optional
6
6
 
7
7
  COMMON_PASSWORDS = {"password", "123456", "qwerty", "letmein", "admin"}
8
8
 
@@ -36,10 +36,10 @@ SYMBOL = re.compile(r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]")
36
36
  BreachedChecker = Callable[[str], bool]
37
37
 
38
38
 
39
- _breached_checker: Optional[BreachedChecker] = None
39
+ _breached_checker: BreachedChecker | None = None
40
40
 
41
41
 
42
- def configure_breached_checker(checker: Optional[BreachedChecker]) -> None:
42
+ def configure_breached_checker(checker: BreachedChecker | None) -> None:
43
43
  global _breached_checker
44
44
  _breached_checker = checker
45
45
 
@@ -60,9 +60,7 @@ def validate_password(pw: str, policy: PasswordPolicy | None = None) -> None:
60
60
  if policy.forbid_common:
61
61
  lowered = pw.lower()
62
62
  # Reject if whole password matches a common one or contains it as a substring
63
- if lowered in COMMON_PASSWORDS or any(
64
- term in lowered for term in COMMON_PASSWORDS
65
- ):
63
+ if lowered in COMMON_PASSWORDS or any(term in lowered for term in COMMON_PASSWORDS):
66
64
  reasons.append("common_password")
67
65
  if policy.forbid_breached and not HIBP_DISABLED:
68
66
  if _breached_checker and _breached_checker(pw):
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import inspect
4
4
  import threading
5
- from typing import Any, Awaitable, Callable, Dict, Iterable, Set
5
+ from collections.abc import Awaitable, Callable, Iterable
6
+ from typing import Any
6
7
 
7
8
  from fastapi import Depends, HTTPException
8
9
 
@@ -12,7 +13,7 @@ from svc_infra.api.fastapi.auth.security import Identity
12
13
  _PERMISSION_LOCK = threading.Lock()
13
14
 
14
15
  # Central role -> permissions mapping. Projects can extend at startup.
15
- PERMISSION_REGISTRY: Dict[str, Set[str]] = {
16
+ PERMISSION_REGISTRY: dict[str, set[str]] = {
16
17
  "admin": {
17
18
  "user.read",
18
19
  "user.write",
@@ -27,13 +28,13 @@ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
27
28
  }
28
29
 
29
30
 
30
- def register_role(role: str, permissions: Set[str]) -> None:
31
+ def register_role(role: str, permissions: set[str]) -> None:
31
32
  """Thread-safe registration of a role and its permissions."""
32
33
  with _PERMISSION_LOCK:
33
34
  PERMISSION_REGISTRY[role] = permissions
34
35
 
35
36
 
36
- def extend_role(role: str, permissions: Set[str]) -> None:
37
+ def extend_role(role: str, permissions: set[str]) -> None:
37
38
  """Thread-safe extension of an existing role's permissions."""
38
39
  with _PERMISSION_LOCK:
39
40
  if role in PERMISSION_REGISTRY:
@@ -42,15 +43,15 @@ def extend_role(role: str, permissions: Set[str]) -> None:
42
43
  PERMISSION_REGISTRY[role] = permissions
43
44
 
44
45
 
45
- def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
46
- perms: Set[str] = set()
46
+ def get_permissions_for_roles(roles: Iterable[str]) -> set[str]:
47
+ perms: set[str] = set()
47
48
  with _PERMISSION_LOCK:
48
49
  for r in roles:
49
50
  perms |= PERMISSION_REGISTRY.get(r, set())
50
51
  return perms
51
52
 
52
53
 
53
- def principal_permissions(principal: Identity) -> Set[str]:
54
+ def principal_permissions(principal: Identity) -> set[str]:
54
55
  roles = getattr(principal.user, "roles", []) or []
55
56
  return get_permissions_for_roles(roles)
56
57
 
@@ -1,8 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import uuid
4
- from datetime import datetime, timedelta, timezone
5
- from typing import Optional
4
+ from datetime import UTC, datetime, timedelta
6
5
 
7
6
  try:
8
7
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,9 +24,9 @@ async def issue_session_and_refresh(
25
24
  db: AsyncSession,
26
25
  *,
27
26
  user_id: uuid.UUID,
28
- tenant_id: Optional[str] = None,
29
- user_agent: Optional[str] = None,
30
- ip_hash: Optional[str] = None,
27
+ tenant_id: str | None = None,
28
+ user_agent: str | None = None,
29
+ ip_hash: str | None = None,
31
30
  ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
32
31
  ) -> tuple[str, RefreshToken]:
33
32
  """Persist a new AuthSession + initial RefreshToken and return raw refresh token.
@@ -43,7 +42,7 @@ async def issue_session_and_refresh(
43
42
  db.add(session_row)
44
43
  raw = generate_refresh_token()
45
44
  token_hash = hash_refresh_token(raw)
46
- expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
45
+ expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
47
46
  rt = RefreshToken(
48
47
  session=session_row,
49
48
  token_hash=token_hash,
@@ -64,7 +63,7 @@ async def rotate_session_refresh(
64
63
 
65
64
  Returns: (new_raw_refresh_token, new_refresh_token_model)
66
65
  """
67
- rotation_ts = datetime.now(timezone.utc)
66
+ rotation_ts = datetime.now(UTC)
68
67
  if current.revoked_at:
69
68
  raise ValueError("refresh token already revoked")
70
69
  if current.expires_at and current.expires_at <= rotation_ts:
@@ -5,7 +5,7 @@ import hmac
5
5
  import json
6
6
  import time
7
7
  from hashlib import sha256
8
- from typing import Any, Dict, List, Optional, Tuple
8
+ from typing import Any
9
9
 
10
10
 
11
11
  def _b64e(b: bytes) -> str:
@@ -26,12 +26,12 @@ def _now() -> int:
26
26
 
27
27
 
28
28
  def sign_cookie(
29
- payload: Dict[str, Any],
29
+ payload: dict[str, Any],
30
30
  *,
31
31
  key: str,
32
- expires_in: Optional[int] = None,
33
- path: Optional[str] = None,
34
- domain: Optional[str] = None,
32
+ expires_in: int | None = None,
33
+ path: str | None = None,
34
+ domain: str | None = None,
35
35
  ) -> str:
36
36
  """Produce a compact signed cookie value with optional expiry and scope binding.
37
37
 
@@ -57,10 +57,10 @@ def verify_cookie(
57
57
  value: str,
58
58
  *,
59
59
  key: str,
60
- old_keys: Optional[List[str]] = None,
61
- expected_path: Optional[str] = None,
62
- expected_domain: Optional[str] = None,
63
- ) -> Tuple[bool, Optional[Dict[str, Any]]]:
60
+ old_keys: list[str] | None = None,
61
+ expected_path: str | None = None,
62
+ expected_domain: str | None = None,
63
+ ) -> tuple[bool, dict[str, Any] | None]:
64
64
  """Verify a signed cookie against the primary key or any old key.
65
65
 
66
66
  Returns (ok, payload). If ok is False, payload will be None.
svc_infra/storage/add.py CHANGED
@@ -6,7 +6,7 @@ Provides helpers to integrate storage backends with FastAPI applications.
6
6
 
7
7
  import logging
8
8
  from contextlib import asynccontextmanager
9
- from typing import Optional, cast
9
+ from typing import cast
10
10
 
11
11
  from fastapi import FastAPI, HTTPException, Query, Request
12
12
  from fastapi.responses import StreamingResponse
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
19
19
 
20
20
  def add_storage(
21
21
  app: FastAPI,
22
- backend: Optional[StorageBackend] = None,
22
+ backend: StorageBackend | None = None,
23
23
  serve_files: bool = False,
24
24
  file_route_prefix: str = "/files",
25
25
  ) -> StorageBackend:
@@ -145,9 +145,7 @@ def add_storage(
145
145
  # Return file
146
146
  return StreamingResponse(
147
147
  iter([data]),
148
- media_type=metadata.get(
149
- "content_type", "application/octet-stream"
150
- ),
148
+ media_type=metadata.get("content_type", "application/octet-stream"),
151
149
  headers={
152
150
  "Content-Disposition": content_disposition,
153
151
  "Content-Length": str(len(data)),
@@ -197,11 +195,10 @@ def get_storage(request: Request) -> StorageBackend:
197
195
  """
198
196
  if not hasattr(request.app.state, "storage"):
199
197
  raise RuntimeError(
200
- "Storage not initialized. "
201
- "Call add_storage(app) during application setup."
198
+ "Storage not initialized. Call add_storage(app) during application setup."
202
199
  )
203
200
 
204
- return cast(StorageBackend, request.app.state.storage)
201
+ return cast("StorageBackend", request.app.state.storage)
205
202
 
206
203
 
207
204
  async def health_check_storage(request: Request) -> dict:
@@ -8,9 +8,9 @@ import hashlib
8
8
  import hmac
9
9
  import json
10
10
  import secrets
11
- from datetime import datetime, timezone
11
+ from datetime import UTC, datetime
12
12
  from pathlib import Path
13
- from typing import Any, Optional, cast
13
+ from typing import Any, cast
14
14
  from urllib.parse import urlencode
15
15
 
16
16
  import aiofiles
@@ -50,7 +50,7 @@ class LocalBackend:
50
50
  self,
51
51
  base_path: str = "/data/uploads",
52
52
  base_url: str = "http://localhost:8000/files",
53
- signing_secret: Optional[str] = None,
53
+ signing_secret: str | None = None,
54
54
  ):
55
55
  self.base_path = Path(base_path)
56
56
  self.base_url = base_url.rstrip("/")
@@ -71,9 +71,7 @@ class LocalBackend:
71
71
  raise InvalidKeyError("Key cannot exceed 1024 characters")
72
72
 
73
73
  # Check for safe characters
74
- safe_chars = set(
75
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/"
76
- )
74
+ safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
77
75
  if not all(c in safe_chars for c in key):
78
76
  raise InvalidKeyError(
79
77
  "Key can only contain alphanumeric, dot, dash, underscore, and slash"
@@ -97,9 +95,7 @@ class LocalBackend:
97
95
  ).hexdigest()
98
96
  return signature
99
97
 
100
- def _verify_signature(
101
- self, key: str, expires_at: int, download: bool, signature: str
102
- ) -> bool:
98
+ def _verify_signature(self, key: str, expires_at: int, download: bool, signature: str) -> bool:
103
99
  """Verify HMAC signature."""
104
100
  expected = self._sign_url(key, expires_at, download)
105
101
  return hmac.compare_digest(expected, signature)
@@ -109,7 +105,7 @@ class LocalBackend:
109
105
  key: str,
110
106
  data: bytes,
111
107
  content_type: str,
112
- metadata: Optional[dict] = None,
108
+ metadata: dict | None = None,
113
109
  ) -> str:
114
110
  """Store file on local filesystem."""
115
111
  self._validate_key(key)
@@ -133,7 +129,7 @@ class LocalBackend:
133
129
  meta_data = {
134
130
  "size": len(data),
135
131
  "content_type": content_type,
136
- "created_at": datetime.now(timezone.utc).isoformat(),
132
+ "created_at": datetime.now(UTC).isoformat(),
137
133
  **(metadata or {}),
138
134
  }
139
135
 
@@ -224,7 +220,7 @@ class LocalBackend:
224
220
  raise StorageFileNotFoundError(f"File not found: {key}")
225
221
 
226
222
  # Calculate expiration timestamp
227
- expires_at = int(datetime.now(timezone.utc).timestamp()) + expires_in
223
+ expires_at = int(datetime.now(UTC).timestamp()) + expires_in
228
224
 
229
225
  # Generate signature
230
226
  signature = self._sign_url(key, expires_at, download)
@@ -240,9 +236,7 @@ class LocalBackend:
240
236
  url = f"{self.base_url}/{key}?{urlencode(params)}"
241
237
  return url
242
238
 
243
- def verify_url(
244
- self, key: str, expires: str, signature: str, download: bool = False
245
- ) -> bool:
239
+ def verify_url(self, key: str, expires: str, signature: str, download: bool = False) -> bool:
246
240
  """
247
241
  Verify a signed URL (for use in file serving endpoint).
248
242
 
@@ -266,7 +260,7 @@ class LocalBackend:
266
260
  return False
267
261
 
268
262
  # Check expiration
269
- now = int(datetime.now(timezone.utc).timestamp())
263
+ now = int(datetime.now(UTC).timestamp())
270
264
  if now > expires_at:
271
265
  return False
272
266
 
@@ -323,15 +317,13 @@ class LocalBackend:
323
317
  return {
324
318
  "size": stat.st_size,
325
319
  "content_type": "application/octet-stream",
326
- "created_at": datetime.fromtimestamp(
327
- stat.st_ctime, tz=timezone.utc
328
- ).isoformat(),
320
+ "created_at": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
329
321
  }
330
322
 
331
323
  try:
332
- async with aiofiles.open(meta_path, "r") as f:
324
+ async with aiofiles.open(meta_path) as f:
333
325
  content = await f.read()
334
- return cast(dict[Any, Any], json.loads(content))
326
+ return cast("dict[Any, Any]", json.loads(content))
335
327
  except (OSError, json.JSONDecodeError) as e:
336
328
  raise StorageError(f"Failed to read metadata for {key}: {e}")
337
329
 
@@ -5,8 +5,7 @@ WARNING: Data is not persisted across restarts. Use only for testing or developm
5
5
  """
6
6
 
7
7
  import asyncio
8
- from datetime import datetime, timezone
9
- from typing import Optional
8
+ from datetime import UTC, datetime
10
9
 
11
10
  from ..base import FileNotFoundError, InvalidKeyError, QuotaExceededError
12
11
 
@@ -57,9 +56,7 @@ class MemoryBackend:
57
56
  raise InvalidKeyError("Key cannot exceed 1024 characters")
58
57
 
59
58
  # Check for safe characters
60
- safe_chars = set(
61
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/"
62
- )
59
+ safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
63
60
  if not all(c in safe_chars for c in key):
64
61
  raise InvalidKeyError(
65
62
  "Key can only contain alphanumeric, dot, dash, underscore, and slash"
@@ -74,7 +71,7 @@ class MemoryBackend:
74
71
  key: str,
75
72
  data: bytes,
76
73
  content_type: str,
77
- metadata: Optional[dict] = None,
74
+ metadata: dict | None = None,
78
75
  ) -> str:
79
76
  """Store file in memory."""
80
77
  self._validate_key(key)
@@ -101,7 +98,7 @@ class MemoryBackend:
101
98
  self._metadata[key] = {
102
99
  "size": len(data),
103
100
  "content_type": content_type,
104
- "created_at": datetime.now(timezone.utc).isoformat(),
101
+ "created_at": datetime.now(UTC).isoformat(),
105
102
  **(metadata or {}),
106
103
  }
107
104
 
@@ -5,7 +5,7 @@ Works with AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio, and
5
5
  any S3-compatible object storage service.
6
6
  """
7
7
 
8
- from typing import Optional, cast
8
+ from typing import cast
9
9
 
10
10
  try:
11
11
  import aioboto3
@@ -68,14 +68,13 @@ class S3Backend:
68
68
  self,
69
69
  bucket: str,
70
70
  region: str = "us-east-1",
71
- endpoint: Optional[str] = None,
72
- access_key: Optional[str] = None,
73
- secret_key: Optional[str] = None,
71
+ endpoint: str | None = None,
72
+ access_key: str | None = None,
73
+ secret_key: str | None = None,
74
74
  ):
75
75
  if aioboto3 is None:
76
76
  raise ImportError(
77
- "aioboto3 is required for S3Backend. "
78
- "Install it with: pip install aioboto3"
77
+ "aioboto3 is required for S3Backend. Install it with: pip install aioboto3"
79
78
  )
80
79
 
81
80
  self.bucket = bucket
@@ -118,7 +117,7 @@ class S3Backend:
118
117
  key: str,
119
118
  data: bytes,
120
119
  content_type: str,
121
- metadata: Optional[dict] = None,
120
+ metadata: dict | None = None,
122
121
  ) -> str:
123
122
  """Store file in S3."""
124
123
  self._validate_key(key)
@@ -131,9 +130,7 @@ class S3Backend:
131
130
 
132
131
  try:
133
132
  session = aioboto3.Session()
134
- async with session.client(
135
- "s3", **self._session_config, **self._client_config
136
- ) as s3:
133
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
137
134
  # Upload file
138
135
  await s3.put_object(
139
136
  Bucket=self.bucket,
@@ -165,12 +162,10 @@ class S3Backend:
165
162
 
166
163
  try:
167
164
  session = aioboto3.Session()
168
- async with session.client(
169
- "s3", **self._session_config, **self._client_config
170
- ) as s3:
165
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
171
166
  response = await s3.get_object(Bucket=self.bucket, Key=key)
172
167
  async with response["Body"] as stream:
173
- return cast(bytes, await stream.read())
168
+ return cast("bytes", await stream.read())
174
169
 
175
170
  except ClientError as e:
176
171
  error_code = e.response.get("Error", {}).get("Code", "Unknown")
@@ -193,9 +188,7 @@ class S3Backend:
193
188
 
194
189
  try:
195
190
  session = aioboto3.Session()
196
- async with session.client(
197
- "s3", **self._session_config, **self._client_config
198
- ) as s3:
191
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
199
192
  await s3.delete_object(Bucket=self.bucket, Key=key)
200
193
  return True
201
194
 
@@ -214,9 +207,7 @@ class S3Backend:
214
207
 
215
208
  try:
216
209
  session = aioboto3.Session()
217
- async with session.client(
218
- "s3", **self._session_config, **self._client_config
219
- ) as s3:
210
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
220
211
  await s3.head_object(Bucket=self.bucket, Key=key)
221
212
  return True
222
213
 
@@ -257,9 +248,7 @@ class S3Backend:
257
248
 
258
249
  try:
259
250
  session = aioboto3.Session()
260
- async with session.client(
261
- "s3", **self._session_config, **self._client_config
262
- ) as s3:
251
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
263
252
  # Prepare parameters
264
253
  params = {"Bucket": self.bucket, "Key": key}
265
254
 
@@ -267,9 +256,7 @@ class S3Backend:
267
256
  if download:
268
257
  # Extract filename from key
269
258
  filename = key.split("/")[-1]
270
- params["ResponseContentDisposition"] = (
271
- f'attachment; filename="{filename}"'
272
- )
259
+ params["ResponseContentDisposition"] = f'attachment; filename="{filename}"'
273
260
 
274
261
  # Generate presigned URL
275
262
  url = await s3.generate_presigned_url(
@@ -277,7 +264,7 @@ class S3Backend:
277
264
  Params=params,
278
265
  ExpiresIn=expires_in,
279
266
  )
280
- return cast(str, url)
267
+ return cast("str", url)
281
268
 
282
269
  except ClientError as e:
283
270
  raise StorageError(f"Failed to generate presigned URL: {e}")
@@ -292,9 +279,7 @@ class S3Backend:
292
279
  """List stored keys with optional prefix filter."""
293
280
  try:
294
281
  session = aioboto3.Session()
295
- async with session.client(
296
- "s3", **self._session_config, **self._client_config
297
- ) as s3:
282
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
298
283
  params = {
299
284
  "Bucket": self.bucket,
300
285
  "MaxKeys": limit,
@@ -320,17 +305,13 @@ class S3Backend:
320
305
 
321
306
  try:
322
307
  session = aioboto3.Session()
323
- async with session.client(
324
- "s3", **self._session_config, **self._client_config
325
- ) as s3:
308
+ async with session.client("s3", **self._session_config, **self._client_config) as s3:
326
309
  response = await s3.head_object(Bucket=self.bucket, Key=key)
327
310
 
328
311
  # Extract metadata
329
312
  metadata = {
330
313
  "size": response["ContentLength"],
331
- "content_type": response.get(
332
- "ContentType", "application/octet-stream"
333
- ),
314
+ "content_type": response.get("ContentType", "application/octet-stream"),
334
315
  "created_at": response["LastModified"].isoformat(),
335
316
  }
336
317
 
svc_infra/storage/base.py CHANGED
@@ -4,7 +4,7 @@ Base storage abstractions and exceptions.
4
4
  Defines the StorageBackend protocol that all storage implementations must follow.
5
5
  """
6
6
 
7
- from typing import Optional, Protocol
7
+ from typing import Protocol
8
8
 
9
9
 
10
10
  class StorageError(Exception):
@@ -60,7 +60,7 @@ class StorageBackend(Protocol):
60
60
  key: str,
61
61
  data: bytes,
62
62
  content_type: str,
63
- metadata: Optional[dict] = None,
63
+ metadata: dict | None = None,
64
64
  ) -> str:
65
65
  """
66
66
  Store file content and return its URL.