svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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 (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/db/outbox.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, Iterable, List, Optional, Protocol
6
+
7
+
8
+ @dataclass
9
+ class OutboxMessage:
10
+ id: int
11
+ topic: str
12
+ payload: Dict[str, Any]
13
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
14
+ attempts: int = 0
15
+ processed_at: Optional[datetime] = None
16
+
17
+
18
+ class OutboxStore(Protocol):
19
+ def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
20
+ pass
21
+
22
+ def fetch_next(
23
+ self, *, topics: Optional[Iterable[str]] = None
24
+ ) -> Optional[OutboxMessage]:
25
+ """Return the next undispatched, unprocessed message (FIFO per-topic), or None.
26
+
27
+ Notes:
28
+ - Messages with attempts > 0 are considered "dispatched" to the job queue and won't be re-enqueued.
29
+ - Delivery retries are handled by the job queue worker, not by re-reading the outbox.
30
+ """
31
+ pass
32
+
33
+ def mark_processed(self, msg_id: int) -> None:
34
+ pass
35
+
36
+ def mark_failed(self, msg_id: int) -> None:
37
+ pass
38
+
39
+
40
+ class InMemoryOutboxStore:
41
+ """Simple in-memory outbox for tests and local runs."""
42
+
43
+ def __init__(self):
44
+ self._seq = 0
45
+ self._messages: List[OutboxMessage] = []
46
+
47
+ def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
48
+ self._seq += 1
49
+ msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
50
+ self._messages.append(msg)
51
+ return msg
52
+
53
+ def fetch_next(
54
+ self, *, topics: Optional[Iterable[str]] = None
55
+ ) -> Optional[OutboxMessage]:
56
+ allowed = set(topics) if topics else None
57
+ for msg in self._messages:
58
+ if msg.processed_at is not None:
59
+ continue
60
+ # skip already dispatched messages (attempts>0)
61
+ if msg.attempts > 0:
62
+ continue
63
+ if allowed is not None and msg.topic not in allowed:
64
+ continue
65
+ return msg
66
+ return None
67
+
68
+ def mark_processed(self, msg_id: int) -> None:
69
+ for msg in self._messages:
70
+ if msg.id == msg_id:
71
+ msg.processed_at = datetime.now(timezone.utc)
72
+ return
73
+
74
+ def mark_failed(self, msg_id: int) -> None:
75
+ for msg in self._messages:
76
+ if msg.id == msg_id:
77
+ msg.attempts += 1
78
+ return
79
+
80
+
81
+ class SqlOutboxStore:
82
+ """Skeleton for a SQL-backed outbox store.
83
+
84
+ Implementations should:
85
+ - INSERT on enqueue
86
+ - SELECT FOR UPDATE SKIP LOCKED (or equivalent) to fetch next
87
+ - UPDATE processed_at (and attempts on failure)
88
+ """
89
+
90
+ def __init__(self, session_factory):
91
+ self._session_factory = session_factory
92
+
93
+ # Placeholders to outline the API; not implemented here.
94
+ def enqueue(
95
+ self, topic: str, payload: Dict[str, Any]
96
+ ) -> OutboxMessage: # pragma: no cover - skeleton
97
+ raise NotImplementedError
98
+
99
+ def fetch_next(
100
+ self, *, topics: Optional[Iterable[str]] = None
101
+ ) -> Optional[OutboxMessage]: # pragma: no cover - skeleton
102
+ raise NotImplementedError
103
+
104
+ def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton
105
+ raise NotImplementedError
106
+
107
+ def mark_failed(self, msg_id: int) -> None: # pragma: no cover - skeleton
108
+ raise NotImplementedError
@@ -7,18 +7,36 @@ import uuid
7
7
  from datetime import datetime, timezone
8
8
  from typing import Optional, Type
9
9
 
10
- from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, UniqueConstraint, text
10
+ from sqlalchemy import (
11
+ JSON,
12
+ Boolean,
13
+ DateTime,
14
+ ForeignKey,
15
+ Index,
16
+ String,
17
+ UniqueConstraint,
18
+ text,
19
+ )
11
20
  from sqlalchemy.ext.mutable import MutableDict, MutableList
12
21
  from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
13
22
 
23
+ from svc_infra.app.env import require_secret
14
24
  from svc_infra.db.sql.base import ModelBase
15
25
  from svc_infra.db.sql.types import GUID
16
26
 
17
- _APIKEY_HMAC_SECRET = os.getenv("APIKEY_HASH_SECRET") or "change-me-low-entropy-dev"
27
+
28
+ def _get_apikey_secret() -> str:
29
+ """Get APIKEY_HASH_SECRET, requiring it in production."""
30
+ return require_secret(
31
+ os.getenv("APIKEY_HASH_SECRET"),
32
+ "APIKEY_HASH_SECRET",
33
+ dev_default="dev-only-apikey-hmac-secret-not-for-production",
34
+ )
18
35
 
19
36
 
20
37
  def _hmac_sha256(s: str) -> str:
21
- return hmac.new(_APIKEY_HMAC_SECRET.encode(), s.encode(), hashlib.sha256).hexdigest()
38
+ secret = _get_apikey_secret()
39
+ return hmac.new(secret.encode(), s.encode(), hashlib.sha256).hexdigest()
22
40
 
23
41
 
24
42
  def _now() -> datetime:
@@ -33,7 +51,9 @@ _ApiKeyModel: Optional[type] = None
33
51
  def get_apikey_model() -> type:
34
52
  """Return the bound ApiKey model (or raise if not enabled)."""
35
53
  if _ApiKeyModel is None:
36
- raise RuntimeError("ApiKey model is not enabled. Call bind_apikey_model(...) first.")
54
+ raise RuntimeError(
55
+ "ApiKey model is not enabled. Call bind_apikey_model(...) first."
56
+ )
37
57
  return _ApiKeyModel
38
58
 
39
59
 
@@ -43,10 +63,12 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
43
63
  Call this once during app boot (e.g., inside add_auth_users when enable_api_keys=True).
44
64
  """
45
65
 
46
- class ApiKey(ModelBase): # type: ignore[misc, valid-type]
66
+ class ApiKey(ModelBase):
47
67
  __tablename__ = table_name
48
68
 
49
- id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
69
+ id: Mapped[uuid.UUID] = mapped_column(
70
+ GUID(), primary_key=True, default=uuid.uuid4
71
+ )
50
72
 
51
73
  @declared_attr
52
74
  def user_id(cls) -> Mapped[uuid.UUID | None]: # noqa: N805
@@ -67,14 +89,18 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
67
89
  key_prefix: Mapped[str] = mapped_column(String(12), index=True, nullable=False)
68
90
  key_hash: Mapped[str] = mapped_column(String(64), nullable=False) # hex sha256
69
91
 
70
- scopes: Mapped[list[str]] = mapped_column(MutableList.as_mutable(JSON), default=list)
92
+ scopes: Mapped[list[str]] = mapped_column(
93
+ MutableList.as_mutable(JSON), default=list
94
+ )
71
95
  active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
72
96
  expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
73
97
  last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
74
98
  meta: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict)
75
99
 
76
100
  created_at = mapped_column(
77
- DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
101
+ DateTime(timezone=True),
102
+ server_default=text("CURRENT_TIMESTAMP"),
103
+ nullable=False,
78
104
  )
79
105
  updated_at = mapped_column(
80
106
  DateTime(timezone=True),
@@ -99,7 +125,9 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
99
125
  import secrets
100
126
 
101
127
  prefix = secrets.token_urlsafe(6).replace("-", "").replace("_", "")[:8]
102
- rand = base64.urlsafe_b64encode(secrets.token_bytes(24)).decode().rstrip("=")
128
+ rand = (
129
+ base64.urlsafe_b64encode(secrets.token_bytes(24)).decode().rstrip("=")
130
+ )
103
131
  plaintext = f"ak_{prefix}_{rand}"
104
132
  return plaintext, prefix, _hmac_sha256(plaintext)
105
133
 
@@ -22,11 +22,15 @@ def _find_auth_mapper() -> Optional[Tuple[str, TypeEngine, str]]:
22
22
  table = mapper.local_table or getattr(cls, "__table__", None)
23
23
  if table is None:
24
24
  continue
25
- pk_cols = list(table.primary_key.columns)
25
+ table_name = getattr(table, "name", None)
26
+ if not isinstance(table_name, str) or not table_name:
27
+ continue
28
+ # SQLAlchemy's primary_key is iterable; don't rely on .columns typing.
29
+ pk_cols = list(table.primary_key)
26
30
  if len(pk_cols) != 1:
27
31
  continue # require single-column PK
28
32
  pk_col = pk_cols[0]
29
- return (table.name, pk_col.type, pk_col.name)
33
+ return (table_name, pk_col.type, pk_col.name)
30
34
  except Exception:
31
35
  pass
32
36
  return None
@@ -58,4 +62,6 @@ def user_fk_constraint(
58
62
  Returns a table-level ForeignKeyConstraint([...], [<auth_table>.<pk>]) for the given column.
59
63
  """
60
64
  table, _pk_type, pk_name = resolve_auth_table_pk()
61
- return ForeignKeyConstraint([column_name], [f"{table}.{pk_name}"], ondelete=ondelete)
65
+ return ForeignKeyConstraint(
66
+ [column_name], [f"{table}.{pk_name}"], ondelete=ondelete
67
+ )
@@ -4,9 +4,13 @@ import re
4
4
  from typing import Sequence
5
5
 
6
6
  # Environment variable names to look up for DB URL
7
+ # Order matters: svc-infra canonical names first, then common PaaS names
7
8
  DEFAULT_DB_ENV_VARS: Sequence[str] = (
8
9
  "SQL_URL",
9
10
  "DB_URL",
11
+ "DATABASE_URL", # Heroku, Railway (public)
12
+ "DATABASE_URL_PRIVATE", # Railway (private networking)
13
+ "PRIVATE_SQL_URL", # Legacy svc-infra naming
10
14
  )
11
15
 
12
16
  # Regex used to detect async drivers from URL drivername
@@ -18,16 +22,16 @@ try:
18
22
  import importlib.resources as pkg
19
23
 
20
24
  _tmpl_pkg = pkg.files("svc_infra.db.sql.templates.setup")
21
- ALEMBIC_INI_TEMPLATE = _tmpl_pkg.joinpath("alembic.ini.tmpl").read_text(encoding="utf-8")
22
- ALEMBIC_SCRIPT_TEMPLATE = _tmpl_pkg.joinpath("script.py.mako.tmpl").read_text(encoding="utf-8")
23
- except Exception:
24
- # Fallbacks (should not normally happen). Provide minimal safe defaults.
25
- ALEMBIC_INI_TEMPLATE = (
26
- """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
25
+ ALEMBIC_INI_TEMPLATE = _tmpl_pkg.joinpath("alembic.ini.tmpl").read_text(
26
+ encoding="utf-8"
27
27
  )
28
- ALEMBIC_INI_TEMPLATE = (
29
- """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
28
+ ALEMBIC_SCRIPT_TEMPLATE = _tmpl_pkg.joinpath("script.py.mako.tmpl").read_text(
29
+ encoding="utf-8"
30
30
  )
31
+ except Exception:
32
+ # 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"""
31
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'
32
36
  __all__ = [
33
37
  "DEFAULT_DB_ENV_VARS",
svc_infra/db/sql/core.py CHANGED
@@ -140,7 +140,7 @@ def revision(
140
140
  cfg,
141
141
  message=message,
142
142
  autogenerate=autogenerate,
143
- head=head,
143
+ head=head or "head",
144
144
  branch_label=branch_label,
145
145
  version_path=version_path,
146
146
  sql=sql,
@@ -319,7 +319,7 @@ def setup_and_migrate(
319
319
  """
320
320
  resolved_url = database_url or get_database_url_from_env(required=True)
321
321
  root = prepare_env()
322
- if create_db_if_missing:
322
+ if create_db_if_missing and resolved_url:
323
323
  ensure_database_exists(resolved_url)
324
324
 
325
325
  mig_dir = init_alembic(
@@ -15,20 +15,19 @@ def _sa_columns(model: type[object]) -> list[Column]:
15
15
  def _py_type(col: Column) -> type:
16
16
  # Prefer SQLAlchemy-provided python_type when available
17
17
  if getattr(col.type, "python_type", None):
18
- return col.type.python_type # type: ignore[no-any-return]
18
+ return col.type.python_type
19
19
 
20
20
  from datetime import date, datetime
21
- from typing import Any as _Any
22
21
  from uuid import UUID
23
22
 
24
23
  from sqlalchemy import JSON, Boolean, Date, DateTime, Integer, String, Text
25
24
 
26
25
  try:
27
26
  from sqlalchemy.dialects.postgresql import JSONB
28
- from sqlalchemy.dialects.postgresql import UUID as PG_UUID # type: ignore
27
+ from sqlalchemy.dialects.postgresql import UUID as PG_UUID
29
28
  except Exception: # pragma: no cover
30
- PG_UUID = None # type: ignore
31
- JSONB = None # type: ignore
29
+ PG_UUID = None # type: ignore[misc,assignment]
30
+ JSONB = None # type: ignore[misc,assignment]
32
31
 
33
32
  t = col.type
34
33
  if PG_UUID is not None and isinstance(t, PG_UUID):
@@ -47,7 +46,7 @@ def _py_type(col: Column) -> type:
47
46
  return dict
48
47
  if JSONB is not None and isinstance(t, JSONB):
49
48
  return dict
50
- return _Any
49
+ return object # fallback type for unknown column types
51
50
 
52
51
 
53
52
  def _exclude_from_create(col: Column) -> bool:
@@ -101,9 +100,13 @@ def make_crud_schemas(
101
100
  name=name,
102
101
  typ=T,
103
102
  required_for_create=bool(
104
- is_required and name not in explicit_excludes and not _exclude_from_create(col)
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)
105
109
  ),
106
- exclude_from_create=bool(name in explicit_excludes or _exclude_from_create(col)),
107
110
  exclude_from_read=bool(name in read_ex),
108
111
  exclude_from_update=bool(name in update_ex),
109
112
  )
@@ -1,11 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Iterable, Optional, Sequence, Set
3
+ import inspect
4
+ import logging
5
+ from typing import Any, Iterable, Optional, Sequence, Set, cast
4
6
 
5
7
  from sqlalchemy import Select, String, and_, func, or_, select
6
8
  from sqlalchemy.ext.asyncio import AsyncSession
7
9
  from sqlalchemy.orm import InstrumentedAttribute, class_mapper
8
10
 
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _escape_ilike(q: str) -> str:
15
+ """Escape special characters for ILIKE pattern matching.
16
+
17
+ Prevents SQL injection via wildcard characters that could match
18
+ unintended data (e.g., % matches any string, _ matches any char).
19
+ """
20
+ return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
21
+
9
22
 
10
23
  class SqlRepository:
11
24
  """
@@ -34,8 +47,8 @@ class SqlRepository:
34
47
  def _model_columns(self) -> set[str]:
35
48
  return {c.key for c in class_mapper(self.model).columns}
36
49
 
37
- def _id_column(self) -> InstrumentedAttribute:
38
- return getattr(self.model, self.id_attr)
50
+ def _id_column(self) -> InstrumentedAttribute[Any]:
51
+ return cast(InstrumentedAttribute[Any], getattr(self.model, self.id_attr))
39
52
 
40
53
  def _base_select(self) -> Select:
41
54
  stmt = select(self.model)
@@ -43,8 +56,12 @@ class SqlRepository:
43
56
  # Filter out soft-deleted rows by timestamp and/or active flag
44
57
  if hasattr(self.model, self.soft_delete_field):
45
58
  stmt = stmt.where(getattr(self.model, self.soft_delete_field).is_(None))
46
- if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
47
- stmt = stmt.where(getattr(self.model, self.soft_delete_flag_field).is_(True))
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
+ )
48
65
  return stmt
49
66
 
50
67
  # basic ops
@@ -56,20 +73,37 @@ class SqlRepository:
56
73
  limit: int,
57
74
  offset: int,
58
75
  order_by: Optional[Sequence[Any]] = None,
76
+ where: Optional[Sequence[Any]] = None,
59
77
  ) -> Sequence[Any]:
60
- stmt = self._base_select().limit(limit).offset(offset)
78
+ stmt = self._base_select()
79
+ if where:
80
+ stmt = stmt.where(and_(*where))
81
+ stmt = stmt.limit(limit).offset(offset)
61
82
  if order_by:
62
83
  stmt = stmt.order_by(*order_by)
63
- rows = (await session.execute(stmt)).scalars().all()
64
- return rows
65
-
66
- async def count(self, session: AsyncSession) -> int:
67
- stmt = select(func.count()).select_from(self._base_select().subquery())
68
- return (await session.execute(stmt)).scalar_one()
69
-
70
- async def get(self, session: AsyncSession, id_value: Any) -> Any | None:
84
+ result = (await session.execute(stmt)).scalars().all()
85
+ return list(result)
86
+
87
+ async def count(
88
+ self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None
89
+ ) -> int:
90
+ base = self._base_select()
91
+ if where:
92
+ base = base.where(and_(*where))
93
+ stmt = select(func.count()).select_from(base.subquery())
94
+ return int((await session.execute(stmt)).scalar_one())
95
+
96
+ async def get(
97
+ self,
98
+ session: AsyncSession,
99
+ id_value: Any,
100
+ *,
101
+ where: Optional[Sequence[Any]] = None,
102
+ ) -> Any | None:
71
103
  # honors soft-delete if configured
72
104
  stmt = self._base_select().where(self._id_column() == id_value)
105
+ if where:
106
+ stmt = stmt.where(and_(*where))
73
107
  return (await session.execute(stmt)).scalars().first()
74
108
 
75
109
  async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
@@ -78,12 +112,18 @@ class SqlRepository:
78
112
  obj = self.model(**filtered)
79
113
  session.add(obj)
80
114
  await session.flush()
115
+ await session.refresh(obj)
81
116
  return obj
82
117
 
83
118
  async def update(
84
- self, session: AsyncSession, id_value: Any, data: dict[str, Any]
119
+ self,
120
+ session: AsyncSession,
121
+ id_value: Any,
122
+ data: dict[str, Any],
123
+ *,
124
+ where: Optional[Sequence[Any]] = None,
85
125
  ) -> Any | None:
86
- obj = await self.get(session, id_value)
126
+ obj = await self.get(session, id_value, where=where)
87
127
  if not obj:
88
128
  return None
89
129
  valid = self._model_columns()
@@ -91,21 +131,40 @@ class SqlRepository:
91
131
  if k in valid and k not in self.immutable_fields:
92
132
  setattr(obj, k, v)
93
133
  await session.flush()
134
+ await session.refresh(obj)
94
135
  return obj
95
136
 
96
- async def delete(self, session: AsyncSession, id_value: Any) -> bool:
97
- obj = await session.get(self.model, id_value)
137
+ async def delete(
138
+ self,
139
+ session: AsyncSession,
140
+ id_value: Any,
141
+ *,
142
+ where: Optional[Sequence[Any]] = None,
143
+ ) -> bool:
144
+ # Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
145
+ if not where:
146
+ obj = await session.get(self.model, id_value)
147
+ else:
148
+ # Respect soft-delete and optional tenant/extra filters by selecting through base select
149
+ stmt = self._base_select().where(self._id_column() == id_value)
150
+ stmt = stmt.where(and_(*where))
151
+ obj = (await session.execute(stmt)).scalars().first()
98
152
  if not obj:
99
153
  return False
100
154
  if self.soft_delete:
101
155
  # Prefer timestamp, also optionally set flag to False
102
- if hasattr(self.model, self.soft_delete_field):
156
+ # Check attributes on the instance to support test doubles without class-level fields
157
+ if hasattr(obj, self.soft_delete_field):
103
158
  setattr(obj, self.soft_delete_field, func.now())
104
- if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
159
+ if self.soft_delete_flag_field and hasattr(
160
+ obj, self.soft_delete_flag_field
161
+ ):
105
162
  setattr(obj, self.soft_delete_flag_field, False)
106
163
  await session.flush()
107
164
  return True
108
- await session.delete(obj)
165
+ delete_result = session.delete(obj)
166
+ if inspect.isawaitable(delete_result):
167
+ await delete_result
109
168
  await session.flush()
110
169
  return True
111
170
 
@@ -118,18 +177,22 @@ class SqlRepository:
118
177
  limit: int,
119
178
  offset: int,
120
179
  order_by: Optional[Sequence[Any]] = None,
180
+ where: Optional[Sequence[Any]] = None,
121
181
  ) -> Sequence[Any]:
122
- ilike = f"%{q}%"
182
+ ilike = f"%{_escape_ilike(q)}%"
123
183
  conditions = []
124
184
  for f in fields:
125
185
  col = getattr(self.model, f, None)
126
186
  if col is not None:
127
187
  try:
128
188
  conditions.append(col.cast(String).ilike(ilike))
129
- except Exception:
189
+ except Exception as e:
130
190
  # skip columns that cannot be used in ilike even with cast
191
+ logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
131
192
  continue
132
193
  stmt = self._base_select()
194
+ if where:
195
+ stmt = stmt.where(and_(*where))
133
196
  if conditions:
134
197
  stmt = stmt.where(or_(*conditions))
135
198
  stmt = stmt.limit(limit).offset(offset)
@@ -137,17 +200,27 @@ class SqlRepository:
137
200
  stmt = stmt.order_by(*order_by)
138
201
  return (await session.execute(stmt)).scalars().all()
139
202
 
140
- async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
141
- ilike = f"%{q}%"
203
+ async def count_filtered(
204
+ self,
205
+ session: AsyncSession,
206
+ *,
207
+ q: str,
208
+ fields: Sequence[str],
209
+ where: Optional[Sequence[Any]] = None,
210
+ ) -> int:
211
+ ilike = f"%{_escape_ilike(q)}%"
142
212
  conditions = []
143
213
  for f in fields:
144
214
  col = getattr(self.model, f, None)
145
215
  if col is not None:
146
216
  try:
147
217
  conditions.append(col.cast(String).ilike(ilike))
148
- except Exception:
218
+ except Exception as e:
219
+ logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
149
220
  continue
150
221
  stmt = self._base_select()
222
+ if where:
223
+ stmt = stmt.where(and_(*where))
151
224
  if conditions:
152
225
  stmt = stmt.where(or_(*conditions))
153
226
  # SELECT COUNT(*) FROM (<stmt>) as t
@@ -34,3 +34,8 @@ class SqlResource:
34
34
 
35
35
  # Only a type reference; no runtime dependency on FastAPI layer
36
36
  service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
37
+
38
+ # Tenancy
39
+ tenant_field: Optional[str] = (
40
+ None # when set, CRUD router will require TenantId and scope by field
41
+ )
@@ -9,7 +9,9 @@ from svc_infra.utils import ensure_init_py, render_template, write
9
9
 
10
10
  # ---------------- helpers ----------------
11
11
 
12
- _INIT_CONTENT_PAIRED = 'from . import models, schemas\n\n__all__ = ["models", "schemas"]\n'
12
+ _INIT_CONTENT_PAIRED = (
13
+ 'from . import models, schemas\n\n__all__ = ["models", "schemas"]\n'
14
+ )
13
15
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
14
16
 
15
17
 
@@ -102,7 +104,9 @@ def scaffold_core(
102
104
  },
103
105
  )
104
106
 
105
- tenant_schema_field = " tenant_id: Optional[str] = None\n" if include_tenant else ""
107
+ tenant_schema_field = (
108
+ " tenant_id: Optional[str] = None\n" if include_tenant else ""
109
+ )
106
110
  schemas_txt = render_template(
107
111
  tmpl_dir="svc_infra.db.sql.templates.models_schemas.entity",
108
112
  name="schemas.py.tmpl",
@@ -24,8 +24,12 @@ class SqlService:
24
24
  async def pre_update(self, data: dict[str, Any]) -> dict[str, Any]:
25
25
  return data
26
26
 
27
- async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
28
- return await self.repo.list(session, limit=limit, offset=offset, order_by=order_by)
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
+ )
29
33
 
30
34
  async def count(self, session: AsyncSession) -> int:
31
35
  return await self.repo.count(session)
@@ -41,9 +45,13 @@ class SqlService:
41
45
  # unique constraint or not-null -> 409/400 instead of 500
42
46
  msg = str(e.orig) if getattr(e, "orig", None) else str(e)
43
47
  if "duplicate key value" in msg or "UniqueViolation" in msg:
44
- raise HTTPException(status_code=409, detail="Record already exists.") from e
48
+ raise HTTPException(
49
+ status_code=409, detail="Record already exists."
50
+ ) from e
45
51
  if "not-null" in msg or "NotNullViolation" in msg:
46
- raise HTTPException(status_code=400, detail="Missing required field.") from e
52
+ raise HTTPException(
53
+ status_code=400, detail="Missing required field."
54
+ ) from e
47
55
  raise # unknown, let your error middleware turn into 500
48
56
 
49
57
  async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
@@ -67,7 +75,9 @@ class SqlService:
67
75
  session, q=q, fields=fields, limit=limit, offset=offset, order_by=order_by
68
76
  )
69
77
 
70
- async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
78
+ async def count_filtered(
79
+ self, session: AsyncSession, *, q: str, fields: Sequence[str]
80
+ ) -> int:
71
81
  return await self.repo.count_filtered(session, q=q, fields=fields)
72
82
 
73
83
  async def exists(self, session: AsyncSession, *, where):