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
@@ -4,8 +4,7 @@ import hashlib
4
4
  import hmac
5
5
  import os
6
6
  import uuid
7
- from datetime import datetime, timezone
8
- from typing import Optional, Type
7
+ from datetime import UTC, datetime
9
8
 
10
9
  from sqlalchemy import (
11
10
  JSON,
@@ -40,24 +39,22 @@ def _hmac_sha256(s: str) -> str:
40
39
 
41
40
 
42
41
  def _now() -> datetime:
43
- return datetime.now(timezone.utc)
42
+ return datetime.now(UTC)
44
43
 
45
44
 
46
45
  # -------------------- Factory & registry --------------------
47
46
 
48
- _ApiKeyModel: Optional[type] = None
47
+ _ApiKeyModel: type | None = None
49
48
 
50
49
 
51
50
  def get_apikey_model() -> type:
52
51
  """Return the bound ApiKey model (or raise if not enabled)."""
53
52
  if _ApiKeyModel is None:
54
- raise RuntimeError(
55
- "ApiKey model is not enabled. Call bind_apikey_model(...) first."
56
- )
53
+ raise RuntimeError("ApiKey model is not enabled. Call bind_apikey_model(...) first.")
57
54
  return _ApiKeyModel
58
55
 
59
56
 
60
- def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type:
57
+ def bind_apikey_model(user_model: type[ModelBase], *, table_name: str = "api_keys") -> type:
61
58
  """
62
59
  Create and register an ApiKey model bound to the provided user_model and table name.
63
60
  Call this once during app boot (e.g., inside add_auth_users when enable_api_keys=True).
@@ -66,12 +63,10 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
66
63
  class ApiKey(ModelBase):
67
64
  __tablename__ = table_name
68
65
 
69
- id: Mapped[uuid.UUID] = mapped_column(
70
- GUID(), primary_key=True, default=uuid.uuid4
71
- )
66
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
72
67
 
73
68
  @declared_attr
74
- def user_id(cls) -> Mapped[uuid.UUID | None]: # noqa: N805
69
+ def user_id(cls) -> Mapped[uuid.UUID | None]:
75
70
  return mapped_column(
76
71
  GUID(),
77
72
  ForeignKey(f"{user_model.__tablename__}.id", ondelete="SET NULL"),
@@ -80,7 +75,7 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
80
75
  )
81
76
 
82
77
  @declared_attr
83
- def user(cls): # noqa: N805
78
+ def user(cls):
84
79
  return relationship(user_model.__name__, lazy="selectin")
85
80
 
86
81
  name: Mapped[str] = mapped_column(String(128), nullable=False)
@@ -89,9 +84,7 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
89
84
  key_prefix: Mapped[str] = mapped_column(String(12), index=True, nullable=False)
90
85
  key_hash: Mapped[str] = mapped_column(String(64), nullable=False) # hex sha256
91
86
 
92
- scopes: Mapped[list[str]] = mapped_column(
93
- MutableList.as_mutable(JSON), default=list
94
- )
87
+ scopes: Mapped[list[str]] = mapped_column(MutableList.as_mutable(JSON), default=list)
95
88
  active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
96
89
  expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
97
90
  last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -125,9 +118,7 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
125
118
  import secrets
126
119
 
127
120
  prefix = secrets.token_urlsafe(6).replace("-", "").replace("_", "")[:8]
128
- rand = (
129
- base64.urlsafe_b64encode(secrets.token_bytes(24)).decode().rstrip("=")
130
- )
121
+ rand = base64.urlsafe_b64encode(secrets.token_bytes(24)).decode().rstrip("=")
131
122
  plaintext = f"ak_{prefix}_{rand}"
132
123
  return plaintext, prefix, _hmac_sha256(plaintext)
133
124
 
@@ -143,7 +134,7 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
143
134
  return ApiKey
144
135
 
145
136
 
146
- def try_autobind_apikey_model(*, require_env: bool = False) -> Optional[type]:
137
+ def try_autobind_apikey_model(*, require_env: bool = False) -> type | None:
147
138
  """
148
139
  If API keys aren’t bound yet, try to discover the User model and bind.
149
140
  - If require_env=True, only bind when AUTH_ENABLE_API_KEYS is truthy.
@@ -161,7 +152,7 @@ def try_autobind_apikey_model(*, require_env: bool = False) -> Optional[type]:
161
152
  from svc_infra.db.sql.base import ModelBase
162
153
 
163
154
  # SQLAlchemy 2.x: iterate registry mappers to get mapped classes
164
- for mapper in list(getattr(ModelBase, "registry").mappers):
155
+ for mapper in list(ModelBase.registry.mappers):
165
156
  cls = mapper.class_
166
157
  if getattr(cls, "__svc_infra_auth_user__", False):
167
158
  return bind_apikey_model(cls) # binds and returns ApiKey
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Optional, Tuple
4
-
5
3
  from sqlalchemy import ForeignKeyConstraint
6
4
  from sqlalchemy.sql.type_api import TypeEngine
7
5
 
@@ -9,7 +7,7 @@ from svc_infra.db.sql.base import ModelBase
9
7
  from svc_infra.db.sql.types import GUID
10
8
 
11
9
 
12
- def _find_auth_mapper() -> Optional[Tuple[str, TypeEngine, str]]:
10
+ def _find_auth_mapper() -> tuple[str, TypeEngine, str] | None:
13
11
  """
14
12
  Returns (table_name, pk_sqlatype, pk_name) for the auth user model.
15
13
  Looks for any mapped class with __svc_infra_auth_user__ = True that
@@ -36,7 +34,7 @@ def _find_auth_mapper() -> Optional[Tuple[str, TypeEngine, str]]:
36
34
  return None
37
35
 
38
36
 
39
- def resolve_auth_table_pk() -> Tuple[str, TypeEngine, str]:
37
+ def resolve_auth_table_pk() -> tuple[str, TypeEngine, str]:
40
38
  """
41
39
  Single source of truth for the auth table and PK.
42
40
  Falls back to ('users', GUID(), 'id') if nothing is marked.
@@ -62,6 +60,4 @@ def user_fk_constraint(
62
60
  Returns a table-level ForeignKeyConstraint([...], [<auth_table>.<pk>]) for the given column.
63
61
  """
64
62
  table, _pk_type, pk_name = resolve_auth_table_pk()
65
- return ForeignKeyConstraint(
66
- [column_name], [f"{table}.{pk_name}"], ondelete=ondelete
67
- )
63
+ return ForeignKeyConstraint([column_name], [f"{table}.{pk_name}"], ondelete=ondelete)
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- from typing import Sequence
4
+ from collections.abc import Sequence
5
5
 
6
6
  # Environment variable names to look up for DB URL
7
7
  # Order matters: svc-infra canonical names first, then common PaaS names
@@ -22,16 +22,16 @@ try:
22
22
  import importlib.resources as pkg
23
23
 
24
24
  _tmpl_pkg = pkg.files("svc_infra.db.sql.templates.setup")
25
- ALEMBIC_INI_TEMPLATE = _tmpl_pkg.joinpath("alembic.ini.tmpl").read_text(
26
- encoding="utf-8"
27
- )
28
- ALEMBIC_SCRIPT_TEMPLATE = _tmpl_pkg.joinpath("script.py.mako.tmpl").read_text(
29
- encoding="utf-8"
30
- )
25
+ ALEMBIC_INI_TEMPLATE = _tmpl_pkg.joinpath("alembic.ini.tmpl").read_text(encoding="utf-8")
26
+ ALEMBIC_SCRIPT_TEMPLATE = _tmpl_pkg.joinpath("script.py.mako.tmpl").read_text(encoding="utf-8")
31
27
  except Exception:
32
28
  # Fallbacks (should not normally happen). Provide minimal safe defaults.
33
- ALEMBIC_INI_TEMPLATE = """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
34
- ALEMBIC_INI_TEMPLATE = """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
29
+ ALEMBIC_INI_TEMPLATE = (
30
+ """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
31
+ )
32
+ ALEMBIC_INI_TEMPLATE = (
33
+ """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
34
+ )
35
35
  ALEMBIC_SCRIPT_TEMPLATE = '"""${message}"""\nfrom alembic import op\nimport sqlalchemy as sa\n\nrevision = ${repr(up_revision)}\ndown_revision = ${repr(down_revision)}\nbranch_labels = ${repr(branch_labels)}\ndepends_on = ${repr(depends_on)}\n\ndef upgrade():\n ${upgrades if upgrades else "pass"}\n\n\ndef downgrade():\n ${downgrades if downgrades else "pass"}\n'
36
36
  __all__ = [
37
37
  "DEFAULT_DB_ENV_VARS",
svc_infra/db/sql/core.py CHANGED
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  import contextlib
4
4
  import io
5
5
  import os
6
+ from collections.abc import Sequence
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Optional, Sequence
9
9
 
10
10
  from alembic import command
11
11
  from alembic.config import Config
@@ -30,7 +30,7 @@ from svc_infra.db.sql.utils import (
30
30
  def init_alembic(
31
31
  *,
32
32
  script_location: str = "migrations",
33
- discover_packages: Optional[Sequence[str]] = None,
33
+ discover_packages: Sequence[str] | None = None,
34
34
  overwrite: bool = False,
35
35
  ) -> Path:
36
36
  """
@@ -157,7 +157,7 @@ def revision(
157
157
  def upgrade(
158
158
  revision_target: str = "head",
159
159
  *,
160
- database_url: Optional[str] = None,
160
+ database_url: str | None = None,
161
161
  ) -> dict:
162
162
  """
163
163
  Apply migrations forward.
@@ -181,7 +181,7 @@ def upgrade(
181
181
  def downgrade(
182
182
  *,
183
183
  revision_target: str = "-1",
184
- database_url: Optional[str] = None,
184
+ database_url: str | None = None,
185
185
  ) -> dict:
186
186
  """Revert migrations down to the specified revision or relative step.
187
187
 
@@ -203,7 +203,7 @@ def downgrade(
203
203
  def current(
204
204
  verbose: bool = False,
205
205
  *,
206
- database_url: Optional[str] = None,
206
+ database_url: str | None = None,
207
207
  ) -> dict:
208
208
  """Print the current database revision(s)."""
209
209
  root = prepare_env()
@@ -224,7 +224,7 @@ def current(
224
224
  def history(
225
225
  *,
226
226
  verbose: bool = False,
227
- database_url: Optional[str] = None,
227
+ database_url: str | None = None,
228
228
  ) -> dict:
229
229
  """Show the migration history for this project."""
230
230
  root = prepare_env()
@@ -245,7 +245,7 @@ def history(
245
245
  def stamp(
246
246
  *,
247
247
  revision_target: str = "head",
248
- database_url: Optional[str] = None,
248
+ database_url: str | None = None,
249
249
  ) -> dict:
250
250
  """Set the current database revision without running migrations. Useful for marking an existing database as up-to-date."""
251
251
  root = prepare_env()
@@ -262,8 +262,8 @@ def stamp(
262
262
 
263
263
  def merge_heads(
264
264
  *,
265
- message: Optional[str] = None,
266
- database_url: Optional[str] = None,
265
+ message: str | None = None,
266
+ database_url: str | None = None,
267
267
  ) -> dict:
268
268
  """Create a merge revision that joins multiple migration heads."""
269
269
  root = prepare_env()
@@ -309,8 +309,8 @@ def setup_and_migrate(
309
309
  create_followup_revision: bool = True,
310
310
  initial_message: str = "initial schema",
311
311
  followup_message: str = "autogen",
312
- database_url: Optional[str] = None,
313
- discover_packages: Optional[Sequence[str]] = None,
312
+ database_url: str | None = None,
313
+ discover_packages: Sequence[str] | None = None,
314
314
  ) -> dict:
315
315
  """
316
316
  Ensure DB + Alembic are ready and up-to-date.
@@ -100,13 +100,9 @@ def make_crud_schemas(
100
100
  name=name,
101
101
  typ=T,
102
102
  required_for_create=bool(
103
- is_required
104
- and name not in explicit_excludes
105
- and not _exclude_from_create(col)
106
- ),
107
- exclude_from_create=bool(
108
- name in explicit_excludes or _exclude_from_create(col)
103
+ is_required and name not in explicit_excludes and not _exclude_from_create(col)
109
104
  ),
105
+ exclude_from_create=bool(name in explicit_excludes or _exclude_from_create(col)),
110
106
  exclude_from_read=bool(name in read_ex),
111
107
  exclude_from_update=bool(name in update_ex),
112
108
  )
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import inspect
4
4
  import logging
5
- from typing import Any, Iterable, Optional, Sequence, Set, cast
5
+ from collections.abc import Iterable, Sequence
6
+ from typing import Any, cast
6
7
 
7
8
  from sqlalchemy import Select, String, and_, func, or_, select
8
9
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -33,14 +34,14 @@ class SqlRepository:
33
34
  soft_delete: bool = False,
34
35
  soft_delete_field: str = "deleted_at",
35
36
  soft_delete_flag_field: str | None = None,
36
- immutable_fields: Optional[Set[str]] = None,
37
+ immutable_fields: set[str] | None = None,
37
38
  ):
38
39
  self.model = model
39
40
  self.id_attr = id_attr
40
41
  self.soft_delete = soft_delete
41
42
  self.soft_delete_field = soft_delete_field
42
43
  self.soft_delete_flag_field = soft_delete_flag_field
43
- self.immutable_fields: Set[str] = set(
44
+ self.immutable_fields: set[str] = set(
44
45
  immutable_fields or {"id", "created_at", "updated_at"}
45
46
  )
46
47
 
@@ -48,7 +49,7 @@ class SqlRepository:
48
49
  return {c.key for c in class_mapper(self.model).columns}
49
50
 
50
51
  def _id_column(self) -> InstrumentedAttribute[Any]:
51
- return cast(InstrumentedAttribute[Any], getattr(self.model, self.id_attr))
52
+ return cast("InstrumentedAttribute[Any]", getattr(self.model, self.id_attr))
52
53
 
53
54
  def _base_select(self) -> Select:
54
55
  stmt = select(self.model)
@@ -56,12 +57,8 @@ class SqlRepository:
56
57
  # Filter out soft-deleted rows by timestamp and/or active flag
57
58
  if hasattr(self.model, self.soft_delete_field):
58
59
  stmt = stmt.where(getattr(self.model, self.soft_delete_field).is_(None))
59
- if self.soft_delete_flag_field and hasattr(
60
- self.model, self.soft_delete_flag_field
61
- ):
62
- stmt = stmt.where(
63
- getattr(self.model, self.soft_delete_flag_field).is_(True)
64
- )
60
+ if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
61
+ stmt = stmt.where(getattr(self.model, self.soft_delete_flag_field).is_(True))
65
62
  return stmt
66
63
 
67
64
  # basic ops
@@ -72,8 +69,8 @@ class SqlRepository:
72
69
  *,
73
70
  limit: int,
74
71
  offset: int,
75
- order_by: Optional[Sequence[Any]] = None,
76
- where: Optional[Sequence[Any]] = None,
72
+ order_by: Sequence[Any] | None = None,
73
+ where: Sequence[Any] | None = None,
77
74
  ) -> Sequence[Any]:
78
75
  stmt = self._base_select()
79
76
  if where:
@@ -84,9 +81,7 @@ class SqlRepository:
84
81
  result = (await session.execute(stmt)).scalars().all()
85
82
  return list(result)
86
83
 
87
- async def count(
88
- self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None
89
- ) -> int:
84
+ async def count(self, session: AsyncSession, *, where: Sequence[Any] | None = None) -> int:
90
85
  base = self._base_select()
91
86
  if where:
92
87
  base = base.where(and_(*where))
@@ -98,7 +93,7 @@ class SqlRepository:
98
93
  session: AsyncSession,
99
94
  id_value: Any,
100
95
  *,
101
- where: Optional[Sequence[Any]] = None,
96
+ where: Sequence[Any] | None = None,
102
97
  ) -> Any | None:
103
98
  # honors soft-delete if configured
104
99
  stmt = self._base_select().where(self._id_column() == id_value)
@@ -121,7 +116,7 @@ class SqlRepository:
121
116
  id_value: Any,
122
117
  data: dict[str, Any],
123
118
  *,
124
- where: Optional[Sequence[Any]] = None,
119
+ where: Sequence[Any] | None = None,
125
120
  ) -> Any | None:
126
121
  obj = await self.get(session, id_value, where=where)
127
122
  if not obj:
@@ -139,7 +134,7 @@ class SqlRepository:
139
134
  session: AsyncSession,
140
135
  id_value: Any,
141
136
  *,
142
- where: Optional[Sequence[Any]] = None,
137
+ where: Sequence[Any] | None = None,
143
138
  ) -> bool:
144
139
  # Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
145
140
  if not where:
@@ -156,9 +151,7 @@ class SqlRepository:
156
151
  # Check attributes on the instance to support test doubles without class-level fields
157
152
  if hasattr(obj, self.soft_delete_field):
158
153
  setattr(obj, self.soft_delete_field, func.now())
159
- if self.soft_delete_flag_field and hasattr(
160
- obj, self.soft_delete_flag_field
161
- ):
154
+ if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
162
155
  setattr(obj, self.soft_delete_flag_field, False)
163
156
  await session.flush()
164
157
  return True
@@ -176,8 +169,8 @@ class SqlRepository:
176
169
  fields: Sequence[str],
177
170
  limit: int,
178
171
  offset: int,
179
- order_by: Optional[Sequence[Any]] = None,
180
- where: Optional[Sequence[Any]] = None,
172
+ order_by: Sequence[Any] | None = None,
173
+ where: Sequence[Any] | None = None,
181
174
  ) -> Sequence[Any]:
182
175
  ilike = f"%{_escape_ilike(q)}%"
183
176
  conditions = []
@@ -206,7 +199,7 @@ class SqlRepository:
206
199
  *,
207
200
  q: str,
208
201
  fields: Sequence[str],
209
- where: Optional[Sequence[Any]] = None,
202
+ where: Sequence[Any] | None = None,
210
203
  ) -> int:
211
204
  ilike = f"%{_escape_ilike(q)}%"
212
205
  conditions = []
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
- from typing import TYPE_CHECKING, Callable, Optional
5
+ from typing import TYPE_CHECKING
5
6
 
6
7
  from svc_infra.db.sql.repository import SqlRepository
7
8
 
@@ -14,28 +15,28 @@ if TYPE_CHECKING:
14
15
  class SqlResource:
15
16
  model: type[object]
16
17
  prefix: str
17
- tags: Optional[list[str]] = None
18
+ tags: list[str] | None = None
18
19
 
19
20
  id_attr: str = "id"
20
21
  soft_delete: bool = False
21
- search_fields: Optional[list[str]] = None
22
- ordering_default: Optional[str] = None
23
- allowed_order_fields: Optional[list[str]] = None
22
+ search_fields: list[str] | None = None
23
+ ordering_default: str | None = None
24
+ allowed_order_fields: list[str] | None = None
24
25
 
25
- read_schema: Optional[type] = None
26
- create_schema: Optional[type] = None
27
- update_schema: Optional[type] = None
26
+ read_schema: type | None = None
27
+ create_schema: type | None = None
28
+ update_schema: type | None = None
28
29
 
29
- read_name: Optional[str] = None
30
- create_name: Optional[str] = None
31
- update_name: Optional[str] = None
30
+ read_name: str | None = None
31
+ create_name: str | None = None
32
+ update_name: str | None = None
32
33
 
33
34
  create_exclude: tuple[str, ...] = ("id",)
34
35
 
35
36
  # Only a type reference; no runtime dependency on FastAPI layer
36
- service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
37
+ service_factory: Callable[[SqlRepository], SqlService] | None = None
37
38
 
38
39
  # Tenancy
39
- tenant_field: Optional[str] = (
40
+ tenant_field: str | None = (
40
41
  None # when set, CRUD router will require TenantId and scope by field
41
42
  )
@@ -2,20 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
- from typing import Any, Dict, Literal, Optional
5
+ from typing import Any, Literal
6
6
 
7
7
  from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
8
8
  from svc_infra.utils import ensure_init_py, render_template, write
9
9
 
10
10
  # ---------------- helpers ----------------
11
11
 
12
- _INIT_CONTENT_PAIRED = (
13
- 'from . import models, schemas\n\n__all__ = ["models", "schemas"]\n'
14
- )
12
+ _INIT_CONTENT_PAIRED = 'from . import models, schemas\n\n__all__ = ["models", "schemas"]\n'
15
13
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
16
14
 
17
15
 
18
- def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> Dict[str, Any]:
16
+ def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> dict[str, Any]:
19
17
  """Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
20
18
  content = _INIT_CONTENT_PAIRED if paired else _INIT_CONTENT_MINIMAL
21
19
  return ensure_init_py(dir_path, overwrite, paired, content)
@@ -33,14 +31,14 @@ def scaffold_core(
33
31
  schemas_dir: Path | str,
34
32
  kind: Kind = "entity",
35
33
  entity_name: str = "Item",
36
- table_name: Optional[str] = None,
34
+ table_name: str | None = None,
37
35
  include_tenant: bool = True,
38
36
  include_soft_delete: bool = False,
39
37
  overwrite: bool = False,
40
38
  same_dir: bool = False,
41
- models_filename: Optional[str] = None,
42
- schemas_filename: Optional[str] = None,
43
- ) -> Dict[str, Any]:
39
+ models_filename: str | None = None,
40
+ schemas_filename: str | None = None,
41
+ ) -> dict[str, Any]:
44
42
  """
45
43
  Create starter model + schema files.
46
44
 
@@ -104,9 +102,7 @@ def scaffold_core(
104
102
  },
105
103
  )
106
104
 
107
- tenant_schema_field = (
108
- " tenant_id: Optional[str] = None\n" if include_tenant else ""
109
- )
105
+ tenant_schema_field = " tenant_id: Optional[str] = None\n" if include_tenant else ""
110
106
  schemas_txt = render_template(
111
107
  tmpl_dir="svc_infra.db.sql.templates.models_schemas.entity",
112
108
  name="schemas.py.tmpl",
@@ -154,12 +150,12 @@ def scaffold_models_core(
154
150
  dest_dir: Path | str,
155
151
  kind: Kind = "entity",
156
152
  entity_name: str = "Item",
157
- table_name: Optional[str] = None,
153
+ table_name: str | None = None,
158
154
  include_tenant: bool = True,
159
155
  include_soft_delete: bool = False,
160
156
  overwrite: bool = False,
161
- models_filename: Optional[str] = None, # <--- NEW
162
- ) -> Dict[str, Any]:
157
+ models_filename: str | None = None, # <--- NEW
158
+ ) -> dict[str, Any]:
163
159
  """Create only a model file (defaults to <snake(entity)>.py unless models_filename is provided)."""
164
160
  dest = normalize_dir(dest_dir)
165
161
 
@@ -220,8 +216,8 @@ def scaffold_schemas_core(
220
216
  entity_name: str = "Item",
221
217
  include_tenant: bool = True,
222
218
  overwrite: bool = False,
223
- schemas_filename: Optional[str] = None, # <--- NEW
224
- ) -> Dict[str, Any]:
219
+ schemas_filename: str | None = None, # <--- NEW
220
+ ) -> dict[str, Any]:
225
221
  """Create only a schema file (defaults to <snake(entity)>.py unless schemas_filename is provided)."""
226
222
  dest = normalize_dir(dest_dir)
227
223
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from fastapi import HTTPException
6
7
  from sqlalchemy.exc import IntegrityError
@@ -24,12 +25,8 @@ class SqlService:
24
25
  async def pre_update(self, data: dict[str, Any]) -> dict[str, Any]:
25
26
  return data
26
27
 
27
- async def list(
28
- self, session: AsyncSession, *, limit: int, offset: int, order_by=None
29
- ):
30
- return await self.repo.list(
31
- session, limit=limit, offset=offset, order_by=order_by
32
- )
28
+ async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
29
+ return await self.repo.list(session, limit=limit, offset=offset, order_by=order_by)
33
30
 
34
31
  async def count(self, session: AsyncSession) -> int:
35
32
  return await self.repo.count(session)
@@ -45,13 +42,9 @@ class SqlService:
45
42
  # unique constraint or not-null -> 409/400 instead of 500
46
43
  msg = str(e.orig) if getattr(e, "orig", None) else str(e)
47
44
  if "duplicate key value" in msg or "UniqueViolation" in msg:
48
- raise HTTPException(
49
- status_code=409, detail="Record already exists."
50
- ) from e
45
+ raise HTTPException(status_code=409, detail="Record already exists.") from e
51
46
  if "not-null" in msg or "NotNullViolation" in msg:
52
- raise HTTPException(
53
- status_code=400, detail="Missing required field."
54
- ) from e
47
+ raise HTTPException(status_code=400, detail="Missing required field.") from e
55
48
  raise # unknown, let your error middleware turn into 500
56
49
 
57
50
  async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
@@ -75,9 +68,7 @@ class SqlService:
75
68
  session, q=q, fields=fields, limit=limit, offset=offset, order_by=order_by
76
69
  )
77
70
 
78
- async def count_filtered(
79
- self, session: AsyncSession, *, q: str, fields: Sequence[str]
80
- ) -> int:
71
+ async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
81
72
  return await self.repo.count_filtered(session, q=q, fields=fields)
82
73
 
83
74
  async def exists(self, session: AsyncSession, *, where):
@@ -1,4 +1,5 @@
1
- from typing import Any, Callable, Optional
1
+ from collections.abc import Callable
2
+ from typing import Any
2
3
 
3
4
  from svc_infra.db.sql.service import SqlService
4
5
 
@@ -9,8 +10,8 @@ class SqlServiceWithHooks(SqlService):
9
10
  def __init__(
10
11
  self,
11
12
  repo,
12
- pre_create: Optional[PreHook] = None,
13
- pre_update: Optional[PreHook] = None,
13
+ pre_create: PreHook | None = None,
14
+ pre_update: PreHook | None = None,
14
15
  ):
15
16
  super().__init__(repo)
16
17
  self._pre_create = pre_create
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from sqlalchemy.ext.asyncio import AsyncSession
6
7
 
@@ -27,9 +28,7 @@ class TenantSqlService(SqlService):
27
28
  return []
28
29
  return [col == self.tenant_id]
29
30
 
30
- async def list(
31
- self, session: AsyncSession, *, limit: int, offset: int, order_by=None
32
- ):
31
+ async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
33
32
  return await self.repo.list(
34
33
  session, limit=limit, offset=offset, order_by=order_by, where=self._where()
35
34
  )
@@ -43,10 +42,7 @@ class TenantSqlService(SqlService):
43
42
  async def create(self, session: AsyncSession, data: dict[str, Any]):
44
43
  data = await self.pre_create(data)
45
44
  # inject tenant_id if model supports it and value missing
46
- if (
47
- self.tenant_field in self.repo._model_columns()
48
- and self.tenant_field not in data
49
- ):
45
+ if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
50
46
  data[self.tenant_field] = self.tenant_id
51
47
  return await self.repo.create(session, data)
52
48
 
@@ -77,12 +73,8 @@ class TenantSqlService(SqlService):
77
73
  where=self._where(),
78
74
  )
79
75
 
80
- async def count_filtered(
81
- self, session: AsyncSession, *, q: str, fields: Sequence[str]
82
- ) -> int:
83
- return await self.repo.count_filtered(
84
- session, q=q, fields=fields, where=self._where()
85
- )
76
+ async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
77
+ return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
86
78
 
87
79
 
88
80
  __all__ = ["TenantSqlService"]