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
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from typing import Callable, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class BackupHealthReport:
10
+ ok: bool
11
+ last_success: Optional[datetime]
12
+ retention_days: Optional[int]
13
+ message: str = ""
14
+
15
+
16
+ def verify_backups(
17
+ *, last_success: Optional[datetime] = None, retention_days: Optional[int] = None
18
+ ) -> BackupHealthReport:
19
+ """Return a basic backup health report.
20
+
21
+ In production, callers should plug a provider-specific checker and translate into this report.
22
+ """
23
+ if last_success is None:
24
+ return BackupHealthReport(
25
+ ok=False,
26
+ last_success=None,
27
+ retention_days=retention_days,
28
+ message="no_backup_seen",
29
+ )
30
+ now = datetime.now(timezone.utc)
31
+ age_days = (now - last_success).total_seconds() / 86400.0
32
+ ok = retention_days is None or age_days <= max(1, retention_days)
33
+ return BackupHealthReport(
34
+ ok=ok, last_success=last_success, retention_days=retention_days
35
+ )
36
+
37
+
38
+ __all__ = ["BackupHealthReport", "verify_backups"]
39
+
40
+
41
+ def make_backup_verification_job(
42
+ checker: Callable[[], BackupHealthReport],
43
+ *,
44
+ on_report: Optional[Callable[[BackupHealthReport], None]] = None,
45
+ ):
46
+ """Return a callable suitable for scheduling in a job runner.
47
+
48
+ The checker should perform provider-specific checks and return a BackupHealthReport.
49
+ If on_report is provided, it will be invoked with the report.
50
+ """
51
+
52
+ def _job() -> BackupHealthReport:
53
+ rep = checker()
54
+ if on_report:
55
+ on_report(rep)
56
+ return rep
57
+
58
+ return _job
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Awaitable, Callable, Iterable, Optional, Protocol
5
+
6
+
7
+ class SqlSession(Protocol): # minimal protocol for tests/integration
8
+ async def execute(self, stmt: Any) -> Any:
9
+ pass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ErasureStep:
14
+ name: str
15
+ run: Callable[[SqlSession, str], Awaitable[int] | int]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ErasurePlan:
20
+ steps: Iterable[ErasureStep]
21
+
22
+
23
+ async def run_erasure(
24
+ session: SqlSession,
25
+ principal_id: str,
26
+ plan: ErasurePlan,
27
+ *,
28
+ on_audit: Optional[Callable[[str, dict[str, Any]], None]] = None,
29
+ ) -> int:
30
+ """Run an erasure plan and optionally emit an audit event.
31
+
32
+ Returns total affected rows across steps.
33
+ """
34
+ total = 0
35
+ for s in plan.steps:
36
+ res = s.run(session, principal_id)
37
+ if hasattr(res, "__await__"):
38
+ res = await res
39
+ total += int(res or 0)
40
+ if on_audit:
41
+ on_audit("erasure.completed", {"principal_id": principal_id, "affected": total})
42
+ return total
43
+
44
+
45
+ __all__ = ["ErasureStep", "ErasurePlan", "run_erasure"]
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from pathlib import Path
5
+ from typing import Awaitable, Callable, Iterable, Optional
6
+
7
+
8
+ async def run_fixtures(
9
+ loaders: Iterable[Callable[[], None | Awaitable[None]]],
10
+ *,
11
+ run_once_file: Optional[str] = None,
12
+ ) -> None:
13
+ """Run a sequence of fixture loaders (sync or async).
14
+
15
+ - If run_once_file is provided and exists, does nothing.
16
+ - On success, creates the run_once_file sentinel (parent dirs included).
17
+ """
18
+ if run_once_file:
19
+ sentinel = Path(run_once_file)
20
+ if sentinel.exists():
21
+ return
22
+ for fn in loaders:
23
+ res = fn()
24
+ if inspect.isawaitable(res):
25
+ await res
26
+ if run_once_file:
27
+ sentinel.parent.mkdir(parents=True, exist_ok=True)
28
+ Path(run_once_file).write_text("ok")
29
+
30
+
31
+ def make_on_load_fixtures(
32
+ *loaders: Callable[[], None | Awaitable[None]], run_once_file: Optional[str] = None
33
+ ) -> Callable[[], Awaitable[None]]:
34
+ """Return an async callable suitable for add_data_lifecycle(on_load_fixtures=...)."""
35
+
36
+ async def _runner() -> None:
37
+ await run_fixtures(loaders, run_once_file=run_once_file)
38
+
39
+ return _runner
40
+
41
+
42
+ __all__ = ["run_fixtures", "make_on_load_fixtures"]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any, Iterable, Optional, Protocol, Sequence
6
+
7
+
8
+ class SqlSession(Protocol): # minimal protocol for tests/integration
9
+ async def execute(self, stmt: Any) -> Any:
10
+ pass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RetentionPolicy:
15
+ name: str
16
+ model: Any # SQLAlchemy model or test double exposing columns
17
+ older_than_days: int
18
+ soft_delete_field: Optional[str] = "deleted_at"
19
+ extra_where: Optional[Sequence[Any]] = None
20
+ hard_delete: bool = False
21
+
22
+
23
+ async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
24
+ """Execute a single retention purge according to policy.
25
+
26
+ If hard_delete is False and soft_delete_field exists on model, set timestamp; else DELETE.
27
+ Returns number of affected rows (best-effort; test doubles may return an int directly).
28
+ """
29
+ cutoff = datetime.now(timezone.utc) - timedelta(days=policy.older_than_days)
30
+ m = policy.model
31
+ where = list(policy.extra_where or [])
32
+ created_col = getattr(m, "created_at", None)
33
+ if created_col is not None and hasattr(created_col, "__le__"):
34
+ where.append(created_col <= cutoff)
35
+
36
+ # Soft-delete path when available and requested
37
+ if (
38
+ not policy.hard_delete
39
+ and policy.soft_delete_field
40
+ and hasattr(m, policy.soft_delete_field)
41
+ ):
42
+ stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff})
43
+ res = await session.execute(stmt)
44
+ return getattr(res, "rowcount", 0)
45
+
46
+ # Hard delete fallback
47
+ stmt = m.delete().where(*where)
48
+ res = await session.execute(stmt)
49
+ return getattr(res, "rowcount", 0)
50
+
51
+
52
+ async def run_retention_purge(
53
+ session: SqlSession, policies: Iterable[RetentionPolicy]
54
+ ) -> int:
55
+ total = 0
56
+ for p in policies:
57
+ total += await purge_policy(session, p)
58
+ return total
59
+
60
+
61
+ __all__ = ["RetentionPolicy", "purge_policy", "run_retention_purge"]
svc_infra/db/__init__.py CHANGED
@@ -0,0 +1,15 @@
1
+ from svc_infra.db.ops import (
2
+ drop_table_safe,
3
+ get_database_url,
4
+ kill_blocking_queries,
5
+ run_sync_sql,
6
+ wait_for_database,
7
+ )
8
+
9
+ __all__ = [
10
+ "drop_table_safe",
11
+ "get_database_url",
12
+ "kill_blocking_queries",
13
+ "run_sync_sql",
14
+ "wait_for_database",
15
+ ]
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Any, Optional, Sequence
4
+ from typing import Any, Optional, Sequence, cast
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, create_model
7
7
 
@@ -27,9 +27,9 @@ class FieldSpec:
27
27
  exclude_from_update: bool = False
28
28
 
29
29
 
30
- def _opt(t: type[Any]) -> tuple[type[Any], object]:
30
+ def _opt(t: type[Any]) -> tuple[Any, Any]:
31
31
  # convenience: Optional[t] with default None
32
- return (t | None, None) # type: ignore[operator]
32
+ return (t | None, None)
33
33
 
34
34
 
35
35
  def make_crud_schemas_from_specs(
@@ -40,9 +40,9 @@ def make_crud_schemas_from_specs(
40
40
  update_name: Optional[str],
41
41
  json_encoders: Optional[dict[type[Any], Any]] = None,
42
42
  ) -> tuple[type[BaseModel], type[BaseModel], type[BaseModel]]:
43
- ann_read: dict[str, tuple[type, object]] = {}
44
- ann_create: dict[str, tuple[type, object]] = {}
45
- ann_update: dict[str, tuple[type, object]] = {}
43
+ ann_read: dict[str, tuple[Any, Any]] = {}
44
+ ann_create: dict[str, tuple[Any, Any]] = {}
45
+ ann_update: dict[str, tuple[Any, Any]] = {}
46
46
 
47
47
  for s in specs:
48
48
  # READ: include unless excluded; all fields Optional
@@ -60,9 +60,9 @@ def make_crud_schemas_from_specs(
60
60
  if not s.exclude_from_update:
61
61
  ann_update[s.name] = _opt(s.typ)
62
62
 
63
- Read = create_model(read_name or "Read", **ann_read) # type: ignore[arg-type]
64
- Create = create_model(create_name or "Create", **ann_create) # type: ignore[arg-type]
65
- Update = create_model(update_name or "Update", **ann_update) # type: ignore[arg-type]
63
+ Read = create_model(read_name or "Read", **cast(dict[str, Any], ann_read))
64
+ Create = create_model(create_name or "Create", **cast(dict[str, Any], ann_create))
65
+ Update = create_model(update_name or "Update", **cast(dict[str, Any], ann_update))
66
66
 
67
67
  cfg = ConfigDict(from_attributes=True)
68
68
  if json_encoders:
svc_infra/db/inbox.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Protocol
5
+
6
+
7
+ class InboxStore(Protocol):
8
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
9
+ """Mark key as processed if not seen; return True if newly marked, False if duplicate."""
10
+ ...
11
+
12
+ def purge_expired(self) -> int:
13
+ """Optional: remove expired keys, return number purged."""
14
+ ...
15
+
16
+ def is_marked(self, key: str) -> bool:
17
+ """Return True if key is already marked (not expired), without modifying it."""
18
+ ...
19
+
20
+
21
+ class InMemoryInboxStore:
22
+ def __init__(self) -> None:
23
+ self._keys: dict[str, float] = {}
24
+
25
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
26
+ now = time.time()
27
+ exp = self._keys.get(key)
28
+ if exp and exp > now:
29
+ return False
30
+ self._keys[key] = now + ttl_seconds
31
+ return True
32
+
33
+ def purge_expired(self) -> int:
34
+ now = time.time()
35
+ to_del = [k for k, e in self._keys.items() if e <= now]
36
+ for k in to_del:
37
+ self._keys.pop(k, None)
38
+ return len(to_del)
39
+
40
+ def is_marked(self, key: str) -> bool:
41
+ now = time.time()
42
+ exp = self._keys.get(key)
43
+ return bool(exp and exp > now)
44
+
45
+
46
+ class SqlInboxStore:
47
+ """Skeleton for a SQL-backed inbox store (dedupe table).
48
+
49
+ Implementations should:
50
+ - INSERT key with expires_at if not exists (unique constraint)
51
+ - Return False on duplicate key violations
52
+ - Periodically DELETE expired rows
53
+ """
54
+
55
+ def __init__(self, session_factory):
56
+ self._session_factory = session_factory
57
+
58
+ def mark_if_new(
59
+ self, key: str, ttl_seconds: int = 24 * 3600
60
+ ) -> bool: # pragma: no cover - skeleton
61
+ raise NotImplementedError
62
+
63
+ def purge_expired(self) -> int: # pragma: no cover - skeleton
64
+ raise NotImplementedError
65
+
66
+ def is_marked(self, key: str) -> bool: # pragma: no cover - skeleton
67
+ raise NotImplementedError
@@ -1,7 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  from svc_infra.db.nosql.resource import NoSqlResource
2
4
 
3
5
  from .repository import NoSqlRepository
4
6
 
7
+
5
8
  __all__ = [
6
9
  "NoSqlResource",
7
10
  "NoSqlRepository",
@@ -1,13 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Iterable, Sequence
4
+ from typing import Any, Iterable, Sequence, cast
5
5
 
6
- from motor.motor_asyncio import AsyncIOMotorDatabase
7
- from pymongo import IndexModel
6
+ try:
7
+ from motor.motor_asyncio import AsyncIOMotorDatabase
8
+ from pymongo import IndexModel
9
+
10
+ HAS_MOTOR = True
11
+ except ImportError: # pragma: no cover
12
+ HAS_MOTOR = False
13
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
14
+ IndexModel = Any # type: ignore[assignment, misc]
8
15
 
9
16
  from svc_infra.db.nosql.indexes import normalize_indexes
10
- from svc_infra.db.nosql.mongo.client import acquire_db, close_mongo, init_mongo, ping_mongo
17
+ from svc_infra.db.nosql.mongo.client import (
18
+ acquire_db,
19
+ close_mongo,
20
+ init_mongo,
21
+ ping_mongo,
22
+ )
11
23
  from svc_infra.db.nosql.resource import NoSqlResource
12
24
  from svc_infra.db.nosql.utils import (
13
25
  get_mongo_dbname_from_env,
@@ -34,7 +46,8 @@ async def _apply_indexes(
34
46
  ) -> list[str]:
35
47
  if not indexes:
36
48
  return []
37
- return await db[collection].create_indexes(list(indexes))
49
+ result = await db[collection].create_indexes(list(indexes))
50
+ return cast(list[str], result)
38
51
 
39
52
 
40
53
  # collection + doc used to "lock" the chosen DB name for this app
@@ -48,7 +61,9 @@ async def assert_db_locked(
48
61
  registry = db.client.get_database(_REG_DB)
49
62
  await registry[_REG_COLL].create_index("service_id", unique=True)
50
63
 
51
- doc = await registry[_REG_COLL].find_one({"service_id": service_id}, projection={"db_name": 1})
64
+ doc = await registry[_REG_COLL].find_one(
65
+ {"service_id": service_id}, projection={"db_name": 1}
66
+ )
52
67
  if doc is None:
53
68
  await registry[_REG_COLL].insert_one(
54
69
  {"service_id": service_id, "db_name": expected_db_name}
@@ -91,9 +106,13 @@ async def prepare_mongo(
91
106
 
92
107
  expected_db = get_mongo_dbname_from_env(required=True)
93
108
  if db.name != expected_db:
94
- raise RuntimeError(f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'.")
109
+ raise RuntimeError(
110
+ f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'."
111
+ )
95
112
 
96
- await assert_db_locked(db, expected_db, service_id=service_id, allow_rebind=allow_rebind)
113
+ await assert_db_locked(
114
+ db, expected_db, service_id=service_id, allow_rebind=allow_rebind
115
+ )
97
116
 
98
117
  # collections
99
118
  colls = [r.resolved_collection() for r in resources]
@@ -109,7 +128,9 @@ async def prepare_mongo(
109
128
  names = await _apply_indexes(db, collection=coll, indexes=idx_models)
110
129
  created_idx[coll] = names
111
130
 
112
- return PrepareResult(ok=True, created_collections=created_colls, created_indexes=created_idx)
131
+ return PrepareResult(
132
+ ok=True, created_collections=created_colls, created_indexes=created_idx
133
+ )
113
134
 
114
135
 
115
136
  def setup_and_prepare(
@@ -66,7 +66,9 @@ def normalize_index(idx: Union[IndexModel, Alias]) -> IndexModel:
66
66
  return IndexModel(keys, **kwargs)
67
67
 
68
68
 
69
- def normalize_indexes(indexes: Optional[Iterable[Union[IndexModel, Alias]]]) -> List[IndexModel]:
69
+ def normalize_indexes(
70
+ indexes: Optional[Iterable[Union[IndexModel, Alias]]],
71
+ ) -> List[IndexModel]:
70
72
  if not indexes:
71
73
  return []
72
74
  return [normalize_index(i) for i in indexes]
@@ -83,7 +83,7 @@ def make_document_crud_schemas(
83
83
  )
84
84
 
85
85
  # Backstop encoders in case any exotic types slip through
86
- encoders = {ObjectId: str, PyObjectId: str}
86
+ encoders: dict[type, Any] = {ObjectId: str, PyObjectId: str}
87
87
  if json_encoders:
88
88
  encoders.update(json_encoders)
89
89
 
@@ -29,17 +29,17 @@ We provide four CLI commands. You can register them on your Typer app or invoke
29
29
 
30
30
  ### Commands
31
31
 
32
- - `mongo-scaffold` — create both document **and** CRUD schemas
33
- - `mongo-scaffold-documents` — create only the **document** model (Pydantic)
34
- - `mongo-scaffold-schemas` — create only the **CRUD schemas**
35
- - `mongo-scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
32
+ - `mongo scaffold` — create both document **and** CRUD schemas
33
+ - `mongo scaffold-documents` — create only the **document** model (Pydantic)
34
+ - `mongo scaffold-schemas` — create only the **CRUD schemas**
35
+ - `mongo scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
36
36
 
37
37
  ### Typical usage
38
38
 
39
39
  #### A) Scaffold documents + schemas together
40
40
 
41
41
  ```bash
42
- yourapp mongo-scaffold \
42
+ yourapp mongo scaffold \
43
43
  --entity-name Product \
44
44
  --documents-dir ./src/your_app/products \
45
45
  --schemas-dir ./src/your_app/products \
@@ -57,7 +57,7 @@ src/your_app/products/schemas.py # ProductRead/ProductCreate/ProductUpdate
57
57
  B) Documents only
58
58
 
59
59
  ```bash
60
- yourapp mongo-scaffold-documents \
60
+ yourapp mongo scaffold-documents \
61
61
  --dest-dir ./src/your_app/products \
62
62
  --entity-name Product \
63
63
  --documents-filename product_doc.py
@@ -66,7 +66,7 @@ yourapp mongo-scaffold-documents \
66
66
  C) Schemas only
67
67
 
68
68
  ```bash
69
- yourapp mongo-scaffold-schemas \
69
+ yourapp mongo scaffold-schemas \
70
70
  --dest-dir ./src/your_app/products \
71
71
  --entity-name Product \
72
72
  --schemas-filename product_schemas.py
@@ -75,7 +75,7 @@ yourapp mongo-scaffold-schemas \
75
75
  D) Starter resources.py
76
76
 
77
77
  ```bash
78
- yourapp mongo-scaffold-resources \
78
+ yourapp mongo scaffold-resources \
79
79
  --dest-dir ./src/your_app/mongo \
80
80
  --filename resources.py \
81
81
  --overwrite
@@ -131,7 +131,7 @@ There are two flavors:
131
131
  A) Async, minimal (connect, create collections, apply indexes)
132
132
 
133
133
  ```bash
134
- yourapp mongo-prepare \
134
+ yourapp mongo prepare \
135
135
  --resources your_app.mongo.resources:RESOURCES \
136
136
  --mongo-url "$MONGO_URL" \
137
137
  --mongo-db "$MONGO_DB"
@@ -140,7 +140,7 @@ yourapp mongo-prepare \
140
140
  B) Synchronous wrapper (end-to-end convenience)
141
141
 
142
142
  ```bash
143
- yourapp mongo-setup-and-prepare \
143
+ yourapp mongo setup-and-prepare \
144
144
  --resources your_app.mongo.resources:RESOURCES \
145
145
  --mongo-url "$MONGO_URL" \
146
146
  --mongo-db "$MONGO_DB"
@@ -149,7 +149,7 @@ yourapp mongo-setup-and-prepare \
149
149
  You can also ping connectivity:
150
150
 
151
151
  ```bash
152
- yourapp mongo-ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
152
+ yourapp mongo ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
153
153
  ```
154
154
 
155
155
  Behind the scenes, preparation also locks a service ID to a DB name to prevent accidental cross-DB usage. You can pass --allow-rebind if you intentionally move environments.
@@ -430,9 +430,9 @@ NoSqlResource(
430
430
  • If using explicit schemas with PyObjectId, make sure model_config.json_encoders includes {PyObjectId: str}.
431
431
  • When using auto-schemas, we expose ObjectId-like fields as str so no custom encoder is needed.
432
432
  • Connected to wrong DB name
433
- • The system locks a service_id to the DB name once prepared. If you change DBs, run mongo-prepare with --allow-rebind.
433
+ • The system locks a service_id to the DB name once prepared. If you change DBs, run `mongo prepare` with --allow-rebind.
434
434
  • Indexes not created
435
- • Double-check RESOURCES[indexes]. Run mongo-prepare again and inspect the output dictionary of created indexes.
435
+ • Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
436
436
 
437
437
 
438
438
 
@@ -1,8 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Optional
3
+ from typing import Any, Optional
4
4
 
5
- from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
5
+ try:
6
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
7
+
8
+ HAS_MOTOR = True
9
+ except ImportError: # pragma: no cover
10
+ HAS_MOTOR = False
11
+ AsyncIOMotorClient = Any # type: ignore[assignment, misc]
12
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
6
13
 
7
14
  from .settings import MongoSettings
8
15
 
@@ -10,6 +17,15 @@ _client: Optional[AsyncIOMotorClient] = None
10
17
  _db: Optional[AsyncIOMotorDatabase] = None
11
18
 
12
19
 
20
+ def _require_motor() -> None:
21
+ """Raise ImportError if motor is not installed."""
22
+ if not HAS_MOTOR:
23
+ raise ImportError(
24
+ "MongoDB support requires the 'motor' package. "
25
+ "Install with: pip install svc-infra[mongodb]"
26
+ )
27
+
28
+
13
29
  def _client_opts(cfg: MongoSettings) -> dict:
14
30
  return {
15
31
  "appname": cfg.appname,
@@ -20,6 +36,7 @@ def _client_opts(cfg: MongoSettings) -> dict:
20
36
 
21
37
 
22
38
  async def init_mongo(cfg: MongoSettings | None = None) -> AsyncIOMotorDatabase:
39
+ _require_motor()
23
40
  global _client, _db
24
41
  cfg = cfg or MongoSettings()
25
42
  if _client is None:
@@ -6,8 +6,12 @@ from pydantic import AnyUrl, BaseModel, Field
6
6
 
7
7
 
8
8
  class MongoSettings(BaseModel):
9
- url: AnyUrl = Field(default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017"))
9
+ url: AnyUrl = Field(
10
+ default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017")
11
+ ) # type: ignore[assignment]
10
12
  db_name: str = Field(default_factory=lambda: os.getenv("MONGO_DB", ""))
11
- appname: str = Field(default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra"))
13
+ appname: str = Field(
14
+ default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra")
15
+ )
12
16
  min_pool_size: int = int(os.getenv("MONGO_MIN_POOL", "0"))
13
17
  max_pool_size: int = int(os.getenv("MONGO_MAX_POOL", "100"))