svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,40 @@
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]]], *, run_once_file: Optional[str] = None
10
+ ) -> None:
11
+ """Run a sequence of fixture loaders (sync or async).
12
+
13
+ - If run_once_file is provided and exists, does nothing.
14
+ - On success, creates the run_once_file sentinel (parent dirs included).
15
+ """
16
+ if run_once_file:
17
+ sentinel = Path(run_once_file)
18
+ if sentinel.exists():
19
+ return
20
+ for fn in loaders:
21
+ res = fn()
22
+ if inspect.isawaitable(res): # type: ignore[arg-type]
23
+ await res # type: ignore[misc]
24
+ if run_once_file:
25
+ sentinel.parent.mkdir(parents=True, exist_ok=True)
26
+ Path(run_once_file).write_text("ok")
27
+
28
+
29
+ def make_on_load_fixtures(
30
+ *loaders: Callable[[], None | Awaitable[None]], run_once_file: Optional[str] = None
31
+ ) -> Callable[[], Awaitable[None]]:
32
+ """Return an async callable suitable for add_data_lifecycle(on_load_fixtures=...)."""
33
+
34
+ async def _runner() -> None:
35
+ await run_fixtures(loaders, run_once_file=run_once_file)
36
+
37
+ return _runner
38
+
39
+
40
+ __all__ = ["run_fixtures", "make_on_load_fixtures"]
@@ -0,0 +1,55 @@
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) # type: ignore[operator]
35
+
36
+ # Soft-delete path when available and requested
37
+ if not policy.hard_delete and policy.soft_delete_field and hasattr(m, policy.soft_delete_field):
38
+ stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff}) # type: ignore[attr-defined]
39
+ res = await session.execute(stmt)
40
+ return getattr(res, "rowcount", 0)
41
+
42
+ # Hard delete fallback
43
+ stmt = m.delete().where(*where) # type: ignore[attr-defined]
44
+ res = await session.execute(stmt)
45
+ return getattr(res, "rowcount", 0)
46
+
47
+
48
+ async def run_retention_purge(session: SqlSession, policies: Iterable[RetentionPolicy]) -> int:
49
+ total = 0
50
+ for p in policies:
51
+ total += await purge_policy(session, p)
52
+ return total
53
+
54
+
55
+ __all__ = ["RetentionPolicy", "purge_policy", "run_retention_purge"]
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
@@ -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
 
svc_infra/db/outbox.py ADDED
@@ -0,0 +1,104 @@
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(self, *, topics: Optional[Iterable[str]] = None) -> Optional[OutboxMessage]:
23
+ """Return the next undispatched, unprocessed message (FIFO per-topic), or None.
24
+
25
+ Notes:
26
+ - Messages with attempts > 0 are considered "dispatched" to the job queue and won't be re-enqueued.
27
+ - Delivery retries are handled by the job queue worker, not by re-reading the outbox.
28
+ """
29
+ pass
30
+
31
+ def mark_processed(self, msg_id: int) -> None:
32
+ pass
33
+
34
+ def mark_failed(self, msg_id: int) -> None:
35
+ pass
36
+
37
+
38
+ class InMemoryOutboxStore:
39
+ """Simple in-memory outbox for tests and local runs."""
40
+
41
+ def __init__(self):
42
+ self._seq = 0
43
+ self._messages: List[OutboxMessage] = []
44
+
45
+ def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
46
+ self._seq += 1
47
+ msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
48
+ self._messages.append(msg)
49
+ return msg
50
+
51
+ def fetch_next(self, *, topics: Optional[Iterable[str]] = None) -> Optional[OutboxMessage]:
52
+ allowed = set(topics) if topics else None
53
+ for msg in self._messages:
54
+ if msg.processed_at is not None:
55
+ continue
56
+ # skip already dispatched messages (attempts>0)
57
+ if msg.attempts > 0:
58
+ continue
59
+ if allowed is not None and msg.topic not in allowed:
60
+ continue
61
+ return msg
62
+ return None
63
+
64
+ def mark_processed(self, msg_id: int) -> None:
65
+ for msg in self._messages:
66
+ if msg.id == msg_id:
67
+ msg.processed_at = datetime.now(timezone.utc)
68
+ return
69
+
70
+ def mark_failed(self, msg_id: int) -> None:
71
+ for msg in self._messages:
72
+ if msg.id == msg_id:
73
+ msg.attempts += 1
74
+ return
75
+
76
+
77
+ class SqlOutboxStore:
78
+ """Skeleton for a SQL-backed outbox store.
79
+
80
+ Implementations should:
81
+ - INSERT on enqueue
82
+ - SELECT FOR UPDATE SKIP LOCKED (or equivalent) to fetch next
83
+ - UPDATE processed_at (and attempts on failure)
84
+ """
85
+
86
+ def __init__(self, session_factory):
87
+ self._session_factory = session_factory
88
+
89
+ # Placeholders to outline the API; not implemented here.
90
+ def enqueue(
91
+ self, topic: str, payload: Dict[str, Any]
92
+ ) -> OutboxMessage: # pragma: no cover - skeleton
93
+ raise NotImplementedError
94
+
95
+ def fetch_next(
96
+ self, *, topics: Optional[Iterable[str]] = None
97
+ ) -> Optional[OutboxMessage]: # pragma: no cover - skeleton
98
+ raise NotImplementedError
99
+
100
+ def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton
101
+ raise NotImplementedError
102
+
103
+ def mark_failed(self, msg_id: int) -> None: # pragma: no cover - skeleton
104
+ raise NotImplementedError
@@ -56,20 +56,31 @@ class SqlRepository:
56
56
  limit: int,
57
57
  offset: int,
58
58
  order_by: Optional[Sequence[Any]] = None,
59
+ where: Optional[Sequence[Any]] = None,
59
60
  ) -> Sequence[Any]:
60
- stmt = self._base_select().limit(limit).offset(offset)
61
+ stmt = self._base_select()
62
+ if where:
63
+ stmt = stmt.where(and_(*where))
64
+ stmt = stmt.limit(limit).offset(offset)
61
65
  if order_by:
62
66
  stmt = stmt.order_by(*order_by)
63
67
  rows = (await session.execute(stmt)).scalars().all()
64
68
  return rows
65
69
 
66
- async def count(self, session: AsyncSession) -> int:
67
- stmt = select(func.count()).select_from(self._base_select().subquery())
70
+ async def count(self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None) -> int:
71
+ base = self._base_select()
72
+ if where:
73
+ base = base.where(and_(*where))
74
+ stmt = select(func.count()).select_from(base.subquery())
68
75
  return (await session.execute(stmt)).scalar_one()
69
76
 
70
- async def get(self, session: AsyncSession, id_value: Any) -> Any | None:
77
+ async def get(
78
+ self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
79
+ ) -> Any | None:
71
80
  # honors soft-delete if configured
72
81
  stmt = self._base_select().where(self._id_column() == id_value)
82
+ if where:
83
+ stmt = stmt.where(and_(*where))
73
84
  return (await session.execute(stmt)).scalars().first()
74
85
 
75
86
  async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
@@ -78,12 +89,18 @@ class SqlRepository:
78
89
  obj = self.model(**filtered)
79
90
  session.add(obj)
80
91
  await session.flush()
92
+ await session.refresh(obj)
81
93
  return obj
82
94
 
83
95
  async def update(
84
- self, session: AsyncSession, id_value: Any, data: dict[str, Any]
96
+ self,
97
+ session: AsyncSession,
98
+ id_value: Any,
99
+ data: dict[str, Any],
100
+ *,
101
+ where: Optional[Sequence[Any]] = None,
85
102
  ) -> Any | None:
86
- obj = await self.get(session, id_value)
103
+ obj = await self.get(session, id_value, where=where)
87
104
  if not obj:
88
105
  return None
89
106
  valid = self._model_columns()
@@ -91,21 +108,32 @@ class SqlRepository:
91
108
  if k in valid and k not in self.immutable_fields:
92
109
  setattr(obj, k, v)
93
110
  await session.flush()
111
+ await session.refresh(obj)
94
112
  return obj
95
113
 
96
- async def delete(self, session: AsyncSession, id_value: Any) -> bool:
97
- obj = await session.get(self.model, id_value)
114
+ async def delete(
115
+ self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
116
+ ) -> bool:
117
+ # Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
118
+ if not where:
119
+ obj = await session.get(self.model, id_value)
120
+ else:
121
+ # Respect soft-delete and optional tenant/extra filters by selecting through base select
122
+ stmt = self._base_select().where(self._id_column() == id_value)
123
+ stmt = stmt.where(and_(*where))
124
+ obj = (await session.execute(stmt)).scalars().first()
98
125
  if not obj:
99
126
  return False
100
127
  if self.soft_delete:
101
128
  # Prefer timestamp, also optionally set flag to False
102
- if hasattr(self.model, self.soft_delete_field):
129
+ # Check attributes on the instance to support test doubles without class-level fields
130
+ if hasattr(obj, self.soft_delete_field):
103
131
  setattr(obj, self.soft_delete_field, func.now())
104
- if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
132
+ if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
105
133
  setattr(obj, self.soft_delete_flag_field, False)
106
134
  await session.flush()
107
135
  return True
108
- await session.delete(obj)
136
+ session.delete(obj)
109
137
  await session.flush()
110
138
  return True
111
139
 
@@ -118,6 +146,7 @@ class SqlRepository:
118
146
  limit: int,
119
147
  offset: int,
120
148
  order_by: Optional[Sequence[Any]] = None,
149
+ where: Optional[Sequence[Any]] = None,
121
150
  ) -> Sequence[Any]:
122
151
  ilike = f"%{q}%"
123
152
  conditions = []
@@ -130,6 +159,8 @@ class SqlRepository:
130
159
  # skip columns that cannot be used in ilike even with cast
131
160
  continue
132
161
  stmt = self._base_select()
162
+ if where:
163
+ stmt = stmt.where(and_(*where))
133
164
  if conditions:
134
165
  stmt = stmt.where(or_(*conditions))
135
166
  stmt = stmt.limit(limit).offset(offset)
@@ -137,7 +168,14 @@ class SqlRepository:
137
168
  stmt = stmt.order_by(*order_by)
138
169
  return (await session.execute(stmt)).scalars().all()
139
170
 
140
- async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
171
+ async def count_filtered(
172
+ self,
173
+ session: AsyncSession,
174
+ *,
175
+ q: str,
176
+ fields: Sequence[str],
177
+ where: Optional[Sequence[Any]] = None,
178
+ ) -> int:
141
179
  ilike = f"%{q}%"
142
180
  conditions = []
143
181
  for f in fields:
@@ -148,6 +186,8 @@ class SqlRepository:
148
186
  except Exception:
149
187
  continue
150
188
  stmt = self._base_select()
189
+ if where:
190
+ stmt = stmt.where(and_(*where))
151
191
  if conditions:
152
192
  stmt = stmt.where(or_(*conditions))
153
193
  # 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
+ )
@@ -18,7 +18,7 @@ class Timestamped(BaseModel):
18
18
 
19
19
  class ProviderAccountBase(BaseModel):
20
20
  model_config = ConfigDict(from_attributes=True)
21
- provider: str = Field(..., examples=["google", "github", "linkedin", "microsoft"])
21
+ provider: str = Field(..., json_schema_extra={"examples": ["google", "github", "linkedin", "microsoft"]})
22
22
  provider_account_id: str
23
23
 
24
24
  class ProviderAccountRead(ProviderAccountBase, Timestamped):
@@ -7,12 +7,8 @@ import sys, pathlib, importlib, pkgutil, traceback
7
7
 
8
8
  from alembic import context
9
9
  from sqlalchemy.engine import make_url, URL
10
- from sqlalchemy.ext.asyncio import create_async_engine
11
10
 
12
- from svc_infra.db.sql.utils import (
13
- get_database_url_from_env,
14
- _ensure_ssl_default_async as _ensure_ssl_default,
15
- )
11
+ from svc_infra.db.sql.utils import get_database_url_from_env
16
12
 
17
13
  try:
18
14
  from svc_infra.db.sql.types import GUID as _GUID # type: ignore
@@ -105,7 +101,6 @@ def _coerce_to_async(u: URL) -> URL:
105
101
 
106
102
  u = make_url(effective_url)
107
103
  u = _coerce_to_async(u)
108
- u = _ensure_ssl_default(u)
109
104
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
110
105
 
111
106
  # feature flags
@@ -177,8 +172,16 @@ def _collect_metadata() -> list[object]:
177
172
  if name not in pkgs:
178
173
  pkgs.append(name)
179
174
 
175
+ # Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
180
176
  if "models" not in pkgs:
181
- pkgs.append("models")
177
+ try:
178
+ spec = getattr(importlib, "util", None)
179
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
180
+ if spec.find_spec("models") is not None:
181
+ pkgs.append("models")
182
+ except Exception:
183
+ # Best-effort; if discovery fails, skip adding bare 'models'
184
+ pass
182
185
 
183
186
  def _import_and_collect(modname: str):
184
187
  try:
@@ -352,7 +355,9 @@ def _do_run_migrations(connection):
352
355
 
353
356
  async def run_migrations_online() -> None:
354
357
  url = config.get_main_option("sqlalchemy.url")
355
- engine = create_async_engine(url)
358
+ # Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
359
+ from svc_infra.db.sql.utils import build_engine
360
+ engine = build_engine(url)
356
361
  async with engine.connect() as connection:
357
362
  await connection.run_sync(_do_run_migrations)
358
363
  await engine.dispose()
@@ -9,8 +9,6 @@ from alembic import context
9
9
  from sqlalchemy.engine import make_url, URL
10
10
 
11
11
  from svc_infra.db.sql.utils import (
12
- _coerce_sync_driver,
13
- _ensure_ssl_default,
14
12
  get_database_url_from_env,
15
13
  build_engine,
16
14
  )
@@ -103,7 +101,6 @@ if not effective_url:
103
101
 
104
102
  u = make_url(effective_url)
105
103
  u = _coerce_sync_driver(u)
106
- u = _ensure_ssl_default(u)
107
104
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
108
105
 
109
106
 
@@ -191,9 +188,16 @@ def _collect_metadata() -> list[object]:
191
188
  if name not in pkgs:
192
189
  pkgs.append(name)
193
190
 
194
- # Always also try a bare 'models'
191
+ # Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
195
192
  if "models" not in pkgs:
196
- pkgs.append("models")
193
+ try:
194
+ spec = getattr(importlib, "util", None)
195
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
196
+ if spec.find_spec("models") is not None:
197
+ pkgs.append("models")
198
+ except Exception:
199
+ # If discovery fails, skip adding bare 'models'
200
+ pass
197
201
 
198
202
  def _import_and_collect(modname: str):
199
203
  try:
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from .service import SqlService
8
+
9
+
10
+ class TenantSqlService(SqlService):
11
+ """
12
+ SQL service wrapper that automatically scopes operations to a tenant.
13
+
14
+ - Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
15
+ - On create, if the model has the tenant field and it's not set in data, injects tenant_id.
16
+ """
17
+
18
+ def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
19
+ super().__init__(repo)
20
+ self.tenant_id = tenant_id
21
+ self.tenant_field = tenant_field
22
+
23
+ def _where(self) -> Sequence[Any]:
24
+ model = self.repo.model
25
+ col = getattr(model, self.tenant_field, None)
26
+ if col is None:
27
+ return []
28
+ return [col == self.tenant_id]
29
+
30
+ async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
31
+ return await self.repo.list(
32
+ session, limit=limit, offset=offset, order_by=order_by, where=self._where()
33
+ )
34
+
35
+ async def count(self, session: AsyncSession) -> int:
36
+ return await self.repo.count(session, where=self._where())
37
+
38
+ async def get(self, session: AsyncSession, id_value: Any):
39
+ return await self.repo.get(session, id_value, where=self._where())
40
+
41
+ async def create(self, session: AsyncSession, data: dict[str, Any]):
42
+ data = await self.pre_create(data)
43
+ # inject tenant_id if model supports it and value missing
44
+ if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
45
+ data[self.tenant_field] = self.tenant_id
46
+ return await self.repo.create(session, data)
47
+
48
+ async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
49
+ data = await self.pre_update(data)
50
+ return await self.repo.update(session, id_value, data, where=self._where())
51
+
52
+ async def delete(self, session: AsyncSession, id_value: Any) -> bool:
53
+ return await self.repo.delete(session, id_value, where=self._where())
54
+
55
+ async def search(
56
+ self,
57
+ session: AsyncSession,
58
+ *,
59
+ q: str,
60
+ fields: Sequence[str],
61
+ limit: int,
62
+ offset: int,
63
+ order_by=None,
64
+ ):
65
+ return await self.repo.search(
66
+ session,
67
+ q=q,
68
+ fields=fields,
69
+ limit=limit,
70
+ offset=offset,
71
+ order_by=order_by,
72
+ where=self._where(),
73
+ )
74
+
75
+ async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
76
+ return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
77
+
78
+
79
+ __all__ = ["TenantSqlService"]